mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 17:33:47 +00:00
Added detailed plugin information to the plugins page (#1013)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
b371b3d54f
commit
f912af321f
26 files changed
+884
-95
No files matched your search
+12
-1
@@ -69,11 +69,15 @@ public static class DirectChatLauncherLuaWriter
|
||||
/// <summary>
|
||||
/// Writes the complete plugin.lua for an installed launcher whose settings changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All metadata is carried over except the time of the last change: changing the settings
|
||||
/// changes the plugin, so that time becomes now.
|
||||
/// </remarks>
|
||||
/// <param name="plugin">The installed launcher whose metadata is carried over.</param>
|
||||
/// <param name="definition">The name, title, description, and chat settings the user chose.</param>
|
||||
/// <returns>The plugin.lua content, ready to be validated and written.</returns>
|
||||
public static string Write(PluginAssistants plugin, DirectChatLauncherDefinition definition) =>
|
||||
Write(DirectChatLauncherPluginMetadata.FromPlugin(plugin), definition);
|
||||
Write(DirectChatLauncherPluginMetadata.FromPlugin(plugin) with { LastChanged = DateTimeOffset.UtcNow }, definition);
|
||||
|
||||
/// <summary>
|
||||
/// 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();
|
||||
|
||||
+3
-2
@@ -10,6 +10,7 @@ namespace AIStudio.Tools.PluginSystem.Assistants;
|
||||
/// </remarks>
|
||||
/// <param name="Id">The plugin ID, which stays with the plugin for its whole life.</param>
|
||||
/// <param name="Version">The plugin version, as it appears in the Lua file.</param>
|
||||
/// <param name="LastChanged">When the plugin was last changed, in UTC, when it states it.</param>
|
||||
/// <param name="Authors">The authors of the plugin.</param>
|
||||
/// <param name="SupportContact">Where users turn with questions about this plugin.</param>
|
||||
/// <param name="SourceURL">Where the plugin comes from.</param>
|
||||
@@ -18,12 +19,12 @@ namespace AIStudio.Tools.PluginSystem.Assistants;
|
||||
/// <param name="IsMaintained">Whether the plugin is still maintained.</param>
|
||||
/// <param name="DeprecationMessage">What users are told when the plugin is deprecated.</param>
|
||||
/// <param name="IsAssistantBuilderGenerated">Whether the Assistant Builder created this plugin.</param>
|
||||
public sealed record DirectChatLauncherPluginMetadata(Guid Id, string Version, IReadOnlyList<string> Authors, string SupportContact, string SourceURL,
|
||||
public sealed record DirectChatLauncherPluginMetadata(Guid Id, string Version, DateTimeOffset? LastChanged, IReadOnlyList<string> Authors, string SupportContact, string SourceURL,
|
||||
IReadOnlyList<PluginCategory> Categories, IReadOnlyList<PluginTargetGroup> TargetGroups, bool IsMaintained, string DeprecationMessage, bool IsAssistantBuilderGenerated)
|
||||
{
|
||||
/// <summary>
|
||||
/// Takes the metadata of an installed launcher for the case where one is edited.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -37,6 +37,11 @@ public interface IPluginMetadata
|
||||
/// </summary>
|
||||
public PluginVersion Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When this plugin was last changed, in UTC, when the plugin states it.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastChanged { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The authors of this plugin.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
|
||||
using Lua;
|
||||
|
||||
// ReSharper disable MemberCanBePrivate.Global
|
||||
@@ -8,8 +10,31 @@ namespace AIStudio.Tools.PluginSystem;
|
||||
/// </summary>
|
||||
public abstract partial class PluginBase : IPluginMetadata, IDisposable
|
||||
{
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginBase));
|
||||
|
||||
/// <summary>
|
||||
/// The only format the field LAST_CHANGED accepts: a point in time in UTC, read and written with
|
||||
/// the invariant culture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public const string LAST_CHANGED_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'";
|
||||
|
||||
/// <summary>
|
||||
/// Writes a point in time the way the field LAST_CHANGED expects it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="lastChanged">When the plugin was last changed, in any time zone.</param>
|
||||
/// <returns>The time in UTC, formatted for the field LAST_CHANGED.</returns>
|
||||
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<string> baseIssues;
|
||||
protected readonly LuaState State;
|
||||
|
||||
@@ -33,6 +58,9 @@ public abstract partial class PluginBase : IPluginMetadata, IDisposable
|
||||
/// <inheritdoc />
|
||||
public PluginVersion Version { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset? LastChanged { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads when the plugin was last changed, when the plugin states it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <returns>The read point in time in UTC, or null when the field is absent or malformed.</returns>
|
||||
private DateTimeOffset? ReadLastChanged()
|
||||
{
|
||||
if (!this.State.Environment["LAST_CHANGED"].TryRead<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the authors of the plugin.
|
||||
/// </summary>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the categories of the plugin.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Recognizes the links in the metadata of plugins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static class PluginLinks
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to read a text as a link which a plugin may offer.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to read. Whitespace around it does not count.</param>
|
||||
/// <param name="uri">The link, when the text is one.</param>
|
||||
/// <returns>True, when the text is an absolute http, https, or mailto link, and a mailto link names a recipient.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a link writes a mail instead of opening a website.
|
||||
/// </summary>
|
||||
/// <param name="uri">The link to check.</param>
|
||||
/// <returns>True, when the link is a mailto link.</returns>
|
||||
public static bool IsMail(Uri uri) => uri.Scheme.Equals(Uri.UriSchemeMailto, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a text is one of the links which the Assistant Builder writes into the plugins it
|
||||
/// generates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="text">The text to check. Whitespace around it does not count.</param>
|
||||
/// <returns>True, when the text is one of these placeholders.</returns>
|
||||
public static bool IsPlaceholder(string text) => text.Trim() is AssistantPluginGenerationService.DEFAULT_SUPPORT_CONTACT or AssistantPluginGenerationService.DEFAULT_SOURCE_URL;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the text by which the user recognizes a link.
|
||||
/// </summary>
|
||||
/// <param name="uri">The link.</param>
|
||||
/// <returns>For a mailto link its recipient, without the scheme and without a subject. For a website the link as the plugin states it.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recipient of a mailto link, which is everything between the colon and the first
|
||||
/// question mark.
|
||||
/// </summary>
|
||||
/// <param name="mailUri">The mailto link.</param>
|
||||
/// <returns>The recipient as the link states it, which may be empty.</returns>
|
||||
private static ReadOnlySpan<char> GetRecipient(Uri mailUri)
|
||||
{
|
||||
var text = mailUri.OriginalString.AsSpan();
|
||||
var recipient = text[(text.IndexOf(':') + 1)..];
|
||||
var queryStart = recipient.IndexOf('?');
|
||||
return queryStart >= 0 ? recipient[..queryStart] : recipient;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isM
|
||||
/// <inheritdoc />
|
||||
public PluginVersion Version { get; } = plugin.Version;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset? LastChanged { get; } = plugin.LastChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string[] Authors { get; } = plugin.Authors;
|
||||
|
||||
|
||||
Reference in new issue
Block a user