Added support for organization-managed introduction texts

This commit is contained in:
Thorsten Sommer 2026-06-20 18:41:21 +02:00
parent e04879fd7f
commit 9b29c9896c
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
9 changed files with 206 additions and 15 deletions

View File

@ -10,21 +10,31 @@
<InnerScrolling>
<MudExpansionPanels Class="mb-3" MultiExpansion="@false">
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.MenuBook" HeaderText="@T("Introduction")" IsExpanded="@true">
<MudText Typo="Typo.h5" Class="mb-3">
@T("Welcome to MindWork AI Studio!")
</MudText>
<MudText Typo="Typo.body1" Class="mb-3" Style="text-align: justify; hyphens: auto;">
@T("Thank you for considering MindWork AI Studio for your AI needs. This app is designed to help you harness the power of Large Language Models (LLMs). Please note that this app doesn't come with an integrated LLM. Instead, you will need to bring an API key from a suitable provider.")
</MudText>
<MudText Typo="Typo.body1" Class="mb-3">
@T("Here's what makes MindWork AI Studio stand out:")
</MudText>
<MudTextList Icon="@Icons.Material.Filled.CheckCircle" Clickable="@true" Items="@this.itemsAdvantages" Class="mb-3"/>
<MudText Typo="Typo.body1" Class="mb-3">
@T("We hope you enjoy using MindWork AI Studio to bring your AI projects to life!")
</MudText>
</ExpansionPanel>
@if (this.SettingsManager.ConfigurationData.App.ShowIntroduction)
{
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.MenuBook" HeaderText="@T("Introduction")" IsExpanded="@true">
<MudText Typo="Typo.h5" Class="mb-3">
@T("Welcome to MindWork AI Studio!")
</MudText>
<MudText Typo="Typo.body1" Class="mb-3" Style="text-align: justify; hyphens: auto;">
@T("Thank you for considering MindWork AI Studio for your AI needs. This app is designed to help you harness the power of Large Language Models (LLMs). Please note that this app doesn't come with an integrated LLM. Instead, you will need to bring an API key from a suitable provider.")
</MudText>
<MudText Typo="Typo.body1" Class="mb-3">
@T("Here's what makes MindWork AI Studio stand out:")
</MudText>
<MudTextList Icon="@Icons.Material.Filled.CheckCircle" Clickable="@true" Items="@this.itemsAdvantages" Class="mb-3"/>
<MudText Typo="Typo.body1" Class="mb-3">
@T("We hope you enjoy using MindWork AI Studio to bring your AI projects to life!")
</MudText>
</ExpansionPanel>
}
@foreach (var introductionPanel in this.introductionPanels)
{
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Info" HeaderText="@introductionPanel.HeaderText">
<MudJustifiedMarkdown Value="@introductionPanel.Introduction.Markdown" />
</ExpansionPanel>
}
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.EventNote" HeaderText="@T("Last Changelog")">
<MudMarkdown Value="@this.LastChangeContent" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE"/>

View File

