mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 20:47:22 +00:00
Adopted further C# 14 and .NET 10 features (#1158)
This commit is contained in:
1 parent
f649febf88
commit
f0fa1411c8
40 files changed
+545
-56
No files matched your search
@@ -281,7 +281,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
|||||||
this.formChangeTimer.Elapsed += (_, _) =>
|
this.formChangeTimer.Elapsed += (_, _) =>
|
||||||
{
|
{
|
||||||
this.formChangeTimer.Stop();
|
this.formChangeTimer.Stop();
|
||||||
this.InvokeAsync(this.OnFormChange).Observe($"{nameof(AssistantBase<TSettings>)}: handling a form change");
|
this.InvokeAsync(this.OnFormChange).Observe($"{nameof(AssistantBase<>)}: handling a form change");
|
||||||
};
|
};
|
||||||
|
|
||||||
this.MightPreselectValues();
|
this.MightPreselectValues();
|
||||||
@@ -321,7 +321,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
private string TB(string fallbackEN) => this.T(fallbackEN, typeof(AssistantBase<TSettings>).Namespace, nameof(AssistantBase<TSettings>));
|
private string TB(string fallbackEN) => this.T(fallbackEN, typeof(AssistantBase<>).Namespace, nameof(AssistantBase<>));
|
||||||
|
|
||||||
private string SubmitButtonStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.ProviderSettings.UsedLLMProvider.GetConfidence(this.SettingsManager).StyleBorder(this.SettingsManager) : string.Empty;
|
private string SubmitButtonStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.ProviderSettings.UsedLLMProvider.GetConfidence(this.SettingsManager).StyleBorder(this.SettingsManager) : string.Empty;
|
||||||
|
|
||||||
@@ -470,7 +470,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
|||||||
Array.Resize(ref this.InputIssues, this.InputIssues.Length + 1);
|
Array.Resize(ref this.InputIssues, this.InputIssues.Length + 1);
|
||||||
this.InputIssues[^1] = issue;
|
this.InputIssues[^1] = issue;
|
||||||
this.InputIsValid = false;
|
this.InputIsValid = false;
|
||||||
this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase<TSettings>)}: rendering an added input issue");
|
this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase<>)}: rendering an added input issue");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -480,7 +480,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
|||||||
{
|
{
|
||||||
this.InputIssues = [];
|
this.InputIssues = [];
|
||||||
this.InputIsValid = true;
|
this.InputIsValid = true;
|
||||||
this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase<TSettings>)}: rendering cleared input issues");
|
this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase<>)}: rendering cleared input issues");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void CreateChatThread()
|
protected void CreateChatThread()
|
||||||
@@ -1009,7 +1009,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
|||||||
{
|
{
|
||||||
await this.ConsumeMediaOutcomeAsync();
|
await this.ConsumeMediaOutcomeAsync();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
}).Observe($"{nameof(AssistantBase<TSettings>)}: consuming a media import outcome");
|
}).Observe($"{nameof(AssistantBase<>)}: consuming a media import outcome");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Consumes a terminal media notification when this assistant is visible.</summary>
|
/// <summary>Consumes a terminal media notification when this assistant is visible.</summary>
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ internal sealed class LauncherTextsResponse
|
|||||||
{
|
{
|
||||||
public const string SCHEMA_VERSION_VALUE = "assistant_builder_launcher_texts_v1";
|
public const string SCHEMA_VERSION_VALUE = "assistant_builder_launcher_texts_v1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How the answer of the model is read.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An answer which names a field twice leaves open which of the values the model meant, so it is
|
||||||
|
/// refused like any other invalid JSON.
|
||||||
|
/// </remarks>
|
||||||
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
|
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
AllowTrailingCommas = false,
|
AllowTrailingCommas = false,
|
||||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||||
MaxDepth = 8,
|
MaxDepth = 8,
|
||||||
|
AllowDuplicateProperties = false,
|
||||||
TypeInfoResolver = LauncherTextsResponseJsonContext.Default,
|
TypeInfoResolver = LauncherTextsResponseJsonContext.Default,
|
||||||
}.AsReadOnly();
|
}.AsReadOnly();
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,20 @@ namespace AIStudio.Assistants.Builder;
|
|||||||
|
|
||||||
internal sealed partial class LuaResponse
|
internal sealed partial class LuaResponse
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// How the answer of the model is read.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An answer which names a field twice leaves open which of the values the model meant, the Lua code
|
||||||
|
/// included, so it is refused like any other invalid JSON.
|
||||||
|
/// </remarks>
|
||||||
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
|
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
AllowTrailingCommas = false,
|
AllowTrailingCommas = false,
|
||||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||||
MaxDepth = 32,
|
MaxDepth = 32,
|
||||||
|
AllowDuplicateProperties = false,
|
||||||
TypeInfoResolver = LuaResponseJsonContext.Default,
|
TypeInfoResolver = LuaResponseJsonContext.Default,
|
||||||
}.AsReadOnly();
|
}.AsReadOnly();
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ internal static class VisualBriefingJson
|
|||||||
/// here even though the persisted manifest writes their member names. Unlike other options of the app,
|
/// here even though the persisted manifest writes their member names. Unlike other options of the app,
|
||||||
/// these are not made read-only right here, because MWAIS0010 wants nothing but the object creation;
|
/// these are not made read-only right here, because MWAIS0010 wants nothing but the object creation;
|
||||||
/// System.Text.Json locks them with their first use anyway.
|
/// System.Text.Json locks them with their first use anyway.
|
||||||
|
///
|
||||||
|
/// The answers of the model are read with these options, too, which therefore refuse a field named
|
||||||
|
/// twice. That rule only concerns reading and changes no byte of the output.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[CanonicalJsonConfiguration]
|
[CanonicalJsonConfiguration]
|
||||||
internal static JsonSerializerOptions Canonical { get; } = new()
|
internal static JsonSerializerOptions Canonical { get; } = new()
|
||||||
@@ -40,6 +43,7 @@ internal static class VisualBriefingJson
|
|||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
Encoder = JavaScriptEncoder.Default,
|
Encoder = JavaScriptEncoder.Default,
|
||||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||||
|
AllowDuplicateProperties = false,
|
||||||
TypeInfoResolver = VisualBriefingCanonicalJsonContext.Default,
|
TypeInfoResolver = VisualBriefingCanonicalJsonContext.Default,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+3
@@ -40,4 +40,7 @@ public enum VisualBriefingStructuredResponseIssueKind
|
|||||||
|
|
||||||
/// <summary>The parsed response violated a semantic stage contract.</summary>
|
/// <summary>The parsed response violated a semantic stage contract.</summary>
|
||||||
SEMANTIC_CONTRACT_INVALID,
|
SEMANTIC_CONTRACT_INVALID,
|
||||||
|
|
||||||
|
/// <summary>An object of the response named the same field more than once.</summary>
|
||||||
|
DUPLICATE_FIELD,
|
||||||
}
|
}
|
||||||
+69
-1
@@ -20,7 +20,7 @@ internal static partial class VisualBriefingStructuredResponseProcessor
|
|||||||
.DisableHtml()
|
.DisableHtml()
|
||||||
.Build();
|
.Build();
|
||||||
private static readonly NullabilityInfoContext NULLABILITY = new();
|
private static readonly NullabilityInfoContext NULLABILITY = new();
|
||||||
private static readonly object CONTRACT_LOCK = new();
|
private static readonly Lock CONTRACT_LOCK = new();
|
||||||
private static readonly Dictionary<Type, ContractShape> CONTRACTS = [];
|
private static readonly Dictionary<Type, ContractShape> CONTRACTS = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -159,6 +159,18 @@ internal static partial class VisualBriefingStructuredResponseProcessor
|
|||||||
"The structured response root must be a JSON object.",
|
"The structured response root must be a JSON object.",
|
||||||
expected: "JSON object");
|
expected: "JSON object");
|
||||||
|
|
||||||
|
var duplicateIssue = FindDuplicateField(document.RootElement, "$");
|
||||||
|
if (duplicateIssue is not null)
|
||||||
|
return Rejected<T>(
|
||||||
|
candidate,
|
||||||
|
duplicateIssue.Kind,
|
||||||
|
VisualBriefingFailureCode.RESPONSE_JSON_INVALID,
|
||||||
|
VisualBriefingValidationRule.JSON_INVALID,
|
||||||
|
ContractIssueMessage(duplicateIssue),
|
||||||
|
duplicateIssue.Path,
|
||||||
|
fieldName: duplicateIssue.FieldName,
|
||||||
|
expected: duplicateIssue.Expected);
|
||||||
|
|
||||||
var contractIssue = Inspect(
|
var contractIssue = Inspect(
|
||||||
document.RootElement,
|
document.RootElement,
|
||||||
GetContract(typeof(T)),
|
GetContract(typeof(T)),
|
||||||
@@ -377,6 +389,58 @@ internal static partial class VisualBriefingStructuredResponseProcessor
|
|||||||
private static long? ToResponseLine(ResponseCandidate candidate, long? candidateLine) =>
|
private static long? ToResponseLine(ResponseCandidate candidate, long? candidateLine) =>
|
||||||
candidateLine is null ? null : candidate.StartLine + candidateLine;
|
candidateLine is null ? null : candidate.StartLine + candidateLine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the first field which an object of the response names a second time.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// JSON allows a name twice, and readers disagree on which of the values counts: the checks below
|
||||||
|
/// see one of them, while the browser which runs the finished briefing may see the other. The
|
||||||
|
/// response is refused instead, and the repair names the field. This looks into every value, also
|
||||||
|
/// into those the contract leaves open, such as the data of a chart.
|
||||||
|
/// </remarks>
|
||||||
|
private static ContractInspectionIssue? FindDuplicateField(JsonElement element, string path)
|
||||||
|
{
|
||||||
|
switch (element.ValueKind)
|
||||||
|
{
|
||||||
|
case JsonValueKind.Object:
|
||||||
|
{
|
||||||
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
var safeField = SafeIdentifier(property.Name);
|
||||||
|
var fieldPath = string.IsNullOrEmpty(safeField) ? path : $"{path}.{safeField}";
|
||||||
|
if (!names.Add(property.Name))
|
||||||
|
return new(
|
||||||
|
VisualBriefingStructuredResponseIssueKind.DUPLICATE_FIELD,
|
||||||
|
fieldPath,
|
||||||
|
safeField,
|
||||||
|
"every field name only once per object");
|
||||||
|
|
||||||
|
var issue = FindDuplicateField(property.Value, fieldPath);
|
||||||
|
if (issue is not null)
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
case JsonValueKind.Array:
|
||||||
|
{
|
||||||
|
var index = 0;
|
||||||
|
foreach (var item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
var issue = FindDuplicateField(item, $"{path}[{index}]");
|
||||||
|
if (issue is not null)
|
||||||
|
return issue;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static ContractInspectionIssue? Inspect(
|
private static ContractInspectionIssue? Inspect(
|
||||||
JsonElement element,
|
JsonElement element,
|
||||||
ContractShape shape,
|
ContractShape shape,
|
||||||
@@ -496,6 +560,10 @@ internal static partial class VisualBriefingStructuredResponseProcessor
|
|||||||
$"The model response contains the unknown field '{issue.FieldName}' at {issue.Path}.",
|
$"The model response contains the unknown field '{issue.FieldName}' at {issue.Path}.",
|
||||||
VisualBriefingStructuredResponseIssueKind.UNKNOWN_FIELD =>
|
VisualBriefingStructuredResponseIssueKind.UNKNOWN_FIELD =>
|
||||||
$"The model response contains an unknown field at {issue.Path}.",
|
$"The model response contains an unknown field at {issue.Path}.",
|
||||||
|
VisualBriefingStructuredResponseIssueKind.DUPLICATE_FIELD when !string.IsNullOrEmpty(issue.FieldName) =>
|
||||||
|
$"The model response contains the field '{issue.FieldName}' more than once at {issue.Path}.",
|
||||||
|
VisualBriefingStructuredResponseIssueKind.DUPLICATE_FIELD =>
|
||||||
|
$"The model response contains a field more than once at {issue.Path}.",
|
||||||
VisualBriefingStructuredResponseIssueKind.REQUIRED_FIELD_MISSING =>
|
VisualBriefingStructuredResponseIssueKind.REQUIRED_FIELD_MISSING =>
|
||||||
$"The required field '{issue.FieldName}' is missing at {issue.Path}.",
|
$"The required field '{issue.FieldName}' is missing at {issue.Path}.",
|
||||||
VisualBriefingStructuredResponseIssueKind.ENUM_VALUE_INVALID =>
|
VisualBriefingStructuredResponseIssueKind.ENUM_VALUE_INVALID =>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase, IAssistantCat
|
|||||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||||
{
|
{
|
||||||
if (this.OwnedByThisBlock(owner))
|
if (this.OwnedByThisBlock(owner))
|
||||||
this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(AssistantBlock<TSettings>)}: rendering a media import change");
|
this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(AssistantBlock<>)}: rendering a media import change");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void DisposeResources()
|
protected override void DisposeResources()
|
||||||
|
|||||||
@@ -1011,9 +1011,7 @@ public partial class ChatComponent : MSGComponentBase
|
|||||||
var automaticOptions = this.GetAutomaticDataSourceOptions();
|
var automaticOptions = this.GetAutomaticDataSourceOptions();
|
||||||
this.lastAppliedAutomaticDataSourceOptions = automaticOptions.CreateCopy();
|
this.lastAppliedAutomaticDataSourceOptions = automaticOptions.CreateCopy();
|
||||||
this.earlyDataSourceOptions = automaticOptions;
|
this.earlyDataSourceOptions = automaticOptions;
|
||||||
if(this.ChatThread is not null)
|
this.ChatThread?.DataSourceOptions = automaticOptions;
|
||||||
this.ChatThread.DataSourceOptions = automaticOptions;
|
|
||||||
|
|
||||||
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(automaticOptions);
|
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(automaticOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
|
|||||||
this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName);
|
this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName);
|
||||||
|
|
||||||
// Order descending by ID means that the newest models probably come first:
|
// Order descending by ID means that the newest models probably come first:
|
||||||
var orderedModels = result.Models.OrderByDescending(n => n.Id);
|
var orderedModels = result.Models.OrderByDescending(n => n.Id, Model.ID_ORDER);
|
||||||
|
|
||||||
this.availableModels.Clear();
|
this.availableModels.Clear();
|
||||||
this.availableModels.AddRange(orderedModels);
|
this.availableModels.AddRange(orderedModels);
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
|
|||||||
this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName);
|
this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName);
|
||||||
|
|
||||||
// Order descending by ID means that the newest models probably come first:
|
// Order descending by ID means that the newest models probably come first:
|
||||||
var orderedModels = result.Models.OrderByDescending(n => n.Id);
|
var orderedModels = result.Models.OrderByDescending(n => n.Id, Model.ID_ORDER);
|
||||||
|
|
||||||
this.availableModels.Clear();
|
this.availableModels.Clear();
|
||||||
this.availableModels.AddRange(orderedModels);
|
this.availableModels.AddRange(orderedModels);
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId
|
|||||||
this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName);
|
this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName);
|
||||||
|
|
||||||
// Order descending by ID means that the newest models probably come first:
|
// Order descending by ID means that the newest models probably come first:
|
||||||
var orderedModels = result.Models.OrderByDescending(n => n.Id);
|
var orderedModels = result.Models.OrderByDescending(n => n.Id, Model.ID_ORDER);
|
||||||
|
|
||||||
this.availableModels.Clear();
|
this.availableModels.Clear();
|
||||||
this.availableModels.AddRange(orderedModels);
|
this.availableModels.AddRange(orderedModels);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
|||||||
|
|
||||||
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
|
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
|
||||||
|
|
||||||
private FileExportNotifications FileExportNotifications => this.fileExportNotifications ??= new(this.Snackbar);
|
private FileExportNotifications FileExportNotifications => field ??= new(this.Snackbar);
|
||||||
|
|
||||||
private string PaddingLeft => this.navBarOpen ? $"padding-left: {NAVBAR_EXPANDED_WIDTH_INT - NAVBAR_COLLAPSED_WIDTH_INT}em;" : "padding-left: 0em;";
|
private string PaddingLeft => this.navBarOpen ? $"padding-left: {NAVBAR_EXPANDED_WIDTH_INT - NAVBAR_COLLAPSED_WIDTH_INT}em;" : "padding-left: 0em;";
|
||||||
|
|
||||||
@@ -85,7 +85,6 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
|||||||
private bool useDarkMode;
|
private bool useDarkMode;
|
||||||
private bool startupCompleted;
|
private bool startupCompleted;
|
||||||
private bool settingsWriteProtectionWarningShown;
|
private bool settingsWriteProtectionWarningShown;
|
||||||
private FileExportNotifications? fileExportNotifications;
|
|
||||||
private readonly SemaphoreSlim mandatoryInfoDialogSemaphore = new(1, 1);
|
private readonly SemaphoreSlim mandatoryInfoDialogSemaphore = new(1, 1);
|
||||||
private readonly SemaphoreSlim promptInjectionDialogSemaphore = new(1, 1);
|
private readonly SemaphoreSlim promptInjectionDialogSemaphore = new(1, 1);
|
||||||
private readonly SemaphoreSlim dataRestorePendingDialogSemaphore = new(1, 1);
|
private readonly SemaphoreSlim dataRestorePendingDialogSemaphore = new(1, 1);
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ namespace AIStudio.Models;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public abstract class ModelFamily
|
public abstract class ModelFamily
|
||||||
{
|
{
|
||||||
private IReadOnlyList<ModelRule>? declaredRules;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Who builds the models of this family.
|
/// Who builds the models of this family.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -48,7 +46,7 @@ public abstract class ModelFamily
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The rules this family states, worked out once.
|
/// The rules this family states, worked out once.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<ModelRule> Rules => this.declaredRules ??= this.BuildRules();
|
public IReadOnlyList<ModelRule> Rules => field ??= this.BuildRules();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adjusts a profile in a way no pattern can express.
|
/// Adjusts a profile in a way no pattern can express.
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ namespace AIStudio.Provider;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class AdditionalApiParametersParser
|
public static class AdditionalApiParametersParser
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Every name only once per object.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The dialog for providers refuses a name written twice, but a configuration plugin brings its
|
||||||
|
/// providers past the dialog. With both values read, it would be open which of them reaches the
|
||||||
|
/// provider, and the dictionary below would refuse the second one with an exception.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly JsonDocumentOptions DOCUMENT_OPTIONS = new()
|
||||||
|
{
|
||||||
|
AllowDuplicateProperties = false,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Try to parse an additional-API-parameters JSON fragment into a dictionary.
|
/// Try to parse an additional-API-parameters JSON fragment into a dictionary.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -30,7 +43,7 @@ public static class AdditionalApiParametersParser
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// The UI stores only the object body, so wrap it before parsing.
|
// The UI stores only the object body, so wrap it before parsing.
|
||||||
using var jsonDoc = JsonDocument.Parse($"{{{additionalJsonApiParameters}}}");
|
using var jsonDoc = JsonDocument.Parse($"{{{additionalJsonApiParameters}}}", DOCUMENT_OPTIONS);
|
||||||
parameters = ConvertToDictionary(jsonDoc.RootElement);
|
parameters = ConvertToDictionary(jsonDoc.RootElement);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C
|
|||||||
public override async Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
public override async Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
|
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
|
||||||
return result with { Models = [..result.Models.Where(model => model.IsChatModel(this.Provider)).OrderBy(x => x.Id)] };
|
return result with { Models = [..result.Models.Where(model => model.IsChatModel(this.Provider)).OrderBy(x => x.Id, Model.ID_ORDER)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -90,7 +90,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C
|
|||||||
public override async Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
public override async Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token);
|
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token);
|
||||||
return result with { Models = [..result.Models.Where(model => model.IsEmbeddingModel(this.Provider)).OrderBy(x => x.Id)] };
|
return result with { Models = [..result.Models.Where(model => model.IsEmbeddingModel(this.Provider)).OrderBy(x => x.Id, Model.ID_ORDER)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Overrides of BaseProvider
|
#region Overrides of BaseProvider
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
|
|||||||
// has not always been only something to talk to. The six above skip that question
|
// has not always been only something to talk to. The six above skip that question
|
||||||
// because they are not a catalog: every one of them was picked by hand.
|
// because they are not a catalog: every one of them was picked by hand.
|
||||||
//
|
//
|
||||||
Models = [..result.Models.Where(model => model.IsChatModel(this.Provider)).Concat(additionalModels).DistinctBy(x => x.Id).OrderBy(x => x.Id)]
|
Models = [..result.Models.Where(model => model.IsChatModel(this.Provider)).Concat(additionalModels).DistinctBy(x => x.Id).OrderBy(x => x.Id, Model.ID_ORDER)]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
using AIStudio.Tools.PluginSystem;
|
using AIStudio.Tools.PluginSystem;
|
||||||
|
|
||||||
namespace AIStudio.Provider;
|
namespace AIStudio.Provider;
|
||||||
@@ -20,6 +22,16 @@ public readonly record struct Model(string Id, string? DisplayName)
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly Model SYSTEM_MODEL = new(SYSTEM_MODEL_ID, null);
|
public static readonly Model SYSTEM_MODEL = new(SYSTEM_MODEL_ID, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orders model IDs by the numbers in them, as people read them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared digit by digit, "gpt-4.10" comes before "gpt-4.9", "o10" before "o3", and a list with the
|
||||||
|
/// newest models first puts an older one on top. The order does not depend on the language of the
|
||||||
|
/// operating system either.
|
||||||
|
/// </remarks>
|
||||||
|
public static readonly StringComparer ID_ORDER = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if this model is the system-configured placeholder.
|
/// Checks if this model is the system-configured placeholder.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ public static class ChatArchiveImporter
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var archive = ZipFile.OpenRead(archivePath);
|
await using var archive = await ZipFile.OpenReadAsync(archivePath, token);
|
||||||
var manifestResult = await DataBackupReader.ReadManifestAsync(archive, token);
|
var manifestResult = await DataBackupReader.ReadManifestAsync(archive, token);
|
||||||
if (manifestResult.Manifest is not { } manifest)
|
if (manifestResult.Manifest is not { } manifest)
|
||||||
return new(false, new(), manifestResult.Issue);
|
return new(false, new(), manifestResult.Issue);
|
||||||
@@ -114,7 +114,7 @@ public static class ChatArchiveImporter
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var manifest = preview.Manifest;
|
var manifest = preview.Manifest;
|
||||||
using var archive = ZipFile.OpenRead(archivePath);
|
await using var archive = await ZipFile.OpenReadAsync(archivePath, token);
|
||||||
var entriesByChat = GroupEntriesByChat(archive);
|
var entriesByChat = GroupEntriesByChat(archive);
|
||||||
var provenance = await ExtractProvenanceStoreAsync(archive, provenanceStore, provenanceDirectory, token);
|
var provenance = await ExtractProvenanceStoreAsync(archive, provenanceStore, provenanceDirectory, token);
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ public static class ChatArchiveImporter
|
|||||||
/// <param name="token">The cancellation token.</param>
|
/// <param name="token">The cancellation token.</param>
|
||||||
private static async Task ExtractEntryAsync(ZipArchiveEntry entry, string destinationPath, CancellationToken token)
|
private static async Task ExtractEntryAsync(ZipArchiveEntry entry, string destinationPath, CancellationToken token)
|
||||||
{
|
{
|
||||||
await using var entryStream = entry.Open();
|
await using var entryStream = await entry.OpenAsync(token);
|
||||||
await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
await entryStream.CopyToAsync(fileStream, token);
|
await entryStream.CopyToAsync(fileStream, token);
|
||||||
}
|
}
|
||||||
@@ -446,7 +446,7 @@ public static class ChatArchiveImporter
|
|||||||
|
|
||||||
private static async Task<string> ReadTextAsync(ZipArchiveEntry entry, CancellationToken token)
|
private static async Task<string> ReadTextAsync(ZipArchiveEntry entry, CancellationToken token)
|
||||||
{
|
{
|
||||||
using var reader = new StreamReader(entry.Open());
|
using var reader = new StreamReader(await entry.OpenAsync(token));
|
||||||
return (await reader.ReadToEndAsync(token)).Trim();
|
return (await reader.ReadToEndAsync(token)).Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,7 +550,7 @@ public static class ChatArchiveImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChatThread? thread;
|
ChatThread? thread;
|
||||||
await using (var threadStream = threadEntry.Entry.Open())
|
await using (var threadStream = await threadEntry.Entry.OpenAsync(token))
|
||||||
thread = await JsonSerializer.DeserializeAsync<ChatThread>(threadStream, WorkspaceBehaviour.JSON_OPTIONS, token);
|
thread = await JsonSerializer.DeserializeAsync<ChatThread>(threadStream, WorkspaceBehaviour.JSON_OPTIONS, token);
|
||||||
|
|
||||||
if (thread is null)
|
if (thread is null)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public static class DataBackupReader
|
|||||||
DataBackupManifest? manifest;
|
DataBackupManifest? manifest;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var manifestStream = manifestEntry.Open();
|
await using var manifestStream = await manifestEntry.OpenAsync(token);
|
||||||
manifest = await JsonSerializer.DeserializeAsync<DataBackupManifest>(manifestStream, DataBackupFormat.JSON_OPTIONS, token);
|
manifest = await JsonSerializer.DeserializeAsync<DataBackupManifest>(manifestStream, DataBackupFormat.JSON_OPTIONS, token);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public static class DataBackupWriter
|
|||||||
var buffer = new byte[BUFFER_SIZE];
|
var buffer = new byte[BUFFER_SIZE];
|
||||||
|
|
||||||
progress?.Report(new(DataBackupPhase.WRITING, 0, totalFiles));
|
progress?.Report(new(DataBackupPhase.WRITING, 0, totalFiles));
|
||||||
using (var archive = new ZipArchive(target, ZipArchiveMode.Create, leaveOpen: true))
|
await using (var archive = await ZipArchive.CreateAsync(target, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null, token))
|
||||||
{
|
{
|
||||||
var settingsEntry = DataBackupContents.ENTRIES.First(entry => entry.Kind is DataBackupEntryKind.SETTINGS);
|
var settingsEntry = DataBackupContents.ENTRIES.First(entry => entry.Kind is DataBackupEntryKind.SETTINGS);
|
||||||
await using (var settingsStream = new MemoryStream(Encoding.UTF8.GetBytes(sources.SettingsJson), writable: false))
|
await using (var settingsStream = new MemoryStream(Encoding.UTF8.GetBytes(sources.SettingsJson), writable: false))
|
||||||
@@ -179,7 +179,7 @@ public static class DataBackupWriter
|
|||||||
|
|
||||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||||
long size = 0;
|
long size = 0;
|
||||||
await using (var entryStream = entry.Open())
|
await using (var entryStream = await entry.OpenAsync(token))
|
||||||
{
|
{
|
||||||
int read;
|
int read;
|
||||||
while ((read = await source.ReadAsync(buffer, token)) > 0)
|
while ((read = await source.ReadAsync(buffer, token)) > 0)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public static class DataRestorePreparer
|
|||||||
Discard(target);
|
Discard(target);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var archive = ZipFile.OpenRead(archivePath);
|
await using var archive = await ZipFile.OpenReadAsync(archivePath, token);
|
||||||
var manifestResult = await DataBackupReader.ReadManifestAsync(archive, token);
|
var manifestResult = await DataBackupReader.ReadManifestAsync(archive, token);
|
||||||
if (manifestResult.Manifest is not { } manifest)
|
if (manifestResult.Manifest is not { } manifest)
|
||||||
return new(false, false, manifestResult.Issue);
|
return new(false, false, manifestResult.Issue);
|
||||||
@@ -218,7 +218,7 @@ public static class DataRestorePreparer
|
|||||||
|
|
||||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||||
long size = 0;
|
long size = 0;
|
||||||
await using (var source = file.Entry.Open())
|
await using (var source = await file.Entry.OpenAsync(token))
|
||||||
await using (var destination = new FileStream(file.DestinationPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
await using (var destination = new FileStream(file.DestinationPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||||
{
|
{
|
||||||
int read;
|
int read;
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ namespace AIStudio.Tools.ERIClient;
|
|||||||
public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable
|
public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable
|
||||||
{
|
{
|
||||||
protected readonly IERIDataSource DataSource = dataSource;
|
protected readonly IERIDataSource DataSource = dataSource;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How AI Studio writes its requests to an ERI server and reads the answers.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An answer which names a field twice is refused, also when the two names differ only in case:
|
||||||
|
/// AI Studio would follow one of the values, such as the provider types a data source allows, while
|
||||||
|
/// the server may have meant the other.
|
||||||
|
/// </remarks>
|
||||||
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
|
internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
@@ -17,6 +25,7 @@ public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable
|
|||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||||
|
AllowDuplicateProperties = false,
|
||||||
Converters =
|
Converters =
|
||||||
{
|
{
|
||||||
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper),
|
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper),
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome
|
|||||||
private static readonly int[] NO_FAILED_PAGES = [];
|
private static readonly int[] NO_FAILED_PAGES = [];
|
||||||
private static readonly PromptInjectionFinding[] NO_FINDINGS = [];
|
private static readonly PromptInjectionFinding[] NO_FINDINGS = [];
|
||||||
|
|
||||||
private readonly IReadOnlyList<PromptInjectionFinding>? promptInjectionFindings;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The prompt-injection attempts the runtime filtered out of the content, if any.
|
/// The prompt-injection attempts the runtime filtered out of the content, if any.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -33,8 +31,8 @@ public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public IReadOnlyList<PromptInjectionFinding> PromptInjectionFindings
|
public IReadOnlyList<PromptInjectionFinding> PromptInjectionFindings
|
||||||
{
|
{
|
||||||
get => this.promptInjectionFindings ?? NO_FINDINGS;
|
get => field ?? NO_FINDINGS;
|
||||||
init => this.promptInjectionFindings = value;
|
init;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -196,11 +196,7 @@ public static partial class Pandoc
|
|||||||
if (uri.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
if (uri.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await RunWithRetriesAsync(
|
await RunWithRetriesAsync(
|
||||||
() =>
|
() => ZipFile.ExtractToDirectoryAsync(pandocTempDownloadFile, stagingDir, true),
|
||||||
{
|
|
||||||
ZipFile.ExtractToDirectory(pandocTempDownloadFile, stagingDir, true);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
},
|
|
||||||
"extracting the Pandoc ZIP archive");
|
"extracting the Pandoc ZIP archive");
|
||||||
}
|
}
|
||||||
else if (uri.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
|
else if (uri.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ public static class PluginArchive
|
|||||||
// Compatibility shim for Windows-created ZIPs with backslashes in entry names (dotnet/runtime#27620);
|
// Compatibility shim for Windows-created ZIPs with backslashes in entry names (dotnet/runtime#27620);
|
||||||
// remove after dotnet/runtime#27620 and #41914 are fixed.
|
// remove after dotnet/runtime#27620 and #41914 are fixed.
|
||||||
// See documentation/compatibility-shims/2026-07-plugin-archive-zip-backslashes.md.
|
// See documentation/compatibility-shims/2026-07-plugin-archive-zip-backslashes.md.
|
||||||
public static void Extract(string sourceArchiveFileName, string destinationDirectory)
|
public static async Task ExtractAsync(string sourceArchiveFileName, string destinationDirectory, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
using var archive = ZipFile.OpenRead(sourceArchiveFileName);
|
await using var archive = await ZipFile.OpenReadAsync(sourceArchiveFileName, token);
|
||||||
Directory.CreateDirectory(destinationDirectory);
|
Directory.CreateDirectory(destinationDirectory);
|
||||||
|
|
||||||
foreach (var entry in archive.Entries)
|
foreach (var entry in archive.Entries)
|
||||||
@@ -37,7 +37,7 @@ public static class PluginArchive
|
|||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||||
entry.ExtractToFile(destinationPath);
|
await entry.ExtractToFileAsync(destinationPath, token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ public static partial class PluginFactory
|
|||||||
await response.Content.CopyToAsync(tempFileStream, cancellationToken);
|
await response.Content.CopyToAsync(tempFileStream, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
ExtractConfigPluginArchive(tempDownloadFile, stagedDirectory);
|
await ExtractConfigPluginArchiveAsync(tempDownloadFile, stagedDirectory, cancellationToken);
|
||||||
|
|
||||||
var configDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
var configDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||||
if (Directory.Exists(configDirectory))
|
if (Directory.Exists(configDirectory))
|
||||||
@@ -129,9 +129,9 @@ public static partial class PluginFactory
|
|||||||
return wasSuccessful;
|
return wasSuccessful;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ExtractConfigPluginArchive(string sourceArchiveFileName, string destinationDirectory)
|
private static async Task ExtractConfigPluginArchiveAsync(string sourceArchiveFileName, string destinationDirectory, CancellationToken token)
|
||||||
{
|
{
|
||||||
PluginArchive.Extract(sourceArchiveFileName, destinationDirectory);
|
await PluginArchive.ExtractAsync(sourceArchiveFileName, destinationDirectory, token);
|
||||||
|
|
||||||
if (!Directory.EnumerateFiles(destinationDirectory, "plugin.lua", SearchOption.AllDirectories).Any())
|
if (!Directory.EnumerateFiles(destinationDirectory, "plugin.lua", SearchOption.AllDirectories).Any())
|
||||||
throw new InvalidDataException("The enterprise configuration plugin archive does not contain a plugin.lua file.");
|
throw new InvalidDataException("The enterprise configuration plugin archive does not contain a plugin.lua file.");
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly SemaphoreSlim registrationSemaphore = new(1, 1);
|
private readonly SemaphoreSlim registrationSemaphore = new(1, 1);
|
||||||
private readonly object runtimeStateLock = new();
|
private readonly Lock runtimeStateLock = new();
|
||||||
private readonly Dictionary<Shortcut, ShortcutState> lastSentStates = [];
|
private readonly Dictionary<Shortcut, ShortcutState> lastSentStates = [];
|
||||||
private readonly Dictionary<Shortcut, string> lastNonEmptyShortcuts = [];
|
private readonly Dictionary<Shortcut, string> lastNonEmptyShortcuts = [];
|
||||||
private readonly Dictionary<Shortcut, ShortcutRuntimeBinding> runtimeBindings = [];
|
private readonly Dictionary<Shortcut, ShortcutRuntimeBinding> runtimeBindings = [];
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ internal sealed partial class FileSourceIndexer
|
|||||||
|
|
||||||
private readonly ConcurrentDictionary<string, DataSourceWatcherRegistration> watchers = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, DataSourceWatcherRegistration> watchers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, CancellationTokenSource> watcherDebounceTokens = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, CancellationTokenSource> watcherDebounceTokens = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly object watcherDebounceLock = new();
|
private readonly Lock watcherDebounceLock = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The data sources handed in last, by their id. A watcher which failed is created anew from here.
|
/// The data sources handed in last, by their id. A watcher which failed is created anew from here.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public sealed partial class PluginInstallService
|
|||||||
if (!this.TryCreateStagingDirectory(PLUGIN_IMPORT_DIRECTORY_PREFIX, out stagingDirectory, out var stagingIssue))
|
if (!this.TryCreateStagingDirectory(PLUGIN_IMPORT_DIRECTORY_PREFIX, out stagingDirectory, out var stagingIssue))
|
||||||
return Error(stagingIssue);
|
return Error(stagingIssue);
|
||||||
|
|
||||||
PluginArchive.Extract(archivePath, stagingDirectory);
|
await PluginArchive.ExtractAsync(archivePath, stagingDirectory, token);
|
||||||
|
|
||||||
var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray();
|
var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray();
|
||||||
if (pluginFiles.Length != 1)
|
if (pluginFiles.Length != 1)
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
|
|||||||
// one next to it, which is what deleting and recreating would come down to.
|
// one next to it, which is what deleting and recreating would come down to.
|
||||||
//
|
//
|
||||||
await using var archiveStream = File.Create(archivePath);
|
await using var archiveStream = File.Create(archivePath);
|
||||||
ZipFile.CreateFromDirectory(pluginRoot, archiveStream, CompressionLevel.Optimal, false);
|
await ZipFile.CreateFromDirectoryAsync(pluginRoot, archiveStream, CompressionLevel.Optimal, false, token);
|
||||||
}, token);
|
}, token);
|
||||||
|
|
||||||
logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath);
|
logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath);
|
||||||
@@ -177,10 +177,10 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
|
|||||||
Directory.CreateDirectory(archiveDirectory);
|
Directory.CreateDirectory(archiveDirectory);
|
||||||
this.CleanUpExpiredArchives(archiveDirectory);
|
this.CleanUpExpiredArchives(archiveDirectory);
|
||||||
|
|
||||||
await Task.Run(() =>
|
await Task.Run(async () =>
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
ZipFile.CreateFromDirectory(pluginRoot, archivePath, CompressionLevel.Optimal, false);
|
await ZipFile.CreateFromDirectoryAsync(pluginRoot, archivePath, CompressionLevel.Optimal, false, token);
|
||||||
}, token);
|
}, token);
|
||||||
|
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
|||||||
{
|
{
|
||||||
private const string INVALID_TOOL_CALL_ERROR = "The tool call was invalid.";
|
private const string INVALID_TOOL_CALL_ERROR = "The tool call was invalid.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How the arguments are read for running a tool: every name only once per object.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// JSON allows a name twice, and readers disagree on which of the values counts. A tool reads one
|
||||||
|
/// of them, while the check for addresses the model wrote into its call reads both, and a provider
|
||||||
|
/// which receives the call back may read the other. A call which names an argument twice therefore
|
||||||
|
/// does not run, and the message of the parser, which names the argument but quotes none of the
|
||||||
|
/// values, goes back to the model. The trace reads the arguments without this rule, so that the
|
||||||
|
/// user still sees what the model sent.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly JsonDocumentOptions EXECUTION_ARGUMENT_OPTIONS = new()
|
||||||
|
{
|
||||||
|
AllowDuplicateProperties = false,
|
||||||
|
};
|
||||||
|
|
||||||
public ToolCallOutcome CreateInvalidToolCallResult(string toolCallId, int order)
|
public ToolCallOutcome CreateInvalidToolCallResult(string toolCallId, int order)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
@@ -116,7 +132,7 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
|||||||
var implementation = runnableTool.Implementation;
|
var implementation = runnableTool.Implementation;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
|
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson, EXECUTION_ARGUMENT_OPTIONS);
|
||||||
var settingsManager = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
var settingsManager = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
- Improved how reliably AI Studio exchanges data with AI providers, external data sources, and web search services.
|
- Improved how reliably AI Studio exchanges data with AI providers, external data sources, and web search services.
|
||||||
- Improved how reliably the Visual Briefing assistant stores your briefings, and how reliably the assistants of AI Studio read the answers of AI models.
|
- Improved how reliably the Visual Briefing assistant stores your briefings, and how reliably the assistants of AI Studio read the answers of AI models.
|
||||||
- Improved how AI Studio colors code blocks: it now highlights them itself instead of through an extra script, which makes the app smaller. This works for Python, JavaScript, TypeScript, Java, C, C++, C#, Go, Kotlin, Rust, SQL, shell scripts, PowerShell, Lua, CSS, HTML, XML, JSON, and YAML; code in other languages, such as Ruby or PHP, appears without colors. When a code block names no language, AI Studio recognizes it from the code, colors the block, and suggests the matching file extension when you save it.
|
- Improved how AI Studio colors code blocks: it now highlights them itself instead of through an extra script, which makes the app smaller. This works for Python, JavaScript, TypeScript, Java, C, C++, C#, Go, Kotlin, Rust, SQL, shell scripts, PowerShell, Lua, CSS, HTML, XML, JSON, and YAML; code in other languages, such as Ruby or PHP, appears without colors. When a code block names no language, AI Studio recognizes it from the code, colors the block, and suggests the matching file extension when you save it.
|
||||||
|
- Improved how carefully AI Studio reads the answers of AI models and external data sources: an answer that contains the same entry twice, possibly with two different values, is now refused instead of AI Studio picking one of them. This covers tool calls, the Assistant Builder, the Visual Briefing assistant, and ERI servers.
|
||||||
|
- Improved the order of the models when you add or edit a provider: AI Studio now sorts the numbers in model names by their value, so that, for example, a model with version 4.10 comes before one with version 4.9, and the newest models stay at the top of the list.
|
||||||
- Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka.
|
- Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka.
|
||||||
- Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again.
|
- Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again.
|
||||||
- Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration.
|
- Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration.
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
- Fixed saving a code block as a file, where the last line could end differently from all other lines, for example, on Windows. All lines of the file now end the same way.
|
- Fixed saving a code block as a file, where the last line could end differently from all other lines, for example, on Windows. All lines of the file now end the same way.
|
||||||
- Fixed the result of a tool in the chat showing umlauts, quotation marks, and other special characters as codes instead of the characters themselves.
|
- Fixed the result of a tool in the chat showing umlauts, quotation marks, and other special characters as codes instead of the characters themselves.
|
||||||
- Fixed the Assistant Builder telling you in English why it could not use the answer of the AI model, even when you chose another language for AI Studio.
|
- Fixed the Assistant Builder telling you in English why it could not use the answer of the AI model, even when you chose another language for AI Studio.
|
||||||
|
- Fixed requests to a provider failing when a configuration plugin gave it additional API parameters that contain the same parameter twice. AI Studio now sends the request without these parameters and names the repeated parameter in its log, as it does for other invalid parameters.
|
||||||
- Upgraded several libraries to improve security.
|
- Upgraded several libraries to improve security.
|
||||||
- Upgraded Tauri to v2.12.5.
|
- Upgraded Tauri to v2.12.5.
|
||||||
- Upgraded MudBlazor to v9.12.0.
|
- Upgraded MudBlazor to v9.12.0.
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ namespace AIStudio.Tests.Analyzers;
|
|||||||
/// refuses the keyword. There, every instance member is reached through the name of the receiver, and a sibling
|
/// refuses the keyword. There, every instance member is reached through the name of the receiver, and a sibling
|
||||||
/// member without it does not even compile. Should a later version of C# reach the receiver without its name,
|
/// member without it does not even compile. Should a later version of C# reach the receiver without its name,
|
||||||
/// the analyzer would demand a <c>this.</c> which nobody can write, and the tests for extension blocks fail.
|
/// the analyzer would demand a <c>this.</c> which nobody can write, and the tests for extension blocks fail.
|
||||||
|
///
|
||||||
|
/// The same holds for the keyword <c>field</c> of a property: its backing field has no name which could follow
|
||||||
|
/// <c>this.</c>, while every other member next to it still needs one.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public sealed class ThisUsageAnalyzerTests
|
public sealed class ThisUsageAnalyzerTests
|
||||||
@@ -46,6 +49,28 @@ public sealed class ThisUsageAnalyzerTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestCase("get => field ?? this.Name; set => field = value;", TestName = "A getter and a setter which reach the backing field")]
|
||||||
|
[TestCase("get; set => field = value.Trim();", TestName = "A setter which reaches the backing field")]
|
||||||
|
[TestCase("get => field ??= this.Describe();", TestName = "A getter which fills the backing field on first use")]
|
||||||
|
public async Task TheBackingFieldOfAPropertyIsLeftAlone(string accessors)
|
||||||
|
{
|
||||||
|
var reported = await AnalyzePropertyAsync(accessors);
|
||||||
|
|
||||||
|
Assert.That(reported, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task AMemberNextToTheBackingFieldStillNeedsThis()
|
||||||
|
{
|
||||||
|
var reported = await AnalyzePropertyAsync("get => field ?? Name; set => field = value;");
|
||||||
|
|
||||||
|
using (Assert.EnterMultipleScope())
|
||||||
|
{
|
||||||
|
Assert.That(reported, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(reported.FirstOrDefault()?.Id, Is.EqualTo("MWAIS0005"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task AClassicExtensionMethodIsLeftAlone()
|
public async Task AClassicExtensionMethodIsLeftAlone()
|
||||||
{
|
{
|
||||||
@@ -140,4 +165,23 @@ public sealed class ThisUsageAnalyzerTests
|
|||||||
|
|
||||||
return await CompilationHarness.AnalyzeAsync(source, new ThisUsageAnalyzer());
|
return await CompilationHarness.AnalyzeAsync(source, new ThisUsageAnalyzer());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<Diagnostic>> AnalyzePropertyAsync(string accessors)
|
||||||
|
{
|
||||||
|
var source =
|
||||||
|
$$"""
|
||||||
|
namespace Sample;
|
||||||
|
|
||||||
|
public sealed class Meeting
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "Kickoff";
|
||||||
|
|
||||||
|
public string Title { {{accessors}} }
|
||||||
|
|
||||||
|
private string Describe() => this.Name;
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
return await CompilationHarness.AnalyzeAsync(source, new ThisUsageAnalyzer());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using AIStudio.Assistants.VisualBriefing;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Assistants.VisualBriefing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks that the Visual Briefing assistant refuses a model answer which names a field twice.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The checks of the assistant would see one of the two values, while the browser which runs the
|
||||||
|
/// finished briefing may see the other. The repair turn names the field, so that the model can fix it.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class VisualBriefingDuplicateFieldTests
|
||||||
|
{
|
||||||
|
[TestCase("""{"contractVersion":1,"contractVersion":2}""", "contractVersion", "$.contractVersion", TestName = "A field of the answer")]
|
||||||
|
[TestCase("""{"contractVersion":1,"facts":[{"id":"fact-1","id":"fact-2"}]}""", "id", "$.facts[0].id", TestName = "A field of an object in a list")]
|
||||||
|
[TestCase("""{"facts":[{"value":{"unit":"EUR","unit":"USD"}}]}""", "unit", "$.facts[0].value.unit", TestName = "A field deep inside a value")]
|
||||||
|
[TestCase("```json\n{\"contractVersion\":1,\"contractVersion\":2}\n```", "contractVersion", "$.contractVersion", TestName = "A field of an answer in a code block")]
|
||||||
|
public void AnAnswerWhichNamesAFieldTwiceIsRefused(string answer, string repeatedField, string path)
|
||||||
|
{
|
||||||
|
var result = VisualBriefingStructuredResponseProcessor.Process<VisualBriefingEvidenceResponse>(answer, _ => null);
|
||||||
|
|
||||||
|
using (Assert.EnterMultipleScope())
|
||||||
|
{
|
||||||
|
Assert.That(result.Response, Is.Null);
|
||||||
|
Assert.That(result.Issue?.Code, Is.EqualTo(VisualBriefingFailureCode.RESPONSE_JSON_INVALID));
|
||||||
|
Assert.That(result.Issue?.Rule, Is.EqualTo(VisualBriefingValidationRule.JSON_INVALID));
|
||||||
|
Assert.That(result.Issue?.Diagnostic?.IssueKind, Is.EqualTo(VisualBriefingStructuredResponseIssueKind.DUPLICATE_FIELD));
|
||||||
|
Assert.That(result.Issue?.Diagnostic?.FieldName, Is.EqualTo(repeatedField));
|
||||||
|
Assert.That(result.Issue?.Diagnostic?.JsonPath, Is.EqualTo(path));
|
||||||
|
Assert.That(result.Issue?.Issue, Does.Contain($"'{repeatedField}'"), "The repair turn names the field.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TheSameFieldInTwoObjectsIsNoDuplicate()
|
||||||
|
{
|
||||||
|
var result = VisualBriefingStructuredResponseProcessor.Process<VisualBriefingEvidenceResponse>(
|
||||||
|
"""{"contractVersion":1,"facts":[{"id":"fact-1"},{"id":"fact-2"}]}""",
|
||||||
|
_ => null);
|
||||||
|
|
||||||
|
Assert.That(result.Issue?.Diagnostic?.IssueKind, Is.Not.EqualTo(VisualBriefingStructuredResponseIssueKind.DUPLICATE_FIELD));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using AIStudio.Provider;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Provider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks how the additional API parameters of a provider are read.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The dialog for providers refuses a parameter named twice, but a configuration plugin brings its
|
||||||
|
/// providers past the dialog. Read anyway, such parameters would leave it open which of the values
|
||||||
|
/// reaches the provider, and they used to break every request with an exception nobody caught.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class AdditionalApiParametersParserTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void TheParametersAreRead()
|
||||||
|
{
|
||||||
|
var isRead = AdditionalApiParametersParser.TryParse("""
|
||||||
|
"temperature": 0.5,
|
||||||
|
"reasoning": { "effort": "low" }
|
||||||
|
""", out var parameters, out var errorMessage);
|
||||||
|
|
||||||
|
using (Assert.EnterMultipleScope())
|
||||||
|
{
|
||||||
|
Assert.That(isRead, Is.True);
|
||||||
|
Assert.That(errorMessage, Is.Null);
|
||||||
|
Assert.That(parameters["temperature"], Is.EqualTo(0.5));
|
||||||
|
Assert.That(parameters["reasoning"], Is.EqualTo(new Dictionary<string, object> { ["effort"] = "low" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCase("\"temperature\": 0.5, \"temperature\": 0.7", "temperature", TestName = "A parameter named twice")]
|
||||||
|
[TestCase("\"reasoning\": { \"effort\": \"low\", \"effort\": \"high\" }", "effort", TestName = "A field named twice inside a parameter")]
|
||||||
|
public void AParameterNamedTwiceIsRefused(string additionalParameters, string repeatedName)
|
||||||
|
{
|
||||||
|
var isRead = AdditionalApiParametersParser.TryParse(additionalParameters, out var parameters, out var errorMessage);
|
||||||
|
|
||||||
|
using (Assert.EnterMultipleScope())
|
||||||
|
{
|
||||||
|
Assert.That(isRead, Is.False);
|
||||||
|
Assert.That(parameters, Is.Empty);
|
||||||
|
Assert.That(errorMessage, Does.Contain($"'{repeatedName}'"), "The log names the parameter to correct.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using AIStudio.Provider;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Provider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the order in which the dialogs for providers list the models, newest first.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared digit by digit, "gpt-4.9" would come before "gpt-4.10" and "o3" before "o10", so an older
|
||||||
|
/// model would be on top of the list.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class ModelIdOrderTests
|
||||||
|
{
|
||||||
|
[TestCase(new[] { "gpt-4.9", "gpt-4.10", "gpt-4.1" }, new[] { "gpt-4.10", "gpt-4.9", "gpt-4.1" }, TestName = "Version numbers after a dot")]
|
||||||
|
[TestCase(new[] { "o3", "o10", "o1" }, new[] { "o10", "o3", "o1" }, TestName = "Numbers after a letter")]
|
||||||
|
[TestCase(new[] { "llama3.1:8b", "llama3.1:70b" }, new[] { "llama3.1:70b", "llama3.1:8b" }, TestName = "Sizes of a model")]
|
||||||
|
[TestCase(new[] { "mistral-large-2407", "mistral-large-2411", "mistral-large-latest" }, new[] { "mistral-large-latest", "mistral-large-2411", "mistral-large-2407" }, TestName = "Dates of a model")]
|
||||||
|
public void TheNewestModelsComeFirst(string[] modelIds, string[] expectedOrder)
|
||||||
|
{
|
||||||
|
var ordered = modelIds.OrderByDescending(id => id, Model.ID_ORDER);
|
||||||
|
|
||||||
|
Assert.That(ordered, Is.EqualTo(expectedOrder));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using AIStudio.Assistants.Builder;
|
||||||
|
using AIStudio.Assistants.VisualBriefing;
|
||||||
|
using AIStudio.Tools.ERIClient;
|
||||||
|
using AIStudio.Tools.ERIClient.DataModel;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Serialization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks that JSON which a model or a server wrote may name a field only once.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// JSON allows a name twice, and readers disagree on which of the values counts. Whatever AI Studio
|
||||||
|
/// checks in such an answer, another reader may see the other value, so these options refuse it. The
|
||||||
|
/// stored data, such as the settings, stays out of this: AI Studio wrote it itself, and refusing it
|
||||||
|
/// would lose it.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class DuplicatePropertyTests
|
||||||
|
{
|
||||||
|
private static IEnumerable<TestCaseData> OptionsForAnswersFromOutside()
|
||||||
|
{
|
||||||
|
yield return new TestCaseData(LuaResponse.JSON_OPTIONS).SetName("The plugins the Assistant Builder writes");
|
||||||
|
yield return new TestCaseData(LauncherTextsResponse.JSON_OPTIONS).SetName("The texts the Assistant Builder writes for a chat launcher");
|
||||||
|
yield return new TestCaseData(VisualBriefingJson.Canonical).SetName("The answers of the model for a visual briefing");
|
||||||
|
yield return new TestCaseData(ERIClientBase.JSON_OPTIONS).SetName("The answers of ERI servers");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCaseSource(nameof(OptionsForAnswersFromOutside))]
|
||||||
|
public void TheOptionsRefuseAFieldNamedTwice(JsonSerializerOptions options) => Assert.That(options.AllowDuplicateProperties, Is.False);
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void APluginWhoseLuaCodeIsNamedTwiceIsRefused()
|
||||||
|
{
|
||||||
|
var isParsed = LuaResponse.TryParse("""{"full_lua":"ID = \"a\"","full_lua":"ID = \"b\""}""", out _, out var error, out var technicalDetails);
|
||||||
|
|
||||||
|
using (Assert.EnterMultipleScope())
|
||||||
|
{
|
||||||
|
Assert.That(isParsed, Is.False);
|
||||||
|
Assert.That(error, Is.EqualTo(LuaResponseParseError.INVALID_JSON));
|
||||||
|
Assert.That(technicalDetails, Does.Contain("'full_lua'"), "The user learns which field the model wrote twice.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TextsForAChatLauncherWithATitleNamedTwiceAreRefused()
|
||||||
|
{
|
||||||
|
var isParsed = LauncherTextsResponse.TryParse("""{"title":"Budget","title":"Travel"}""", out _, out var error, out var technicalDetails);
|
||||||
|
|
||||||
|
using (Assert.EnterMultipleScope())
|
||||||
|
{
|
||||||
|
Assert.That(isParsed, Is.False);
|
||||||
|
Assert.That(error, Is.EqualTo(LuaResponseParseError.INVALID_JSON));
|
||||||
|
Assert.That(technicalDetails, Does.Contain("'title'"), "The user learns which field the model wrote twice.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCase("""{"allowedProviderType":"SELF_HOSTED","allowedProviderType":"ANY"}""", TestName = "The same name twice")]
|
||||||
|
[TestCase("""{"allowedProviderType":"SELF_HOSTED","AllowedProviderType":"ANY"}""", TestName = "Two names which differ only in case")]
|
||||||
|
public void SecurityRequirementsOfAnERIServerWhichNameAFieldTwiceAreRefused(string answer)
|
||||||
|
{
|
||||||
|
var exception = Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<SecurityRequirements>(answer, ERIClientBase.JSON_OPTIONS));
|
||||||
|
|
||||||
|
Assert.That(exception.Message, Does.Contain("AllowedProviderType").IgnoreCase, "The message names the field the server wrote twice.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
using AIStudio.Tools.PluginSystem;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Tools.PluginSystem;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks how plugin archives are unpacked, from an import or from the configuration server of an organization.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// AI Studio unpacks these archives itself rather than through ZipFile.ExtractToDirectory, which on Linux and
|
||||||
|
/// macOS would keep a backslash in an entry name as part of the file name. Archives packed on Windows by hand
|
||||||
|
/// or with another tool hold such names, and the plugin.lua inside would never be found.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class PluginArchiveTests
|
||||||
|
{
|
||||||
|
private string testDirectory = string.Empty;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void CreateTestDirectory()
|
||||||
|
{
|
||||||
|
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-plugin-archive-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(this.testDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void DeleteTestDirectory()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(this.testDirectory))
|
||||||
|
Directory.Delete(this.testDirectory, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task AnArchivePackedOnWindowsIsUnpackedIntoItsFolders()
|
||||||
|
{
|
||||||
|
var archivePath = await this.CreateArchiveAsync(new ArchiveEntry("O\\plugin.lua", "ID = \"o\""), new ArchiveEntry("O\\lib\\"), new ArchiveEntry("O\\lib\\helpers.lua", "return {}"));
|
||||||
|
var destination = Path.Combine(this.testDirectory, "unpacked");
|
||||||
|
|
||||||
|
await PluginArchive.ExtractAsync(archivePath, destination);
|
||||||
|
|
||||||
|
using (Assert.EnterMultipleScope())
|
||||||
|
{
|
||||||
|
Assert.That(await File.ReadAllTextAsync(Path.Combine(destination, "O", "plugin.lua")), Is.EqualTo("ID = \"o\""));
|
||||||
|
Assert.That(await File.ReadAllTextAsync(Path.Combine(destination, "O", "lib", "helpers.lua")), Is.EqualTo("return {}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCase("../plugin.lua", TestName = "An entry above the destination")]
|
||||||
|
[TestCase("/plugin.lua", TestName = "An entry with an absolute path")]
|
||||||
|
public async Task AnEntryOutsideOfTheDestinationIsRefused(string entryName)
|
||||||
|
{
|
||||||
|
var archivePath = await this.CreateArchiveAsync(new ArchiveEntry(entryName, "ID = \"o\""));
|
||||||
|
var destination = Path.Combine(this.testDirectory, "unpacked");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => PluginArchive.ExtractAsync(archivePath, destination));
|
||||||
|
Assert.That(File.Exists(Path.Combine(this.testDirectory, "plugin.lua")), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ADirectoryEntryWithDataIsRefused()
|
||||||
|
{
|
||||||
|
var archivePath = await this.CreateArchiveAsync(new ArchiveEntry("O/", "ID = \"o\""));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => PluginArchive.ExtractAsync(archivePath, Path.Combine(this.testDirectory, "unpacked")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task AnArchiveIsNotUnpackedAfterTheCancellation()
|
||||||
|
{
|
||||||
|
var archivePath = await this.CreateArchiveAsync(new ArchiveEntry("O/plugin.lua", "ID = \"o\""));
|
||||||
|
var destination = Path.Combine(this.testDirectory, "unpacked");
|
||||||
|
|
||||||
|
await Assert.CatchAsync<OperationCanceledException>(() => PluginArchive.ExtractAsync(archivePath, destination, new CancellationToken(canceled: true)));
|
||||||
|
Assert.That(File.Exists(Path.Combine(destination, "O", "plugin.lua")), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateArchiveAsync(params ArchiveEntry[] entries)
|
||||||
|
{
|
||||||
|
var archivePath = Path.Combine(this.testDirectory, $"{Guid.NewGuid():N}.mwplugin");
|
||||||
|
await using var archiveStream = File.Create(archivePath);
|
||||||
|
await using var archive = await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Create, leaveOpen: false, entryNameEncoding: null);
|
||||||
|
foreach (var archiveEntry in entries)
|
||||||
|
{
|
||||||
|
var entry = archive.CreateEntry(archiveEntry.Name);
|
||||||
|
if (archiveEntry.Content is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await using var entryStream = await entry.OpenAsync();
|
||||||
|
await using var writer = new StreamWriter(entryStream);
|
||||||
|
await writer.WriteAsync(archiveEntry.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return archivePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An entry of a test archive: a file with its content, or a directory without one.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record ArchiveEntry(string Name, string? Content = null);
|
||||||
|
}
|
||||||
@@ -95,6 +95,35 @@ public sealed class ToolExecutorTests : ToolRegistryTestBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestCase("""{"query":"budget","query":"https://attacker.example/?mail=board-meeting"}""", "query", TestName = "An argument named twice")]
|
||||||
|
[TestCase("""{"filter":{"sender":"budget","sender":"https://attacker.example/?mail=board-meeting"}}""", "sender", TestName = "A field named twice inside an argument")]
|
||||||
|
public async Task ACallWhichNamesAnArgumentTwiceDoesNotRun(string argumentsJson, string repeatedName)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The tool would read one of the two values, while the check for addresses the model wrote
|
||||||
|
// into its call reads both. Which one a provider reads when the call goes back to it is
|
||||||
|
// anybody's guess:
|
||||||
|
//
|
||||||
|
var executed = false;
|
||||||
|
var tool = new TestTool(Definition(), execute: _ =>
|
||||||
|
{
|
||||||
|
executed = true;
|
||||||
|
return new ToolExecutionResult { TextContent = "A passage from the handbook." };
|
||||||
|
});
|
||||||
|
|
||||||
|
var outcome = await this.Execute(tool, new ChatThread(), argumentsJson);
|
||||||
|
|
||||||
|
using (Assert.EnterMultipleScope())
|
||||||
|
{
|
||||||
|
Assert.That(executed, Is.False);
|
||||||
|
Assert.That(outcome.Trace.Status, Is.EqualTo(ToolInvocationTraceStatus.ERROR));
|
||||||
|
Assert.That(outcome.Content, Does.Contain($"'{repeatedName}'"), "The model learns which name it wrote twice.");
|
||||||
|
Assert.That(outcome.Content, Does.Not.Contain("budget").And.Not.Contain("attacker"), "The message quotes none of the values, which may carry secrets.");
|
||||||
|
Assert.That(outcome.Trace.Arguments, Is.Not.Empty, "The user still sees what the model sent.");
|
||||||
|
AssertDemandsNothing(outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task WhatAResultDemandsTightensTheChat()
|
public async Task WhatAResultDemandsTightensTheChat()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,6 @@ This works around the behavior described in dotnet/runtime#27620. A related upst
|
|||||||
## Removal Checklist
|
## Removal Checklist
|
||||||
|
|
||||||
- Confirm supported .NET runtimes and administrator packaging guidance no longer require accepting backslashes in enterprise ZIP entry names.
|
- Confirm supported .NET runtimes and administrator packaging guidance no longer require accepting backslashes in enterprise ZIP entry names.
|
||||||
- Replace `PluginArchive.Extract(...)` with `ZipFile.ExtractToDirectory(...)`.
|
- Replace `PluginArchive.ExtractAsync(...)` with `ZipFile.ExtractToDirectoryAsync(...)`.
|
||||||
- Stop treating `\` as a separator in `ZipEntryNames.Normalize`, and drop the backslash cases from `ZipEntryNamesTests`. Keep the checks against rooted, drive-qualified, and `..` names: they protect every extraction, not just this shim.
|
- Stop treating `\` as a separator in `ZipEntryNames.Normalize`, and drop the backslash cases from `ZipEntryNamesTests`. Keep the checks against rooted, drive-qualified, and `..` names: they protect every extraction, not just this shim.
|
||||||
- Update this document's status to `Removed`.
|
- Update this document's status to `Removed`.
|
||||||
Reference in new issue
Block a user