From f912af321f5c818ac875136e42b86339420f4a1d Mon Sep 17 00:00:00 2001 From: nilskruthoff <69095224+nilskruthoff@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:02:28 +0200 Subject: [PATCH] Added detailed plugin information to the plugins page (#1013) Co-authored-by: Thorsten Sommer --- .../Assistants/I18N/allTexts.lua | 87 +++++++++++-- .../Components/InfoTableRow.razor | 11 ++ .../Components/InfoTableRow.razor.cs | 32 +++++ .../Components/PluginLinkButton.razor | 18 +++ .../Components/PluginLinkButton.razor.cs | 28 ++++ .../Dialogs/PluginInfoDialog.razor | 78 +++++++++++ .../Dialogs/PluginInfoDialog.razor.cs | 70 ++++++++++ app/MindWork AI Studio/Pages/Plugins.razor | 39 +++--- app/MindWork AI Studio/Pages/Plugins.razor.cs | 11 +- .../examples/translation/plugin.lua | 1 + .../Plugins/assistants/plugin.lua | 3 + .../Plugins/configuration/plugin.lua | 3 + .../plugin.lua | 89 +++++++++++-- .../plugin.lua | 87 +++++++++++-- .../Plugins/models/plugin.lua | 3 + .../Assistants/DirectChatLauncherLuaWriter.cs | 13 +- .../DirectChatLauncherPluginMetadata.cs | 5 +- .../Tools/PluginSystem/IPluginMetadata.cs | 5 + .../Tools/PluginSystem/PluginBase.cs | 100 ++++++++------ .../Tools/PluginSystem/PluginLinks.cs | 83 ++++++++++++ .../Tools/PluginSystem/PluginMetadata.cs | 3 + .../AssistantPluginGenerationService.cs | 11 +- .../wwwroot/changelog/v26.10.1.md | 2 + .../LeftOverConfigurationObjectsTests.cs | 2 + .../PluginSystem/PluginLastChangedTests.cs | 122 ++++++++++++++++++ .../Tools/PluginSystem/PluginLinksTests.cs | 73 +++++++++++ 26 files changed, 884 insertions(+), 95 deletions(-) create mode 100644 app/MindWork AI Studio/Components/InfoTableRow.razor create mode 100644 app/MindWork AI Studio/Components/InfoTableRow.razor.cs create mode 100644 app/MindWork AI Studio/Components/PluginLinkButton.razor create mode 100644 app/MindWork AI Studio/Components/PluginLinkButton.razor.cs create mode 100644 app/MindWork AI Studio/Dialogs/PluginInfoDialog.razor create mode 100644 app/MindWork AI Studio/Dialogs/PluginInfoDialog.razor.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginLinks.cs create mode 100644 app/Tests/Tools/PluginSystem/PluginLastChangedTests.cs create mode 100644 app/Tests/Tools/PluginSystem/PluginLinksTests.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index aa9acb00..2def4eb0 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -4303,6 +4303,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T4033722845"] = "Do y -- Delete configuration plugin UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T459830575"] = "Delete configuration plugin" +-- Send a mail +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINLINKBUTTON::T1999487139"] = "Send a mail" + +-- Open website +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINLINKBUTTON::T4239378936"] = "Open website" + -- Alpha phase means that we are working on the last details before the beta phase. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PREVIEWALPHA::T166807685"] = "Alpha phase means that we are working on the last details before the beta phase." @@ -7297,6 +7303,72 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T914647109"] = "Sends da -- Destination UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T994314591"] = "Destination" +-- Version +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1573770551"] = "Version" + +-- Plugin ID +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1661076691"] = "Plugin ID" + +-- Description +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1725856265"] = "Description" + +-- Authors +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1985367263"] = "Authors" + +-- Related link +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2077261368"] = "Related link" + +-- Managed by your organization +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2100092229"] = "Managed by your organization" + +-- No target groups specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2178642403"] = "No target groups specified" + +-- Here are the details of this plugin. Most of them come from its authors, and AI Studio shows them as stated without being able to verify them. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T223598799"] = "Here are the details of this plugin. Most of them come from its authors, and AI Studio shows them as stated without being able to verify them." + +-- No categories specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2267956060"] = "No categories specified" + +-- Target groups +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2402831960"] = "Target groups" + +-- Support contact +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2434966596"] = "Support contact" + +-- Last changed +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2839174209"] = "Last changed" + +-- The authors marked this plugin as no longer maintained. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3041246111"] = "The authors marked this plugin as no longer maintained." + +-- Email contact +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3304251427"] = "Email contact" + +-- Close +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3448155331"] = "Close" + +-- Type +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3512062061"] = "Type" + +-- Copies the plugin ID to the clipboard +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3514306881"] = "Copies the plugin ID to the clipboard" + +-- No authors specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3586343532"] = "No authors specified" + +-- Categories +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T4034259341"] = "Categories" + +-- The authors marked this plugin as deprecated: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T497068698"] = "The authors marked this plugin as deprecated: {0}" + +-- Origin +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T529732719"] = "Origin" + +-- Not specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T641241064"] = "Not specified" + -- Load what the AI should do from file UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1254789334"] = "Load what the AI should do from file" @@ -10321,6 +10393,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T923110805"] = "A test configurat -- Install Pandoc UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Install Pandoc" +-- Plugin information: {0} +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1155531948"] = "Plugin information: {0}" + -- Potentially Dangerous Plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1229643769"] = "Potentially Dangerous Plugin" @@ -10351,21 +10426,18 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T169921408"] = "Import plugin from a -- Disabled Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Disabled Plugins" +-- Plugin information +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1743041840"] = "Plugin information" + -- Edit assistant plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1851885496"] = "Edit assistant plugin" -- Plugin installed. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1889482678"] = "Plugin installed." --- Send a mail -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1999487139"] = "Send a mail" - -- Enable plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin" --- No source url available -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2058912565"] = "No source url available" - -- Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins" @@ -10432,9 +10504,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4066679817"] = "The automatic securi -- The assistant plugin '{0}' has been successfully revised. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4157246824"] = "The assistant plugin '{0}' has been successfully revised." --- Open website -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Open website" - -- Change what this tile opens UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4272203100"] = "Change what this tile opens" diff --git a/app/MindWork AI Studio/Components/InfoTableRow.razor b/app/MindWork AI Studio/Components/InfoTableRow.razor new file mode 100644 index 00000000..0589b8ae --- /dev/null +++ b/app/MindWork AI Studio/Components/InfoTableRow.razor @@ -0,0 +1,11 @@ + + + + + @this.Label + + + + @this.ChildContent + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/InfoTableRow.razor.cs b/app/MindWork AI Studio/Components/InfoTableRow.razor.cs new file mode 100644 index 00000000..a235ef9b --- /dev/null +++ b/app/MindWork AI Studio/Components/InfoTableRow.razor.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +/// +/// One row of a table that shows information: an icon and a label in the first cell, the value in the +/// second. +/// +/// +/// Place it inside the body of a MudSimpleTable. The value is free content, so a row may hold buttons +/// next to its text. +/// +public partial class InfoTableRow : ComponentBase +{ + /// + /// The icon in front of the label. + /// + [Parameter] + public string Icon { get; set; } = Icons.Material.Filled.Info; + + /// + /// The label, which names what the row shows. + /// + [Parameter, EditorRequired] + public string Label { get; set; } = string.Empty; + + /// + /// The value of the row. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/PluginLinkButton.razor b/app/MindWork AI Studio/Components/PluginLinkButton.razor new file mode 100644 index 00000000..1646d368 --- /dev/null +++ b/app/MindWork AI Studio/Components/PluginLinkButton.razor @@ -0,0 +1,18 @@ +@using AIStudio.Tools.PluginSystem +@inherits MSGComponentBase + +@if (PluginLinks.TryParse(this.Url, out var link) && !PluginLinks.IsPlaceholder(this.Url)) +{ + if (PluginLinks.IsMail(link)) + { + + + + } + else + { + + + + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/PluginLinkButton.razor.cs b/app/MindWork AI Studio/Components/PluginLinkButton.razor.cs new file mode 100644 index 00000000..cd81a568 --- /dev/null +++ b/app/MindWork AI Studio/Components/PluginLinkButton.razor.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +/// +/// Opens a link from the metadata of a plugin: a website in the browser, or a new mail in the mail +/// program. +/// +/// +/// The button only appears when there is something to open. A support contact may be plain text, such as +/// a name or a phone number, and the links which the Assistant Builder fills in for every plugin tell +/// nothing about the plugin itself. A disabled button would only leave the user wondering why it cannot +/// be clicked. +/// +public partial class PluginLinkButton : MSGComponentBase +{ + /// + /// The text to link, as the plugin states it. + /// + [Parameter, EditorRequired] + public string Url { get; set; } = string.Empty; + + /// + /// The size of the button. + /// + [Parameter] + public Size Size { get; set; } = Size.Medium; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/PluginInfoDialog.razor b/app/MindWork AI Studio/Dialogs/PluginInfoDialog.razor new file mode 100644 index 00000000..1df4e518 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/PluginInfoDialog.razor @@ -0,0 +1,78 @@ +@inherits MSGComponentBase + + + + @T("Here are the details of this plugin. Most of them come from its authors, and AI Studio shows them as stated without being able to verify them.") + + + + + + @this.Plugin.Description + + + @this.TypeText + + + @this.Plugin.Version + + + @this.LastChangedText + + + @this.AuthorsText + + @if (!string.IsNullOrWhiteSpace(this.Plugin.SupportContact)) + { + + + @this.SupportContactText + + + + } + @if (!string.IsNullOrWhiteSpace(this.Plugin.SourceURL)) + { + + + @this.SourceText + + + + } + + @this.CategoriesText + + + @this.TargetGroupsText + + + + @this.Plugin.Id + + + + @if (this.Plugin.IsManagedByConfigServer) + { + + @T("Managed by your organization") + + } + + + + @if (!this.Plugin.IsMaintained || !string.IsNullOrWhiteSpace(this.Plugin.DeprecationMessage)) + { + + @(string.IsNullOrWhiteSpace(this.Plugin.DeprecationMessage) + ? T("The authors marked this plugin as no longer maintained.") + : string.Format(T("The authors marked this plugin as deprecated: {0}"), this.Plugin.DeprecationMessage)) + + } + + + + @T("Close") + + + diff --git a/app/MindWork AI Studio/Dialogs/PluginInfoDialog.razor.cs b/app/MindWork AI Studio/Dialogs/PluginInfoDialog.razor.cs new file mode 100644 index 00000000..d711ac16 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/PluginInfoDialog.razor.cs @@ -0,0 +1,70 @@ +using AIStudio.Components; +using AIStudio.Tools.PluginSystem; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Dialogs; + +/// +/// Shows the details of a plugin on the plugins page: what its authors state about it, and whether an +/// organization manages it. +/// +/// +/// The plugins page lists only the name, the version, and the description of a plugin. Everything else +/// a plugin states, such as its authors or where to get support, would otherwise only be found in its +/// code. +/// +public partial class PluginInfoDialog : MSGComponentBase +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + /// + /// The plugin to show. The metadata alone would not do, because only an available plugin knows + /// whether an organization manages it. + /// + [Parameter] + public IAvailablePlugin Plugin { get; set; } = null!; + + private string TypeText => this.Plugin.Type.GetName(); + + private string AuthorsText => this.Plugin.Authors.Length > 0 ? string.Join(", ", this.Plugin.Authors) : this.T("No authors specified"); + + private string CategoriesText => this.Plugin.Categories.Length > 0 ? string.Join(", ", this.Plugin.Categories.Select(category => category.GetName())) : this.T("No categories specified"); + + private string TargetGroupsText => this.Plugin.TargetGroups.Length > 0 ? string.Join(", ", this.Plugin.TargetGroups.Select(targetGroup => targetGroup.Name())) : this.T("No target groups specified"); + + /// + /// The plugin states the time in UTC; the user reads it in their own time zone. + /// + private string LastChangedText => this.Plugin.LastChanged?.ToLocalTime().ToString("g", I18N.I.Culture) ?? this.T("Not specified"); + + private string SupportContactText => this.GetLinkText(this.Plugin.SupportContact); + + private string SourceText => this.GetLinkText(this.Plugin.SourceURL); + + /// + /// The field SOURCE_URL holds a website, such as a repository or a wiki, or a mail address. The + /// label and the icon of its row name which of both it is, since "source" would fit neither a wiki + /// nor a mail address. + /// + private bool IsSourceAMail => PluginLinks.TryParse(this.Plugin.SourceURL, out var link) && PluginLinks.IsMail(link); + + private string SourceLabel => this.IsSourceAMail ? this.T("Email contact") : this.T("Related link"); + + private string SourceIcon => this.IsSourceAMail ? Icons.Material.Filled.ContactMail : Icons.Material.Filled.Link; + + /// + /// Shows a link the way the user recognizes it: a mail address without its scheme, and a placeholder + /// of the Assistant Builder as not stated. A text which is no link stays as it is. + /// + private string GetLinkText(string text) + { + if (PluginLinks.IsPlaceholder(text)) + return this.T("Not specified"); + + return PluginLinks.TryParse(text, out var link) ? PluginLinks.GetDisplayText(link) : text; + } + + private void Close() => this.MudDialog.Close(); +} diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index 62204c14..dfb91dda 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -1,6 +1,5 @@ @using AIStudio.Tools.PluginSystem @using AIStudio.Tools.PluginSystem.Assistants -@using AIStudio.Tools.Services @inherits MSGComponentBase @attribute [Route(Routes.PLUGINS)] @@ -79,9 +78,20 @@ - - @context.Name - + + + @context.Name + + + v@(context.Version) + + @if (context is IAvailablePlugin { IsInternal: false } infoPlugin) + { + + + + } + @context.Description @@ -106,26 +116,11 @@ } - @if (context is { IsInternal: false } && !string.IsNullOrWhiteSpace(context.SourceURL)) + @if (context is { IsInternal: false }) { - var sourceUrl = context.SourceURL; - var isSendingMail = IsSendingMail(sourceUrl); - if (isSendingMail) - { - var isDefaultSupportContact = string.Equals(sourceUrl, AssistantPluginGenerationService.DEFAULT_SUPPORT_CONTACT, StringComparison.Ordinal); - - - - } - else - { - var isDefaultSourceUrl = string.Equals(sourceUrl, AssistantPluginGenerationService.DEFAULT_SOURCE_URL, StringComparison.Ordinal); - - - - } + } - + @if (context is IAvailablePlugin editablePlugin && CanEditAssistantPlugin(editablePlugin)) { diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs index cddf43c7..cde1857a 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor.cs +++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs @@ -485,7 +485,16 @@ public partial class Plugins : MSGComponentBase await dialogReference.Result; } - private static bool IsSendingMail(string sourceUrl) => sourceUrl.TrimStart().StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); + private async Task ShowPluginInfoAsync(IAvailablePlugin plugin) + { + var dialogParameters = new DialogParameters + { + { x => x.Plugin, plugin }, + }; + + var dialogReference = await this.DialogService.ShowAsync(string.Format(this.T("Plugin information: {0}"), plugin.Name), dialogParameters, DialogOptions.FULLSCREEN); + await dialogReference.Result; + } private PluginAssistants? TryGetAssistantPlugin(Guid pluginId) => PluginFactory.RunningPlugins.OfType().FirstOrDefault(x => x.Id == pluginId); diff --git a/app/MindWork AI Studio/Plugins/assistants/examples/translation/plugin.lua b/app/MindWork AI Studio/Plugins/assistants/examples/translation/plugin.lua index bc6f8e19..5adace2e 100644 --- a/app/MindWork AI Studio/Plugins/assistants/examples/translation/plugin.lua +++ b/app/MindWork AI Studio/Plugins/assistants/examples/translation/plugin.lua @@ -2,6 +2,7 @@ ID = "54f8f4a2-cd10-4a5f-b2d8-2e0f7875f9e4" NAME = "Translation" DESCRIPTION = "Assistant plugin example that translates text into a selected target language." VERSION = "1.0.0" +LAST_CHANGED = "2026-09-22T12:00:00Z" TYPE = "ASSISTANT" AUTHORS = {"MindWork AI"} SUPPORT_CONTACT = "mailto:info@mindwork.ai" diff --git a/app/MindWork AI Studio/Plugins/assistants/plugin.lua b/app/MindWork AI Studio/Plugins/assistants/plugin.lua index 54a28f2e..d5869186 100644 --- a/app/MindWork AI Studio/Plugins/assistants/plugin.lua +++ b/app/MindWork AI Studio/Plugins/assistants/plugin.lua @@ -22,6 +22,9 @@ DESCRIPTION = "This is a pre-defined configuration of " -- The version of the plugin: VERSION = "1.0.0" +-- When this plugin was last changed, in UTC, formatted as YYYY-MM-DDTHH:MM:SSZ. Optional. +LAST_CHANGED = "2025-12-31T12:00:00Z" + -- The type of the plugin: TYPE = "ASSISTANT" diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 9bd44175..c762e839 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -21,6 +21,9 @@ DESCRIPTION = "This is a pre-defined configuration of " -- The version of the plugin: VERSION = "1.0.0" +-- When this plugin was last changed, in UTC, formatted as YYYY-MM-DDTHH:MM:SSZ. Optional. +LAST_CHANGED = "2025-12-31T12:00:00Z" + -- The type of the plugin: TYPE = "CONFIGURATION" diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 8278f51e..13ac0fbe 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -4305,6 +4305,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T4033722845"] = "Möc -- Delete configuration plugin UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T459830575"] = "Konfigurations-Plugin löschen" +-- Send a mail +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINLINKBUTTON::T1999487139"] = "E-Mail senden" + +-- Open website +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINLINKBUTTON::T4239378936"] = "Website öffnen" + -- Alpha phase means that we are working on the last details before the beta phase. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PREVIEWALPHA::T166807685"] = "Alpha-Phase bedeutet, dass wir an den letzten Details arbeiten, bevor die Beta-Phase beginnt." @@ -7198,7 +7204,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1642243064"] = "Quelle" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1974491324"] = "Sie sind dabei, ein Sprach-Plugin aus einer Datei zu installieren." -- Authors -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1985367263"] = "Autor:innen" +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1985367263"] = "Autoren" -- Data source UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2034620186"] = "Datenquelle" @@ -7299,6 +7305,72 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T914647109"] = "Sendet D -- Destination UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T994314591"] = "Ziel" +-- Version +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1573770551"] = "Version" + +-- Plugin ID +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1661076691"] = "Plugin-ID" + +-- Description +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1725856265"] = "Beschreibung" + +-- Authors +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1985367263"] = "Autoren" + +-- Related link +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2077261368"] = "Weiterführender Link" + +-- Managed by your organization +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2100092229"] = "Von Ihrer Organisation verwaltet" + +-- No target groups specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2178642403"] = "Keine Zielgruppen angegeben" + +-- Here are the details of this plugin. Most of them come from its authors, and AI Studio shows them as stated without being able to verify them. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T223598799"] = "Hier finden Sie die Details zu diesem Plugin. Die meisten Angaben stammen von seinen Autoren. AI Studio zeigt sie so an, wie sie angegeben wurden, und kann sie nicht überprüfen." + +-- No categories specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2267956060"] = "Keine Kategorien angegeben" + +-- Target groups +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2402831960"] = "Zielgruppen" + +-- Support contact +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2434966596"] = "Supportkontakt" + +-- Last changed +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2839174209"] = "Zuletzt geändert" + +-- The authors marked this plugin as no longer maintained. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3041246111"] = "Die Autoren haben dieses Plugin als nicht mehr gepflegt gekennzeichnet." + +-- Email contact +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3304251427"] = "E-Mail-Kontakt" + +-- Close +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3448155331"] = "Schließen" + +-- Type +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3512062061"] = "Typ" + +-- Copies the plugin ID to the clipboard +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3514306881"] = "Kopiert die Plugin-ID in die Zwischenablage" + +-- No authors specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3586343532"] = "Keine Autoren angegeben" + +-- Categories +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T4034259341"] = "Kategorien" + +-- The authors marked this plugin as deprecated: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T497068698"] = "Die Autoren haben dieses Plugin als veraltet gekennzeichnet: {0}" + +-- Origin +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T529732719"] = "Herkunft" + +-- Not specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T641241064"] = "Nicht angegeben" + -- Load what the AI should do from file UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1254789334"] = "Lade aus einer Datei, was die KI tun soll" @@ -10323,6 +10395,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T923110805"] = "Eine Testkonfigur -- Install Pandoc UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Pandoc installieren" +-- Plugin information: {0} +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1155531948"] = "Plugin-Informationen: {0}" + -- Potentially Dangerous Plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1229643769"] = "Potenziell gefährliches Plugin" @@ -10353,21 +10428,18 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T169921408"] = "Plugin aus einer Date -- Disabled Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Deaktivierte Plugins" +-- Plugin information +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1743041840"] = "Plugin-Informationen" + -- Edit assistant plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1851885496"] = "Assistent-Plugin bearbeiten" -- Plugin installed. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1889482678"] = "Plugin installiert." --- Send a mail -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1999487139"] = "E-Mail senden" - -- Enable plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Plugin aktivieren" --- No source url available -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2058912565"] = "Keine Quell-URL verfügbar" - -- Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins" @@ -10434,9 +10506,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4066679817"] = "Die automatische Sic -- The assistant plugin '{0}' has been successfully revised. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4157246824"] = "Das Assistenten-Plugin „{0}“ wurde erfolgreich überarbeitet." --- Open website -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Website öffnen" - -- Change what this tile opens UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4272203100"] = "Ändern, was diese Kachel öffnet" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 234ff0a8..df3e9a4d 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -4305,6 +4305,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T4033722845"] = "Do y -- Delete configuration plugin UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T459830575"] = "Delete configuration plugin" +-- Send a mail +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINLINKBUTTON::T1999487139"] = "Send a mail" + +-- Open website +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINLINKBUTTON::T4239378936"] = "Open website" + -- Alpha phase means that we are working on the last details before the beta phase. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PREVIEWALPHA::T166807685"] = "Alpha phase means that we are working on the last details before the beta phase." @@ -7299,6 +7305,72 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T914647109"] = "Sends da -- Destination UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T994314591"] = "Destination" +-- Version +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1573770551"] = "Version" + +-- Plugin ID +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1661076691"] = "Plugin ID" + +-- Description +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1725856265"] = "Description" + +-- Authors +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T1985367263"] = "Authors" + +-- Related link +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2077261368"] = "Related link" + +-- Managed by your organization +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2100092229"] = "Managed by your organization" + +-- No target groups specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2178642403"] = "No target groups specified" + +-- Here are the details of this plugin. Most of them come from its authors, and AI Studio shows them as stated without being able to verify them. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T223598799"] = "Here are the details of this plugin. Most of them come from its authors, and AI Studio shows them as stated without being able to verify them." + +-- No categories specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2267956060"] = "No categories specified" + +-- Target groups +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2402831960"] = "Target groups" + +-- Support contact +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2434966596"] = "Support contact" + +-- Last changed +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T2839174209"] = "Last changed" + +-- The authors marked this plugin as no longer maintained. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3041246111"] = "The authors marked this plugin as no longer maintained." + +-- Email contact +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3304251427"] = "Email contact" + +-- Close +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3448155331"] = "Close" + +-- Type +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3512062061"] = "Type" + +-- Copies the plugin ID to the clipboard +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3514306881"] = "Copies the plugin ID to the clipboard" + +-- No authors specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T3586343532"] = "No authors specified" + +-- Categories +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T4034259341"] = "Categories" + +-- The authors marked this plugin as deprecated: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T497068698"] = "The authors marked this plugin as deprecated: {0}" + +-- Origin +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T529732719"] = "Origin" + +-- Not specified +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGININFODIALOG::T641241064"] = "Not specified" + -- Load what the AI should do from file UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1254789334"] = "Load what the AI should do from file" @@ -10323,6 +10395,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T923110805"] = "A test configurat -- Install Pandoc UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Install Pandoc" +-- Plugin information: {0} +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1155531948"] = "Plugin information: {0}" + -- Potentially Dangerous Plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1229643769"] = "Potentially Dangerous Plugin" @@ -10353,21 +10428,18 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T169921408"] = "Import plugin from a -- Disabled Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Disabled Plugins" +-- Plugin information +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1743041840"] = "Plugin information" + -- Edit assistant plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1851885496"] = "Edit assistant plugin" -- Plugin installed. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1889482678"] = "Plugin installed." --- Send a mail -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1999487139"] = "Send a mail" - -- Enable plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin" --- No source url available -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2058912565"] = "No source url available" - -- Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins" @@ -10434,9 +10506,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4066679817"] = "The automatic securi -- The assistant plugin '{0}' has been successfully revised. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4157246824"] = "The assistant plugin '{0}' has been successfully revised." --- Open website -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Open website" - -- Change what this tile opens UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4272203100"] = "Change what this tile opens" diff --git a/app/MindWork AI Studio/Plugins/models/plugin.lua b/app/MindWork AI Studio/Plugins/models/plugin.lua index d68f2b3a..a25c1846 100644 --- a/app/MindWork AI Studio/Plugins/models/plugin.lua +++ b/app/MindWork AI Studio/Plugins/models/plugin.lua @@ -16,6 +16,9 @@ DESCRIPTION = "Describes the models runs itself" -- The version of the plugin: VERSION = "1.0.0" +-- When this plugin was last changed, in UTC, formatted as YYYY-MM-DDTHH:MM:SSZ. Optional. +LAST_CHANGED = "2025-12-31T12:00:00Z" + -- The type of the plugin: TYPE = "MODEL" diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DirectChatLauncherLuaWriter.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DirectChatLauncherLuaWriter.cs index 976318dc..9660d9fd 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DirectChatLauncherLuaWriter.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DirectChatLauncherLuaWriter.cs @@ -69,11 +69,15 @@ public static class DirectChatLauncherLuaWriter /// /// Writes the complete plugin.lua for an installed launcher whose settings changed. /// + /// + /// All metadata is carried over except the time of the last change: changing the settings + /// changes the plugin, so that time becomes now. + /// /// The installed launcher whose metadata is carried over. /// The name, title, description, and chat settings the user chose. /// The plugin.lua content, ready to be validated and written. public static string Write(PluginAssistants plugin, DirectChatLauncherDefinition definition) => - Write(DirectChatLauncherPluginMetadata.FromPlugin(plugin), definition); + Write(DirectChatLauncherPluginMetadata.FromPlugin(plugin) with { LastChanged = DateTimeOffset.UtcNow }, definition); /// /// Writes the complete plugin.lua for a launcher. @@ -114,6 +118,13 @@ public static class DirectChatLauncherLuaWriter builder.AppendLine($"VERSION = \"{plugin.Version}\""); builder.AppendLine(); + if (plugin.LastChanged is { } lastChanged) + { + builder.AppendLine("-- When this plugin was last changed, in UTC, formatted as YYYY-MM-DDTHH:MM:SSZ. Optional."); + builder.AppendLine($"LAST_CHANGED = \"{PluginBase.FormatLastChanged(lastChanged)}\""); + builder.AppendLine(); + } + builder.AppendLine("-- The type of the plugin:"); builder.AppendLine($"TYPE = \"{nameof(PluginType.ASSISTANT)}\""); builder.AppendLine(); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DirectChatLauncherPluginMetadata.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DirectChatLauncherPluginMetadata.cs index b761148c..15ecad6e 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DirectChatLauncherPluginMetadata.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DirectChatLauncherPluginMetadata.cs @@ -10,6 +10,7 @@ namespace AIStudio.Tools.PluginSystem.Assistants; /// /// The plugin ID, which stays with the plugin for its whole life. /// The plugin version, as it appears in the Lua file. +/// When the plugin was last changed, in UTC, when it states it. /// The authors of the plugin. /// Where users turn with questions about this plugin. /// Where the plugin comes from. @@ -18,12 +19,12 @@ namespace AIStudio.Tools.PluginSystem.Assistants; /// Whether the plugin is still maintained. /// What users are told when the plugin is deprecated. /// Whether the Assistant Builder created this plugin. -public sealed record DirectChatLauncherPluginMetadata(Guid Id, string Version, IReadOnlyList Authors, string SupportContact, string SourceURL, +public sealed record DirectChatLauncherPluginMetadata(Guid Id, string Version, DateTimeOffset? LastChanged, IReadOnlyList Authors, string SupportContact, string SourceURL, IReadOnlyList Categories, IReadOnlyList TargetGroups, bool IsMaintained, string DeprecationMessage, bool IsAssistantBuilderGenerated) { /// /// Takes the metadata of an installed launcher for the case where one is edited. /// - public static DirectChatLauncherPluginMetadata FromPlugin(PluginAssistants plugin) => new(plugin.Id, plugin.Version.ToString(), plugin.Authors, + public static DirectChatLauncherPluginMetadata FromPlugin(PluginAssistants plugin) => new(plugin.Id, plugin.Version.ToString(), plugin.LastChanged, plugin.Authors, plugin.SupportContact, plugin.SourceURL, plugin.Categories, plugin.TargetGroups, plugin.IsMaintained, plugin.DeprecationMessage, plugin.IsAssistantBuilderGenerated); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/IPluginMetadata.cs b/app/MindWork AI Studio/Tools/PluginSystem/IPluginMetadata.cs index 4115cb67..fa6430a4 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/IPluginMetadata.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/IPluginMetadata.cs @@ -37,6 +37,11 @@ public interface IPluginMetadata /// public PluginVersion Version { get; } + /// + /// When this plugin was last changed, in UTC, when the plugin states it. + /// + public DateTimeOffset? LastChanged { get; } + /// /// The authors of this plugin. /// diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs index c35657e5..73d126b2 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs @@ -1,3 +1,5 @@ +using System.Globalization; + using Lua; // ReSharper disable MemberCanBePrivate.Global @@ -8,8 +10,31 @@ namespace AIStudio.Tools.PluginSystem; /// public abstract partial class PluginBase : IPluginMetadata, IDisposable { + private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginBase)); + + /// + /// The only format the field LAST_CHANGED accepts: a point in time in UTC, read and written with + /// the invariant culture. + /// + /// + /// A date alone cannot be shown in the time zone of the user: whether a day in UTC is the same day + /// locally depends on the time. The field therefore always carries the time, and the UI converts it. + /// + public const string LAST_CHANGED_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'"; + + /// + /// Writes a point in time the way the field LAST_CHANGED expects it. + /// + /// + /// The format ends in a literal Z, so the time has to be converted to UTC first. Otherwise, a + /// local time would be written as if it were UTC. + /// + /// When the plugin was last changed, in any time zone. + /// The time in UTC, formatted for the field LAST_CHANGED. + public static string FormatLastChanged(DateTimeOffset lastChanged) => lastChanged.ToUniversalTime().ToString(LAST_CHANGED_FORMAT, CultureInfo.InvariantCulture); + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginBase).Namespace, nameof(PluginBase)); - + private readonly IReadOnlyCollection baseIssues; protected readonly LuaState State; @@ -33,6 +58,9 @@ public abstract partial class PluginBase : IPluginMetadata, IDisposable /// public PluginVersion Version { get; } + /// + public DateTimeOffset? LastChanged { get; } + /// public string[] Authors { get; } = []; @@ -119,7 +147,9 @@ public abstract partial class PluginBase : IPluginMetadata, IDisposable this.Version = version; else if(this is not NoPlugin) issues.Add(issue); - + + this.LastChanged = this.ReadLastChanged(); + if(this.TryInitAuthors(out issue, out var authors)) this.Authors = authors; else if(this is not NoPlugin) @@ -274,6 +304,29 @@ public abstract partial class PluginBase : IPluginMetadata, IDisposable return true; } + /// + /// Reads when the plugin was last changed, when the plugin states it. + /// + /// + /// Unlike the other fields, LAST_CHANGED is optional: existing plugins were shipped before this + /// field existed, and a missing or malformed value is not a reason to refuse loading an otherwise + /// valid plugin. A malformed value is logged and treated as absent. + /// + /// The read point in time in UTC, or null when the field is absent or malformed. + private DateTimeOffset? ReadLastChanged() + { + if (!this.State.Environment["LAST_CHANGED"].TryRead(out var lastChangedText)) + return null; + + if (!DateTimeOffset.TryParseExact(lastChangedText, LAST_CHANGED_FORMAT, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var lastChanged)) + { + LOG.LogWarning("The field LAST_CHANGED of plugin '{PluginName}' is not a valid point in time in UTC of the form YYYY-MM-DDTHH:MM:SSZ. Ignoring it. Please fix the plugin.", this.Name); + return null; + } + + return lastChanged; + } + /// /// Tries to read the authors of the plugin. /// @@ -346,55 +399,22 @@ public abstract partial class PluginBase : IPluginMetadata, IDisposable } url = url.Trim(); - if (!Uri.TryCreate(url, UriKind.Absolute, out var sourceUri)) + if (!PluginLinks.TryParse(url, out var sourceUri)) { - url = string.Empty; - message = TB("The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'."); - return false; - } + message = url.StartsWith($"{Uri.UriSchemeMailto}:", StringComparison.OrdinalIgnoreCase) + ? TB("The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient.") + : TB("The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'."); - var isHttp = sourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); - var isHttps = sourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); - var isMailTo = sourceUri.Scheme.Equals(Uri.UriSchemeMailto, StringComparison.OrdinalIgnoreCase); - if (!isHttp && !isHttps && !isMailTo) - { url = string.Empty; - message = TB("The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'."); return false; } - if (isMailTo) - { - var recipient = ExtractMailtoRecipient(url); - if (string.IsNullOrWhiteSpace(recipient)) - { - url = string.Empty; - message = TB("The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient."); - return false; - } - } - url = sourceUri.ToString(); - + message = string.Empty; return true; } - private static string ExtractMailtoRecipient(string rawUrl) - { - var separatorIndex = rawUrl.IndexOf(':'); - if (separatorIndex < 0 || separatorIndex + 1 >= rawUrl.Length) - return string.Empty; - - var schemeSpecificPart = rawUrl[(separatorIndex + 1)..]; - var queryStart = schemeSpecificPart.IndexOf('?'); - var recipient = queryStart >= 0 - ? schemeSpecificPart[..queryStart] - : schemeSpecificPart; - - return recipient.Trim(); - } - /// /// Tries to read the categories of the plugin. /// diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginLinks.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginLinks.cs new file mode 100644 index 00000000..70363e0c --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginLinks.cs @@ -0,0 +1,83 @@ +using System.Diagnostics.CodeAnalysis; + +using AIStudio.Tools.Services; + +namespace AIStudio.Tools.PluginSystem; + +/// +/// Recognizes the links in the metadata of plugins. +/// +/// +/// The source URL of a plugin has to be such a link, and its support contact may be one. A plugin may +/// only link to a website or to a mail address: another scheme, such as file: or javascript:, would let a +/// plugin open anything on the computer of the user with a single click. Loading a plugin and showing its +/// links both decide with this class, so that AI Studio never offers a link which loading would refuse. +/// +public static class PluginLinks +{ + /// + /// Tries to read a text as a link which a plugin may offer. + /// + /// The text to read. Whitespace around it does not count. + /// The link, when the text is one. + /// True, when the text is an absolute http, https, or mailto link, and a mailto link names a recipient. + public static bool TryParse(string text, [NotNullWhen(true)] out Uri? uri) + { + if (Uri.TryCreate(text.Trim(), UriKind.Absolute, out uri) && IsAllowed(uri)) + return true; + + uri = null; + return false; + } + + /// + /// Checks whether a link writes a mail instead of opening a website. + /// + /// The link to check. + /// True, when the link is a mailto link. + public static bool IsMail(Uri uri) => uri.Scheme.Equals(Uri.UriSchemeMailto, StringComparison.OrdinalIgnoreCase); + + /// + /// Checks whether a text is one of the links which the Assistant Builder writes into the plugins it + /// generates. + /// + /// + /// Every plugin has to state a support contact and a source URL, so the Assistant Builder fills in + /// links to MindWork AI. They tell nothing about the plugin itself, so AI Studio treats them as not + /// stated: it neither shows them nor offers a button for them. The check goes by the text alone, so + /// these links count as not stated in any other plugin as well. + /// + /// The text to check. Whitespace around it does not count. + /// True, when the text is one of these placeholders. + public static bool IsPlaceholder(string text) => text.Trim() is AssistantPluginGenerationService.DEFAULT_SUPPORT_CONTACT or AssistantPluginGenerationService.DEFAULT_SOURCE_URL; + + /// + /// Gets the text by which the user recognizes a link. + /// + /// The link. + /// For a mailto link its recipient, without the scheme and without a subject. For a website the link as the plugin states it. + public static string GetDisplayText(Uri uri) => IsMail(uri) ? Uri.UnescapeDataString(GetRecipient(uri).Trim().ToString()) : uri.OriginalString; + + private static bool IsAllowed(Uri uri) + { + // Without a recipient, the mail program would open a mail to nobody: + if (IsMail(uri)) + return !GetRecipient(uri).IsWhiteSpace(); + + return uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets the recipient of a mailto link, which is everything between the colon and the first + /// question mark. + /// + /// The mailto link. + /// The recipient as the link states it, which may be empty. + private static ReadOnlySpan GetRecipient(Uri mailUri) + { + var text = mailUri.OriginalString.AsSpan(); + var recipient = text[(text.IndexOf(':') + 1)..]; + var queryStart = recipient.IndexOf('?'); + return queryStart >= 0 ? recipient[..queryStart] : recipient; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs index 7491e9f1..5fc3a3db 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs @@ -22,6 +22,9 @@ public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isM /// public PluginVersion Version { get; } = plugin.Version; + /// + public DateTimeOffset? LastChanged { get; } = plugin.LastChanged; + /// public string[] Authors { get; } = plugin.Authors; diff --git a/app/MindWork AI Studio/Tools/Services/AssistantPluginGenerationService.cs b/app/MindWork AI Studio/Tools/Services/AssistantPluginGenerationService.cs index 43f1cf04..bf3d4c0c 100644 --- a/app/MindWork AI Studio/Tools/Services/AssistantPluginGenerationService.cs +++ b/app/MindWork AI Studio/Tools/Services/AssistantPluginGenerationService.cs @@ -29,7 +29,13 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry, private const string DEFAULT_AUTHOR = "MindWork AI - Assistant Builder"; public const string DEFAULT_SUPPORT_CONTACT = "mailto:info@mindwork.ai"; public const string DEFAULT_SOURCE_URL = "https://github.com/MindWorkAI/AI-Studio"; - + + /// + /// Now as the plugin field LAST_CHANGED expects it. Every plugin this service writes or has a + /// model write is a change made now. + /// + private static string NowAsLastChanged => PluginBase.FormatLastChanged(DateTimeOffset.UtcNow); + private static readonly AssistantContextFile[] ASSISTANT_CONTEXT_FILES = [ new("Assistant plugin schema", "Plugins/assistants/README.md", IsRequired: true), @@ -148,6 +154,7 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry, var metadata = new DirectChatLauncherPluginMetadata( request.PluginId, DEFAULT_VERSION, + DateTimeOffset.UtcNow, [DEFAULT_AUTHOR], DEFAULT_SUPPORT_CONTACT, DEFAULT_SOURCE_URL, @@ -426,6 +433,7 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry, ID = "{{request.PluginId}}" VERSION = "{{DEFAULT_VERSION}}" + LAST_CHANGED = "{{NowAsLastChanged}}" TYPE = "ASSISTANT" AUTHORS = {"{{DEFAULT_AUTHOR}}"} SUPPORT_CONTACT = "{{DEFAULT_SUPPORT_CONTACT}}" @@ -620,6 +628,7 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry, - A null value in the request JSON means that detail is not available. Never write the word "null" or a field name into the plugin. - Keep ID = "{{plugin.Id}}" exactly. Do not create a new plugin ID. - Keep TYPE = "ASSISTANT". + - Set LAST_CHANGED = "{{NowAsLastChanged}}" right after VERSION, replacing any earlier value. - Keep the assistant locally managed. DEPLOYED_USING_CONFIG_SERVER must not be true. {{builderMetadataRule}} - Set assistant.kind to "CHAT_LAUNCHER" exactly when the revised ASSISTANT table uses LaunchBehavior = "OPEN_WORKSPACE_CHAT_BY_NAME" or "OPEN_TEMPORARY_CHAT"; otherwise set it to "FORM". diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md index 7cc9a779..1588d97c 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md @@ -16,6 +16,8 @@ - Improved how AI Studio keeps the message you have not sent yet. When AI Studio stores your chats automatically, your message and its attachments now stay with the chat, and they are back when you open the chat again, for example, after you switched to another chat. - Improved how AI Studio keeps the message of a new chat. When AI Studio stores your chats automatically and you leave a new chat before you send your message, AI Studio now stores the chat with your message. You find it among your disappearing chats, or in its workspace when you started it there. - Improved how AI Studio indexes large local data sources. It now works through them in steps of a few minutes, so your other data sources, such as your mailboxes, stay up to date in between. +- Improved the plugins page with the details of each plugin. The info button next to the name of a plugin shows, among other things, its version, its authors, and where to get support. +- Improved the details of a plugin on the plugins page, which now show when the plugin was last changed, in your time zone. Plugin authors can state this time, and AI Studio fills it in by itself when it writes a plugin for you, for example, with the Assistant Builder. - Changed the banner on the start page to a fresh design that shows what AI Studio can do today. - Fixed a warning about the confidence level that stayed in the dialog for adding a local data source, even after you chose an embedding provider that meets it. The dialog now checks again whenever you change either of them, so a warning also appears when you switch to a provider that falls short. - Fixed two confidence levels sharing the same name in the German user interface. Moderate is called "Mäßig" again, and Medium stays "Mittel". diff --git a/app/Tests/Tools/PluginSystem/LeftOverConfigurationObjectsTests.cs b/app/Tests/Tools/PluginSystem/LeftOverConfigurationObjectsTests.cs index bec16402..41626a6c 100644 --- a/app/Tests/Tools/PluginSystem/LeftOverConfigurationObjectsTests.cs +++ b/app/Tests/Tools/PluginSystem/LeftOverConfigurationObjectsTests.cs @@ -98,6 +98,8 @@ public sealed class LeftOverConfigurationObjectsTests public PluginVersion Version => new(1, 0, 0); + public DateTimeOffset? LastChanged => null; + public string[] Authors => []; public string SupportContact => string.Empty; diff --git a/app/Tests/Tools/PluginSystem/PluginLastChangedTests.cs b/app/Tests/Tools/PluginSystem/PluginLastChangedTests.cs new file mode 100644 index 00000000..fe821bcb --- /dev/null +++ b/app/Tests/Tools/PluginSystem/PluginLastChangedTests.cs @@ -0,0 +1,122 @@ +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.PluginSystem.Assistants; + +namespace AIStudio.Tests.Tools.PluginSystem; + +/// +/// Checks how a plugin states when it was last changed. +/// +/// +/// LAST_CHANGED is optional and always a point in time in UTC, so that the UI can show it in the time +/// zone of the user. A date alone cannot be converted: whether a day in UTC is the same day locally +/// depends on the time. A plugin without the field, or with a value of another form, still loads, and +/// the value then counts as absent. AI Studio writes the field itself whenever it writes a direct chat +/// launcher, and what it writes has to load back to the same point in time. +/// +[TestFixture] +public sealed class PluginLastChangedTests +{ + private static readonly Guid PLUGIN_ID = Guid.Parse("3f6a2c8e-9b1d-4e7a-8c5f-2d4b6e8a0c1f"); + + private static readonly DateTimeOffset LAST_CHANGED = new(2026, 10, 12, 14, 30, 5, TimeSpan.Zero); + + private const string LAST_CHANGED_LINE = "LAST_CHANGED = \"2026-10-12T14:30:05Z\""; + + [Test] + public async Task APointInTimeInUtcIsRead() + { + using var plugin = await LoadModelPlugin(LAST_CHANGED_LINE); + + Assert.Multiple(() => + { + Assert.That(plugin.IsValid, Is.True, string.Join(" ", plugin.Issues)); + Assert.That(plugin.LastChanged, Is.EqualTo(LAST_CHANGED)); + Assert.That(plugin.LastChanged?.Offset, Is.EqualTo(TimeSpan.Zero), "The UI converts from UTC, so the value has to be in UTC."); + }); + } + + [Test] + public async Task AMissingFieldIsNoIssue() + { + using var plugin = await LoadModelPlugin(string.Empty); + + Assert.Multiple(() => + { + Assert.That(plugin.IsValid, Is.True, string.Join(" ", plugin.Issues)); + Assert.That(plugin.LastChanged, Is.Null); + }); + } + + [TestCase("\"2026-10-12\"")] + [TestCase("\"2026-10-12T14:30:05\"")] + [TestCase("\"2026-10-12T16:30:05+02:00\"")] + [TestCase("\"2026-13-01T00:00:00Z\"")] + [TestCase("\"12.10.2026 14:30:05\"")] + [TestCase("20261012")] + public async Task AMalformedValueIsIgnoredAndThePluginStillLoads(string luaValue) + { + using var plugin = await LoadModelPlugin($"LAST_CHANGED = {luaValue}"); + + Assert.Multiple(() => + { + Assert.That(plugin.IsValid, Is.True, "A malformed LAST_CHANGED is no reason to refuse an otherwise valid plugin."); + Assert.That(plugin.LastChanged, Is.Null); + }); + } + + [Test] + public void ALocalTimeIsWrittenInUtc() + { + var localTime = new DateTimeOffset(2026, 10, 12, 16, 30, 5, TimeSpan.FromHours(2)); + + Assert.That(PluginBase.FormatLastChanged(localTime), Is.EqualTo("2026-10-12T14:30:05Z"), "The format ends in a literal Z, so a local time must not be written as it is."); + } + + [Test] + public void TheWriterLeavesTheFieldOutWithoutAPointInTime() + { + var lua = DirectChatLauncherLuaWriter.Write(CreateLauncherMetadata(null), CreateLauncherDefinition()); + + Assert.That(lua, Does.Not.Contain("LAST_CHANGED")); + } + + [Test] + public async Task WhatTheWriterWritesLoadsBackToTheSamePointInTime() + { + var lua = DirectChatLauncherLuaWriter.Write(CreateLauncherMetadata(LAST_CHANGED), CreateLauncherDefinition()); + using var plugin = await PluginFactory.Load(null, lua); + + Assert.Multiple(() => + { + Assert.That(lua, Does.Contain(LAST_CHANGED_LINE)); + Assert.That(plugin.IsValid, Is.True, string.Join(" ", plugin.Issues)); + Assert.That(plugin.LastChanged, Is.EqualTo(LAST_CHANGED)); + }); + } + + /// + /// Loads a model plugin, the plugin type with the fewest fields beyond the ones every plugin has. + /// + /// The Lua line which states LAST_CHANGED, or an empty string. + private static Task LoadModelPlugin(string lastChangedLine) => PluginFactory.Load(null, $$""" + ID = "{{PLUGIN_ID}}" + NAME = "Models for the tests" + DESCRIPTION = "A model plugin which declares no models." + VERSION = "1.0.0" + {{lastChangedLine}} + TYPE = "MODEL" + AUTHORS = {"MindWork AI"} + SUPPORT_CONTACT = "MindWork AI" + SOURCE_URL = "https://github.com/MindWorkAI/AI-Studio" + CATEGORIES = {"CORE"} + TARGET_GROUPS = {"EVERYONE"} + IS_MAINTAINED = true + DEPRECATION_MESSAGE = "" + MODELS = {} + """); + + private static DirectChatLauncherPluginMetadata CreateLauncherMetadata(DateTimeOffset? lastChanged) => new(PLUGIN_ID, "1.0.0", lastChanged, ["MindWork AI"], "MindWork AI", + "https://github.com/MindWorkAI/AI-Studio", [PluginCategory.CORE], [PluginTargetGroup.EVERYONE], IsMaintained: true, DeprecationMessage: string.Empty, IsAssistantBuilderGenerated: false); + + private static DirectChatLauncherDefinition CreateLauncherDefinition() => new("Launcher for the tests", "Launcher for the tests", "Opens a disappearing chat.", new AssistantChatLaunchConfiguration(string.Empty, null, null, null, null, null)); +} \ No newline at end of file diff --git a/app/Tests/Tools/PluginSystem/PluginLinksTests.cs b/app/Tests/Tools/PluginSystem/PluginLinksTests.cs new file mode 100644 index 00000000..d1e820e9 --- /dev/null +++ b/app/Tests/Tools/PluginSystem/PluginLinksTests.cs @@ -0,0 +1,73 @@ +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Services; + +namespace AIStudio.Tests.Tools.PluginSystem; + +/// +/// Checks which texts in the metadata of a plugin count as links. +/// +/// +/// AI Studio offers such a link as a button, and a click opens it right away. A plugin may therefore +/// only link to a website or to a mail address. Any other scheme would let a plugin open something on +/// the computer of the user, and plain text, which a support contact may be, gets no button at all. The +/// links which the Assistant Builder fills in tell nothing about a plugin and count as not stated. +/// +[TestFixture] +public sealed class PluginLinksTests +{ + [TestCase("https://example.org")] + [TestCase("http://example.org/plugins")] + [TestCase("mailto:support@example.org")] + [TestCase("mailto:support@example.org?subject=Plugin")] + [TestCase(" https://example.org ")] + public void AWebsiteOrAMailAddressIsALink(string text) + { + Assert.That(PluginLinks.TryParse(text, out _), Is.True); + } + + [TestCase("")] + [TestCase("MindWork AI")] + [TestCase("example.org")] + [TestCase("file:///etc/passwd")] + [TestCase("javascript:alert(1)")] + [TestCase("ftp://example.org")] + [TestCase("mailto:")] + [TestCase("mailto:?subject=Plugin")] + public void AnythingElseIsNoLink(string text) + { + Assert.That(PluginLinks.TryParse(text, out _), Is.False); + } + + [Test] + public void OnlyAMailtoLinkWritesAMail() + { + Assert.Multiple(() => + { + Assert.That(PluginLinks.TryParse("mailto:support@example.org", out var mail) && PluginLinks.IsMail(mail), Is.True); + Assert.That(PluginLinks.TryParse("https://example.org", out var website) && PluginLinks.IsMail(website), Is.False); + }); + } + + [TestCase(AssistantPluginGenerationService.DEFAULT_SUPPORT_CONTACT, true)] + [TestCase(AssistantPluginGenerationService.DEFAULT_SOURCE_URL, true)] + [TestCase($" {AssistantPluginGenerationService.DEFAULT_SOURCE_URL} ", true)] + [TestCase("mailto:support@example.org", false)] + [TestCase("https://github.com/MindWorkAI/AI-Studio/wiki", false)] + public void OnlyTheLinksOfTheAssistantBuilderArePlaceholders(string text, bool isPlaceholder) + { + Assert.That(PluginLinks.IsPlaceholder(text), Is.EqualTo(isPlaceholder)); + } + + [TestCase("mailto:support@example.org", "support@example.org")] + [TestCase("mailto:support@example.org?subject=Plugin", "support@example.org")] + [TestCase("mailto:support%2Bplugins@example.org", "support+plugins@example.org")] + [TestCase("https://example.org/plugins?id=1", "https://example.org/plugins?id=1")] + public void AMailLinkShowsItsAddressAndAWebsiteShowsItself(string text, string displayText) + { + Assert.Multiple(() => + { + Assert.That(PluginLinks.TryParse(text, out var link), Is.True); + Assert.That(PluginLinks.GetDisplayText(link!), Is.EqualTo(displayText)); + }); + } +} \ No newline at end of file