@ -1,5 +1,6 @@
using AIStudio.Components;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using Microsoft.AspNetCore.Components;
@ -18,13 +19,19 @@ public partial class Home : MSGComponentBase
private string LastChangeContent { get; set; } = string.Empty;
private TextItem[] itemsAdvantages = [];
private List<HomeIntroductionPanelData> introductionPanels = [];
private sealed record HomeIntroductionPanelData(string HeaderText, DataIntroduction Introduction);
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
await base.OnInitializedAsync();
this.InitializeAdvantagesItems();
this.RefreshIntroductionPanels();
// Read the last change content asynchronously
// without blocking the UI thread:
@ -69,10 +76,12 @@ public partial class Home : MSGComponentBase
{
case Event.PLUGINS_RELOADED:
this.InitializeAdvantagesItems();
this.RefreshIntroductionPanels();
await this.InvokeAsync(this.StateHasChanged);
break;
case Event.CONFIGURATION_CHANGED:
this.RefreshIntroductionPanels();
await this.InvokeAsync(this.StateHasChanged);
break;
}
@ -80,6 +89,17 @@ public partial class Home : MSGComponentBase
#endregion
private void RefreshIntroductionPanels()
{
this.introductionPanels = PluginFactory.GetIntroductions()
.Select(introduction =>
{
var headerText = $"{introduction.Title} ({T("Version")} {introduction.VersionText})";
return new HomeIntroductionPanelData(headerText, introduction);
})
.ToList();
}
private async Task ReadLastChangeAsync()
{
var latest = Changelog.LOGS.MaxBy(n => n.Build);

View File

@ -207,6 +207,9 @@ CONFIG["SETTINGS"] = {}
-- Configure whether the quick start guide is shown on the welcome page.
-- CONFIG["SETTINGS"]["DataApp.ShowQuickStartGuide"] = false
-- Configure whether the built-in introduction is shown on the welcome page.
-- CONFIG["SETTINGS"]["DataApp.ShowIntroduction"] = false
-- Configure the user permission to add providers:
-- CONFIG["SETTINGS"]["DataApp.AllowUserToAddProvider"] = false
@ -336,6 +339,26 @@ CONFIG["CHAT_TEMPLATES"] = {}
-- }
-- }
-- Introduction texts shown as expansion panels on the welcome page:
CONFIG["INTRODUCTIONS"] = {}
-- An example introduction:
-- CONFIG["INTRODUCTIONS"][#CONFIG["INTRODUCTIONS"]+1] = {
-- ["Id"] = "00000000-0000-0000-0000-000000000000",
-- ["Title"] = "Welcome to Your Organization's AI Studio",
-- ["Version"] = "1",
-- ["Index"] = 1,
-- ["Markdown"] = [===[
-- ## Getting Started
--
-- This AI Studio installation is managed by your organization.
-- Please use the preconfigured providers and follow your internal
-- AI usage guidelines.
--
-- Further information is available in the [internal wiki](https://example.org/wiki).
-- ]===]
-- }
-- Mandatory infos that users must explicitly accept before using AI Studio:
-- AI Studio asks users again when Version, Title, or Markdown change.
-- Changing Version additionally allows the UI to communicate that a new version is available.

View File

@ -57,6 +57,11 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary>
public StartPage StartPage { get; set; } = ManagedConfiguration.Register(configSelection, n => n.StartPage, StartPage.HOME);
/// <summary>
/// Should the built-in introduction be visible on the home page?
/// </summary>
public bool ShowIntroduction { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShowIntroduction, true);
/// <summary>
/// Should the quick start guide be visible on the home page?
/// </summary>

View File

@ -0,0 +1,85 @@
using Lua;
namespace AIStudio.Settings.DataModel;
public sealed record DataIntroduction
{
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger<DataIntroduction>();
/// <summary>
/// The stable ID of the introduction.
/// </summary>
public string Id { get; private init; } = string.Empty;
/// <summary>
/// The ID of the enterprise configuration plugin that provides this introduction.
/// </summary>
public Guid EnterpriseConfigurationPluginId { get; private init; } = Guid.Empty;
/// <summary>
/// The title shown to the user.
/// </summary>
public string Title { get; private init; } = string.Empty;
/// <summary>
/// The configured version string shown to the user.
/// </summary>
public string VersionText { get; private init; } = string.Empty;
/// <summary>
/// The sort index used on the home page.
/// </summary>
public int Index { get; private init; } = 1;
/// <summary>
/// The Markdown content shown to the user.
/// </summary>
public string Markdown { get; private init; } = string.Empty;
public static bool TryParseConfiguration(int idx, LuaTable table, Guid configPluginId, out DataIntroduction introduction)
{
introduction = new DataIntroduction();
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
{
LOG.LogWarning("The configured introduction {IntroductionIndex} does not contain a valid ID. The ID must be a valid GUID.", idx);
return false;
}
if (!table.TryGetValue("Title", out var titleValue) || !titleValue.TryRead<string>(out var title) || string.IsNullOrWhiteSpace(title))
{
LOG.LogWarning("The configured introduction {IntroductionIndex} does not contain a valid Title field.", idx);
return false;
}
if (!table.TryGetValue("Version", out var versionValue) || !versionValue.TryRead<string>(out var versionText) || string.IsNullOrWhiteSpace(versionText))
{
LOG.LogWarning("The configured introduction {IntroductionIndex} does not contain a valid Version field.", idx);
return false;
}
if (!table.TryGetValue("Markdown", out var markdownValue) || !markdownValue.TryRead<string>(out var markdown) || string.IsNullOrWhiteSpace(markdown))
{
LOG.LogWarning("The configured introduction {IntroductionIndex} does not contain a valid Markdown field.", idx);
return false;
}
var index = 1;
if (table.TryGetValue("Index", out var indexValue) && !indexValue.TryRead(out index))
{
LOG.LogWarning("The configured introduction {IntroductionIndex} does not contain a valid Index field. The Index must be an integer.", idx);
return false;
}
introduction = new DataIntroduction
{
Id = id.ToString(),
Title = title,
VersionText = versionText,
Index = index,
Markdown = AIStudio.Tools.Markdown.RemoveSharedIndentation(markdown),
EnterpriseConfigurationPluginId = configPluginId,
};
return true;
}
}

View File

@ -14,6 +14,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
private List<PluginConfigurationObject> configObjects = [];
private List<DataMandatoryInfo> mandatoryInfos = [];
private List<DataIntroduction> introductions = [];
/// <summary>
/// The list of configuration objects. Configuration objects are, e.g., providers or chat templates.
@ -25,6 +26,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
/// </summary>
public IReadOnlyList<DataMandatoryInfo> MandatoryInfos => this.mandatoryInfos;
/// <summary>
/// The list of introductions provided by this configuration plugin.
/// </summary>
public IReadOnlyList<DataIntroduction> Introductions => this.introductions;
/// <summary>
/// True/false when explicitly configured in the plugin, otherwise null.
/// </summary>
@ -130,6 +136,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
{
this.configObjects.Clear();
this.mandatoryInfos.Clear();
this.introductions.Clear();
// Ensure that the main CONFIG table exists and is a valid Lua table:
if (!this.State.Environment["CONFIG"].TryRead<LuaTable>(out var mainTable))
@ -154,6 +161,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: what should be the start page?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.StartPage, this.Id, settingsTable, dryRun);
// Config: show built-in introduction on the home page?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowIntroduction, this.Id, settingsTable, dryRun);
// Config: show quick start guide on the home page?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowQuickStartGuide, this.Id, settingsTable, dryRun);
@ -206,6 +216,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Handle configured mandatory infos:
this.TryReadMandatoryInfos(mainTable);
// Handle configured introductions:
this.TryReadIntroductions(mainTable);
// Config: preselected provider?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun);
@ -240,4 +253,25 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
LOG.LogWarning("The table 'MANDATORY_INFOS' entry at index {Index} does not contain a valid mandatory info (config plugin id: {ConfigPluginId}).", i, this.Id);
}
}
private void TryReadIntroductions(LuaTable mainTable)
{
if (!mainTable.TryGetValue("INTRODUCTIONS", out var introductionsValue) || !introductionsValue.TryRead<LuaTable>(out var introductionsTable))
return;
for (var i = 1; i <= introductionsTable.ArrayLength; i++)
{
var luaIntroductionValue = introductionsTable[i];
if (!luaIntroductionValue.TryRead<LuaTable>(out var luaIntroductionTable))
{
LOG.LogWarning("The table 'INTRODUCTIONS' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", i, this.Id);
continue;
}
if (DataIntroduction.TryParseConfiguration(i, luaIntroductionTable, this.Id, out var introduction))
this.introductions.Add(introduction);
else
LOG.LogWarning("The table 'INTRODUCTIONS' entry at index {Index} does not contain a valid introduction (config plugin id: {ConfigPluginId}).", i, this.Id);
}
}
}

View File

@ -214,6 +214,10 @@ public static partial class PluginFactory
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.StartPage, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the built-in introduction visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowIntroduction, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the quick start guide visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowQuickStartGuide, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;

View File

@ -136,4 +136,13 @@ public static partial class PluginFactory
.SelectMany(plugin => plugin.MandatoryInfos)
.ToList();
}
public static IReadOnlyList<DataIntroduction> GetIntroductions()
{
return RUNNING_PLUGINS
.OfType<PluginConfiguration>()
.SelectMany(plugin => plugin.Introductions)
.OrderBy(introduction => introduction.Index)
.ToList();
}
}

View File

@ -1,4 +1,5 @@
# v26.6.2, build 242 (2026-06-xx xx:xx UTC)
- Added a read-only view for organization-managed profiles and chat templates, so users can inspect the content while the organization remains in control of changes.
- Added support for organization-managed introduction texts on the home page. Configuration plugins can now add custom Markdown introductions and hide the built-in introduction.
- Fixed organization-managed chat templates not showing the correct icon in the chat template selection menu.
- Fixed self-hosted provider API keys sometimes being stored under a localized name. AI Studio now uses a stable key name, keeps correct entries working, and automatically migrates known localized entries for LLM, transcription, and embedding providers. Organizations using configuration plugins do not need to change their plugins; affected users who still see an invalid API key warning should open the provider, transcription, or embedding settings and update the API key once.