mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 17:32:11 +00:00
Merge branch 'main' into pr/901
# Conflicts: # app/MindWork AI Studio/Pages/Assistants.razor # app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md
This commit is contained in:
commit
e22ee39c9a
50
AGENTS.md
50
AGENTS.md
@ -29,14 +29,44 @@ dotnet run build
|
||||
```
|
||||
This builds the .NET app as a Tauri "sidecar" binary, which is required even for development.
|
||||
|
||||
### Running .NET builds from an agent
|
||||
- Do not run `.NET` builds such as `dotnet run build`, `dotnet build`, or similar build commands from an agent. Codex agents can hit a known sandbox issue during `.NET` builds, typically surfacing as `CSSM_ModuleLoad()` or other sandbox-related failures.
|
||||
- Instead, ask the user to run the `.NET` build locally in their IDE and report the result back.
|
||||
- Recommend the canonical repo build flow for the user: open an IDE terminal in the repository and run `cd app/Build && dotnet run build`.
|
||||
- If the context fits better, it is also acceptable to ask the user to start the build using their IDE's built-in build action, as long as it is clear the build must be run locally by the user.
|
||||
- After asking for the build, wait for the user's feedback before diagnosing issues, making follow-up changes, or suggesting the next step.
|
||||
- Treat the user's build output, error messages, or success confirmation as the source of truth for further troubleshooting.
|
||||
- For reference: https://github.com/openai/codex/issues/4915
|
||||
### Running builds from an agent
|
||||
Agents must not start builds through their own shell: agent shells run sandboxed, and `.NET` builds
|
||||
hit a known sandbox issue there, typically surfacing as `CSSM_ModuleLoad()` or other sandbox-related
|
||||
failures (for reference: https://github.com/openai/codex/issues/4915). This applies to `dotnet run build`,
|
||||
`dotnet build`, `cargo build`, and similar commands.
|
||||
|
||||
Instead, use the JetBrains IDE MCP servers. They execute in the IDE process, which runs outside the
|
||||
agent sandbox:
|
||||
|
||||
- `rider` for the .NET solution at `app/MindWork AI Studio.sln`
|
||||
- `rustrover` for the Rust runtime at `runtime/`
|
||||
|
||||
Pass the `rootFolder` parameter on every call, e.g. the absolute path of the `app` directory for Rider
|
||||
and of `runtime` for RustRover. It avoids ambiguous calls when several IDE windows are open.
|
||||
|
||||
**Compile check of the .NET code:** start `mcp__rider__build_solution_start`, then poll
|
||||
`mcp__rider__build_solution_state` until its state is `Completed` and read `buildIsSuccess` plus the
|
||||
collected problems. `mcp__rider__get_project_problems` reports the current Problems View without
|
||||
triggering a new build.
|
||||
|
||||
**Build script commands** such as the canonical build or the I18N collection run through the IDE
|
||||
terminal, because they are more than a solution build:
|
||||
|
||||
```
|
||||
mcp__rider__execute_terminal_command command: "cd app/Build && dotnet run build"
|
||||
mcp__rider__execute_terminal_command command: "cd app/Build && dotnet run collect-i18n"
|
||||
```
|
||||
|
||||
**Rust builds** work the same way through the matching `rustrover` tools.
|
||||
|
||||
Notes:
|
||||
- The IDE may ask the user to confirm a terminal command. Wait for the result instead of retrying the
|
||||
command in the agent shell.
|
||||
- When the IDE is not running or its MCP server is unavailable, fall back to asking the user to run
|
||||
`cd app/Build && dotnet run build` locally, and wait for their feedback before diagnosing issues or
|
||||
making follow-up changes.
|
||||
- Treat the build output, error messages, or success confirmation as the source of truth for further
|
||||
troubleshooting, no matter whether it came from the MCP server or from the user.
|
||||
|
||||
### Running Tests
|
||||
Currently, no automated test suite exists in the repository.
|
||||
@ -113,7 +143,7 @@ Plugins can configure:
|
||||
- etc.
|
||||
|
||||
Configuration plugins provide three kinds of values:
|
||||
- **Managed settings:** simple values such as booleans, numbers, strings, enums, lists, or sets handled through `ManagedConfiguration`. These values may be locked or used as organization defaults. Which configuration plugin owns a locked setting is persisted in `Data.ManagedLockedConfigurations`, and organization defaults are tracked in `Data.ManagedEditableDefaults`. Both are cleaned up generically by `ManagedConfiguration.CleanupLeftOverManagedConfigurations(...)` when the owning plugin is gone.
|
||||
- **Managed settings:** simple values such as booleans, numbers, strings, enums, lists, or sets handled through `ManagedConfiguration`. These values may be locked or used as organization defaults. Which configuration plugin owns a locked setting is persisted in `Data.ManagedLockedConfigurations`, and organization defaults are tracked in `Data.ManagedEditableDefaults`. Both are cleaned up generically by `ManagedConfiguration.CleanupLeftOverManagedConfigurations(...)` when the owning plugin is gone. The value a setting had before a configuration plugin took it over is kept in `Data.ManagedUserValueSnapshots` and restored by that same clean-up, so removing a plugin hands the user's own value back instead of the app default.
|
||||
- **Managed configuration objects:** complex Lua tables that are persisted into `SettingsManager.ConfigurationData`, implement `IConfigurationObject`, and are cleaned up through `PluginConfigurationObject.CleanLeftOverConfigurationObjects(...)`. Examples include providers, profiles, chat templates, data sources, and document analysis policies.
|
||||
- **Live plugin content:** complex Lua tables that implement `ILivePluginContent` and are read live from running plugins instead of being persisted to `ConfigurationData`. Examples include `MANDATORY_INFOS` and `INTRODUCTIONS`. If live plugin content creates persistent side data, add a dedicated cleanup path for that side data, like mandatory-info acceptances.
|
||||
|
||||
@ -193,7 +223,7 @@ Multi-level confidence scheme allows users to control which providers see which
|
||||
- **No automated formatting for Rust or .NET files** - Never run automated formatters on Rust files (`.rs`) or .NET files (`.cs`, `.razor`, `.csproj`, etc.). Only make the minimal manual formatting changes required for the specific edit.
|
||||
- **I18N resources are generated** - Do not manually edit `app/MindWork AI Studio/Assistants/I18N/allTexts.lua`, `app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua`, or `app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua`. These files are updated automatically by the I18N process.
|
||||
- **Spaces in paths** - Always quote paths with spaces in bash commands
|
||||
- **Agent-run .NET builds** - Do not run `.NET` builds from an agent. Ask the user to run the build locally in their IDE, preferably via `cd app/Build && dotnet run build` in an IDE terminal, then wait for their feedback before continuing.
|
||||
- **Agent-run builds** - Never start `.NET` or Rust builds in the agent's own shell; it is sandboxed. Use the `rider` and `rustrover` MCP servers instead, which build in the IDE outside that sandbox. See "Running builds from an agent" above.
|
||||
- **Debug environment** - Reads `startup.env` file with IPC credentials
|
||||
- **Production environment** - Runtime launches .NET sidecar with environment variables
|
||||
- **MudBlazor** - Component library requires DI setup in Program.cs
|
||||
|
||||
2
app/.codex/config.toml
Normal file
2
app/.codex/config.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[mcp_servers.rider]
|
||||
url = "http://127.0.0.1:64482/stream"
|
||||
@ -8,6 +8,7 @@ using AIStudio.Dialogs.Settings;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@ -596,10 +597,10 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<NoSettingsPane
|
||||
/// </remarks>
|
||||
private async Task ProcessOneFileAsync(BatchProcessingFileResult fileResult, string resolvedOutputDirectory, CancellationToken token)
|
||||
{
|
||||
string fileContent;
|
||||
FileExtractionResult extraction;
|
||||
try
|
||||
{
|
||||
fileContent = await this.RustService.ReadArbitraryFileData(fileResult.FilePath, int.MaxValue);
|
||||
extraction = await this.RustService.ReadArbitraryFileData(fileResult.FilePath, int.MaxValue);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@ -607,6 +608,26 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<NoSettingsPane
|
||||
return;
|
||||
}
|
||||
|
||||
if (!extraction.HasUsableContent)
|
||||
{
|
||||
this.Logger.LogError("Reading the batch file '{FilePath}' failed: code={ErrorCode}, message='{ErrorMessage}'.", fileResult.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
|
||||
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, extraction.ToUserMessage(fileResult.FileName));
|
||||
return;
|
||||
}
|
||||
|
||||
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
|
||||
{
|
||||
this.Logger.LogWarning("Parts of the batch file '{FilePath}' could not be read: pages={FailedPages}.", fileResult.FilePath, string.Join(", ", extraction.FailedPages));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(fileResult.FileName)));
|
||||
}
|
||||
|
||||
if (extraction.HasExtensionMismatch)
|
||||
{
|
||||
this.Logger.LogWarning("The batch file '{FilePath}' is actually a '{DetectedFormat}'.", fileResult.FilePath, extraction.DetectedFormat);
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(fileResult.FileName)));
|
||||
}
|
||||
|
||||
var fileContent = extraction.Content;
|
||||
if (string.IsNullOrWhiteSpace(fileContent))
|
||||
{
|
||||
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("Was not able to extract any text from this file."));
|
||||
|
||||
@ -17,7 +17,7 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!;
|
||||
private PluginInstallService PluginInstallService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private AssistantPluginGenerationService AssistantPluginGenerationService { get; init; } = null!;
|
||||
@ -500,7 +500,7 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
|
||||
this.isCheckingPlugin = true;
|
||||
try
|
||||
{
|
||||
var result = await this.AssistantPluginInstallService.CheckInstallabilityAsync(this.generatedLuaAssistant, CancellationToken.None);
|
||||
var result = await this.PluginInstallService.CheckInstallabilityAsync(this.generatedLuaAssistant, CancellationToken.None);
|
||||
this.pluginCheckResult = result;
|
||||
if (!result.Success)
|
||||
{
|
||||
@ -530,7 +530,7 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
|
||||
this.isInstallingPlugin = true;
|
||||
try
|
||||
{
|
||||
var result = await this.AssistantPluginInstallService.InstallAsync(this.generatedLuaAssistant, CancellationToken.None);
|
||||
var result = await this.PluginInstallService.InstallAsync(this.generatedLuaAssistant, CancellationToken.None);
|
||||
this.pluginInstallResult = result;
|
||||
if (!result.Success)
|
||||
{
|
||||
|
||||
@ -716,7 +716,28 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileContent = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
|
||||
var extraction = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
|
||||
if (!extraction.HasUsableContent)
|
||||
{
|
||||
this.Logger.LogError("Reading the document '{FilePath}' failed and it will not be analyzed: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, extraction.ToUserMessage(document.FileName)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
|
||||
{
|
||||
this.Logger.LogWarning("Parts of the document '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
// The file was read correctly, but its extension lies about what it contains:
|
||||
if (extraction.HasExtensionMismatch)
|
||||
{
|
||||
this.Logger.LogWarning("The document '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
var fileContent = extraction.Content;
|
||||
sb.AppendLine($"""
|
||||
|
||||
## DOCUMENT {numDocuments}:
|
||||
|
||||
@ -1942,9 +1942,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T534887559"] =
|
||||
-- Please provide a custom language.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T656744944"] = "Please provide a custom language."
|
||||
|
||||
-- The custom prompt guide file is empty or could not be read.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T1173408044"] = "The custom prompt guide file is empty or could not be read."
|
||||
|
||||
-- Use English for complex prompts and explicitly request response language if needed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T119999744"] = "Use English for complex prompts and explicitly request response language if needed."
|
||||
|
||||
@ -3073,6 +3070,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "No, kee
|
||||
-- Export Chat to Microsoft Word
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T861873672"] = "Export Chat to Microsoft Word"
|
||||
|
||||
-- The file '{0}' is currently not available and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T1432544573"] = "The file '{0}' is currently not available and was not sent."
|
||||
|
||||
-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3267850764"] = "The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings."
|
||||
|
||||
@ -3121,24 +3121,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3571008422"] = "Assistan
|
||||
-- The result is ready.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T661906146"] = "The result is ready."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T1318944584"] = "The assistant cannot be deleted while background work is still running."
|
||||
|
||||
-- Delete assistant plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T1692493145"] = "Delete assistant plugin"
|
||||
|
||||
-- Delete Assistant Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T3637071001"] = "Delete Assistant Plugin"
|
||||
|
||||
-- The '{0}' assistant plugin has been successfully removed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T3815023384"] = "The '{0}' assistant plugin has been successfully removed."
|
||||
|
||||
-- The assistant plugin '{0}' could not be deleted: {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T3985264168"] = "The assistant plugin '{0}' could not be deleted: {1}"
|
||||
|
||||
-- Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T4033722845"] = "Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."
|
||||
|
||||
-- Show or hide the detailed security information.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1045105126"] = "Show or hide the detailed security information."
|
||||
|
||||
@ -3586,6 +3568,39 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Co
|
||||
-- Cannot copy this content type to clipboard.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T3937637647"] = "Cannot copy this content type to clipboard."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T1318944584"] = "The assistant cannot be deleted while background work is still running."
|
||||
|
||||
-- Delete assistant plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T1692493145"] = "Delete assistant plugin"
|
||||
|
||||
-- Do you really want to delete the language plugin '{0}'? This permanently deletes its local plugin files. When it is your chosen language, AI Studio returns to choosing the language automatically.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T1744561175"] = "Do you really want to delete the language plugin '{0}'? This permanently deletes its local plugin files. When it is your chosen language, AI Studio returns to choosing the language automatically."
|
||||
|
||||
-- Delete language plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T2707495447"] = "Delete language plugin"
|
||||
|
||||
-- The plugin '{0}' could not be deleted: {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T2738963920"] = "The plugin '{0}' could not be deleted: {1}"
|
||||
|
||||
-- Delete Language Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T2990518039"] = "Delete Language Plugin"
|
||||
|
||||
-- Delete Configuration Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T3395354991"] = "Delete Configuration Plugin"
|
||||
|
||||
-- The plugin '{0}' has been successfully removed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T3476138264"] = "The plugin '{0}' has been successfully removed."
|
||||
|
||||
-- Delete Assistant Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T3637071001"] = "Delete Assistant Plugin"
|
||||
|
||||
-- Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T4033722845"] = "Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."
|
||||
|
||||
-- Delete configuration plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T459830575"] = "Delete configuration plugin"
|
||||
|
||||
-- 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."
|
||||
|
||||
@ -4978,6 +4993,84 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T823785464"] = "Allow th
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T900713019"] = "Cancel"
|
||||
|
||||
-- {0} LLM providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T121235760"] = "{0} LLM providers"
|
||||
|
||||
-- {0} profiles
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T1238255445"] = "{0} profiles"
|
||||
|
||||
-- No
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T1642511898"] = "No"
|
||||
|
||||
-- {0} introductions on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2107991661"] = "{0} introductions on the welcome page"
|
||||
|
||||
-- {0} mandatory information
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2150386772"] = "{0} mandatory information"
|
||||
|
||||
-- You can install the plugin again later, but any changes you made to its settings are lost.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2156367745"] = "You can install the plugin again later, but any changes you made to its settings are lost."
|
||||
|
||||
-- {0} profile
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2342765572"] = "{0} profile"
|
||||
|
||||
-- {0} introduction on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2426110502"] = "{0} introduction on the welcome page"
|
||||
|
||||
-- {0} embedding providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2438407498"] = "{0} embedding providers"
|
||||
|
||||
-- Yes, delete it
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2466176832"] = "Yes, delete it"
|
||||
|
||||
-- This also removes everything the configuration plugin had set up:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T264970454"] = "This also removes everything the configuration plugin had set up:"
|
||||
|
||||
-- {0} transcription provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2681055470"] = "{0} transcription provider"
|
||||
|
||||
-- {0} chat templates
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3235448458"] = "{0} chat templates"
|
||||
|
||||
-- {0} document analysis policy
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3278137746"] = "{0} document analysis policy"
|
||||
|
||||
-- The configuration plugin is not running, so we cannot tell what it had set up. Anything it configured will be removed as well.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T330559934"] = "The configuration plugin is not running, so we cannot tell what it had set up. Anything it configured will be removed as well."
|
||||
|
||||
-- {0} LLM provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3410030691"] = "{0} LLM provider"
|
||||
|
||||
-- Do you really want to delete the configuration plugin '{0}'? This permanently deletes its local plugin files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3616855807"] = "Do you really want to delete the configuration plugin '{0}'? This permanently deletes its local plugin files."
|
||||
|
||||
-- {0} settings return to their default values
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3841220170"] = "{0} settings return to their default values"
|
||||
|
||||
-- {0} setting returns to its default value
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T384701293"] = "{0} setting returns to its default value"
|
||||
|
||||
-- {0} mandatory informations
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3971735909"] = "{0} mandatory informations"
|
||||
|
||||
-- {0} chat template
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T4147879421"] = "{0} chat template"
|
||||
|
||||
-- {0} data sources, including their credentials in your operating system's keychain
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T4193757254"] = "{0} data sources, including their credentials in your operating system's keychain"
|
||||
|
||||
-- {0} document analysis policies
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T449490978"] = "{0} document analysis policies"
|
||||
|
||||
-- {0} data source, including its credentials in your operating system's keychain
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T511418335"] = "{0} data source, including its credentials in your operating system's keychain"
|
||||
|
||||
-- {0} transcription providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T767586087"] = "{0} transcription providers"
|
||||
|
||||
-- {0} embedding provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T818101181"] = "{0} embedding provider"
|
||||
|
||||
-- No
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T1642511898"] = "No"
|
||||
|
||||
@ -5437,6 +5530,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEINFODIALOG::T3688254408"]
|
||||
-- Your security policy
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEINFODIALOG::T4081226330"] = "Your security policy"
|
||||
|
||||
-- Please wait while we load the content of your file. Depending on the file type and size, this may take a moment.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T1205126512"] = "Please wait while we load the content of your file. Depending on the file type and size, this may take a moment."
|
||||
|
||||
-- Markdown View
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T1373123357"] = "Markdown View"
|
||||
|
||||
@ -5689,6 +5785,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T504404155"] = "Accept the ter
|
||||
-- Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking "Accept GPL and archive," you agree to the terms of the GPL license. Software under GPL is free of charge and free to use.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T523908375"] = "Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking \"Accept GPL and archive,\" you agree to the terms of the GPL license. Software under GPL is free of charge and free to use."
|
||||
|
||||
-- {0} profiles
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1238255445"] = "{0} profiles"
|
||||
|
||||
-- Install plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1525735539"] = "Install plugin"
|
||||
|
||||
@ -5704,6 +5803,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1974491324"] = "You are
|
||||
-- Authors
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1985367263"] = "Authors"
|
||||
|
||||
-- Data source
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2034620186"] = "Data source"
|
||||
|
||||
-- A configuration takes effect right after the installation and has no on/off switch. Please check what it sets up:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2051328106"] = "A configuration takes effect right after the installation and has no on/off switch. Please check what it sets up:"
|
||||
|
||||
-- Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2053517490"] = "Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source."
|
||||
|
||||
@ -5713,12 +5818,36 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2063808316"] = "You are
|
||||
-- You are about to install a configuration plugin from a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T21052500"] = "You are about to install a configuration plugin from a file."
|
||||
|
||||
-- {0} introductions on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2107991661"] = "{0} introductions on the welcome page"
|
||||
|
||||
-- You are about to install a theme plugin from a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2163853103"] = "You are about to install a theme plugin from a file."
|
||||
|
||||
-- {0} profile
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2342765572"] = "{0} profile"
|
||||
|
||||
-- {0} introduction on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2426110502"] = "{0} introduction on the welcome page"
|
||||
|
||||
-- Support contact
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2434966596"] = "Support contact"
|
||||
|
||||
-- Name
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T266367750"] = "Name"
|
||||
|
||||
-- {0} setting it takes control of
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2868009192"] = "{0} setting it takes control of"
|
||||
|
||||
-- {0} settings it takes control of
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3190775003"] = "{0} settings it takes control of"
|
||||
|
||||
-- {0} chat templates
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3235448458"] = "{0} chat templates"
|
||||
|
||||
-- {0} document analysis policy
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3278137746"] = "{0} document analysis policy"
|
||||
|
||||
-- This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3415610475"] = "This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}."
|
||||
|
||||
@ -5728,18 +5857,45 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3424652889"] = "Unknown
|
||||
-- Type
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3512062061"] = "Type"
|
||||
|
||||
-- {0} mandatory information you have to accept before using AI Studio
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3540986519"] = "{0} mandatory information you have to accept before using AI Studio"
|
||||
|
||||
-- Transcription provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3566003684"] = "Transcription provider"
|
||||
|
||||
-- Replace plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4068580334"] = "Replace plugin"
|
||||
|
||||
-- LLM provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4099016901"] = "LLM provider"
|
||||
|
||||
-- {0} chat template
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4147879421"] = "{0} chat template"
|
||||
|
||||
-- {0} document analysis policies
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T449490978"] = "{0} document analysis policies"
|
||||
|
||||
-- The authors marked this plugin as deprecated: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T497068698"] = "The authors marked this plugin as deprecated: {0}"
|
||||
|
||||
-- It also brings:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T713968030"] = "It also brings:"
|
||||
|
||||
-- You are about to install a plugin from a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T841685558"] = "You are about to install a plugin from a file."
|
||||
|
||||
-- Embedding provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T877326195"] = "Embedding provider"
|
||||
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T900713019"] = "Cancel"
|
||||
|
||||
-- Sends data to
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T914647109"] = "Sends data to"
|
||||
|
||||
-- Destination
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T994314591"] = "Destination"
|
||||
|
||||
-- Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1458195391"] = "Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally."
|
||||
|
||||
@ -7789,6 +7945,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1290340974"] = "Unknown configur
|
||||
-- Copies the configuration slot to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1347508205"] = "Copies the configuration slot to the clipboard"
|
||||
|
||||
-- Once the encoding of a text file is known, encoding_rs turns its content into the text AI Studio works with. Together with chardetng, this lets AI Studio read text, CSV, and similar files no matter which encoding they were saved in.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1378412877"] = "Once the encoding of a text file is known, encoding_rs turns its content into the text AI Studio works with. Together with chardetng, this lets AI Studio read text, CSV, and similar files no matter which encoding they were saved in."
|
||||
|
||||
-- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat."
|
||||
|
||||
@ -7819,6 +7978,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1629800076"] = "Building on .NET
|
||||
-- AI Studio creates a log file at startup, in which events during startup are recorded. After startup, another log file is created that records all events that occur during the use of the app. This includes any errors that may occur. Depending on when an error occurs (at startup or during use), the contents of these log files can be helpful for troubleshooting. Sensitive information such as passwords is not included in the log files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1630237140"] = "AI Studio creates a log file at startup, in which events during startup are recorded. After startup, another log file is created that records all events that occur during the use of the app. This includes any errors that may occur. Depending on when an error occurs (at startup or during use), the contents of these log files can be helpful for troubleshooting. Sensitive information such as passwords is not included in the log files."
|
||||
|
||||
-- Plugin directory:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1698127325"] = "Plugin directory:"
|
||||
|
||||
-- Consent:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T171952677"] = "Consent:"
|
||||
|
||||
@ -7897,6 +8059,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux AppImages b
|
||||
-- Used PDFium version
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2368247719"] = "Used PDFium version"
|
||||
|
||||
-- Text files are not always saved in the same encoding: files written on Windows often use a legacy one. chardetng recognizes which encoding a text file uses, so AI Studio can read it instead of rejecting it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T236832881"] = "Text files are not always saved in the same encoding: files written on Windows often use a legacy one. chardetng recognizes which encoding a text file uses, so AI Studio can read it instead of rejecting it."
|
||||
|
||||
-- installation provided by the system
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2371107659"] = "installation provided by the system"
|
||||
|
||||
@ -7984,6 +8149,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3002755581"] = "This library ide
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog"
|
||||
|
||||
-- Test configuration: nobody deployed this configuration. It is valid until you restart AI Studio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3019585985"] = "Test configuration: nobody deployed this configuration. It is valid until you restart AI Studio."
|
||||
|
||||
-- External HTTPS custom root certificates are configured but not active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3021325354"] = "External HTTPS custom root certificates are configured but not active."
|
||||
|
||||
@ -7999,6 +8167,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T313276297"] = "Connect AI Studio
|
||||
-- Have feature ideas? Submit suggestions for future AI Studio enhancements.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3178730036"] = "Have feature ideas? Submit suggestions for future AI Studio enhancements."
|
||||
|
||||
-- Copies the plugin directory to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3182878147"] = "Copies the plugin directory to the clipboard"
|
||||
|
||||
-- Hide Details
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3183837919"] = "Hide Details"
|
||||
|
||||
@ -8122,6 +8293,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code
|
||||
-- Executable path
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4164953312"] = "Executable path"
|
||||
|
||||
-- AI Studio removed {0} test configuration(s) while starting. A test configuration is valid for one session: place it again while AI Studio is running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4172838224"] = "AI Studio removed {0} test configuration(s) while starting. A test configuration is valid for one session: place it again while AI Studio is running."
|
||||
|
||||
-- We use the HtmlAgilityPack to extract content from the web. This is necessary, e.g., when you provide a URL as input for an assistant.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4184485147"] = "We use the HtmlAgilityPack to extract content from the web. This is necessary, e.g., when you provide a URL as input for an assistant."
|
||||
|
||||
@ -8191,6 +8365,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T870640199"] = "For some data tra
|
||||
-- How to update
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "How to update"
|
||||
|
||||
-- A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T923110805"] = "A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app."
|
||||
|
||||
-- Install Pandoc
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Install Pandoc"
|
||||
|
||||
@ -8203,18 +8380,30 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1430375822"] = "Disable plugin"
|
||||
-- Import
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1463683828"] = "Import"
|
||||
|
||||
-- Import plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1467093263"] = "Import plugin"
|
||||
|
||||
-- Assistant Audit
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1506922856"] = "Assistant Audit"
|
||||
|
||||
-- Internal Plugins
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T158493184"] = "Internal Plugins"
|
||||
|
||||
-- Plugin updated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1646565893"] = "Plugin updated."
|
||||
|
||||
-- Import plugin from a file
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T169921408"] = "Import plugin from a file"
|
||||
|
||||
-- Disabled Plugins
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Disabled Plugins"
|
||||
|
||||
-- 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"
|
||||
|
||||
@ -8224,9 +8413,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin"
|
||||
-- No source url available
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2058912565"] = "No source url available"
|
||||
|
||||
-- Assistant installed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2069785341"] = "Assistant installed."
|
||||
|
||||
-- Plugins
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins"
|
||||
|
||||
@ -8248,9 +8434,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3143506997"] = "The assistant plugin
|
||||
-- An error occurred while sharing the plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3184210266"] = "An error occurred while sharing the plugin."
|
||||
|
||||
-- Import assistant plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3246593895"] = "Import assistant plugin"
|
||||
|
||||
-- Your organization has disabled exporting plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3342440765"] = "Your organization has disabled exporting plugins."
|
||||
|
||||
@ -8281,9 +8464,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3902690643"] = "Install Plugin"
|
||||
-- Please drop only one plugin archive at a time.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3974628410"] = "Please drop only one plugin archive at a time."
|
||||
|
||||
-- Assistant updated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T40397082"] = "Assistant updated."
|
||||
|
||||
-- The automatic security audit for the assistant plugin '{0}' failed. Please run it manually.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4066679817"] = "The automatic security audit for the assistant plugin '{0}' failed. Please run it manually."
|
||||
|
||||
@ -9148,6 +9328,66 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T599774443"] = "The
|
||||
-- policy files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T632340680"] = "policy files"
|
||||
|
||||
-- The file type of '{0}' could not be determined, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1459702734"] = "The file type of '{0}' could not be determined, so the file was not sent."
|
||||
|
||||
-- The file '{0}' is an executable program and was not sent, regardless of its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1481258284"] = "The file '{0}' is an executable program and was not sent, regardless of its file extension."
|
||||
|
||||
-- The file '{0}' could not be read and was not sent. When the file is stored on a network drive, the drive might be unavailable, or another program might be blocking the file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1488076079"] = "The file '{0}' could not be read and was not sent. When the file is stored on a network drive, the drive might be unavailable, or another program might be blocking the file."
|
||||
|
||||
-- The pages {1} of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1928400379"] = "The pages {1} of the file '{0}' could not be read. The remaining content was sent."
|
||||
|
||||
-- Parts of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2036654169"] = "Parts of the file '{0}' could not be read. The remaining content was sent."
|
||||
|
||||
-- The file type of '{0}' is not supported, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2064321829"] = "The file type of '{0}' is not supported, so the file was not sent."
|
||||
|
||||
-- The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2240855899"] = "The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely."
|
||||
|
||||
-- The file '{0}' is currently open in another program, which is why it was not sent. Please close the file and try again. When the file is stored on a shared network drive, a colleague might have it open.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2701144378"] = "The file '{0}' is currently open in another program, which is why it was not sent. Please close the file and try again. When the file is stored on a shared network drive, a colleague might have it open."
|
||||
|
||||
-- Reading the file '{0}' took too long and was stopped, so the file was not sent. When the file is stored on a network drive, the connection might be slow or interrupted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2793077828"] = "Reading the file '{0}' took too long and was stopped, so the file was not sent. When the file is stored on a network drive, the connection might be slow or interrupted."
|
||||
|
||||
-- The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2891768359"] = "The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely."
|
||||
|
||||
-- No text could be read from the file '{0}', so it was not sent. It might contain images only, such as a scanned PDF without a text layer, or no readable text at all.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2897122009"] = "No text could be read from the file '{0}', so it was not sent. It might contain images only, such as a scanned PDF without a text layer, or no readable text at all."
|
||||
|
||||
-- The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3262447403"] = "The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent."
|
||||
|
||||
-- The file '{0}' is actually a {1} and was read as such. Please correct its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3297602719"] = "The file '{0}' is actually a {1} and was read as such. Please correct its file extension."
|
||||
|
||||
-- The file '{0}' is not a text file and was not sent. Its content could not be read as text, so it might have a wrong file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3303873344"] = "The file '{0}' is not a text file and was not sent. Its content could not be read as text, so it might have a wrong file extension."
|
||||
|
||||
-- The file '{0}' could not be read and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3527027650"] = "The file '{0}' could not be read and was not sent."
|
||||
|
||||
-- The file '{0}' is protected and could not be opened, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3840033580"] = "The file '{0}' is protected and could not be opened, so it was not sent."
|
||||
|
||||
-- AI Studio was not able to start its PDF engine, so the file '{0}' was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3927045859"] = "AI Studio was not able to start its PDF engine, so the file '{0}' was not sent."
|
||||
|
||||
-- The file '{0}' does not exist anymore and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4071378057"] = "The file '{0}' does not exist anymore and was not sent."
|
||||
|
||||
-- The file '{0}' did not provide any content and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4291141931"] = "The file '{0}' did not provide any content and was not sent."
|
||||
|
||||
-- Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T594894810"] = "Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent."
|
||||
|
||||
-- AI Studio couldn't install Pandoc because the archive was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio couldn't install Pandoc because the archive was not found."
|
||||
|
||||
@ -9676,6 +9916,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
|
||||
-- Office Files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office Files"
|
||||
|
||||
-- Tabular text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T13157661"] = "Tabular text"
|
||||
|
||||
-- Executable
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1364437037"] = "Executable"
|
||||
|
||||
@ -9826,102 +10069,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T4
|
||||
-- Please create an assistant draft first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T4269176489"] = "Please create an assistant draft first."
|
||||
|
||||
-- Internal assistant plugins cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1084244321"] = "Internal assistant plugins cannot be deleted."
|
||||
|
||||
-- This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1138181282"] = "This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."
|
||||
|
||||
-- The assistant plugin directory is outside the local assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1211881977"] = "The assistant plugin directory is outside the local assistant plugin directory."
|
||||
|
||||
-- Only assistant plugins can be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1288328479"] = "Only assistant plugins can be edited."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1318944584"] = "The assistant cannot be deleted while background work is still running."
|
||||
|
||||
-- Currently, only assistant plugins can be imported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T139615196"] = "Currently, only assistant plugins can be imported."
|
||||
|
||||
-- Please select a plugin archive with the extension .mwplugin or .zip.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1809137998"] = "Please select a plugin archive with the extension .mwplugin or .zip."
|
||||
|
||||
-- The selected plugin archive does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1821013825"] = "The selected plugin archive does not exist."
|
||||
|
||||
-- No Lua plugin code was generated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1839013358"] = "No Lua plugin code was generated."
|
||||
|
||||
-- The assistant plugin directory does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2148384567"] = "The assistant plugin directory does not exist."
|
||||
|
||||
-- The resolved plugin directory is outside the assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2223071618"] = "The resolved plugin directory is outside the assistant plugin directory."
|
||||
|
||||
-- Unexpected error: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2350673880"] = "Unexpected error: {0}"
|
||||
|
||||
-- The generated assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2441747251"] = "The generated assistant plugin uses the ID of another installed plugin."
|
||||
|
||||
-- Config server managed assistant plugins cannot be replaced.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2594571117"] = "Config server managed assistant plugins cannot be replaced."
|
||||
|
||||
-- The assistant plugin has no local directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2682912892"] = "The assistant plugin has no local directory."
|
||||
|
||||
-- The AI Studio data directory is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2712481762"] = "The AI Studio data directory is not initialized yet."
|
||||
|
||||
-- The imported assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2777304537"] = "The imported assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- Only assistant plugins can be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2864597027"] = "Only assistant plugins can be deleted."
|
||||
|
||||
-- The generated plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2955055168"] = "The generated plugin is not an assistant plugin. Issue: {0}"
|
||||
|
||||
-- The imported assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2971411166"] = "The imported assistant plugin uses the ID of another installed plugin."
|
||||
|
||||
-- Your organization has disabled importing plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3212529834"] = "Your organization has disabled importing plugins."
|
||||
|
||||
-- The plugin archive must contain exactly one plugin.lua file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3355918609"] = "The plugin archive must contain exactly one plugin.lua file."
|
||||
|
||||
-- Config Server managed assistant plugins cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3751820312"] = "Config Server managed assistant plugins cannot be deleted."
|
||||
|
||||
-- Only assistants generated by the Assistant Builder can be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3940247198"] = "Only assistants generated by the Assistant Builder can be deleted."
|
||||
|
||||
-- The edited plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3984111892"] = "The edited plugin is not an assistant plugin. Issue: {0}"
|
||||
|
||||
-- The plugin system is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3984839613"] = "The plugin system is not initialized yet."
|
||||
|
||||
-- The plugin file is outside the assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T4062980447"] = "The plugin file is outside the assistant plugin directory."
|
||||
|
||||
-- The edited assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T554567780"] = "The edited assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- The edited assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T584770023"] = "The edited assistant plugin uses the ID of another installed plugin."
|
||||
|
||||
-- The edited assistant plugin must keep the same plugin ID.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T693124809"] = "The edited assistant plugin must keep the same plugin ID."
|
||||
|
||||
-- Internal assistant plugins cannot be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T816339833"] = "Internal assistant plugins cannot be edited."
|
||||
|
||||
-- The generated assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "The generated assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- The voice recording shortcut currently works only while AI Studio is focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "The voice recording shortcut currently works only while AI Studio is focused."
|
||||
|
||||
@ -9973,6 +10120,114 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701
|
||||
-- Pandoc may be required for importing files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Pandoc may be required for importing files."
|
||||
|
||||
-- This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1138181282"] = "This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."
|
||||
|
||||
-- The imported plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1195382910"] = "The imported plugin uses the ID of another installed plugin."
|
||||
|
||||
-- The assistant plugin directory is outside the local assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1211881977"] = "The assistant plugin directory is outside the local assistant plugin directory."
|
||||
|
||||
-- Only assistant plugins can be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1288328479"] = "Only assistant plugins can be edited."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1318944584"] = "The assistant cannot be deleted while background work is still running."
|
||||
|
||||
-- Plugins deployed by your organization cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1348456011"] = "Plugins deployed by your organization cannot be deleted."
|
||||
|
||||
-- The resolved plugin directory is outside the plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1559620698"] = "The resolved plugin directory is outside the plugin directory."
|
||||
|
||||
-- Please select a plugin archive with the extension .mwplugin or .zip.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1809137998"] = "Please select a plugin archive with the extension .mwplugin or .zip."
|
||||
|
||||
-- The selected plugin archive does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1821013825"] = "The selected plugin archive does not exist."
|
||||
|
||||
-- No Lua plugin code was generated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1839013358"] = "No Lua plugin code was generated."
|
||||
|
||||
-- Only assistant, configuration, and language plugins can be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1878846406"] = "Only assistant, configuration, and language plugins can be deleted."
|
||||
|
||||
-- Your organization has disabled importing configuration plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2134532120"] = "Your organization has disabled importing configuration plugins."
|
||||
|
||||
-- The assistant plugin directory does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2148384567"] = "The assistant plugin directory does not exist."
|
||||
|
||||
-- The plugin directory does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2221093487"] = "The plugin directory does not exist."
|
||||
|
||||
-- Unexpected error: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2350673880"] = "Unexpected error: {0}"
|
||||
|
||||
-- The generated assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2441747251"] = "The generated assistant plugin uses the ID of another installed plugin."
|
||||
|
||||
-- This individual plugin’s directory is outside the expected plugins directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2486199999"] = "This individual plugin’s directory is outside the expected plugins directory."
|
||||
|
||||
-- The assistant plugin has no local directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2682912892"] = "The assistant plugin has no local directory."
|
||||
|
||||
-- The AI Studio data directory is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2712481762"] = "The AI Studio data directory is not initialized yet."
|
||||
|
||||
-- Only assistant, configuration, and language plugins can be imported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2909113247"] = "Only assistant, configuration, and language plugins can be imported."
|
||||
|
||||
-- The generated plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2955055168"] = "The generated plugin is not an assistant plugin. Issue: {0}"
|
||||
|
||||
-- Your organization has disabled importing plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3212529834"] = "Your organization has disabled importing plugins."
|
||||
|
||||
-- The plugin has no local directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3284289028"] = "The plugin has no local directory."
|
||||
|
||||
-- The plugin archive must contain exactly one plugin.lua file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3355918609"] = "The plugin archive must contain exactly one plugin.lua file."
|
||||
|
||||
-- Your organization deployed a configuration with the same ID. An imported configuration must not take its place.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T352004699"] = "Your organization deployed a configuration with the same ID. An imported configuration must not take its place."
|
||||
|
||||
-- The imported plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3634046009"] = "The imported plugin is invalid. Issue: {0}"
|
||||
|
||||
-- Plugins shipped with AI Studio cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3841213017"] = "Plugins shipped with AI Studio cannot be deleted."
|
||||
|
||||
-- The edited plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3984111892"] = "The edited plugin is not an assistant plugin. Issue: {0}"
|
||||
|
||||
-- The plugin system is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3984839613"] = "The plugin system is not initialized yet."
|
||||
|
||||
-- The plugin file is outside the assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T4062980447"] = "The plugin file is outside the assistant plugin directory."
|
||||
|
||||
-- Plugins deployed by your organization cannot be replaced.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T553820956"] = "Plugins deployed by your organization cannot be replaced."
|
||||
|
||||
-- The edited assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T554567780"] = "The edited assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- The edited assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T584770023"] = "The edited assistant plugin uses the ID of another installed plugin."
|
||||
|
||||
-- The edited assistant plugin must keep the same plugin ID.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T693124809"] = "The edited assistant plugin must keep the same plugin ID."
|
||||
|
||||
-- Internal assistant plugins cannot be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T816339833"] = "Internal assistant plugins cannot be edited."
|
||||
|
||||
-- The generated assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T939708112"] = "The generated assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- Internal plugins cannot be shared.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T1668534561"] = "Internal plugins cannot be shared."
|
||||
|
||||
@ -10081,9 +10336,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4261248356"] = "Sources pro
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T185447014"] = "Pandoc Installation"
|
||||
|
||||
-- Pandoc may be required for importing files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T2596465560"] = "Pandoc may be required for importing files."
|
||||
|
||||
-- The file path is null or empty and the file therefore can not be loaded.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T932243993"] = "The file path is null or empty and the file therefore can not be loaded."
|
||||
|
||||
|
||||
@ -579,9 +579,10 @@ public partial class AssistantPromptOptimizer : AssistantBaseCore<SettingsDialog
|
||||
try
|
||||
{
|
||||
this.isLoadingCustomPromptGuide = true;
|
||||
this.customPromptingGuidelineContent = await UserFile.LoadFileData(fileAttachment.FilePath, this.RustService, this.DialogService);
|
||||
if (string.IsNullOrWhiteSpace(this.customPromptingGuidelineContent))
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, T("The custom prompt guide file is empty or could not be read.")));
|
||||
|
||||
// A failure was already reported by UserFile.LoadFileData, so we only keep the content:
|
||||
var extraction = await UserFile.LoadFileData(fileAttachment.FilePath, this.RustService, this.DialogService);
|
||||
this.customPromptingGuidelineContent = extraction.HasUsableContent ? extraction.Content : string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@ -382,7 +382,28 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileContent = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
|
||||
var extraction = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
|
||||
if (!extraction.HasUsableContent)
|
||||
{
|
||||
this.Logger.LogError("Reading the document '{FilePath}' failed and it will not be used: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, extraction.ToUserMessage(document.FileName)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
|
||||
{
|
||||
this.Logger.LogWarning("Parts of the document '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
// The file was read correctly, but its extension lies about what it contains:
|
||||
if (extraction.HasExtensionMismatch)
|
||||
{
|
||||
this.Logger.LogWarning("The document '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
var fileContent = extraction.Content;
|
||||
sb.AppendLine($"""
|
||||
|
||||
## DOCUMENT {numDocuments}:
|
||||
|
||||
@ -5,6 +5,7 @@ using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.RAG.RAGProcesses;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
@ -14,6 +15,7 @@ namespace AIStudio.Chat;
|
||||
public sealed class ContentText : IContent
|
||||
{
|
||||
private static readonly ILogger<ContentText> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ContentText>();
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ContentText).Namespace, nameof(ContentText));
|
||||
|
||||
/// <summary>
|
||||
@ -266,50 +268,106 @@ public sealed class ContentText : IContent
|
||||
// Get the list of existing documents:
|
||||
var existingDocuments = normalizedAttachments.Where(x => x.Type is FileAttachmentType.DOCUMENT && x.Exists).ToList();
|
||||
|
||||
// Log warning for missing files:
|
||||
//
|
||||
// Report missing files. We tell the user about them instead of only logging: on a
|
||||
// network drive, a file which is temporarily unreachable looks exactly like a deleted
|
||||
// one, and silently dropping it would let the AI answer without that document.
|
||||
//
|
||||
var missingDocuments = normalizedAttachments.Except(existingDocuments).Where(x => x.Type is FileAttachmentType.DOCUMENT).ToList();
|
||||
if (missingDocuments.Count > 0)
|
||||
foreach (var missingDocument in missingDocuments)
|
||||
LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingDocument}'.", missingDocument.FilePath);
|
||||
|
||||
foreach (var missingDocument in missingDocuments)
|
||||
{
|
||||
LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingDocument}'.", missingDocument.FilePath);
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.FindInPage, string.Format(TB("The file '{0}' is currently not available and was not sent."), missingDocument.FileName)));
|
||||
}
|
||||
|
||||
// Only proceed if there are existing, allowed documents:
|
||||
if (existingDocuments.Count > 0)
|
||||
{
|
||||
// Check Pandoc availability once before processing file attachments
|
||||
var pandocState = await Pandoc.CheckAvailabilityAsync(Program.RUST_SERVICE, showMessages: true, showSuccessMessage: false);
|
||||
//
|
||||
// Pandoc is only needed for the few formats we convert with it. PDFs, text files,
|
||||
// spreadsheets, and presentations are read by the runtime itself, so a missing
|
||||
// Pandoc installation must not stop them.
|
||||
//
|
||||
var pandocIsUsable = true;
|
||||
if (existingDocuments.Any(document => FileTypes.RequiresPandoc(document.FilePath)))
|
||||
{
|
||||
var pandocState = await Pandoc.CheckAvailabilityAsync(Program.RUST_SERVICE, showMessages: true, showSuccessMessage: false);
|
||||
pandocIsUsable = pandocState is { IsAvailable: true, CheckWasSuccessful: true };
|
||||
|
||||
if (!pandocState.IsAvailable)
|
||||
LOGGER.LogWarning("File attachments could not be processed because Pandoc is not available.");
|
||||
else if (!pandocState.CheckWasSuccessful)
|
||||
LOGGER.LogWarning("File attachments could not be processed because the Pandoc version check failed.");
|
||||
else
|
||||
if (!pandocState.IsAvailable)
|
||||
LOGGER.LogWarning("File attachments which need Pandoc could not be processed because Pandoc is not available.");
|
||||
else if (!pandocState.CheckWasSuccessful)
|
||||
LOGGER.LogWarning("File attachments which need Pandoc could not be processed because the Pandoc version check failed.");
|
||||
}
|
||||
|
||||
//
|
||||
// The document blocks are collected separately, so we only announce attached
|
||||
// files when at least one of them could actually be read. Announcing files we
|
||||
// then hand over as empty blocks makes the AI answer about an empty document.
|
||||
//
|
||||
var documentBlocks = new StringBuilder();
|
||||
foreach(var document in existingDocuments)
|
||||
{
|
||||
if (document.IsForbidden)
|
||||
{
|
||||
LOGGER.LogWarning("File attachment '{FilePath}' has a forbidden file type and will be skipped.", document.FilePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pandocIsUsable && FileTypes.RequiresPandoc(document.FilePath))
|
||||
{
|
||||
LOGGER.LogWarning("The file attachment '{FilePath}' needs Pandoc and will be skipped.", document.FilePath);
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Description, FileExtractionErrorCode.PANDOC_UNAVAILABLE.ToUserMessage(document.FileName)));
|
||||
continue;
|
||||
}
|
||||
|
||||
var extraction = await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue);
|
||||
if (!extraction.HasUsableContent)
|
||||
{
|
||||
LOGGER.LogError("Reading the file attachment '{FilePath}' failed and it will not be sent: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Description, extraction.ToUserMessage(document.FileName)));
|
||||
continue;
|
||||
}
|
||||
|
||||
//
|
||||
// The file is usable, but we lost parts of it. The user has to know which
|
||||
// parts are missing, because the answer will be based on the rest.
|
||||
//
|
||||
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
|
||||
{
|
||||
LOGGER.LogWarning("Parts of the file attachment '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages));
|
||||
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
// The file was read correctly, but its extension lies about what it contains:
|
||||
if (extraction.HasExtensionMismatch)
|
||||
{
|
||||
LOGGER.LogWarning("The file attachment '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
|
||||
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
|
||||
}
|
||||
|
||||
documentBlocks.AppendLine();
|
||||
documentBlocks.AppendLine("---------------------------------------");
|
||||
documentBlocks.AppendLine($"File path: {document.FilePath}");
|
||||
documentBlocks.AppendLine("File content:");
|
||||
documentBlocks.AppendLine("````");
|
||||
documentBlocks.AppendLine(extraction.Content);
|
||||
documentBlocks.AppendLine("````");
|
||||
}
|
||||
|
||||
if (documentBlocks.Length > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("The following files are attached to this message:");
|
||||
foreach(var document in existingDocuments)
|
||||
{
|
||||
if (document.IsForbidden)
|
||||
{
|
||||
LOGGER.LogWarning("File attachment '{FilePath}' has a forbidden file type and will be skipped.", document.FilePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---------------------------------------");
|
||||
sb.AppendLine($"File path: {document.FilePath}");
|
||||
sb.AppendLine("File content:");
|
||||
sb.AppendLine("````");
|
||||
sb.AppendLine(await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue));
|
||||
sb.AppendLine("````");
|
||||
}
|
||||
|
||||
var numImages = normalizedAttachments.Count(x => x is { IsImage: true, Exists: true });
|
||||
if (numImages > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Additionally, there are {numImages} image file(s) attached to this message. ");
|
||||
sb.AppendLine("Please consider them as part of the message content and use them to answer accordingly.");
|
||||
}
|
||||
sb.Append(documentBlocks);
|
||||
}
|
||||
|
||||
var numImages = normalizedAttachments.Count(x => x is { IsImage: true, Exists: true });
|
||||
if (numImages > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Additionally, there are {numImages} image file(s) attached to this message. ");
|
||||
sb.AppendLine("Please consider them as part of the message content and use them to answer accordingly.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -321,4 +379,4 @@ public sealed class ContentText : IContent
|
||||
/// The text content.
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class AssistantBlock<TSettings> : MSGComponentBase where TSettings : IComponent
|
||||
public partial class AssistantBlock<TSettings> : MSGComponentBase, IAssistantCategoryMember where TSettings : IComponent
|
||||
{
|
||||
/// <summary>
|
||||
/// Describes the assistant session indicator shown on top of the assistant icon.
|
||||
@ -58,6 +58,12 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
[Parameter]
|
||||
public PreviewFeatures RequiredPreviewFeature { get; set; } = PreviewFeatures.NONE;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the assistant category this block belongs to, if any.
|
||||
/// </summary>
|
||||
[CascadingParameter]
|
||||
public AssistantCategoryBlock? Category { get; set; }
|
||||
|
||||
[Inject]
|
||||
private MudTheme ColorTheme { get; init; } = null!;
|
||||
|
||||
@ -88,7 +94,8 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
|
||||
private string BlockStyle => $"border-width: 3px; border-color: {this.BorderColor}; border-radius: 12px; border-style: solid; max-width: 20em;";
|
||||
|
||||
private bool IsVisible => this.SettingsManager.IsAssistantVisible(this.Component, assistantName: this.Name, requiredPreviewFeature: this.RequiredPreviewFeature);
|
||||
/// <inheritdoc />
|
||||
public bool IsVisible => this.SettingsManager.IsAssistantVisible(this.Component, assistantName: this.Name, requiredPreviewFeature: this.RequiredPreviewFeature);
|
||||
|
||||
private bool HasSettingsPanel => typeof(TSettings) != typeof(NoSettingsPanel);
|
||||
|
||||
@ -153,6 +160,7 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
this.Category?.RegisterAssistant(this);
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
@ -165,6 +173,7 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
this.Category?.UnregisterAssistant(this);
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
@if (this.HasVisibleAssistant)
|
||||
{
|
||||
<MudText Typo="Typo.h4" Class="@this.HeaderClass">
|
||||
@this.Title
|
||||
</MudText>
|
||||
}
|
||||
<CascadingValue Value="this" IsFixed="@true">
|
||||
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="@this.StackClass">
|
||||
@this.ChildContent
|
||||
</MudStack>
|
||||
</CascadingValue>
|
||||
@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Renders one category of assistants together with its heading.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The heading is derived from the assistant blocks inside this category: it is rendered only when
|
||||
/// at least one of them is visible. Thus, hiding assistants by configuration can never leave an
|
||||
/// empty category heading behind.
|
||||
/// </remarks>
|
||||
public partial class AssistantCategoryBlock : ComponentBase
|
||||
{
|
||||
private readonly HashSet<IAssistantCategoryMember> members = [];
|
||||
|
||||
/// <summary>
|
||||
/// The heading of this category.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The CSS classes used for the heading.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string HeaderClass { get; set; } = "mb-2 mr-3 mt-6";
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds an assistant block to this category.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Assistant blocks call this while they initialize, i.e. after this category was rendered for
|
||||
/// the first time. Hence, we have to render again to show the heading.
|
||||
/// </remarks>
|
||||
/// <param name="member">The assistant block which belongs to this category.</param>
|
||||
internal void RegisterAssistant(IAssistantCategoryMember member)
|
||||
{
|
||||
if (this.members.Add(member))
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an assistant block from this category.
|
||||
/// </summary>
|
||||
/// <param name="member">The assistant block which no longer belongs to this category.</param>
|
||||
internal void UnregisterAssistant(IAssistantCategoryMember member) => this.members.Remove(member);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether at least one assistant of this category is visible right now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We evaluate this live instead of caching it. That way, changes to the configuration take
|
||||
/// effect as soon as the assistants page renders again.
|
||||
/// </remarks>
|
||||
private bool HasVisibleAssistant => this.members.Any(member => member.IsVisible);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CSS classes used for the assistant stack.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The stack must be rendered even when no assistant is visible, because the assistant blocks
|
||||
/// register themselves while rendering. Without any visible assistant, we drop the margin so
|
||||
/// that a hidden category leaves no gap behind.
|
||||
/// </remarks>
|
||||
private string StackClass => this.HasVisibleAssistant ? "mb-3" : string.Empty;
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class AssistantPluginDeleteAction : MSGComponentBase
|
||||
{
|
||||
[Parameter, EditorRequired]
|
||||
public IAvailablePlugin Plugin { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<AssistantPluginDeleteAction> Logger { get; init; } = null!;
|
||||
|
||||
private bool CanDelete => AssistantPluginInstallService.CanDeleteInstalledAssistant(this.Plugin);
|
||||
|
||||
private bool IsBlockedByActiveWork => this.AssistantPluginInstallService.HasActiveAssistantWork(this.Plugin.Id);
|
||||
|
||||
private string Tooltip => this.IsBlockedByActiveWork
|
||||
? this.T("The assistant cannot be deleted while background work is still running.")
|
||||
: this.T("Delete assistant plugin");
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.ApplyFilters([], [ Event.ASSISTANT_SESSION_CHANGED, Event.ASSISTANT_SESSION_FINISHED ]);
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaTranscriptionStateChanged;
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAssistantPluginAsync()
|
||||
{
|
||||
if (!this.CanDelete || this.IsBlockedByActiveWork)
|
||||
return;
|
||||
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{
|
||||
x => x.Message,
|
||||
string.Format(this.T("Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."), this.Plugin.Name)
|
||||
},
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(this.T("Delete Assistant Plugin"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var result = await this.AssistantPluginInstallService.DeleteInstalledAssistantAsync(this.Plugin, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
{
|
||||
this.Logger.LogError("Failed to delete assistant plugin '{PluginName}' ({PluginId}) from '{PluginDirectory}' with issue '{Issue}'.", result.PluginName, result.PluginId, result.PluginDirectory, result.Issue);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.DeleteForever, string.Format(this.T("The assistant plugin '{0}' could not be deleted: {1}"), this.Plugin.Name, result.Issue)));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Check, string.Format(this.T("The '{0}' assistant plugin has been successfully removed."), result.PluginName)));
|
||||
}
|
||||
|
||||
private void OnMediaTranscriptionStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.EndsWith($":{this.Plugin.Id}", StringComparison.Ordinal))
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
|
||||
{
|
||||
if (triggeredEvent is Event.ASSISTANT_SESSION_CHANGED or Event.ASSISTANT_SESSION_FINISHED)
|
||||
this.StateHasChanged();
|
||||
|
||||
return base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data);
|
||||
}
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaTranscriptionStateChanged;
|
||||
base.DisposeResources();
|
||||
}
|
||||
}
|
||||
@ -443,23 +443,31 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
var mediaPaths = existingPaths.Where(IsTranscribableMedia).ToList();
|
||||
var regularPaths = existingPaths.Except(mediaPaths).ToList();
|
||||
|
||||
var canAddRegularFiles = true;
|
||||
if (regularPaths.Count > 0)
|
||||
//
|
||||
// Only the formats we convert with Pandoc depend on a Pandoc installation. Everything
|
||||
// else, PDFs in particular, is read by the Rust runtime itself, so those files must stay
|
||||
// attachable without Pandoc.
|
||||
//
|
||||
var canAddPandocFiles = true;
|
||||
if (regularPaths.Any(FileTypes.RequiresPandoc))
|
||||
{
|
||||
var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync(
|
||||
showSuccessMessage: false,
|
||||
showDialog: true);
|
||||
canAddRegularFiles = pandocState.IsAvailable;
|
||||
canAddPandocFiles = pandocState.IsAvailable;
|
||||
}
|
||||
|
||||
foreach (var path in regularPaths)
|
||||
{
|
||||
if (!canAddRegularFiles)
|
||||
break;
|
||||
if (!canAddPandocFiles && FileTypes.RequiresPandoc(path))
|
||||
{
|
||||
this.Logger.LogWarning("The file '{Path}' needs Pandoc and was not attached.", path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider))
|
||||
continue;
|
||||
|
||||
|
||||
this.DocumentPaths.Add(FileAttachment.FromPath(path));
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an assistant block which belongs to an assistant category.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Assistant blocks are generic over their settings dialog. This interface gives the category block
|
||||
/// access to their visibility without the need to know that type parameter.
|
||||
/// </remarks>
|
||||
public interface IAssistantCategoryMember
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the assistant is visible right now.
|
||||
/// </summary>
|
||||
bool IsVisible { get; }
|
||||
}
|
||||
@ -7,7 +7,7 @@
|
||||
Color="Color.Error"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Medium"
|
||||
Disabled="@this.IsBlockedByActiveWork"
|
||||
OnClick="@this.DeleteAssistantPluginAsync" />
|
||||
Disabled="@(this.isDeleting || this.IsBlockedByActiveWork)"
|
||||
OnClick="@this.DeletePluginAsync" />
|
||||
</MudTooltip>
|
||||
}
|
||||
169
app/MindWork AI Studio/Components/PluginDeleteAction.razor.cs
Normal file
169
app/MindWork AI Studio/Components/PluginDeleteAction.razor.cs
Normal file
@ -0,0 +1,169 @@
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Lets users remove a plugin they installed or placed themselves.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without this action, such a plugin could only be removed from the data directory by hand. That is
|
||||
/// especially painful for configuration plugins, which have no activation switch at all. Plugins
|
||||
/// shipped with AI Studio and plugins deployed by an organization stay untouched: the action does
|
||||
/// not appear for them.
|
||||
/// </remarks>
|
||||
public partial class PluginDeleteAction : MSGComponentBase
|
||||
{
|
||||
[Parameter, EditorRequired]
|
||||
public IAvailablePlugin Plugin { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private PluginInstallService PluginInstallService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<PluginDeleteAction> Logger { get; init; } = null!;
|
||||
|
||||
private bool isDeleting;
|
||||
|
||||
private bool IsAssistant => this.Plugin.Type is PluginType.ASSISTANT;
|
||||
|
||||
private bool CanDelete => PluginInstallService.CanDeletePlugin(this.Plugin);
|
||||
|
||||
/// <summary>
|
||||
/// True while an assistant still owns background work. We keep the action visible and block it
|
||||
/// instead of hiding it, so that the tooltip can explain why it does nothing right now.
|
||||
/// </summary>
|
||||
private bool IsBlockedByActiveWork => this.IsAssistant && this.PluginInstallService.HasActiveAssistantWork(this.Plugin.Id);
|
||||
|
||||
private string Tooltip
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.IsBlockedByActiveWork)
|
||||
return this.T("The assistant cannot be deleted while background work is still running.");
|
||||
|
||||
return this.Plugin.Type switch
|
||||
{
|
||||
PluginType.ASSISTANT => this.T("Delete assistant plugin"),
|
||||
PluginType.CONFIGURATION => this.T("Delete configuration plugin"),
|
||||
|
||||
_ => this.T("Delete language plugin"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Only an assistant can be busy. We watch its sessions and transcriptions, so the action
|
||||
// reflects the current state without the user reloading the page:
|
||||
this.ApplyFilters([], this.IsAssistant ? [Event.ASSISTANT_SESSION_CHANGED, Event.ASSISTANT_SESSION_FINISHED] : []);
|
||||
if (this.IsAssistant)
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaTranscriptionStateChanged;
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
|
||||
{
|
||||
if (triggeredEvent is Event.ASSISTANT_SESSION_CHANGED or Event.ASSISTANT_SESSION_FINISHED)
|
||||
this.StateHasChanged();
|
||||
|
||||
return base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data);
|
||||
}
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
if (this.IsAssistant)
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaTranscriptionStateChanged;
|
||||
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task DeletePluginAsync()
|
||||
{
|
||||
if (!this.CanDelete || this.isDeleting || this.IsBlockedByActiveWork)
|
||||
return;
|
||||
|
||||
if (!await this.ConfirmDeletionAsync())
|
||||
return;
|
||||
|
||||
this.isDeleting = true;
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await this.PluginInstallService.DeletePluginAsync(this.Plugin, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
{
|
||||
this.Logger.LogError("Failed to delete {PluginType} plugin '{PluginName}' ({PluginId}) from '{PluginDirectory}' with issue '{Issue}'.", this.Plugin.Type, result.PluginName, result.PluginId, result.PluginDirectory, result.Issue);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.DeleteForever, string.Format(this.T("The plugin '{0}' could not be deleted: {1}"), this.Plugin.Name, result.Issue)));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Check, string.Format(this.T("The plugin '{0}' has been successfully removed."), result.PluginName)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isDeleting = false;
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the user before the deletion. A configuration gets the dialog listing its consequences,
|
||||
/// because removing it also removes the providers and settings it brought. Assistants and
|
||||
/// language plugins only own their own files, so a plain confirmation is enough.
|
||||
/// </summary>
|
||||
private async Task<bool> ConfirmDeletionAsync()
|
||||
{
|
||||
if (this.Plugin.Type is PluginType.CONFIGURATION)
|
||||
{
|
||||
var configurationParameters = new DialogParameters<ConfigurationPluginDeleteDialog>
|
||||
{
|
||||
{ x => x.PluginName, this.Plugin.Name },
|
||||
{ x => x.Summary, this.PluginInstallService.BuildConfigurationDeleteSummary(this.Plugin) },
|
||||
};
|
||||
|
||||
var configurationDialog = await this.DialogService.ShowAsync<ConfigurationPluginDeleteDialog>(this.T("Delete Configuration Plugin"), configurationParameters, DialogOptions.FULLSCREEN);
|
||||
return await configurationDialog.Result is { Canceled: false };
|
||||
}
|
||||
|
||||
var title = this.IsAssistant
|
||||
? this.T("Delete Assistant Plugin")
|
||||
: this.T("Delete Language Plugin");
|
||||
|
||||
var message = this.IsAssistant
|
||||
? string.Format(this.T("Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."), this.Plugin.Name)
|
||||
: string.Format(this.T("Do you really want to delete the language plugin '{0}'? This permanently deletes its local plugin files. When it is your chosen language, AI Studio returns to choosing the language automatically."), this.Plugin.Name);
|
||||
|
||||
var parameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, message },
|
||||
};
|
||||
|
||||
var dialog = await this.DialogService.ShowAsync<ConfirmDialog>(title, parameters, DialogOptions.FULLSCREEN);
|
||||
return await dialog.Result is { Canceled: false };
|
||||
}
|
||||
|
||||
private void OnMediaTranscriptionStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.EndsWith($":{this.Plugin.Id}", StringComparison.Ordinal))
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
}
|
||||
@ -324,8 +324,13 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
|
||||
try
|
||||
{
|
||||
var fileContent = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService);
|
||||
await this.ApplyFileContentAsync(fileContent, filePath);
|
||||
var extraction = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService);
|
||||
|
||||
// The failure was already reported by UserFile.LoadFileData, so we only stop here:
|
||||
if (!extraction.HasUsableContent)
|
||||
return false;
|
||||
|
||||
await this.ApplyFileContentAsync(extraction.Content, filePath);
|
||||
this.Logger.LogInformation("Successfully loaded file content: {FilePath}", filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ public partial class AssistantPluginEditorDialog : MSGComponentBase
|
||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!;
|
||||
private PluginInstallService PluginInstallService { get; init; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public Guid PluginId { get; set; }
|
||||
@ -105,7 +105,7 @@ public partial class AssistantPluginEditorDialog : MSGComponentBase
|
||||
try
|
||||
{
|
||||
var editedLua = await this.codeEditor.GetCodeAsync();
|
||||
var result = await this.AssistantPluginInstallService.UpdateInstalledAssistantAsync(this.plugin, editedLua, CancellationToken.None);
|
||||
var result = await this.PluginInstallService.UpdateInstalledAssistantAsync(this.plugin, editedLua, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
{
|
||||
LOGGER.LogError($"Failed to update assistant plugin '{result.PluginName}' ({result.PluginId}) in '{result.PluginDirectory}' with issue '{result.Issue}'.");
|
||||
|
||||
@ -23,7 +23,7 @@ public partial class AssistantPluginRevisionDialog : MSGComponentBase
|
||||
private AssistantPluginGenerationService AssistantPluginGenerationService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!;
|
||||
private PluginInstallService PluginInstallService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!;
|
||||
@ -144,7 +144,7 @@ public partial class AssistantPluginRevisionDialog : MSGComponentBase
|
||||
if (this.availablePlugin is null)
|
||||
return;
|
||||
|
||||
this.revisionCheckResult = await this.AssistantPluginInstallService.CheckInstalledAssistantUpdateAsync(this.availablePlugin, this.revisedLua, CancellationToken.None);
|
||||
this.revisionCheckResult = await this.PluginInstallService.CheckInstalledAssistantUpdateAsync(this.availablePlugin, this.revisedLua, CancellationToken.None);
|
||||
if (this.revisionCheckResult.Success)
|
||||
return;
|
||||
|
||||
@ -168,7 +168,7 @@ public partial class AssistantPluginRevisionDialog : MSGComponentBase
|
||||
|
||||
try
|
||||
{
|
||||
var result = await this.AssistantPluginInstallService.UpdateInstalledAssistantAsync(this.availablePlugin, this.revisedLua, CancellationToken.None);
|
||||
var result = await this.PluginInstallService.UpdateInstalledAssistantAsync(this.availablePlugin, this.revisedLua, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
{
|
||||
LOGGER.LogError($"Failed to revise assistant plugin '{result.PluginName}' ({result.PluginId}) in '{result.PluginDirectory}' with issue '{result.Issue}'.");
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
@inherits MSGComponentBase
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@(string.Format(T("Do you really want to delete the configuration plugin '{0}'? This permanently deletes its local plugin files."), this.PluginName))
|
||||
</MudJustifiedText>
|
||||
|
||||
@if (this.Consequences.Count > 0)
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-1">
|
||||
@T("This also removes everything the configuration plugin had set up:")
|
||||
</MudJustifiedText>
|
||||
|
||||
<MudList T="string" Class="mb-3">
|
||||
@foreach (var consequence in this.Consequences)
|
||||
{
|
||||
<MudListItem T="string" Icon="@Icons.Material.Filled.RemoveCircleOutline" IconColor="Color.Error">
|
||||
@consequence
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("The configuration plugin is not running, so we cannot tell what it had set up. Anything it configured will be removed as well.")
|
||||
</MudJustifiedText>
|
||||
}
|
||||
|
||||
<MudJustifiedText Typo="Typo.body2">
|
||||
@T("You can install the plugin again later, but any changes you made to its settings are lost.")
|
||||
</MudJustifiedText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
|
||||
@T("No")
|
||||
</MudButton>
|
||||
<MudButton OnClick="@this.Confirm" Variant="Variant.Filled" Color="Color.Error">
|
||||
@T("Yes, delete it")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
@ -0,0 +1,69 @@
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Asks the user whether a local configuration plugin may be deleted, and shows what the deletion
|
||||
/// takes with it.
|
||||
/// </summary>
|
||||
public partial class ConfigurationPluginDeleteDialog : MSGComponentBase
|
||||
{
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the configuration plugin about to be deleted.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string PluginName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// What the deletion removes besides the plugin directory.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public ConfigurationPluginDeleteSummary Summary { get; set; } = ConfigurationPluginDeleteSummary.EMPTY;
|
||||
|
||||
private List<string> Consequences => this.BuildConsequences();
|
||||
|
||||
/// <summary>
|
||||
/// Turns the summary into the lines shown to the user. Only what is actually affected is listed,
|
||||
/// so the dialog stays short for a configuration plugin that just locks a single setting.
|
||||
/// </summary>
|
||||
private List<string> BuildConsequences()
|
||||
{
|
||||
var consequences = new List<string>();
|
||||
var summary = this.Summary;
|
||||
|
||||
Add(summary.LlmProviders, this.T("{0} LLM provider"), this.T("{0} LLM providers"));
|
||||
Add(summary.TranscriptionProviders, this.T("{0} transcription provider"), this.T("{0} transcription providers"));
|
||||
Add(summary.EmbeddingProviders, this.T("{0} embedding provider"), this.T("{0} embedding providers"));
|
||||
Add(summary.ChatTemplates, this.T("{0} chat template"), this.T("{0} chat templates"));
|
||||
Add(summary.Profiles, this.T("{0} profile"), this.T("{0} profiles"));
|
||||
Add(summary.DocumentAnalysisPolicies, this.T("{0} document analysis policy"), this.T("{0} document analysis policies"));
|
||||
Add(summary.MandatoryInfos, this.T("{0} mandatory information"), this.T("{0} mandatory informations"));
|
||||
Add(summary.Introductions, this.T("{0} introduction on the welcome page"), this.T("{0} introductions on the welcome page"));
|
||||
|
||||
// Data sources are called out separately: removing them also deletes their credentials from
|
||||
// the operating system's keychain, which the user cannot undo by reinstalling the plugin.
|
||||
Add(summary.DataSources,
|
||||
this.T("{0} data source, including its credentials in your operating system's keychain"),
|
||||
this.T("{0} data sources, including their credentials in your operating system's keychain"));
|
||||
|
||||
Add(summary.LockedSettings, this.T("{0} setting returns to its default value"), this.T("{0} settings return to their default values"));
|
||||
|
||||
return consequences;
|
||||
|
||||
void Add(int count, string singular, string plural)
|
||||
{
|
||||
if (count > 0)
|
||||
consequences.Add(string.Format(count == 1 ? singular : plural, count));
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
|
||||
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
@ -33,6 +33,22 @@
|
||||
@T("The specified file could not be found. The file have been moved, deleted, renamed, or is otherwise inaccessible.")
|
||||
</MudAlert>
|
||||
}
|
||||
else if (this.isLoadingContent)
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body1" Class="my-3">
|
||||
@T("Please wait while we load the content of your file. Depending on the file type and size, this may take a moment.")
|
||||
</MudJustifiedText>
|
||||
<MudSkeleton Width="30%" Height="42px"/>
|
||||
<MudSkeleton Width="80%"/>
|
||||
<MudSkeleton Width="100%"/>
|
||||
<MudSkeleton Width="90%"/>
|
||||
}
|
||||
else if (this.loadFailureMessage is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="my-2">
|
||||
@this.loadFailureMessage
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
|
||||
|
||||
@ -20,7 +20,18 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
|
||||
[Parameter]
|
||||
public string FileContent { get; set; } = string.Empty;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Set when reading the file failed, so the dialog shows the reason instead of empty content.
|
||||
/// </summary>
|
||||
private string? loadFailureMessage;
|
||||
|
||||
/// <summary>
|
||||
/// True while we extract the file content. Reading happens after the first render, so the
|
||||
/// dialog can tell the user that it is working instead of showing an empty document.
|
||||
/// </summary>
|
||||
private bool isLoadingContent;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
@ -30,25 +41,52 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
[Inject]
|
||||
private ILogger<DocumentCheckDialog> Logger { get; init; } = null!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
//
|
||||
// Decide before the first render whether we have to read the file at all. Images are shown
|
||||
// as they are, a missing file shows its own message, and content a caller already handed
|
||||
// us is reused instead of being extracted a second time:
|
||||
//
|
||||
this.isLoadingContent =
|
||||
this.Document is not null &&
|
||||
!this.Document.IsImage &&
|
||||
this.Document.Exists &&
|
||||
string.IsNullOrWhiteSpace(this.FileContent);
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && this.Document is not null)
|
||||
{
|
||||
if (!this.isLoadingContent)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (!this.Document.IsImage)
|
||||
{
|
||||
var fileContent = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService);
|
||||
this.FileContent = fileContent;
|
||||
}
|
||||
var extraction = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService);
|
||||
this.FileContent = extraction.Content;
|
||||
|
||||
//
|
||||
// This dialog exists so the user can check what we hand to the AI. Showing an
|
||||
// empty document when reading the file failed would answer that question wrong.
|
||||
//
|
||||
if (!extraction.HasUsableContent)
|
||||
this.loadFailureMessage = extraction.ToUserMessage(this.Document.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document);
|
||||
this.FileContent = string.Empty;
|
||||
this.loadFailureMessage = FileExtractionErrorCode.INTERNAL.ToUserMessage(this.Document.FileName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isLoadingContent = false;
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
this.StateHasChanged();
|
||||
}
|
||||
else if (firstRender)
|
||||
this.Logger.LogWarning("Document check dialog opened without a valid file path.");
|
||||
|
||||
@ -35,6 +35,51 @@
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@if (this.Preview.ConfigurationSummary is { HasAnyContent: true } configurationSummary)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mb-3">
|
||||
@T("A configuration takes effect right after the installation and has no on/off switch. Please check what it sets up:")
|
||||
</MudAlert>
|
||||
|
||||
@if (configurationSummary.Destinations.Count > 0)
|
||||
{
|
||||
<MudSimpleTable Dense="@true" Striped="@true" Class="mb-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@T("Sends data to")</th>
|
||||
<th>@T("Name")</th>
|
||||
<th>@T("Destination")</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var destination in configurationSummary.Destinations)
|
||||
{
|
||||
<tr>
|
||||
<td>@this.DestinationTypeLabel(destination.Type)</td>
|
||||
<td>@destination.Name</td>
|
||||
<td><strong>@destination.Endpoint</strong></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
|
||||
@if (this.ConfigurationContents.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mb-1">
|
||||
@T("It also brings:")
|
||||
</MudText>
|
||||
<MudList T="string" Class="mb-3">
|
||||
@foreach (var content in this.ConfigurationContents)
|
||||
{
|
||||
<MudListItem T="string" Icon="@Icons.Material.Filled.AddCircleOutline">
|
||||
@content
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(this.Preview.Plugin.DeprecationMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mb-3">
|
||||
|
||||
@ -41,6 +41,48 @@ public partial class PluginImportDialog : MSGComponentBase
|
||||
? string.Join(", ", this.Preview.Plugin.Authors)
|
||||
: this.T("Unknown");
|
||||
|
||||
/// <summary>
|
||||
/// Names the kind of a destination a configuration plugin brings.
|
||||
/// </summary>
|
||||
private string DestinationTypeLabel(PluginConfigurationObjectType objectType) => objectType switch
|
||||
{
|
||||
PluginConfigurationObjectType.LLM_PROVIDER => this.T("LLM provider"),
|
||||
PluginConfigurationObjectType.EMBEDDING_PROVIDER => this.T("Embedding provider"),
|
||||
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => this.T("Transcription provider"),
|
||||
PluginConfigurationObjectType.DATA_SOURCE => this.T("Data source"),
|
||||
|
||||
_ => this.T("Unknown"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Everything a configuration plugin brings besides its providers and data sources. Only what is
|
||||
/// actually there gets listed, so the dialog stays short for a small configuration.
|
||||
/// </summary>
|
||||
private List<string> ConfigurationContents
|
||||
{
|
||||
get
|
||||
{
|
||||
var contents = new List<string>();
|
||||
if (this.Preview.ConfigurationSummary is not { } summary)
|
||||
return contents;
|
||||
|
||||
Add(summary.DeclaredSettings, this.T("{0} setting it takes control of"), this.T("{0} settings it takes control of"));
|
||||
Add(summary.ChatTemplates, this.T("{0} chat template"), this.T("{0} chat templates"));
|
||||
Add(summary.Profiles, this.T("{0} profile"), this.T("{0} profiles"));
|
||||
Add(summary.DocumentAnalysisPolicies, this.T("{0} document analysis policy"), this.T("{0} document analysis policies"));
|
||||
Add(summary.MandatoryInfos, this.T("{0} mandatory information you have to accept before using AI Studio"), this.T("{0} mandatory information you have to accept before using AI Studio"));
|
||||
Add(summary.Introductions, this.T("{0} introduction on the welcome page"), this.T("{0} introductions on the welcome page"));
|
||||
|
||||
return contents;
|
||||
|
||||
void Add(int count, string singular, string plural)
|
||||
{
|
||||
if (count > 0)
|
||||
contents.Add(string.Format(count == 1 ? singular : plural, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
|
||||
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(true));
|
||||
|
||||
@ -12,36 +12,19 @@
|
||||
|
||||
<InnerScrolling>
|
||||
|
||||
@if (this.SettingsManager.IsAnyCategoryAssistantVisible("General",
|
||||
(Components.TEXT_SUMMARIZER_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.TRANSLATION_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.GRAMMAR_SPELLING_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.REWRITE_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.PROMPT_OPTIMIZER_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.SYNONYMS_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.META_ASSISTANT, PreviewFeatures.PRE_META_ASSISTANT_V1)
|
||||
))
|
||||
{
|
||||
<MudText Typo="Typo.h4" Class="mb-2 mr-3">
|
||||
@T("General")
|
||||
</MudText>
|
||||
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
|
||||
<AssistantBlock TSettings="SettingsDialogTextSummarizer" Component="Components.TEXT_SUMMARIZER_ASSISTANT" Name="@T("Text Summarizer")" Description="@T("Use an LLM to summarize a given text.")" Icon="@Icons.Material.Filled.TextSnippet" Link="@Routes.ASSISTANT_SUMMARIZER"/>
|
||||
<AssistantBlock TSettings="SettingsDialogTranslation" Component="Components.TRANSLATION_ASSISTANT" Name="@T("Translation")" Description="@T("Translate text into another language.")" Icon="@Icons.Material.Filled.Translate" Link="@Routes.ASSISTANT_TRANSLATION"/>
|
||||
<AssistantBlock TSettings="SettingsDialogGrammarSpelling" Component="Components.GRAMMAR_SPELLING_ASSISTANT" Name="@T("Grammar & Spelling")" Description="@T("Check grammar and spelling of a given text.")" Icon="@Icons.Material.Filled.Edit" Link="@Routes.ASSISTANT_GRAMMAR_SPELLING"/>
|
||||
<AssistantBlock TSettings="SettingsDialogRewrite" Component="Components.REWRITE_ASSISTANT" Name="@T("Rewrite & Improve")" Description="@T("Rewrite and improve a given text for a chosen style.")" Icon="@Icons.Material.Filled.Edit" Link="@Routes.ASSISTANT_REWRITE"/>
|
||||
<AssistantBlock TSettings="SettingsDialogPromptOptimizer" Component="Components.PROMPT_OPTIMIZER_ASSISTANT" Name="@T("Prompt Optimizer")" Description="@T("Optimize your prompt using a structured guideline.")" Icon="@Icons.Material.Filled.AutoFixHigh" Link="@Routes.ASSISTANT_PROMPT_OPTIMIZER"/>
|
||||
<AssistantBlock TSettings="SettingsDialogSynonyms" Component="Components.SYNONYMS_ASSISTANT" Name="@T("Synonyms")" Description="@T("Find synonyms for a given word or phrase.")" Icon="@Icons.Material.Filled.Spellcheck" Link="@Routes.ASSISTANT_SYNONYMS"/>
|
||||
<AssistantBlock TSettings="NoSettingsPanel" Component="Components.META_ASSISTANT" RequiredPreviewFeature="PreviewFeatures.PRE_META_ASSISTANT_V1" Name="@T("Assistant Builder")" Description="@T("Generate your own assistants.")" Icon="@Icons.Material.Filled.AutoMode" Link="@Routes.ASSISTANT_META_ASSISTANT"/>
|
||||
</MudStack>
|
||||
}
|
||||
<AssistantCategoryBlock Title="@T("General")" HeaderClass="mb-2 mr-3">
|
||||
<AssistantBlock TSettings="SettingsDialogTextSummarizer" Component="Components.TEXT_SUMMARIZER_ASSISTANT" Name="@T("Text Summarizer")" Description="@T("Use an LLM to summarize a given text.")" Icon="@Icons.Material.Filled.TextSnippet" Link="@Routes.ASSISTANT_SUMMARIZER"/>
|
||||
<AssistantBlock TSettings="SettingsDialogTranslation" Component="Components.TRANSLATION_ASSISTANT" Name="@T("Translation")" Description="@T("Translate text into another language.")" Icon="@Icons.Material.Filled.Translate" Link="@Routes.ASSISTANT_TRANSLATION"/>
|
||||
<AssistantBlock TSettings="SettingsDialogGrammarSpelling" Component="Components.GRAMMAR_SPELLING_ASSISTANT" Name="@T("Grammar & Spelling")" Description="@T("Check grammar and spelling of a given text.")" Icon="@Icons.Material.Filled.Edit" Link="@Routes.ASSISTANT_GRAMMAR_SPELLING"/>
|
||||
<AssistantBlock TSettings="SettingsDialogRewrite" Component="Components.REWRITE_ASSISTANT" Name="@T("Rewrite & Improve")" Description="@T("Rewrite and improve a given text for a chosen style.")" Icon="@Icons.Material.Filled.Edit" Link="@Routes.ASSISTANT_REWRITE"/>
|
||||
<AssistantBlock TSettings="SettingsDialogPromptOptimizer" Component="Components.PROMPT_OPTIMIZER_ASSISTANT" Name="@T("Prompt Optimizer")" Description="@T("Optimize your prompt using a structured guideline.")" Icon="@Icons.Material.Filled.AutoFixHigh" Link="@Routes.ASSISTANT_PROMPT_OPTIMIZER"/>
|
||||
<AssistantBlock TSettings="SettingsDialogSynonyms" Component="Components.SYNONYMS_ASSISTANT" Name="@T("Synonyms")" Description="@T("Find synonyms for a given word or phrase.")" Icon="@Icons.Material.Filled.Spellcheck" Link="@Routes.ASSISTANT_SYNONYMS"/>
|
||||
<AssistantBlock TSettings="NoSettingsPanel" Component="Components.META_ASSISTANT" RequiredPreviewFeature="PreviewFeatures.PRE_META_ASSISTANT_V1" Name="@T("Assistant Builder")" Description="@T("Generate your own assistants.")" Icon="@Icons.Material.Filled.AutoMode" Link="@Routes.ASSISTANT_META_ASSISTANT"/>
|
||||
</AssistantCategoryBlock>
|
||||
|
||||
@if (this.AssistantPlugins.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
|
||||
@T("Installed Assistants")
|
||||
</MudText>
|
||||
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
|
||||
<AssistantCategoryBlock Title="@T("Installed Assistants")">
|
||||
@foreach (var assistantPlugin in this.AssistantPlugins)
|
||||
{
|
||||
var securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, assistantPlugin);
|
||||
@ -58,7 +41,7 @@
|
||||
<AdditionalActions>
|
||||
@if (availablePlugin is not null)
|
||||
{
|
||||
<AssistantPluginDeleteAction Plugin="@availablePlugin" />
|
||||
<PluginDeleteAction Plugin="@availablePlugin" />
|
||||
}
|
||||
</AdditionalActions>
|
||||
<SecurityBadge>
|
||||
@ -66,78 +49,35 @@
|
||||
</SecurityBadge>
|
||||
</AssistantBlock>
|
||||
}
|
||||
</MudStack>
|
||||
</AssistantCategoryBlock>
|
||||
}
|
||||
|
||||
@if (this.SettingsManager.IsAnyCategoryAssistantVisible("Business",
|
||||
(Components.EMAIL_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.DOCUMENT_ANALYSIS_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.BATCH_PROCESSING_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.MY_TASKS_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.AGENDA_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.JOB_POSTING_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.LEGAL_CHECK_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.ICON_FINDER_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.SLIDE_BUILDER_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.VISUAL_BRIEFING_ASSISTANT, Components.VISUAL_BRIEFING_ASSISTANT.RequiredPreviewFeature())
|
||||
))
|
||||
{
|
||||
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
|
||||
@T("Business")
|
||||
</MudText>
|
||||
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
|
||||
<AssistantBlock TSettings="SettingsDialogWritingEMails" Component="Components.EMAIL_ASSISTANT" Name="@T("E-Mail")" Description="@T("Generate an e-mail for a given context.")" Icon="@Icons.Material.Filled.Email" Link="@Routes.ASSISTANT_EMAIL"/>
|
||||
<AssistantBlock TSettings="NoSettingsPanel" Component="Components.DOCUMENT_ANALYSIS_ASSISTANT" Name="@T("Document Analysis")" Description="@T("Analyze a document regarding defined rules and extract key information.")" Icon="@Icons.Material.Filled.DocumentScanner" Link="@Routes.ASSISTANT_DOCUMENT_ANALYSIS"/>
|
||||
<AssistantBlock TSettings="NoSettingsPanel" Component="Components.BATCH_PROCESSING_ASSISTANT" Name="@T("Batch Processing")" Description="@T("Process all documents of a folder in one batch run and collect the results.")" Icon="@Icons.Material.Filled.DynamicFeed" Link="@Routes.ASSISTANT_BATCH_PROCESSING"/>
|
||||
<AssistantBlock TSettings="SettingsDialogMyTasks" Component="Components.MY_TASKS_ASSISTANT" Name="@T("My Tasks")" Description="@T("Analyze a text or an email for tasks you need to complete.")" Icon="@Icons.Material.Filled.Task" Link="@Routes.ASSISTANT_MY_TASKS"/>
|
||||
<AssistantBlock TSettings="SettingsDialogAgenda" Component="Components.AGENDA_ASSISTANT" Name="@T("Agenda Planner")" Description="@T("Generate an agenda for a given meeting, seminar, etc.")" Icon="@Icons.Material.Filled.CalendarToday" Link="@Routes.ASSISTANT_AGENDA"/>
|
||||
<AssistantBlock TSettings="SettingsDialogJobPostings" Component="Components.JOB_POSTING_ASSISTANT" Name="@T("Job Posting")" Description="@T("Generate a job posting for a given job description.")" Icon="@Icons.Material.Filled.Work" Link="@Routes.ASSISTANT_JOB_POSTING"/>
|
||||
<AssistantBlock TSettings="SettingsDialogLegalCheck" Component="Components.LEGAL_CHECK_ASSISTANT" Name="@T("Legal Check")" Description="@T("Ask a question about a legal document.")" Icon="@Icons.Material.Filled.Gavel" Link="@Routes.ASSISTANT_LEGAL_CHECK"/>
|
||||
<AssistantBlock TSettings="SettingsDialogIconFinder" Component="Components.ICON_FINDER_ASSISTANT" Name="@T("Icon Finder")" Description="@T("Use an LLM to find an icon for a given context.")" Icon="@Icons.Material.Filled.FindInPage" Link="@Routes.ASSISTANT_ICON_FINDER"/>
|
||||
<AssistantBlock TSettings="SettingsDialogSlideBuilder" Component="Components.SLIDE_BUILDER_ASSISTANT" Name="@T("Slide Planner Assistant")" Description="@T("Develop slide content based on a given topic and content.")" Icon="@Icons.Material.Filled.Slideshow" Link="@Routes.ASSISTANT_SLIDE_BUILDER"/>
|
||||
<AssistantBlock TSettings="SettingsDialogVisualBriefing" Component="Components.VISUAL_BRIEFING_ASSISTANT" RequiredPreviewFeature="Components.VISUAL_BRIEFING_ASSISTANT.RequiredPreviewFeature()" Name="@T("Visual Briefing Assistant")" Description="@T("Turn documents, data, images, audio, and video into an audience-ready interactive briefing.")" Icon="@Icons.Material.Filled.DashboardCustomize" Link="@Routes.ASSISTANT_VISUAL_BRIEFING" />
|
||||
</MudStack>
|
||||
}
|
||||
<AssistantCategoryBlock Title="@T("Business")">
|
||||
<AssistantBlock TSettings="SettingsDialogWritingEMails" Component="Components.EMAIL_ASSISTANT" Name="@T("E-Mail")" Description="@T("Generate an e-mail for a given context.")" Icon="@Icons.Material.Filled.Email" Link="@Routes.ASSISTANT_EMAIL"/>
|
||||
<AssistantBlock TSettings="NoSettingsPanel" Component="Components.DOCUMENT_ANALYSIS_ASSISTANT" Name="@T("Document Analysis")" Description="@T("Analyze a document regarding defined rules and extract key information.")" Icon="@Icons.Material.Filled.DocumentScanner" Link="@Routes.ASSISTANT_DOCUMENT_ANALYSIS"/>
|
||||
<AssistantBlock TSettings="NoSettingsPanel" Component="Components.BATCH_PROCESSING_ASSISTANT" Name="@T("Batch Processing")" Description="@T("Process all documents of a folder in one batch run and collect the results.")" Icon="@Icons.Material.Filled.DynamicFeed" Link="@Routes.ASSISTANT_BATCH_PROCESSING"/>
|
||||
<AssistantBlock TSettings="SettingsDialogMyTasks" Component="Components.MY_TASKS_ASSISTANT" Name="@T("My Tasks")" Description="@T("Analyze a text or an email for tasks you need to complete.")" Icon="@Icons.Material.Filled.Task" Link="@Routes.ASSISTANT_MY_TASKS"/>
|
||||
<AssistantBlock TSettings="SettingsDialogAgenda" Component="Components.AGENDA_ASSISTANT" Name="@T("Agenda Planner")" Description="@T("Generate an agenda for a given meeting, seminar, etc.")" Icon="@Icons.Material.Filled.CalendarToday" Link="@Routes.ASSISTANT_AGENDA"/>
|
||||
<AssistantBlock TSettings="SettingsDialogJobPostings" Component="Components.JOB_POSTING_ASSISTANT" Name="@T("Job Posting")" Description="@T("Generate a job posting for a given job description.")" Icon="@Icons.Material.Filled.Work" Link="@Routes.ASSISTANT_JOB_POSTING"/>
|
||||
<AssistantBlock TSettings="SettingsDialogLegalCheck" Component="Components.LEGAL_CHECK_ASSISTANT" Name="@T("Legal Check")" Description="@T("Ask a question about a legal document.")" Icon="@Icons.Material.Filled.Gavel" Link="@Routes.ASSISTANT_LEGAL_CHECK"/>
|
||||
<AssistantBlock TSettings="SettingsDialogIconFinder" Component="Components.ICON_FINDER_ASSISTANT" Name="@T("Icon Finder")" Description="@T("Use an LLM to find an icon for a given context.")" Icon="@Icons.Material.Filled.FindInPage" Link="@Routes.ASSISTANT_ICON_FINDER"/>
|
||||
<AssistantBlock TSettings="SettingsDialogSlideBuilder" Component="Components.SLIDE_BUILDER_ASSISTANT" Name="@T("Slide Planner Assistant")" Description="@T("Develop slide content based on a given topic and content.")" Icon="@Icons.Material.Filled.Slideshow" Link="@Routes.ASSISTANT_SLIDE_BUILDER"/>
|
||||
<AssistantBlock TSettings="SettingsDialogVisualBriefing" Component="Components.VISUAL_BRIEFING_ASSISTANT" RequiredPreviewFeature="Components.VISUAL_BRIEFING_ASSISTANT.RequiredPreviewFeature()" Name="@T("Visual Briefing Assistant")" Description="@T("Turn documents, data, images, audio, and video into an audience-ready interactive briefing.")" Icon="@Icons.Material.Filled.DashboardCustomize" Link="@Routes.ASSISTANT_VISUAL_BRIEFING" />
|
||||
</AssistantCategoryBlock>
|
||||
|
||||
@if (this.SettingsManager.IsAnyCategoryAssistantVisible("Learning",
|
||||
(Components.BIAS_DAY_ASSISTANT, PreviewFeatures.NONE)
|
||||
))
|
||||
{
|
||||
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
|
||||
@T("Learning")
|
||||
</MudText>
|
||||
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
|
||||
<AssistantBlock TSettings="SettingsDialogAssistantBias" Component="Components.BIAS_DAY_ASSISTANT" Name="@T("Bias of the Day")" Description="@T("Learn about one cognitive bias every day.")" Icon="@Icons.Material.Filled.Psychology" Link="@Routes.ASSISTANT_BIAS"/>
|
||||
</MudStack>
|
||||
}
|
||||
<AssistantCategoryBlock Title="@T("Learning")">
|
||||
<AssistantBlock TSettings="SettingsDialogAssistantBias" Component="Components.BIAS_DAY_ASSISTANT" Name="@T("Bias of the Day")" Description="@T("Learn about one cognitive bias every day.")" Icon="@Icons.Material.Filled.Psychology" Link="@Routes.ASSISTANT_BIAS"/>
|
||||
</AssistantCategoryBlock>
|
||||
|
||||
@if (this.SettingsManager.IsAnyCategoryAssistantVisible("Software Engineering",
|
||||
(Components.CODING_ASSISTANT, PreviewFeatures.NONE),
|
||||
(Components.ERI_ASSISTANT, PreviewFeatures.PRE_RAG_2024),
|
||||
(Components.LOG_VIEWER_ASSISTANT, PreviewFeatures.NONE)
|
||||
))
|
||||
{
|
||||
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
|
||||
@T("Software Engineering")
|
||||
</MudText>
|
||||
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
|
||||
<AssistantBlock TSettings="SettingsDialogCoding" Component="Components.CODING_ASSISTANT" Name="@T("Coding")" Description="@T("Get coding and debugging support from an LLM.")" Icon="@Icons.Material.Filled.Code" Link="@Routes.ASSISTANT_CODING"/>
|
||||
<AssistantBlock TSettings="SettingsDialogERIServer" Component="Components.ERI_ASSISTANT" RequiredPreviewFeature="PreviewFeatures.PRE_RAG_2024" Name="@T("ERI Server")" Description="@T("Generate an ERI server to integrate business systems.")" Icon="@Icons.Material.Filled.PrivateConnectivity" Link="@Routes.ASSISTANT_ERI"/>
|
||||
</MudStack>
|
||||
}
|
||||
<AssistantCategoryBlock Title="@T("Software Engineering")">
|
||||
<AssistantBlock TSettings="SettingsDialogCoding" Component="Components.CODING_ASSISTANT" Name="@T("Coding")" Description="@T("Get coding and debugging support from an LLM.")" Icon="@Icons.Material.Filled.Code" Link="@Routes.ASSISTANT_CODING"/>
|
||||
<AssistantBlock TSettings="SettingsDialogERIServer" Component="Components.ERI_ASSISTANT" RequiredPreviewFeature="PreviewFeatures.PRE_RAG_2024" Name="@T("ERI Server")" Description="@T("Generate an ERI server to integrate business systems.")" Icon="@Icons.Material.Filled.PrivateConnectivity" Link="@Routes.ASSISTANT_ERI"/>
|
||||
</AssistantCategoryBlock>
|
||||
|
||||
@if (this.SettingsManager.IsAnyCategoryAssistantVisible("AI Studio Development",
|
||||
(Components.I18N_ASSISTANT, PreviewFeatures.NONE)
|
||||
))
|
||||
{
|
||||
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
|
||||
@T("AI Studio Development")
|
||||
</MudText>
|
||||
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
|
||||
<AssistantBlock TSettings="SettingsDialogI18N" Component="Components.I18N_ASSISTANT" Name="@T("Localization")" Description="@T("Translate AI Studio text content into other languages")" Icon="@Icons.Material.Filled.Translate" Link="@Routes.ASSISTANT_AI_STUDIO_I18N"/>
|
||||
<AssistantBlock TSettings="NoSettingsPanel" Component="Components.LOG_VIEWER_ASSISTANT" Name="@T("Log Viewer")" Description="@T("View and filter AI Studio log files.")" Icon="@Icons.Material.Filled.Article" Link="@Routes.ASSISTANT_LOG_VIEWER"/>
|
||||
</MudStack>
|
||||
}
|
||||
<AssistantCategoryBlock Title="@T("AI Studio Development")">
|
||||
<AssistantBlock TSettings="SettingsDialogI18N" Component="Components.I18N_ASSISTANT" Name="@T("Localization")" Description="@T("Translate AI Studio text content into other languages")" Icon="@Icons.Material.Filled.Translate" Link="@Routes.ASSISTANT_AI_STUDIO_I18N"/>
|
||||
<AssistantBlock TSettings="NoSettingsPanel" Component="Components.LOG_VIEWER_ASSISTANT" Name="@T("Log Viewer")" Description="@T("View and filter AI Studio log files.")" Icon="@Icons.Material.Filled.Article" Link="@Routes.ASSISTANT_LOG_VIEWER"/>
|
||||
</AssistantCategoryBlock>
|
||||
|
||||
</InnerScrolling>
|
||||
</div>
|
||||
@ -158,6 +158,31 @@
|
||||
break;
|
||||
}
|
||||
|
||||
@*
|
||||
A staged test configuration speaks for the organization without anybody
|
||||
having deployed it. We report it without the details having to be expanded:
|
||||
*@
|
||||
@if (this.testConfigPlugins.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-2">
|
||||
@T("A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app.")
|
||||
</MudText>
|
||||
@foreach (var testConfigPlugin in this.testConfigPlugins)
|
||||
{
|
||||
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.Science"
|
||||
HeaderText="@testConfigPlugin.Name"
|
||||
Items="@this.BuildTestConfigurationItems(testConfigPlugin)"
|
||||
ShowWarning="@true"
|
||||
WarningText="@T("Test configuration: nobody deployed this configuration. It is valid until you restart AI Studio.")"/>
|
||||
}
|
||||
}
|
||||
else if (PluginFactory.RemovedTestConfigurationsAtStartup > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-2">
|
||||
@string.Format(T("AI Studio removed {0} test configuration(s) while starting. A test configuration is valid for one session: place it again while AI Studio is running."), PluginFactory.RemovedTestConfigurationsAtStartup)
|
||||
</MudText>
|
||||
}
|
||||
|
||||
@if (this.HasEnterpriseConfigurationDetails)
|
||||
{
|
||||
<MudButton StartIcon="@(this.showEnterpriseConfigDetails ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
|
||||
@ -315,6 +340,8 @@
|
||||
<ThirdPartyComponent Name="windows-rs" Developer="Microsoft, Kenny Kerr, Ryan Levick, Rafael Rivera, sivadeilra, Marijn Suijten & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/microsoft/windows-rs/blob/master/license-mit" RepositoryUrl="https://github.com/microsoft/windows-rs" UseCase="@T("The windows-rs project provides access to Windows APIs from Rust. We use several libraries from this project: windows-registry is used to read the desired configuration in Windows enterprise environments. The windows and windows-collections libraries are used to open the native Windows share dialog, e.g., when you share a plugin with others.")"/>
|
||||
<ThirdPartyComponent Name="objc2" Developer="Steven Sheldon, Mads Marquart, silvanshade, Dzmitry Malyshau, Felix Nemo Kaaman, adamnemecek, Samuel Sleight, Paul Mabileau & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/madsmtm/objc2/blob/main/LICENSE-MIT.txt" RepositoryUrl="https://github.com/madsmtm/objc2" UseCase="@T("The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others.")"/>
|
||||
<ThirdPartyComponent Name="file-format" Developer="Mickaël Malécot & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/mmalecot/file-format/blob/main/LICENSE-MIT" RepositoryUrl="https://github.com/mmalecot/file-format" UseCase="@T("This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing.")"/>
|
||||
<ThirdPartyComponent Name="chardetng" Developer="Henri Sivonen & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/hsivonen/chardetng/blob/main/LICENSE-MIT" RepositoryUrl="https://github.com/hsivonen/chardetng" UseCase="@T("Text files are not always saved in the same encoding: files written on Windows often use a legacy one. chardetng recognizes which encoding a text file uses, so AI Studio can read it instead of rejecting it.")"/>
|
||||
<ThirdPartyComponent Name="encoding_rs" Developer="Henri Sivonen, M. Larsen, kornelski, Manish Goregaokar & Open Source Community" LicenseName="MIT & BSD-3-Clause" LicenseUrl="https://github.com/hsivonen/encoding_rs/blob/main/COPYRIGHT" RepositoryUrl="https://github.com/hsivonen/encoding_rs" UseCase="@T("Once the encoding of a text file is known, encoding_rs turns its content into the text AI Studio works with. Together with chardetng, this lets AI Studio read text, CSV, and similar files no matter which encoding they were saved in.")"/>
|
||||
<ThirdPartyComponent Name="Symphonia" Developer="Philip Deljanov & Open Source Community" LicenseName="MPL-2.0" LicenseUrl="https://github.com/pdeljanov/Symphonia/blob/v0.6.0/LICENSE" RepositoryUrl="https://github.com/pdeljanov/Symphonia" UseCase="@T("Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.")"/>
|
||||
<ThirdPartyComponent Name="Ropus" Developer="0x4D44, Xiph.Org, Skype Limited, Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding, Erik de Castro Lopo, Mozilla, Amazon & Open Source Community" LicenseName="BSD-3-Clause" LicenseUrl="https://github.com/0x4D44/ropus/blob/main/LICENSE" RepositoryUrl="https://github.com/0x4d44/ropus" UseCase="@T("Ropus provides the Opus encoder and decoder used by the media pipeline.")"/>
|
||||
<ThirdPartyComponent Name="Rubato" Developer="Henrik Enquist & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/HEnquist/rubato/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/HEnquist/rubato" UseCase="@T("We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding.")"/>
|
||||
|
||||
@ -107,10 +107,16 @@ public partial class Information : MSGComponentBase
|
||||
private bool showVectorStoreDetails;
|
||||
private bool showExternalHttpCustomRootCertificateDetails;
|
||||
|
||||
private List<IAvailablePlugin> configPlugins = PluginFactory.AvailablePlugins
|
||||
.Where(x => x.Type is PluginType.CONFIGURATION)
|
||||
.OfType<IAvailablePlugin>()
|
||||
.ToList();
|
||||
private List<IAvailablePlugin> configPlugins = [];
|
||||
|
||||
/// <summary>
|
||||
/// The configuration plugins an administrator staged for a test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// They are kept apart from the other configuration plugins: nobody deployed them, yet they act
|
||||
/// on behalf of the organization while they are loaded. That deserves its own note.
|
||||
/// </remarks>
|
||||
private List<IAvailablePlugin> testConfigPlugins = [];
|
||||
|
||||
private List<EnterpriseEnvironment> enterpriseEnvironments = EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.ToList();
|
||||
|
||||
@ -201,11 +207,14 @@ public partial class Information : MSGComponentBase
|
||||
|
||||
private void RefreshEnterpriseConfigurationState()
|
||||
{
|
||||
this.configPlugins = PluginFactory.AvailablePlugins
|
||||
var availableConfigPlugins = PluginFactory.AvailablePlugins
|
||||
.Where(x => x.Type is PluginType.CONFIGURATION)
|
||||
.OfType<IAvailablePlugin>()
|
||||
.ToList();
|
||||
|
||||
this.testConfigPlugins = availableConfigPlugins.Where(plugin => PluginFactory.IsEnterpriseTestConfigurationPath(plugin.LocalPath)).ToList();
|
||||
this.configPlugins = availableConfigPlugins.Except(this.testConfigPlugins).ToList();
|
||||
|
||||
this.enterpriseEnvironments = EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.ToList();
|
||||
this.mandatoryInfoPanels = PluginFactory.GetMandatoryInfos()
|
||||
.Select(info =>
|
||||
@ -404,6 +413,27 @@ public partial class Information : MSGComponentBase
|
||||
return plugin.ManagedConfigurationId == configurationId && plugin.Id != configurationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects what a user needs to find and judge a staged test configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There is no enterprise environment behind it, so we show what identifies it instead: the plugin
|
||||
/// ID it claims and the directory it was staged in.
|
||||
/// </remarks>
|
||||
private IReadOnlyList<ConfigInfoRowItem> BuildTestConfigurationItems(IAvailablePlugin plugin) =>
|
||||
[
|
||||
new(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration plugin ID:")} {plugin.Id}",
|
||||
plugin.Id.ToString(),
|
||||
T("Copies the configuration plugin ID to the clipboard")),
|
||||
|
||||
new(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Plugin directory:")} {plugin.LocalPath}",
|
||||
plugin.LocalPath,
|
||||
T("Copies the plugin directory to the clipboard"),
|
||||
"margin-top: 4px;"),
|
||||
];
|
||||
|
||||
private string ExternalHttpCustomRootCertificateWarningText
|
||||
{
|
||||
get
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
</MudText>
|
||||
<MudSpacer />
|
||||
<LockableButton Text="@T("Import")"
|
||||
Tooltip="@T("Import assistant plugin")"
|
||||
Tooltip="@T("Import plugin from a file")"
|
||||
Icon="@IMPORT_ICON"
|
||||
ButtonVariant="Variant.Outlined"
|
||||
ButtonColor="Color.Default"
|
||||
@ -138,7 +138,7 @@
|
||||
|
||||
@if (context is IAvailablePlugin availablePlugin)
|
||||
{
|
||||
<AssistantPluginDeleteAction Plugin="@availablePlugin" />
|
||||
<PluginDeleteAction Plugin="@availablePlugin" />
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@ -36,7 +36,7 @@ public partial class Plugins : MSGComponentBase
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!;
|
||||
private PluginInstallService PluginInstallService { get; init; } = null!;
|
||||
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(Plugins));
|
||||
|
||||
@ -236,11 +236,16 @@ public partial class Plugins : MSGComponentBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sharing is limited to assistant plugins because the import accepts only those. Otherwise,
|
||||
/// users would create archives nobody can install. Widen this once the import supports more
|
||||
/// plugin types.
|
||||
/// The plugin types users may share. This list has to match what the import accepts, otherwise
|
||||
/// users would create archives nobody can install.
|
||||
/// </summary>
|
||||
private static bool CanSharePlugin(IAvailablePlugin plugin) => plugin is { IsInternal: false, IsManagedByConfigServer: false, Type: PluginType.ASSISTANT } && !string.IsNullOrWhiteSpace(plugin.LocalPath);
|
||||
private static readonly PluginType[] SHAREABLE_PLUGIN_TYPES = [PluginType.ASSISTANT, PluginType.CONFIGURATION, PluginType.LANGUAGE];
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin may be shared or exported as an archive. Plugins shipped with
|
||||
/// AI Studio and plugins deployed by an organization stay with their owner.
|
||||
/// </summary>
|
||||
private static bool CanSharePlugin(IAvailablePlugin plugin) => plugin is { IsInternal: false, IsManagedByConfigServer: false } && SHAREABLE_PLUGIN_TYPES.Contains(plugin.Type) && !string.IsNullOrWhiteSpace(plugin.LocalPath);
|
||||
|
||||
/// <summary>
|
||||
/// Highlights the plugin table while the user drags a file over the page, so it is visible
|
||||
@ -354,7 +359,7 @@ public partial class Plugins : MSGComponentBase
|
||||
if (!this.AllowPluginImport)
|
||||
return;
|
||||
|
||||
var selection = await this.RustService.SelectFile(this.T("Import assistant plugin"), [FileTypes.PLUGIN_ARCHIVE]);
|
||||
var selection = await this.RustService.SelectFile(this.T("Import plugin"), [FileTypes.PLUGIN_ARCHIVE]);
|
||||
if (selection.UserCancelled)
|
||||
return;
|
||||
|
||||
@ -379,7 +384,7 @@ public partial class Plugins : MSGComponentBase
|
||||
|
||||
try
|
||||
{
|
||||
var result = await this.AssistantPluginInstallService.InstallArchiveAsync(archivePath, this.ConfirmPluginImportAsync, CancellationToken.None);
|
||||
var result = await this.PluginInstallService.InstallArchiveAsync(archivePath, this.ConfirmPluginImportAsync, CancellationToken.None);
|
||||
if (result.Cancelled)
|
||||
return;
|
||||
|
||||
@ -394,8 +399,8 @@ public partial class Plugins : MSGComponentBase
|
||||
}
|
||||
|
||||
var message = result.ReplacedExisting
|
||||
? this.T("Assistant updated.")
|
||||
: this.T("Assistant installed.");
|
||||
? this.T("Plugin updated.")
|
||||
: this.T("Plugin installed.");
|
||||
|
||||
// We do not announce the reload ourselves: a successful installation ran LoadAll, which
|
||||
// already sent PLUGINS_RELOADED. The import changes no settings either, so there is
|
||||
|
||||
@ -238,6 +238,21 @@ CONFIG["SETTINGS"] = {}
|
||||
-- DataAssistantPluginAudit.EnterpriseApprovedPlugins.
|
||||
-- ------
|
||||
|
||||
-- ------
|
||||
-- What happens to a setting when your configuration is removed
|
||||
-- ------
|
||||
--
|
||||
-- AI Studio remembers the value a setting had before a configuration took it over.
|
||||
-- Once no configuration manages that setting anymore -- because your IT department
|
||||
-- stopped deploying this configuration, because the user deleted it, or because a test
|
||||
-- configuration ended -- the user gets that value back. When there is nothing to
|
||||
-- restore, e.g. for a setting the user had never changed, AI Studio falls back to its
|
||||
-- own default value.
|
||||
--
|
||||
-- One case differs: when you allow users to override a setting and somebody makes use
|
||||
-- of that, their choice outlives your configuration and stays as it is.
|
||||
-- ------
|
||||
|
||||
-- Configure the update check interval:
|
||||
-- Allowed values are: NO_CHECK, DISABLE_UPDATES, ONCE_STARTUP, HOURLY, DAILY, WEEKLY
|
||||
-- NO_CHECK disables automatic checks, but users can still check and install updates manually.
|
||||
@ -278,6 +293,13 @@ CONFIG["SETTINGS"] = {}
|
||||
-- When set to false, the import button on the plugins page stays visible but is disabled.
|
||||
-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportPlugins"] = false
|
||||
|
||||
-- Configure the user permission to import configuration plugin archives from disk.
|
||||
-- This is a second gate on top of DataApp.AllowUserToImportPlugins: both must allow the
|
||||
-- import. Configuration plugins get their own switch because they can do far more than an
|
||||
-- assistant: they define LLM providers and data sources, and they lock settings. You may
|
||||
-- therefore let users import assistants while keeping configurations to your IT department.
|
||||
-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportConfigurationPlugins"] = false
|
||||
|
||||
-- Configure the user permission to share or export plugins as archives.
|
||||
-- When set to false, the share button on the plugins page stays visible but is disabled.
|
||||
-- On Linux, this button exports the plugin archive instead of using a native share sheet.
|
||||
@ -461,6 +483,12 @@ CONFIG["SETTINGS"] = {}
|
||||
-- You can generate the exact hash with the build-script command:
|
||||
-- dotnet run --project app/Build -- assistant-plugin-hash "<plugin-dir>" --lua-snippet
|
||||
--
|
||||
-- Only works in configurations your configuration server deploys. An approval marks an
|
||||
-- assistant plugin as safe without any audit, and AI Studio then tells users that their
|
||||
-- organization approved it. A configuration plugin that a user placed locally therefore
|
||||
-- cannot approve anything: AI Studio ignores its approvals and writes a warning to the
|
||||
-- log. This is decided by where the plugin is stored, not by DEPLOYED_USING_CONFIG_SERVER.
|
||||
--
|
||||
-- Adds up, does not replace: approvals of all your configurations are combined, so a
|
||||
-- department configuration can approve additional assistant plugins without repeating
|
||||
-- the approvals of the base configuration. Each configuration keeps its own approvals,
|
||||
|
||||
@ -1944,9 +1944,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T534887559"] =
|
||||
-- Please provide a custom language.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T656744944"] = "Bitte wählen Sie eine eigene Sprache aus."
|
||||
|
||||
-- The custom prompt guide file is empty or could not be read.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T1173408044"] = "Der benutzerdefinierte Prompting Leitfaden ist leer oder konnte nicht gelesen werden."
|
||||
|
||||
-- Use English for complex prompts and explicitly request response language if needed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T119999744"] = "Verwenden Sie Englisch für komplexe Prompts und fordern Sie dann explizit die gewünschte Antwortsprache im Prompt an."
|
||||
|
||||
@ -3075,6 +3072,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "Nein, b
|
||||
-- Export Chat to Microsoft Word
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T861873672"] = "Chat in Microsoft Word exportieren"
|
||||
|
||||
-- The file '{0}' is currently not available and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T1432544573"] = "Die Datei „{0}“ ist derzeit nicht verfügbar und wurde nicht gesendet."
|
||||
|
||||
-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3267850764"] = "Das ausgewählte Modell '{0}' ist bei '{1}' (Anbieter={2}) nicht mehr verfügbar. Bitte passen Sie Ihre Anbietereinstellungen an."
|
||||
|
||||
@ -3123,24 +3123,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3571008422"] = "Assisten
|
||||
-- The result is ready.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T661906146"] = "Das Ergebnis ist fertig."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T1318944584"] = "Der Assistent kann nicht gelöscht werden, solange noch Hintergrundaufgaben ausgeführt werden."
|
||||
|
||||
-- Delete assistant plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T1692493145"] = "Assistenten-Plugin löschen"
|
||||
|
||||
-- Delete Assistant Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T3637071001"] = "Assistenten-Plugin löschen"
|
||||
|
||||
-- The '{0}' assistant plugin has been successfully removed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T3815023384"] = "Das Assistenten-Plugin „{0}“ wurde erfolgreich entfernt."
|
||||
|
||||
-- The assistant plugin '{0}' could not be deleted: {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T3985264168"] = "Das Assistenten-Plugin „{0}“ konnte nicht gelöscht werden: {1}"
|
||||
|
||||
-- Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T4033722845"] = "Möchtest du das Assistenten-Plug-in „{0}“ wirklich löschen? Dadurch werden die lokalen Plug-in-Dateien dauerhaft gelöscht."
|
||||
|
||||
-- Show or hide the detailed security information.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1045105126"] = "Detaillierte Sicherheitsinformationen anzeigen oder ausblenden."
|
||||
|
||||
@ -3588,6 +3570,39 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Ko
|
||||
-- Cannot copy this content type to clipboard.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T3937637647"] = "Dieser Inhaltstyp kann nicht in die Zwischenablage kopiert werden."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T1318944584"] = "Der Assistent kann nicht gelöscht werden, solange noch Hintergrundaufgaben ausgeführt werden."
|
||||
|
||||
-- Delete assistant plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T1692493145"] = "Assistenten-Plugin löschen"
|
||||
|
||||
-- Do you really want to delete the language plugin '{0}'? This permanently deletes its local plugin files. When it is your chosen language, AI Studio returns to choosing the language automatically.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T1744561175"] = "Möchten Sie das Sprach-Plugin „{0}“ wirklich löschen? Dadurch werden die lokalen Plugin-Dateien dauerhaft gelöscht. Wenn dies Ihre ausgewählte Sprache ist, stellt AI Studio wieder auf die automatische Sprachauswahl um."
|
||||
|
||||
-- Delete language plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T2707495447"] = "Sprach-Plugin löschen"
|
||||
|
||||
-- The plugin '{0}' could not be deleted: {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T2738963920"] = "Das Plugin „{0}“ konnte nicht gelöscht werden: {1}"
|
||||
|
||||
-- Delete Language Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T2990518039"] = "Sprach-Plugin löschen"
|
||||
|
||||
-- Delete Configuration Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T3395354991"] = "Konfigurations-Plugin löschen"
|
||||
|
||||
-- The plugin '{0}' has been successfully removed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T3476138264"] = "Das Plugin „{0}“ wurde erfolgreich entfernt."
|
||||
|
||||
-- Delete Assistant Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T3637071001"] = "Assistenten-Plugin löschen"
|
||||
|
||||
-- Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T4033722845"] = "Möchten Sie das Assistenten-Plugin „{0}“ wirklich löschen? Dadurch werden die lokalen Plugin-Dateien dauerhaft gelöscht."
|
||||
|
||||
-- Delete configuration plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T459830575"] = "Konfigurations-Plugin löschen"
|
||||
|
||||
-- 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."
|
||||
|
||||
@ -4980,6 +4995,84 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T823785464"] = "Erlauben
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T900713019"] = "Abbrechen"
|
||||
|
||||
-- {0} LLM providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T121235760"] = "{0} LLM-Anbieter"
|
||||
|
||||
-- {0} profiles
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T1238255445"] = "{0} Profile"
|
||||
|
||||
-- No
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T1642511898"] = "Nein"
|
||||
|
||||
-- {0} introductions on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2107991661"] = "{0} Einführungen auf der Willkommensseite"
|
||||
|
||||
-- {0} mandatory information
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2150386772"] = "{0} Pflichtangabe"
|
||||
|
||||
-- You can install the plugin again later, but any changes you made to its settings are lost.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2156367745"] = "Du kannst das Plugin später erneut installieren, aber alle Änderungen an seinen Einstellungen gehen verloren."
|
||||
|
||||
-- {0} profile
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2342765572"] = "{0} Profil"
|
||||
|
||||
-- {0} introduction on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2426110502"] = "{0} Einführung auf der Willkommensseite"
|
||||
|
||||
-- {0} embedding providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2438407498"] = "{0} Anbieter für Einbettungen"
|
||||
|
||||
-- Yes, delete it
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2466176832"] = "Ja, löschen"
|
||||
|
||||
-- This also removes everything the configuration plugin had set up:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T264970454"] = "Dadurch wird auch alles entfernt, was das Konfigurations-Plugin eingerichtet hat:"
|
||||
|
||||
-- {0} transcription provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2681055470"] = "{0} Anbieter für Transkriptionen"
|
||||
|
||||
-- {0} chat templates
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3235448458"] = "{0} Chat-Vorlagen"
|
||||
|
||||
-- {0} document analysis policy
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3278137746"] = "{0} Regelwerk der Dokumentenanalyse"
|
||||
|
||||
-- The configuration plugin is not running, so we cannot tell what it had set up. Anything it configured will be removed as well.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T330559934"] = "Das Konfigurations-Plugin wird nicht ausgeführt, daher können wir nicht feststellen, was es eingerichtet hat. Alles, was es konfiguriert hat, wird ebenfalls entfernt."
|
||||
|
||||
-- {0} LLM provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3410030691"] = "{0} LLM-Anbieter"
|
||||
|
||||
-- Do you really want to delete the configuration plugin '{0}'? This permanently deletes its local plugin files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3616855807"] = "Möchten Sie das Konfigurations-Plugin „{0}“ wirklich löschen? Dadurch werden seine lokalen Plugin-Dateien dauerhaft gelöscht."
|
||||
|
||||
-- {0} settings return to their default values
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3841220170"] = "{0} Einstellungen werden auf ihre Standardwerte zurückgesetzt."
|
||||
|
||||
-- {0} setting returns to its default value
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T384701293"] = "{0} Einstellung wird auf den Standardwert zurückgesetzt."
|
||||
|
||||
-- {0} mandatory informations
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3971735909"] = "{0} Pflichtangaben"
|
||||
|
||||
-- {0} chat template
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T4147879421"] = "{0} Chat-Vorlage"
|
||||
|
||||
-- {0} data sources, including their credentials in your operating system's keychain
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T4193757254"] = "{0} Datenquellen, einschließlich ihrer Zugangsdaten im Schlüsselbund Ihres Betriebssystems"
|
||||
|
||||
-- {0} document analysis policies
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T449490978"] = "{0} Regelwerke der Dokumentenanalyse"
|
||||
|
||||
-- {0} data source, including its credentials in your operating system's keychain
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T511418335"] = "{0} Datenquelle einschließlich ihrer Zugangsdaten im Schlüsselbund Ihres Betriebssystems"
|
||||
|
||||
-- {0} transcription providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T767586087"] = "{0} Anbieter für Transkriptionen"
|
||||
|
||||
-- {0} embedding provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T818101181"] = "{0} Anbieter für Einbettungen"
|
||||
|
||||
-- No
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T1642511898"] = "Nein"
|
||||
|
||||
@ -5439,6 +5532,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEINFODIALOG::T3688254408"]
|
||||
-- Your security policy
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEINFODIALOG::T4081226330"] = "Ihre Sicherheitsrichtlinie"
|
||||
|
||||
-- Please wait while we load the content of your file. Depending on the file type and size, this may take a moment.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T1205126512"] = "Bitte warten Sie, während wir den Inhalt Ihrer Datei laden. Je nach Dateityp und -größe kann dies einen Moment dauern."
|
||||
|
||||
-- Markdown View
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T1373123357"] = "Markdown-Ansicht"
|
||||
|
||||
@ -5691,6 +5787,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T504404155"] = "Akzeptieren Si
|
||||
-- Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking "Accept the GPL and download the archive," you agree to the terms of the GPL license. Software under GPL is free of charge and free to use.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T523908375"] = "Pandoc wird unter der GNU General Public License v2 (GPL) vertrieben. Wenn Sie auf „GPL akzeptieren und Archiv herunterladen“ klicken, stimmen Sie den Bedingungen der GPL-Lizenz zu. Software unter der GPL ist kostenlos und frei nutzbar."
|
||||
|
||||
-- {0} profiles
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1238255445"] = "{0} Profile"
|
||||
|
||||
-- Install plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1525735539"] = "Plugin installieren"
|
||||
|
||||
@ -5706,6 +5805,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1974491324"] = "Sie sin
|
||||
-- Authors
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1985367263"] = "Autor:innen"
|
||||
|
||||
-- Data source
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2034620186"] = "Datenquelle"
|
||||
|
||||
-- A configuration takes effect right after the installation and has no on/off switch. Please check what it sets up:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2051328106"] = "Eine Konfiguration wird direkt nach der Installation wirksam und kann nicht ein- oder ausgeschaltet werden. Bitte prüfen Sie, was sie einrichtet:"
|
||||
|
||||
-- Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2053517490"] = "Plugins enthalten Code, der innerhalb von AI Studio ausgeführt wird. Installieren Sie Plugins nur, wenn Sie der Quelle vertrauen."
|
||||
|
||||
@ -5715,12 +5820,36 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2063808316"] = "Sie sin
|
||||
-- You are about to install a configuration plugin from a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T21052500"] = "Sie sind dabei, ein Konfigurations-Plugin aus einer Datei zu installieren."
|
||||
|
||||
-- {0} introductions on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2107991661"] = "{0} Einführungen auf der Willkommensseite"
|
||||
|
||||
-- You are about to install a theme plugin from a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2163853103"] = "Sie sind dabei, ein Design-Plugin aus einer Datei zu installieren."
|
||||
|
||||
-- {0} profile
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2342765572"] = "{0} Profil"
|
||||
|
||||
-- {0} introduction on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2426110502"] = "{0} Einführung auf der Willkommensseite"
|
||||
|
||||
-- Support contact
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2434966596"] = "Supportkontakt"
|
||||
|
||||
-- Name
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T266367750"] = "Name"
|
||||
|
||||
-- {0} setting it takes control of
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2868009192"] = "{0} Einstellung, die es übernimmt"
|
||||
|
||||
-- {0} settings it takes control of
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3190775003"] = "{0} Einstellungen, die es übernimmt"
|
||||
|
||||
-- {0} chat templates
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3235448458"] = "{0} Chat-Vorlagen"
|
||||
|
||||
-- {0} document analysis policy
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3278137746"] = "{0} Regelwerk für die Dokumentenanalyse"
|
||||
|
||||
-- This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3415610475"] = "Dies ersetzt das bereits installierte Plugin „{0}“. Version {1} wird durch Version {2} ersetzt."
|
||||
|
||||
@ -5730,18 +5859,45 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3424652889"] = "Unbekan
|
||||
-- Type
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3512062061"] = "Typ"
|
||||
|
||||
-- {0} mandatory information you have to accept before using AI Studio
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3540986519"] = "{0} Pflichtinformationen, die Sie vor der Nutzung von AI Studio akzeptieren müssen"
|
||||
|
||||
-- Transcription provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3566003684"] = "Transkriptionsanbieter"
|
||||
|
||||
-- Replace plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4068580334"] = "Plugin ersetzen"
|
||||
|
||||
-- LLM provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4099016901"] = "LLM-Anbieter"
|
||||
|
||||
-- {0} chat template
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4147879421"] = "{0} Chat-Vorlage"
|
||||
|
||||
-- {0} document analysis policies
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T449490978"] = "{0} Regelwerke für die Dokumentanalyse"
|
||||
|
||||
-- The authors marked this plugin as deprecated: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T497068698"] = "Die Autoren haben dieses Plugin als veraltet gekennzeichnet: {0}"
|
||||
|
||||
-- It also brings:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T713968030"] = "Außerdem bietet es:"
|
||||
|
||||
-- You are about to install a plugin from a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T841685558"] = "Sie sind dabei, ein Plugin aus einer Datei zu installieren."
|
||||
|
||||
-- Embedding provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T877326195"] = "Anbieter für Einbettungen"
|
||||
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T900713019"] = "Abbrechen"
|
||||
|
||||
-- Sends data to
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T914647109"] = "Sendet Daten an"
|
||||
|
||||
-- Destination
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T994314591"] = "Ziel"
|
||||
|
||||
-- Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1458195391"] = "Teilen Sie der KI mit, was sie machen soll. Was sind ihre Ziele oder was möchten Sie erreichen? Zum Beispiel, dass die KI Sie duzt."
|
||||
|
||||
@ -7791,6 +7947,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1290340974"] = "Unbekanntes Konf
|
||||
-- Copies the configuration slot to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1347508205"] = "Kopiert den Slot der Konfiguration in die Zwischenablage"
|
||||
|
||||
-- Once the encoding of a text file is known, encoding_rs turns its content into the text AI Studio works with. Together with chardetng, this lets AI Studio read text, CSV, and similar files no matter which encoding they were saved in.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1378412877"] = "Sobald die Zeichenkodierung einer Textdatei bekannt ist, wandelt encoding_rs ihren Inhalt in den Text um, mit dem AI Studio arbeitet. Zusammen mit chardetng kann AI Studio dadurch Text-, CSV- und ähnliche Dateien lesen – unabhängig davon, in welcher Kodierung sie gespeichert wurden."
|
||||
|
||||
-- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "Diese Bibliothek wird verwendet, um PDF-Dateien zu lesen. Das ist zum Beispiel notwendig, um PDFs als Datenquelle für einen Chat zu nutzen."
|
||||
|
||||
@ -7821,6 +7980,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1629800076"] = "Basierend auf .N
|
||||
-- AI Studio creates a log file at startup, in which events during startup are recorded. After startup, another log file is created that records all events that occur during the use of the app. This includes any errors that may occur. Depending on when an error occurs (at startup or during use), the contents of these log files can be helpful for troubleshooting. Sensitive information such as passwords is not included in the log files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1630237140"] = "AI Studio erstellt beim Start eine Protokolldatei, in der Ereignisse während des Starts aufgezeichnet werden. Nach dem Start wird eine weitere Protokolldatei erstellt, die alle Ereignisse während der Nutzung der App dokumentiert. Dazu gehören auch eventuell auftretende Fehler. Je nachdem, wann ein Fehler auftritt (beim Start oder während der Nutzung), können die Inhalte dieser Protokolldateien bei der Fehlerbehebung hilfreich sein. Sensible Informationen wie Passwörter werden nicht in den Protokolldateien gespeichert."
|
||||
|
||||
-- Plugin directory:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1698127325"] = "Plugin-Verzeichnis:"
|
||||
|
||||
-- Consent:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T171952677"] = "Zustimmung:"
|
||||
|
||||
@ -7899,6 +8061,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux-AppImages b
|
||||
-- Used PDFium version
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2368247719"] = "Verwendete PDFium-Version"
|
||||
|
||||
-- Text files are not always saved in the same encoding: files written on Windows often use a legacy one. chardetng recognizes which encoding a text file uses, so AI Studio can read it instead of rejecting it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T236832881"] = "Textdateien werden nicht immer mit derselben Zeichenkodierung gespeichert: Dateien, die unter Windows erstellt wurden, verwenden oft eine ältere Kodierung. chardetng erkennt, welche Zeichenkodierung eine Textdatei verwendet, sodass AI Studio sie lesen kann, statt sie abzulehnen."
|
||||
|
||||
-- installation provided by the system
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2371107659"] = "Installation vom System bereitgestellt"
|
||||
|
||||
@ -7986,6 +8151,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3002755581"] = "Diese Bibliothek
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Änderungsprotokoll"
|
||||
|
||||
-- Test configuration: nobody deployed this configuration. It is valid until you restart AI Studio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3019585985"] = "Testkonfiguration: Niemand hat diese Konfiguration bereitgestellt. Sie ist gültig, bis Sie AI Studio neu starten."
|
||||
|
||||
-- External HTTPS custom root certificates are configured but not active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3021325354"] = "Externe benutzerdefinierte Stammzertifikate sind konfiguriert, aber nicht aktiv."
|
||||
|
||||
@ -8001,6 +8169,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T313276297"] = "Verbinden Sie AI
|
||||
-- Have feature ideas? Submit suggestions for future AI Studio enhancements.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3178730036"] = "Haben Sie Ideen für neue Funktionen? Senden Sie uns Vorschläge für zukünftige Verbesserungen von AI Studio."
|
||||
|
||||
-- Copies the plugin directory to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3182878147"] = "Kopiert den Plugin-Ordner in die Zwischenablage"
|
||||
|
||||
-- Hide Details
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3183837919"] = "Details ausblenden"
|
||||
|
||||
@ -8124,6 +8295,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code
|
||||
-- Executable path
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4164953312"] = "Pfad der ausführbaren Datei"
|
||||
|
||||
-- AI Studio removed {0} test configuration(s) while starting. A test configuration is valid for one session: place it again while AI Studio is running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4172838224"] = "AI Studio hat beim Starten {0} Testkonfigurationen entfernt. Eine Testkonfiguration gilt nur für eine Sitzung: Fügen Sie sie erneut hinzu, während AI Studio ausgeführt wird."
|
||||
|
||||
-- We use the HtmlAgilityPack to extract content from the web. This is necessary, e.g., when you provide a URL as input for an assistant.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4184485147"] = "Wir verwenden das HtmlAgilityPack, um Inhalte aus dem Internet zu extrahieren. Das ist zum Beispiel notwendig, wenn Sie eine URL als Eingabe für einen Assistenten angeben."
|
||||
|
||||
@ -8193,6 +8367,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T870640199"] = "Für einige Daten
|
||||
-- How to update
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "Update-Anleitung"
|
||||
|
||||
-- A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T923110805"] = "Eine Testkonfiguration ist aktiv. Sie funktioniert wie eine Konfiguration Ihrer Organisation und kann beispielsweise Plugins für Assistenten genehmigen. AI Studio entfernt sie beim nächsten Start der App."
|
||||
|
||||
-- Install Pandoc
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Pandoc installieren"
|
||||
|
||||
@ -8205,18 +8382,30 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1430375822"] = "Plugin deaktivieren"
|
||||
-- Import
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1463683828"] = "Importieren"
|
||||
|
||||
-- Import plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1467093263"] = "Plugin importieren"
|
||||
|
||||
-- Assistant Audit
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1506922856"] = "Assistentenprüfung"
|
||||
|
||||
-- Internal Plugins
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T158493184"] = "Interne Plugins"
|
||||
|
||||
-- Plugin updated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1646565893"] = "Plugin aktualisiert."
|
||||
|
||||
-- Import plugin from a file
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T169921408"] = "Plugin aus einer Datei importieren"
|
||||
|
||||
-- Disabled Plugins
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Deaktivierte Plugins"
|
||||
|
||||
-- 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"
|
||||
|
||||
@ -8226,9 +8415,6 @@ 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"
|
||||
|
||||
-- Assistant installed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2069785341"] = "Assistent installiert."
|
||||
|
||||
-- Plugins
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins"
|
||||
|
||||
@ -8250,9 +8436,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3143506997"] = "Das Assistent-Plugin
|
||||
-- An error occurred while sharing the plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3184210266"] = "Beim Teilen des Plugins ist ein Fehler aufgetreten."
|
||||
|
||||
-- Import assistant plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3246593895"] = "Assistenten-Plugin importieren"
|
||||
|
||||
-- Your organization has disabled exporting plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3342440765"] = "Ihre Organisation hat das Exportieren von Plugins deaktiviert."
|
||||
|
||||
@ -8283,9 +8466,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3902690643"] = "Plugin installieren"
|
||||
-- Please drop only one plugin archive at a time.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3974628410"] = "Bitte legen Sie jeweils nur ein Plugin-Archiv gleichzeitig ab."
|
||||
|
||||
-- Assistant updated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T40397082"] = "Assistent aktualisiert."
|
||||
|
||||
-- The automatic security audit for the assistant plugin '{0}' failed. Please run it manually.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4066679817"] = "Die automatische Sicherheitsprüfung für das Assistenten-Plugin „{0}“ ist fehlgeschlagen. Bitte führen Sie sie manuell aus."
|
||||
|
||||
@ -9150,6 +9330,66 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T599774443"] = "Das
|
||||
-- policy files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T632340680"] = "Richtliniendateien"
|
||||
|
||||
-- The file type of '{0}' could not be determined, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1459702734"] = "Der Dateityp von „{0}“ konnte nicht bestimmt werden. Daher wurde die Datei nicht gesendet."
|
||||
|
||||
-- The file '{0}' is an executable program and was not sent, regardless of its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1481258284"] = "Die Datei „{0}“ ist ein ausführbares Programm und wurde unabhängig von ihrer Dateierweiterung nicht gesendet."
|
||||
|
||||
-- The file '{0}' could not be read and was not sent. When the file is stored on a network drive, the drive might be unavailable, or another program might be blocking the file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1488076079"] = "Die Datei „{0}“ konnte nicht gelesen und daher nicht gesendet werden. Wenn die Datei auf einem Netzlaufwerk gespeichert ist, ist das Laufwerk möglicherweise nicht verfügbar oder ein anderes Programm blockiert die Datei."
|
||||
|
||||
-- The pages {1} of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1928400379"] = "Die Seiten {1} der Datei „{0}“ konnten nicht gelesen werden. Der verbleibende Inhalt wurde gesendet."
|
||||
|
||||
-- Parts of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2036654169"] = "Teile der Datei „{0}“ konnten nicht gelesen werden. Der verbleibende Inhalt wurde gesendet."
|
||||
|
||||
-- The file type of '{0}' is not supported, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2064321829"] = "Der Dateityp von „{0}“ wird nicht unterstützt. Die Datei wurde daher nicht gesendet."
|
||||
|
||||
-- The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2240855899"] = "Die Datei „{0}“ ist keine lesbare Tabellenkalkulation und wurde nicht gesendet. Möglicherweise ist sie beschädigt oder unvollständig übertragen worden."
|
||||
|
||||
-- The file '{0}' is currently open in another program, which is why it was not sent. Please close the file and try again. When the file is stored on a shared network drive, a colleague might have it open.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2701144378"] = "Die Datei „{0}“ ist derzeit in einem anderen Programm geöffnet und wurde daher nicht gesendet. Bitte schließen Sie die Datei und versuchen Sie es erneut. Wenn die Datei auf einem freigegebenen Netzlaufwerk gespeichert ist, könnte sie von einem Kollegen geöffnet sein."
|
||||
|
||||
-- Reading the file '{0}' took too long and was stopped, so the file was not sent. When the file is stored on a network drive, the connection might be slow or interrupted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2793077828"] = "Das Lesen der Datei „{0}“ dauerte zu lange und wurde abgebrochen. Daher wurde die Datei nicht gesendet. Wenn die Datei auf einem Netzlaufwerk gespeichert ist, könnte die Verbindung langsam oder unterbrochen sein."
|
||||
|
||||
-- The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2891768359"] = "Die Datei „{0}“ ist keine lesbare PDF-Datei und wurde nicht gesendet. Sie ist möglicherweise beschädigt oder wurde unvollständig übertragen."
|
||||
|
||||
-- No text could be read from the file '{0}', so it was not sent. It might contain images only, such as a scanned PDF without a text layer, or no readable text at all.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2897122009"] = "Aus der Datei „{0}“ konnte kein Text gelesen werden, daher wurde sie nicht gesendet. Möglicherweise enthält sie nur Bilder, etwa ein gescanntes PDF ohne Textebene, oder gar keinen lesbaren Text."
|
||||
|
||||
-- The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3262447403"] = "Die Datei „{0}“ ist eine {1}, die AI Studio nicht lesen kann. Daher wurde sie nicht gesendet."
|
||||
|
||||
-- The file '{0}' is actually a {1} and was read as such. Please correct its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3297602719"] = "Die Datei „{0}“ ist tatsächlich eine {1} und wurde als solche gelesen. Bitte korrigieren Sie ihre Dateiendung."
|
||||
|
||||
-- The file '{0}' is not a text file and was not sent. Its content could not be read as text, so it might have a wrong file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3303873344"] = "Die Datei „{0}“ ist keine Textdatei und wurde nicht gesendet. Ihr Inhalt konnte nicht als Text gelesen werden; möglicherweise hat sie die falsche Dateiendung."
|
||||
|
||||
-- The file '{0}' could not be read and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3527027650"] = "Die Datei „{0}“ konnte nicht gelesen und daher nicht gesendet werden."
|
||||
|
||||
-- The file '{0}' is protected and could not be opened, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3840033580"] = "Die Datei „{0}“ ist geschützt und konnte nicht geöffnet werden. Daher wurde sie nicht gesendet."
|
||||
|
||||
-- AI Studio was not able to start its PDF engine, so the file '{0}' was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3927045859"] = "AI Studio konnte das PDF-System nicht starten, daher wurde die Datei „{0}“ nicht gesendet."
|
||||
|
||||
-- The file '{0}' does not exist anymore and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4071378057"] = "Die Datei „{0}“ existiert nicht mehr und wurde nicht gesendet."
|
||||
|
||||
-- The file '{0}' did not provide any content and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4291141931"] = "Die Datei „{0}“ enthielt keinen Inhalt und wurde nicht gesendet."
|
||||
|
||||
-- Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T594894810"] = "Zum Lesen der Datei „{0}“ wird Pandoc benötigt. Da Pandoc nicht verfügbar ist, wurde die Datei nicht gesendet."
|
||||
|
||||
-- AI Studio couldn't install Pandoc because the archive was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio konnte Pandoc nicht installieren, da das Archiv nicht gefunden wurde."
|
||||
|
||||
@ -9678,6 +9918,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
|
||||
-- Office Files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office-Dateien"
|
||||
|
||||
-- Tabular text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T13157661"] = "Tabellarischer Text"
|
||||
|
||||
-- Executable
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1364437037"] = "Ausführbare Dateien"
|
||||
|
||||
@ -9828,102 +10071,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T4
|
||||
-- Please create an assistant draft first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T4269176489"] = "Bitte erstellen Sie zuerst einen Entwurf für den Assistenten."
|
||||
|
||||
-- Internal assistant plugins cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1084244321"] = "Interne Assistenten-Plugins können nicht gelöscht werden."
|
||||
|
||||
-- This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1138181282"] = "Dieses Plugin-Archiv gibt an, von einem Konfigurationsserver verwaltet zu werden. Nur die IT-Abteilung Ihrer Organisation kann solche Plugins bereitstellen."
|
||||
|
||||
-- The assistant plugin directory is outside the local assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1211881977"] = "Das Assistenten-Plugin-Verzeichnis befindet sich außerhalb des lokalen Assistenten-Plugin-Verzeichnisses."
|
||||
|
||||
-- Only assistant plugins can be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1288328479"] = "Nur Assistant-Plugins können bearbeitet werden."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1318944584"] = "Der Assistent kann nicht gelöscht werden, solange noch Hintergrundaktivitäten ausgeführt werden."
|
||||
|
||||
-- Currently, only assistant plugins can be imported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T139615196"] = "Derzeit können nur Assistenten-Plugins importiert werden."
|
||||
|
||||
-- Please select a plugin archive with the extension .mwplugin or .zip.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1809137998"] = "Bitte wählen Sie ein Plugin-Archiv mit der Dateiendung .mwplugin oder .zip aus."
|
||||
|
||||
-- The selected plugin archive does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1821013825"] = "Das ausgewählte Plugin-Archiv existiert nicht."
|
||||
|
||||
-- No Lua plugin code was generated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1839013358"] = "Es wurde kein Lua-Plugin-Code generiert."
|
||||
|
||||
-- The assistant plugin directory does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2148384567"] = "Das Verzeichnis für das Assistenten-Plugin existiert nicht."
|
||||
|
||||
-- The resolved plugin directory is outside the assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2223071618"] = "Das ermittelte Plugin-Verzeichnis liegt außerhalb des Plugin-Verzeichnisses des Assistenten."
|
||||
|
||||
-- Unexpected error: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2350673880"] = "Unerwarteter Fehler: {0}"
|
||||
|
||||
-- The generated assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2441747251"] = "Das generierte Assistenten-Plugin verwendet die ID eines anderen installierten Plugins."
|
||||
|
||||
-- Config server managed assistant plugins cannot be replaced.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2594571117"] = "Vom Konfigurationsserver verwaltete Assistenten-Plugins können nicht ersetzt werden."
|
||||
|
||||
-- The assistant plugin has no local directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2682912892"] = "Das Assistenten-Plugin hat kein lokales Verzeichnis."
|
||||
|
||||
-- The AI Studio data directory is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2712481762"] = "Das Datenverzeichnis von AI Studio ist noch nicht initialisiert."
|
||||
|
||||
-- The imported assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2777304537"] = "Das importierte Assistenten-Plugin ist ungültig. Problem: {0}"
|
||||
|
||||
-- Only assistant plugins can be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2864597027"] = "Nur Assistant-Plugins können gelöscht werden."
|
||||
|
||||
-- The generated plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2955055168"] = "Das generierte Plugin ist kein Assistenten-Plugin. Problem: {0}"
|
||||
|
||||
-- The imported assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2971411166"] = "Das importierte Assistenten-Plugin verwendet die ID eines anderen installierten Plugins."
|
||||
|
||||
-- Your organization has disabled importing plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3212529834"] = "Ihre Organisation hat das Importieren von Plugins deaktiviert."
|
||||
|
||||
-- The plugin archive must contain exactly one plugin.lua file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3355918609"] = "Das Plugin-Archiv muss genau eine plugin.lua-Datei enthalten."
|
||||
|
||||
-- Config Server managed assistant plugins cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3751820312"] = "Von einem Config-Server verwaltete Assistenten-Plugins können nicht gelöscht werden."
|
||||
|
||||
-- Only assistants generated by the Assistant Builder can be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3940247198"] = "Nur mit dem Assistant Builder erstellte Assistenten können gelöscht werden."
|
||||
|
||||
-- The edited plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3984111892"] = "Das bearbeitete Plugin ist kein Assistenten-Plugin. Problem: {0}"
|
||||
|
||||
-- The plugin system is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3984839613"] = "Das Plugin-System ist noch nicht initialisiert."
|
||||
|
||||
-- The plugin file is outside the assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T4062980447"] = "Die Plugin-Datei befindet sich außerhalb des Assistenten-Plugin-Verzeichnisses."
|
||||
|
||||
-- The edited assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T554567780"] = "Das bearbeitete Assistenten-Plugin ist ungültig. Problem: {0}"
|
||||
|
||||
-- The edited assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T584770023"] = "Das bearbeitete Assistenten-Plugin verwendet die ID eines anderen installierten Plugins."
|
||||
|
||||
-- The edited assistant plugin must keep the same plugin ID.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T693124809"] = "Das bearbeitete Assistant-Plugin muss dieselbe Plugin-ID beibehalten."
|
||||
|
||||
-- Internal assistant plugins cannot be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T816339833"] = "Interne Assistenten-Plugins können nicht bearbeitet werden."
|
||||
|
||||
-- The generated assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "Das generierte Assistenten-Plugin ist ungültig. Problem: {0}"
|
||||
|
||||
-- The voice recording shortcut currently works only while AI Studio is focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "Die Tastenkombination für Sprachaufnahmen funktioniert derzeit nur, wenn AI Studio im Vordergrund aktiv ist."
|
||||
|
||||
@ -9975,6 +10122,114 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701
|
||||
-- Pandoc may be required for importing files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Zum Importieren von Dateien kann Pandoc erforderlich sein."
|
||||
|
||||
-- This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1138181282"] = "Dieses Plugin-Archiv gibt an, von einem Konfigurationsserver verwaltet zu werden. Nur die IT-Abteilung Ihrer Organisation kann solche Plugins bereitstellen."
|
||||
|
||||
-- The imported plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1195382910"] = "Das importierte Plugin verwendet die ID eines anderen installierten Plugins."
|
||||
|
||||
-- The assistant plugin directory is outside the local assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1211881977"] = "Das Assistenten-Plugin-Verzeichnis befindet sich außerhalb des lokalen Assistenten-Plugin-Verzeichnisses."
|
||||
|
||||
-- Only assistant plugins can be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1288328479"] = "Nur Assistant-Plugins können bearbeitet werden."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1318944584"] = "Der Assistent kann nicht gelöscht werden, solange noch Hintergrundaktivitäten ausgeführt werden."
|
||||
|
||||
-- Plugins deployed by your organization cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1348456011"] = "Von Ihrer Organisation bereitgestellte Plugins können nicht gelöscht werden."
|
||||
|
||||
-- The resolved plugin directory is outside the plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1559620698"] = "Das ermittelte Plugin-Verzeichnis befindet sich außerhalb des Plugin-Verzeichnisses."
|
||||
|
||||
-- Please select a plugin archive with the extension .mwplugin or .zip.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1809137998"] = "Bitte wählen Sie ein Plugin-Archiv mit der Dateiendung .mwplugin oder .zip aus."
|
||||
|
||||
-- The selected plugin archive does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1821013825"] = "Das ausgewählte Plugin-Archiv existiert nicht."
|
||||
|
||||
-- No Lua plugin code was generated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1839013358"] = "Es wurde kein Lua-Plugin-Code generiert."
|
||||
|
||||
-- Only assistant, configuration, and language plugins can be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1878846406"] = "Nur Assistenten-, Konfigurations- und Sprach-Plugins können gelöscht werden."
|
||||
|
||||
-- Your organization has disabled importing configuration plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2134532120"] = "Ihre Organisation hat das Importieren von Konfigurations-Plugins deaktiviert."
|
||||
|
||||
-- The assistant plugin directory does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2148384567"] = "Das Verzeichnis für das Assistenten-Plugin existiert nicht."
|
||||
|
||||
-- The plugin directory does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2221093487"] = "Das Plugin-Verzeichnis existiert nicht."
|
||||
|
||||
-- Unexpected error: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2350673880"] = "Unerwarteter Fehler: {0}"
|
||||
|
||||
-- The generated assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2441747251"] = "Das generierte Assistenten-Plugin verwendet die ID eines anderen installierten Plugins."
|
||||
|
||||
-- This individual plugin’s directory is outside the expected plugins directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2486199999"] = "Das Verzeichnis dieses einzelnen Plugins liegt außerhalb des erwarteten Plugin-Verzeichnisses."
|
||||
|
||||
-- The assistant plugin has no local directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2682912892"] = "Das Assistenten-Plugin hat kein lokales Verzeichnis."
|
||||
|
||||
-- The AI Studio data directory is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2712481762"] = "Das Datenverzeichnis von AI Studio ist noch nicht initialisiert."
|
||||
|
||||
-- Only assistant, configuration, and language plugins can be imported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2909113247"] = "Es können nur Assistenten-, Konfigurations- und Sprach-Plugins importiert werden."
|
||||
|
||||
-- The generated plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2955055168"] = "Das generierte Plugin ist kein Assistenten-Plugin. Problem: {0}"
|
||||
|
||||
-- Your organization has disabled importing plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3212529834"] = "Ihre Organisation hat das Importieren von Plugins deaktiviert."
|
||||
|
||||
-- The plugin has no local directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3284289028"] = "Das Plugin hat kein lokales Verzeichnis."
|
||||
|
||||
-- The plugin archive must contain exactly one plugin.lua file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3355918609"] = "Das Plugin-Archiv muss genau eine plugin.lua-Datei enthalten."
|
||||
|
||||
-- Your organization deployed a configuration with the same ID. An imported configuration must not take its place.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T352004699"] = "Ihre Organisation hat bereits eine Konfiguration mit derselben ID bereitgestellt. Eine importierte Konfiguration darf diese nicht ersetzen."
|
||||
|
||||
-- The imported plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3634046009"] = "Das importierte Plugin ist ungültig. Problem: {0}"
|
||||
|
||||
-- Plugins shipped with AI Studio cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3841213017"] = "Mit AI Studio ausgelieferte Plugins können nicht gelöscht werden."
|
||||
|
||||
-- The edited plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3984111892"] = "Das bearbeitete Plugin ist kein Assistenten-Plugin. Problem: {0}"
|
||||
|
||||
-- The plugin system is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3984839613"] = "Das Plugin-System ist noch nicht initialisiert."
|
||||
|
||||
-- The plugin file is outside the assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T4062980447"] = "Die Plugin-Datei befindet sich außerhalb des Assistenten-Plugin-Verzeichnisses."
|
||||
|
||||
-- Plugins deployed by your organization cannot be replaced.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T553820956"] = "Von Ihrer Organisation bereitgestellte Plugins können nicht ersetzt werden."
|
||||
|
||||
-- The edited assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T554567780"] = "Das bearbeitete Assistenten-Plugin ist ungültig. Problem: {0}"
|
||||
|
||||
-- The edited assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T584770023"] = "Das bearbeitete Assistenten-Plugin verwendet die ID eines anderen installierten Plugins."
|
||||
|
||||
-- The edited assistant plugin must keep the same plugin ID.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T693124809"] = "Das bearbeitete Assistant-Plugin muss dieselbe Plugin-ID beibehalten."
|
||||
|
||||
-- Internal assistant plugins cannot be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T816339833"] = "Interne Assistenten-Plugins können nicht bearbeitet werden."
|
||||
|
||||
-- The generated assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T939708112"] = "Das generierte Assistenten-Plugin ist ungültig. Problem: {0}"
|
||||
|
||||
-- Internal plugins cannot be shared.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T1668534561"] = "Interne Plugins können nicht geteilt werden."
|
||||
|
||||
@ -10083,9 +10338,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4261248356"] = "Von der KI
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T185447014"] = "Pandoc-Installation"
|
||||
|
||||
-- Pandoc may be required for importing files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T2596465560"] = "Für das Importieren von Dateien ist möglicherweise Pandoc erforderlich."
|
||||
|
||||
-- The file path is null or empty and the file therefore can not be loaded.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T932243993"] = "Der Dateipfad ist leer, daher kann die Datei nicht geladen werden."
|
||||
|
||||
|
||||
@ -1944,9 +1944,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T534887559"] =
|
||||
-- Please provide a custom language.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::MYTASKS::ASSISTANTMYTASKS::T656744944"] = "Please provide a custom language."
|
||||
|
||||
-- The custom prompt guide file is empty or could not be read.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T1173408044"] = "The custom prompt guide file is empty or could not be read."
|
||||
|
||||
-- Use English for complex prompts and explicitly request response language if needed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::PROMPTOPTIMIZER::ASSISTANTPROMPTOPTIMIZER::T119999744"] = "Use English for complex prompts and explicitly request response language if needed."
|
||||
|
||||
@ -3075,6 +3072,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "No, kee
|
||||
-- Export Chat to Microsoft Word
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T861873672"] = "Export Chat to Microsoft Word"
|
||||
|
||||
-- The file '{0}' is currently not available and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T1432544573"] = "The file '{0}' is currently not available and was not sent."
|
||||
|
||||
-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3267850764"] = "The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings."
|
||||
|
||||
@ -3123,24 +3123,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3571008422"] = "Assistan
|
||||
-- The result is ready.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T661906146"] = "The result is ready."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T1318944584"] = "The assistant cannot be deleted while background work is still running."
|
||||
|
||||
-- Delete assistant plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T1692493145"] = "Delete assistant plugin"
|
||||
|
||||
-- Delete Assistant Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T3637071001"] = "Delete Assistant Plugin"
|
||||
|
||||
-- The '{0}' assistant plugin has been successfully removed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T3815023384"] = "The '{0}' assistant plugin has been successfully removed."
|
||||
|
||||
-- The assistant plugin '{0}' could not be deleted: {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T3985264168"] = "The assistant plugin '{0}' could not be deleted: {1}"
|
||||
|
||||
-- Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINDELETEACTION::T4033722845"] = "Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."
|
||||
|
||||
-- Show or hide the detailed security information.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1045105126"] = "Show or hide the detailed security information."
|
||||
|
||||
@ -3588,6 +3570,39 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Co
|
||||
-- Cannot copy this content type to clipboard.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T3937637647"] = "Cannot copy this content type to clipboard."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T1318944584"] = "The assistant cannot be deleted while background work is still running."
|
||||
|
||||
-- Delete assistant plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T1692493145"] = "Delete assistant plugin"
|
||||
|
||||
-- Do you really want to delete the language plugin '{0}'? This permanently deletes its local plugin files. When it is your chosen language, AI Studio returns to choosing the language automatically.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T1744561175"] = "Do you really want to delete the language plugin '{0}'? This permanently deletes its local plugin files. When it is your chosen language, AI Studio returns to choosing the language automatically."
|
||||
|
||||
-- Delete language plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T2707495447"] = "Delete language plugin"
|
||||
|
||||
-- The plugin '{0}' could not be deleted: {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T2738963920"] = "The plugin '{0}' could not be deleted: {1}"
|
||||
|
||||
-- Delete Language Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T2990518039"] = "Delete Language Plugin"
|
||||
|
||||
-- Delete Configuration Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T3395354991"] = "Delete Configuration Plugin"
|
||||
|
||||
-- The plugin '{0}' has been successfully removed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T3476138264"] = "The plugin '{0}' has been successfully removed."
|
||||
|
||||
-- Delete Assistant Plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T3637071001"] = "Delete Assistant Plugin"
|
||||
|
||||
-- Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T4033722845"] = "Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."
|
||||
|
||||
-- Delete configuration plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PLUGINDELETEACTION::T459830575"] = "Delete configuration plugin"
|
||||
|
||||
-- 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."
|
||||
|
||||
@ -4980,6 +4995,84 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T823785464"] = "Allow th
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T900713019"] = "Cancel"
|
||||
|
||||
-- {0} LLM providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T121235760"] = "{0} LLM providers"
|
||||
|
||||
-- {0} profiles
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T1238255445"] = "{0} profiles"
|
||||
|
||||
-- No
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T1642511898"] = "No"
|
||||
|
||||
-- {0} introductions on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2107991661"] = "{0} introductions on the welcome page"
|
||||
|
||||
-- {0} mandatory information
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2150386772"] = "{0} mandatory information"
|
||||
|
||||
-- You can install the plugin again later, but any changes you made to its settings are lost.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2156367745"] = "You can install the plugin again later, but any changes you made to its settings are lost."
|
||||
|
||||
-- {0} profile
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2342765572"] = "{0} profile"
|
||||
|
||||
-- {0} introduction on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2426110502"] = "{0} introduction on the welcome page"
|
||||
|
||||
-- {0} embedding providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2438407498"] = "{0} embedding providers"
|
||||
|
||||
-- Yes, delete it
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2466176832"] = "Yes, delete it"
|
||||
|
||||
-- This also removes everything the configuration plugin had set up:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T264970454"] = "This also removes everything the configuration plugin had set up:"
|
||||
|
||||
-- {0} transcription provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2681055470"] = "{0} transcription provider"
|
||||
|
||||
-- {0} chat templates
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3235448458"] = "{0} chat templates"
|
||||
|
||||
-- {0} document analysis policy
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3278137746"] = "{0} document analysis policy"
|
||||
|
||||
-- The configuration plugin is not running, so we cannot tell what it had set up. Anything it configured will be removed as well.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T330559934"] = "The configuration plugin is not running, so we cannot tell what it had set up. Anything it configured will be removed as well."
|
||||
|
||||
-- {0} LLM provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3410030691"] = "{0} LLM provider"
|
||||
|
||||
-- Do you really want to delete the configuration plugin '{0}'? This permanently deletes its local plugin files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3616855807"] = "Do you really want to delete the configuration plugin '{0}'? This permanently deletes its local plugin files."
|
||||
|
||||
-- {0} settings return to their default values
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3841220170"] = "{0} settings return to their default values"
|
||||
|
||||
-- {0} setting returns to its default value
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T384701293"] = "{0} setting returns to its default value"
|
||||
|
||||
-- {0} mandatory informations
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T3971735909"] = "{0} mandatory informations"
|
||||
|
||||
-- {0} chat template
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T4147879421"] = "{0} chat template"
|
||||
|
||||
-- {0} data sources, including their credentials in your operating system's keychain
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T4193757254"] = "{0} data sources, including their credentials in your operating system's keychain"
|
||||
|
||||
-- {0} document analysis policies
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T449490978"] = "{0} document analysis policies"
|
||||
|
||||
-- {0} data source, including its credentials in your operating system's keychain
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T511418335"] = "{0} data source, including its credentials in your operating system's keychain"
|
||||
|
||||
-- {0} transcription providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T767586087"] = "{0} transcription providers"
|
||||
|
||||
-- {0} embedding provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T818101181"] = "{0} embedding provider"
|
||||
|
||||
-- No
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T1642511898"] = "No"
|
||||
|
||||
@ -5439,6 +5532,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEINFODIALOG::T3688254408"]
|
||||
-- Your security policy
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEINFODIALOG::T4081226330"] = "Your security policy"
|
||||
|
||||
-- Please wait while we load the content of your file. Depending on the file type and size, this may take a moment.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T1205126512"] = "Please wait while we load the content of your file. Depending on the file type and size, this may take a moment."
|
||||
|
||||
-- Markdown View
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T1373123357"] = "Markdown View"
|
||||
|
||||
@ -5691,6 +5787,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T504404155"] = "Accept the ter
|
||||
-- Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking "Accept the GPL and download the archive," you agree to the terms of the GPL license. Software under GPL is free of charge and free to use.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T523908375"] = "Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking \"Accept the GPL and download the archive,\" you agree to the terms of the GPL license. Software under GPL is free of charge and free to use."
|
||||
|
||||
-- {0} profiles
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1238255445"] = "{0} profiles"
|
||||
|
||||
-- Install plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1525735539"] = "Install plugin"
|
||||
|
||||
@ -5706,6 +5805,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1974491324"] = "You are
|
||||
-- Authors
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1985367263"] = "Authors"
|
||||
|
||||
-- Data source
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2034620186"] = "Data source"
|
||||
|
||||
-- A configuration takes effect right after the installation and has no on/off switch. Please check what it sets up:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2051328106"] = "A configuration takes effect right after the installation and has no on/off switch. Please check what it sets up:"
|
||||
|
||||
-- Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2053517490"] = "Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source."
|
||||
|
||||
@ -5715,12 +5820,36 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2063808316"] = "You are
|
||||
-- You are about to install a configuration plugin from a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T21052500"] = "You are about to install a configuration plugin from a file."
|
||||
|
||||
-- {0} introductions on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2107991661"] = "{0} introductions on the welcome page"
|
||||
|
||||
-- You are about to install a theme plugin from a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2163853103"] = "You are about to install a theme plugin from a file."
|
||||
|
||||
-- {0} profile
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2342765572"] = "{0} profile"
|
||||
|
||||
-- {0} introduction on the welcome page
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2426110502"] = "{0} introduction on the welcome page"
|
||||
|
||||
-- Support contact
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2434966596"] = "Support contact"
|
||||
|
||||
-- Name
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T266367750"] = "Name"
|
||||
|
||||
-- {0} setting it takes control of
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2868009192"] = "{0} setting it takes control of"
|
||||
|
||||
-- {0} settings it takes control of
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3190775003"] = "{0} settings it takes control of"
|
||||
|
||||
-- {0} chat templates
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3235448458"] = "{0} chat templates"
|
||||
|
||||
-- {0} document analysis policy
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3278137746"] = "{0} document analysis policy"
|
||||
|
||||
-- This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3415610475"] = "This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}."
|
||||
|
||||
@ -5730,18 +5859,45 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3424652889"] = "Unknown
|
||||
-- Type
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3512062061"] = "Type"
|
||||
|
||||
-- {0} mandatory information you have to accept before using AI Studio
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3540986519"] = "{0} mandatory information you have to accept before using AI Studio"
|
||||
|
||||
-- Transcription provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3566003684"] = "Transcription provider"
|
||||
|
||||
-- Replace plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4068580334"] = "Replace plugin"
|
||||
|
||||
-- LLM provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4099016901"] = "LLM provider"
|
||||
|
||||
-- {0} chat template
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4147879421"] = "{0} chat template"
|
||||
|
||||
-- {0} document analysis policies
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T449490978"] = "{0} document analysis policies"
|
||||
|
||||
-- The authors marked this plugin as deprecated: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T497068698"] = "The authors marked this plugin as deprecated: {0}"
|
||||
|
||||
-- It also brings:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T713968030"] = "It also brings:"
|
||||
|
||||
-- You are about to install a plugin from a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T841685558"] = "You are about to install a plugin from a file."
|
||||
|
||||
-- Embedding provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T877326195"] = "Embedding provider"
|
||||
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T900713019"] = "Cancel"
|
||||
|
||||
-- Sends data to
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T914647109"] = "Sends data to"
|
||||
|
||||
-- Destination
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T994314591"] = "Destination"
|
||||
|
||||
-- Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1458195391"] = "Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally."
|
||||
|
||||
@ -7791,6 +7947,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1290340974"] = "Unknown configur
|
||||
-- Copies the configuration slot to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1347508205"] = "Copies the configuration slot to the clipboard"
|
||||
|
||||
-- Once the encoding of a text file is known, encoding_rs turns its content into the text AI Studio works with. Together with chardetng, this lets AI Studio read text, CSV, and similar files no matter which encoding they were saved in.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1378412877"] = "Once the encoding of a text file is known, encoding_rs turns its content into the text AI Studio works with. Together with chardetng, this lets AI Studio read text, CSV, and similar files no matter which encoding they were saved in."
|
||||
|
||||
-- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat."
|
||||
|
||||
@ -7821,6 +7980,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1629800076"] = "Building on .NET
|
||||
-- AI Studio creates a log file at startup, in which events during startup are recorded. After startup, another log file is created that records all events that occur during the use of the app. This includes any errors that may occur. Depending on when an error occurs (at startup or during use), the contents of these log files can be helpful for troubleshooting. Sensitive information such as passwords is not included in the log files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1630237140"] = "AI Studio creates a log file at startup, in which events during startup are recorded. After startup, another log file is created that records all events that occur during the use of the app. This includes any errors that may occur. Depending on when an error occurs (at startup or during use), the contents of these log files can be helpful for troubleshooting. Sensitive information such as passwords is not included in the log files."
|
||||
|
||||
-- Plugin directory:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1698127325"] = "Plugin directory:"
|
||||
|
||||
-- Consent:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T171952677"] = "Consent:"
|
||||
|
||||
@ -7899,6 +8061,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux AppImages b
|
||||
-- Used PDFium version
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2368247719"] = "Used PDFium version"
|
||||
|
||||
-- Text files are not always saved in the same encoding: files written on Windows often use a legacy one. chardetng recognizes which encoding a text file uses, so AI Studio can read it instead of rejecting it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T236832881"] = "Text files are not always saved in the same encoding: files written on Windows often use a legacy one. chardetng recognizes which encoding a text file uses, so AI Studio can read it instead of rejecting it."
|
||||
|
||||
-- installation provided by the system
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2371107659"] = "installation provided by the system"
|
||||
|
||||
@ -7986,6 +8151,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3002755581"] = "This library ide
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog"
|
||||
|
||||
-- Test configuration: nobody deployed this configuration. It is valid until you restart AI Studio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3019585985"] = "Test configuration: nobody deployed this configuration. It is valid until you restart AI Studio."
|
||||
|
||||
-- External HTTPS custom root certificates are configured but not active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3021325354"] = "External HTTPS custom root certificates are configured but not active."
|
||||
|
||||
@ -8001,6 +8169,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T313276297"] = "Connect AI Studio
|
||||
-- Have feature ideas? Submit suggestions for future AI Studio enhancements.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3178730036"] = "Have feature ideas? Submit suggestions for future AI Studio enhancements."
|
||||
|
||||
-- Copies the plugin directory to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3182878147"] = "Copies the plugin directory to the clipboard"
|
||||
|
||||
-- Hide Details
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3183837919"] = "Hide Details"
|
||||
|
||||
@ -8124,6 +8295,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code
|
||||
-- Executable path
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4164953312"] = "Executable path"
|
||||
|
||||
-- AI Studio removed {0} test configuration(s) while starting. A test configuration is valid for one session: place it again while AI Studio is running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4172838224"] = "AI Studio removed {0} test configuration(s) while starting. A test configuration is valid for one session: place it again while AI Studio is running."
|
||||
|
||||
-- We use the HtmlAgilityPack to extract content from the web. This is necessary, e.g., when you provide a URL as input for an assistant.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4184485147"] = "We use the HtmlAgilityPack to extract content from the web. This is necessary, e.g., when you provide a URL as input for an assistant."
|
||||
|
||||
@ -8193,6 +8367,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T870640199"] = "For some data tra
|
||||
-- How to update
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T906183311"] = "How to update"
|
||||
|
||||
-- A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T923110805"] = "A test configuration is active. It acts like a configuration of your organization and may, for example, approve assistant plugins. AI Studio removes it the next time you start the app."
|
||||
|
||||
-- Install Pandoc
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T986578435"] = "Install Pandoc"
|
||||
|
||||
@ -8205,18 +8382,30 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1430375822"] = "Disable plugin"
|
||||
-- Import
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1463683828"] = "Import"
|
||||
|
||||
-- Import plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1467093263"] = "Import plugin"
|
||||
|
||||
-- Assistant Audit
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1506922856"] = "Assistant Audit"
|
||||
|
||||
-- Internal Plugins
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T158493184"] = "Internal Plugins"
|
||||
|
||||
-- Plugin updated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1646565893"] = "Plugin updated."
|
||||
|
||||
-- Import plugin from a file
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T169921408"] = "Import plugin from a file"
|
||||
|
||||
-- Disabled Plugins
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Disabled Plugins"
|
||||
|
||||
-- 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"
|
||||
|
||||
@ -8226,9 +8415,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin"
|
||||
-- No source url available
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2058912565"] = "No source url available"
|
||||
|
||||
-- Assistant installed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2069785341"] = "Assistant installed."
|
||||
|
||||
-- Plugins
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins"
|
||||
|
||||
@ -8250,9 +8436,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3143506997"] = "The assistant plugin
|
||||
-- An error occurred while sharing the plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3184210266"] = "An error occurred while sharing the plugin."
|
||||
|
||||
-- Import assistant plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3246593895"] = "Import assistant plugin"
|
||||
|
||||
-- Your organization has disabled exporting plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3342440765"] = "Your organization has disabled exporting plugins."
|
||||
|
||||
@ -8283,9 +8466,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3902690643"] = "Install Plugin"
|
||||
-- Please drop only one plugin archive at a time.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3974628410"] = "Please drop only one plugin archive at a time."
|
||||
|
||||
-- Assistant updated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T40397082"] = "Assistant updated."
|
||||
|
||||
-- The automatic security audit for the assistant plugin '{0}' failed. Please run it manually.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4066679817"] = "The automatic security audit for the assistant plugin '{0}' failed. Please run it manually."
|
||||
|
||||
@ -9150,6 +9330,66 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T599774443"] = "The
|
||||
-- policy files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T632340680"] = "policy files"
|
||||
|
||||
-- The file type of '{0}' could not be determined, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1459702734"] = "The file type of '{0}' could not be determined, so the file was not sent."
|
||||
|
||||
-- The file '{0}' is an executable program and was not sent, regardless of its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1481258284"] = "The file '{0}' is an executable program and was not sent, regardless of its file extension."
|
||||
|
||||
-- The file '{0}' could not be read and was not sent. When the file is stored on a network drive, the drive might be unavailable, or another program might be blocking the file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1488076079"] = "The file '{0}' could not be read and was not sent. When the file is stored on a network drive, the drive might be unavailable, or another program might be blocking the file."
|
||||
|
||||
-- The pages {1} of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T1928400379"] = "The pages {1} of the file '{0}' could not be read. The remaining content was sent."
|
||||
|
||||
-- Parts of the file '{0}' could not be read. The remaining content was sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2036654169"] = "Parts of the file '{0}' could not be read. The remaining content was sent."
|
||||
|
||||
-- The file type of '{0}' is not supported, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2064321829"] = "The file type of '{0}' is not supported, so the file was not sent."
|
||||
|
||||
-- The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2240855899"] = "The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely."
|
||||
|
||||
-- The file '{0}' is currently open in another program, which is why it was not sent. Please close the file and try again. When the file is stored on a shared network drive, a colleague might have it open.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2701144378"] = "The file '{0}' is currently open in another program, which is why it was not sent. Please close the file and try again. When the file is stored on a shared network drive, a colleague might have it open."
|
||||
|
||||
-- Reading the file '{0}' took too long and was stopped, so the file was not sent. When the file is stored on a network drive, the connection might be slow or interrupted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2793077828"] = "Reading the file '{0}' took too long and was stopped, so the file was not sent. When the file is stored on a network drive, the connection might be slow or interrupted."
|
||||
|
||||
-- The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2891768359"] = "The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely."
|
||||
|
||||
-- No text could be read from the file '{0}', so it was not sent. It might contain images only, such as a scanned PDF without a text layer, or no readable text at all.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2897122009"] = "No text could be read from the file '{0}', so it was not sent. It might contain images only, such as a scanned PDF without a text layer, or no readable text at all."
|
||||
|
||||
-- The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3262447403"] = "The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent."
|
||||
|
||||
-- The file '{0}' is actually a {1} and was read as such. Please correct its file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3297602719"] = "The file '{0}' is actually a {1} and was read as such. Please correct its file extension."
|
||||
|
||||
-- The file '{0}' is not a text file and was not sent. Its content could not be read as text, so it might have a wrong file extension.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3303873344"] = "The file '{0}' is not a text file and was not sent. Its content could not be read as text, so it might have a wrong file extension."
|
||||
|
||||
-- The file '{0}' could not be read and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3527027650"] = "The file '{0}' could not be read and was not sent."
|
||||
|
||||
-- The file '{0}' is protected and could not be opened, so it was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3840033580"] = "The file '{0}' is protected and could not be opened, so it was not sent."
|
||||
|
||||
-- AI Studio was not able to start its PDF engine, so the file '{0}' was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3927045859"] = "AI Studio was not able to start its PDF engine, so the file '{0}' was not sent."
|
||||
|
||||
-- The file '{0}' does not exist anymore and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4071378057"] = "The file '{0}' does not exist anymore and was not sent."
|
||||
|
||||
-- The file '{0}' did not provide any content and was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T4291141931"] = "The file '{0}' did not provide any content and was not sent."
|
||||
|
||||
-- Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T594894810"] = "Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent."
|
||||
|
||||
-- AI Studio couldn't install Pandoc because the archive was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T1059477764"] = "AI Studio couldn't install Pandoc because the archive was not found."
|
||||
|
||||
@ -9678,6 +9918,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
|
||||
-- Office Files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office Files"
|
||||
|
||||
-- Tabular text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T13157661"] = "Tabular text"
|
||||
|
||||
-- Executable
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1364437037"] = "Executable"
|
||||
|
||||
@ -9828,102 +10071,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T4
|
||||
-- Please create an assistant draft first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T4269176489"] = "Please create an assistant draft first."
|
||||
|
||||
-- Internal assistant plugins cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1084244321"] = "Internal assistant plugins cannot be deleted."
|
||||
|
||||
-- This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1138181282"] = "This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."
|
||||
|
||||
-- The assistant plugin directory is outside the local assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1211881977"] = "The assistant plugin directory is outside the local assistant plugin directory."
|
||||
|
||||
-- Only assistant plugins can be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1288328479"] = "Only assistant plugins can be edited."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1318944584"] = "The assistant cannot be deleted while background work is still running."
|
||||
|
||||
-- Currently, only assistant plugins can be imported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T139615196"] = "Currently, only assistant plugins can be imported."
|
||||
|
||||
-- Please select a plugin archive with the extension .mwplugin or .zip.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1809137998"] = "Please select a plugin archive with the extension .mwplugin or .zip."
|
||||
|
||||
-- The selected plugin archive does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1821013825"] = "The selected plugin archive does not exist."
|
||||
|
||||
-- No Lua plugin code was generated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1839013358"] = "No Lua plugin code was generated."
|
||||
|
||||
-- The assistant plugin directory does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2148384567"] = "The assistant plugin directory does not exist."
|
||||
|
||||
-- The resolved plugin directory is outside the assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2223071618"] = "The resolved plugin directory is outside the assistant plugin directory."
|
||||
|
||||
-- Unexpected error: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2350673880"] = "Unexpected error: {0}"
|
||||
|
||||
-- The generated assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2441747251"] = "The generated assistant plugin uses the ID of another installed plugin."
|
||||
|
||||
-- Config server managed assistant plugins cannot be replaced.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2594571117"] = "Config server managed assistant plugins cannot be replaced."
|
||||
|
||||
-- The assistant plugin has no local directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2682912892"] = "The assistant plugin has no local directory."
|
||||
|
||||
-- The AI Studio data directory is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2712481762"] = "The AI Studio data directory is not initialized yet."
|
||||
|
||||
-- The imported assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2777304537"] = "The imported assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- Only assistant plugins can be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2864597027"] = "Only assistant plugins can be deleted."
|
||||
|
||||
-- The generated plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2955055168"] = "The generated plugin is not an assistant plugin. Issue: {0}"
|
||||
|
||||
-- The imported assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2971411166"] = "The imported assistant plugin uses the ID of another installed plugin."
|
||||
|
||||
-- Your organization has disabled importing plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3212529834"] = "Your organization has disabled importing plugins."
|
||||
|
||||
-- The plugin archive must contain exactly one plugin.lua file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3355918609"] = "The plugin archive must contain exactly one plugin.lua file."
|
||||
|
||||
-- Config Server managed assistant plugins cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3751820312"] = "Config Server managed assistant plugins cannot be deleted."
|
||||
|
||||
-- Only assistants generated by the Assistant Builder can be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3940247198"] = "Only assistants generated by the Assistant Builder can be deleted."
|
||||
|
||||
-- The edited plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3984111892"] = "The edited plugin is not an assistant plugin. Issue: {0}"
|
||||
|
||||
-- The plugin system is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3984839613"] = "The plugin system is not initialized yet."
|
||||
|
||||
-- The plugin file is outside the assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T4062980447"] = "The plugin file is outside the assistant plugin directory."
|
||||
|
||||
-- The edited assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T554567780"] = "The edited assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- The edited assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T584770023"] = "The edited assistant plugin uses the ID of another installed plugin."
|
||||
|
||||
-- The edited assistant plugin must keep the same plugin ID.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T693124809"] = "The edited assistant plugin must keep the same plugin ID."
|
||||
|
||||
-- Internal assistant plugins cannot be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T816339833"] = "Internal assistant plugins cannot be edited."
|
||||
|
||||
-- The generated assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "The generated assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- The voice recording shortcut currently works only while AI Studio is focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "The voice recording shortcut currently works only while AI Studio is focused."
|
||||
|
||||
@ -9975,6 +10122,114 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701
|
||||
-- Pandoc may be required for importing files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Pandoc may be required for importing files."
|
||||
|
||||
-- This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1138181282"] = "This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."
|
||||
|
||||
-- The imported plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1195382910"] = "The imported plugin uses the ID of another installed plugin."
|
||||
|
||||
-- The assistant plugin directory is outside the local assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1211881977"] = "The assistant plugin directory is outside the local assistant plugin directory."
|
||||
|
||||
-- Only assistant plugins can be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1288328479"] = "Only assistant plugins can be edited."
|
||||
|
||||
-- The assistant cannot be deleted while background work is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1318944584"] = "The assistant cannot be deleted while background work is still running."
|
||||
|
||||
-- Plugins deployed by your organization cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1348456011"] = "Plugins deployed by your organization cannot be deleted."
|
||||
|
||||
-- The resolved plugin directory is outside the plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1559620698"] = "The resolved plugin directory is outside the plugin directory."
|
||||
|
||||
-- Please select a plugin archive with the extension .mwplugin or .zip.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1809137998"] = "Please select a plugin archive with the extension .mwplugin or .zip."
|
||||
|
||||
-- The selected plugin archive does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1821013825"] = "The selected plugin archive does not exist."
|
||||
|
||||
-- No Lua plugin code was generated.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1839013358"] = "No Lua plugin code was generated."
|
||||
|
||||
-- Only assistant, configuration, and language plugins can be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1878846406"] = "Only assistant, configuration, and language plugins can be deleted."
|
||||
|
||||
-- Your organization has disabled importing configuration plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2134532120"] = "Your organization has disabled importing configuration plugins."
|
||||
|
||||
-- The assistant plugin directory does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2148384567"] = "The assistant plugin directory does not exist."
|
||||
|
||||
-- The plugin directory does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2221093487"] = "The plugin directory does not exist."
|
||||
|
||||
-- Unexpected error: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2350673880"] = "Unexpected error: {0}"
|
||||
|
||||
-- The generated assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2441747251"] = "The generated assistant plugin uses the ID of another installed plugin."
|
||||
|
||||
-- This individual plugin’s directory is outside the expected plugins directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2486199999"] = "This individual plugin’s directory is outside the expected plugins directory."
|
||||
|
||||
-- The assistant plugin has no local directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2682912892"] = "The assistant plugin has no local directory."
|
||||
|
||||
-- The AI Studio data directory is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2712481762"] = "The AI Studio data directory is not initialized yet."
|
||||
|
||||
-- Only assistant, configuration, and language plugins can be imported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2909113247"] = "Only assistant, configuration, and language plugins can be imported."
|
||||
|
||||
-- The generated plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2955055168"] = "The generated plugin is not an assistant plugin. Issue: {0}"
|
||||
|
||||
-- Your organization has disabled importing plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3212529834"] = "Your organization has disabled importing plugins."
|
||||
|
||||
-- The plugin has no local directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3284289028"] = "The plugin has no local directory."
|
||||
|
||||
-- The plugin archive must contain exactly one plugin.lua file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3355918609"] = "The plugin archive must contain exactly one plugin.lua file."
|
||||
|
||||
-- Your organization deployed a configuration with the same ID. An imported configuration must not take its place.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T352004699"] = "Your organization deployed a configuration with the same ID. An imported configuration must not take its place."
|
||||
|
||||
-- The imported plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3634046009"] = "The imported plugin is invalid. Issue: {0}"
|
||||
|
||||
-- Plugins shipped with AI Studio cannot be deleted.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3841213017"] = "Plugins shipped with AI Studio cannot be deleted."
|
||||
|
||||
-- The edited plugin is not an assistant plugin. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3984111892"] = "The edited plugin is not an assistant plugin. Issue: {0}"
|
||||
|
||||
-- The plugin system is not initialized yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3984839613"] = "The plugin system is not initialized yet."
|
||||
|
||||
-- The plugin file is outside the assistant plugin directory.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T4062980447"] = "The plugin file is outside the assistant plugin directory."
|
||||
|
||||
-- Plugins deployed by your organization cannot be replaced.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T553820956"] = "Plugins deployed by your organization cannot be replaced."
|
||||
|
||||
-- The edited assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T554567780"] = "The edited assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- The edited assistant plugin uses the ID of another installed plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T584770023"] = "The edited assistant plugin uses the ID of another installed plugin."
|
||||
|
||||
-- The edited assistant plugin must keep the same plugin ID.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T693124809"] = "The edited assistant plugin must keep the same plugin ID."
|
||||
|
||||
-- Internal assistant plugins cannot be edited.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T816339833"] = "Internal assistant plugins cannot be edited."
|
||||
|
||||
-- The generated assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T939708112"] = "The generated assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- Internal plugins cannot be shared.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T1668534561"] = "Internal plugins cannot be shared."
|
||||
|
||||
@ -10083,9 +10338,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4261248356"] = "Sources pro
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T185447014"] = "Pandoc Installation"
|
||||
|
||||
-- Pandoc may be required for importing files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T2596465560"] = "Pandoc may be required for importing files."
|
||||
|
||||
-- The file path is null or empty and the file therefore can not be loaded.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::USERFILE::T932243993"] = "The file path is null or empty and the file therefore can not be loaded."
|
||||
|
||||
|
||||
@ -173,7 +173,7 @@ internal sealed class Program
|
||||
builder.Services.AddSingleton<VisualBriefingBuildOrchestrator>();
|
||||
builder.Services.AddSingleton<VisualBriefingPreviewTokenService>();
|
||||
builder.Services.AddSingleton<IMediaTranscriptStorage, VisualBriefingTranscriptStorage>();
|
||||
builder.Services.AddSingleton<AssistantPluginInstallService>();
|
||||
builder.Services.AddSingleton<PluginInstallService>();
|
||||
builder.Services.AddSingleton<UpdatePolicy>();
|
||||
builder.Services.AddSingleton<AssistantPluginGenerationService>();
|
||||
builder.Services.AddSingleton<DataSourceService>();
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
@ -57,6 +58,31 @@ public record ConfigMeta<TClass, TValue> : ConfigMetaBase
|
||||
/// <inheritdoc/>
|
||||
public override bool RemovePluginContribution(Guid configPluginId) => this.pluginContributions.Remove(configPluginId);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string SerializeCurrentValue() => ManagedConfiguration.SerializeManagedScalarValue(this.GetValue());
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string SerializeCurrentValueAsJson() => JsonSerializer.Serialize(this.GetValue(), SettingsManager.JSON_OPTIONS);
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool TrySetValueFromJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<TValue>(json, SettingsManager.JSON_OPTIONS);
|
||||
if (value is null)
|
||||
return false;
|
||||
|
||||
this.SetValue(value);
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.LogWarning(e, $"Was not able to restore the value of the setting '{this.SettingName}' from its snapshot '{json}'. Using the default value instead.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void Reset()
|
||||
{
|
||||
|
||||
@ -12,6 +12,8 @@ public abstract record ConfigMetaBase(string SettingName) : IConfig
|
||||
{
|
||||
protected static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
|
||||
protected static ILogger Log => Program.LOGGER_FACTORY.CreateLogger(nameof(ConfigMetaBase));
|
||||
|
||||
/// <summary>
|
||||
/// The persisted name of the configuration setting.
|
||||
/// </summary>
|
||||
@ -89,14 +91,14 @@ public abstract record ConfigMetaBase(string SettingName) : IConfig
|
||||
public void ResetLockedConfiguration()
|
||||
{
|
||||
SettingsManagerAccess.ConfigurationData.ManagedLockedConfigurations.Remove(this.SettingName);
|
||||
|
||||
|
||||
this.IsLocked = false;
|
||||
this.LockedByConfigPluginId = Guid.Empty;
|
||||
|
||||
|
||||
if (this.ManagedMode is ManagedConfigurationMode.LOCKED)
|
||||
this.ManagedMode = null;
|
||||
|
||||
this.Reset();
|
||||
this.RestoreUserValueOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -137,6 +139,29 @@ public abstract record ConfigMetaBase(string SettingName) : IConfig
|
||||
this.EditableDefaultByConfigPluginId = Guid.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the editable-default state and hands the setting back to the user.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without a snapshot of the user's value, the current value stays as it is. That is the
|
||||
/// difference to a locked setting: the user was allowed to change an editable default all
|
||||
/// along, so its value is a plausible choice of theirs. Resetting it to the app's default would
|
||||
/// take away something nobody asked us to remove.
|
||||
/// </remarks>
|
||||
/// <param name="keepCurrentValue">
|
||||
/// True when the user has changed the value in the meantime. Their decision outlives the
|
||||
/// configuration plugin, so the snapshot is dropped instead of applied.
|
||||
/// </param>
|
||||
public void ResetEditableDefaultConfiguration(bool keepCurrentValue)
|
||||
{
|
||||
this.ClearEditableDefaultConfiguration();
|
||||
|
||||
if (keepCurrentValue)
|
||||
this.ClearUserValueSnapshot();
|
||||
else
|
||||
this.TryRestoreUserValueSnapshot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the contribution of one configuration plugin without changing the current value.
|
||||
/// </summary>
|
||||
@ -144,6 +169,96 @@ public abstract record ConfigMetaBase(string SettingName) : IConfig
|
||||
/// <returns>True when that plugin had a contribution, otherwise false.</returns>
|
||||
public abstract bool RemovePluginContribution(Guid configPluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the value the user had chosen before a configuration plugin took over
|
||||
/// this setting is still available.
|
||||
/// </summary>
|
||||
public bool HasUserValueSnapshot => SettingsManagerAccess.ConfigurationData.ManagedUserValueSnapshots.ContainsKey(this.SettingName);
|
||||
|
||||
/// <summary>
|
||||
/// Remembers the current value as the user's value, so that it can be restored once no
|
||||
/// configuration plugin manages this setting anymore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only an unmanaged setting holds a value which belongs to the user. When one configuration
|
||||
/// plugin takes a setting over from another, the current value belongs to the previous plugin,
|
||||
/// so the snapshot of the user's value must survive that handover untouched.<br/><br/>
|
||||
/// The persisted editable default counts as managed as well: unlike a locked setting, it is not
|
||||
/// restored into the in-memory state when the settings are loaded, so right after a start it is
|
||||
/// the only evidence that a configuration plugin is already in charge.
|
||||
/// </remarks>
|
||||
public void CaptureUserValueSnapshot()
|
||||
{
|
||||
if (this.ManagedMode is not null || SettingsManagerAccess.ConfigurationData.ManagedEditableDefaults.ContainsKey(this.SettingName))
|
||||
return;
|
||||
|
||||
var snapshots = SettingsManagerAccess.ConfigurationData.ManagedUserValueSnapshots;
|
||||
if (snapshots.ContainsKey(this.SettingName))
|
||||
return;
|
||||
|
||||
snapshots[this.SettingName] = this.SerializeCurrentValueAsJson();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the value the user had chosen before a configuration plugin took over this setting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The snapshot is consumed either way: when it cannot be applied, keeping it would mean trying
|
||||
/// the same broken value again on every start.
|
||||
/// </remarks>
|
||||
/// <returns>True when a snapshot was available and could be applied, otherwise false.</returns>
|
||||
private bool TryRestoreUserValueSnapshot()
|
||||
{
|
||||
var snapshots = SettingsManagerAccess.ConfigurationData.ManagedUserValueSnapshots;
|
||||
if (!snapshots.Remove(this.SettingName, out var snapshot))
|
||||
return false;
|
||||
|
||||
return this.TrySetValueFromJson(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops the snapshot of the user's value without changing the current value.
|
||||
/// </summary>
|
||||
/// <returns>True when a snapshot was dropped, otherwise false.</returns>
|
||||
public bool ClearUserValueSnapshot() => SettingsManagerAccess.ConfigurationData.ManagedUserValueSnapshots.Remove(this.SettingName);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current value the same way the managed states record it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is meant for comparisons, e.g. to tell whether the user has changed an editable default
|
||||
/// in the meantime. It is not meant for restoring a value: the representation is lossy.
|
||||
/// </remarks>
|
||||
public abstract string SerializeCurrentValue();
|
||||
|
||||
/// <summary>
|
||||
/// Restores the user's value, or falls back to the default value when no snapshot is available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Settings which a configuration plugin managed before this app version has no snapshot, and
|
||||
/// neither has a setting whose value the user never changed. The default value is the best
|
||||
/// answer in both cases.
|
||||
/// </remarks>
|
||||
private void RestoreUserValueOrDefault()
|
||||
{
|
||||
if (this.TryRestoreUserValueSnapshot())
|
||||
return;
|
||||
|
||||
this.Reset();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current value as JSON, so that it can be restored without losing information.
|
||||
/// </summary>
|
||||
protected abstract string SerializeCurrentValueAsJson();
|
||||
|
||||
/// <summary>
|
||||
/// Applies a value which was serialized by SerializeCurrentValueAsJson.
|
||||
/// </summary>
|
||||
/// <param name="json">The serialized value.</param>
|
||||
/// <returns>True when the value could be applied, otherwise false.</returns>
|
||||
protected abstract bool TrySetValueFromJson(string json);
|
||||
|
||||
/// <summary>
|
||||
/// Resets the configuration property to its default value.
|
||||
/// </summary>
|
||||
|
||||
@ -68,6 +68,18 @@ public sealed class Data
|
||||
/// </summary>
|
||||
public Dictionary<string, Guid> ManagedLockedConfigurations { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The value each managed setting had before a configuration plugin took it over, as JSON.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A configuration plugin might be removed later, e.g. when a test configuration ends or when an
|
||||
/// organization withdraws its configuration. The value the user had chosen before belongs to the
|
||||
/// user, so we keep it here and restore it instead of falling back to the app's default value.
|
||||
/// The snapshot is taken once, when a setting becomes managed, and is consumed when no
|
||||
/// configuration plugin manages that setting anymore.
|
||||
/// </remarks>
|
||||
public Dictionary<string, string> ManagedUserValueSnapshots { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Cached audit results for assistant plugins.
|
||||
/// </summary>
|
||||
|
||||
@ -154,6 +154,16 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
|
||||
/// </summary>
|
||||
public bool AllowUserToImportPlugins { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportPlugins, true);
|
||||
|
||||
/// <summary>
|
||||
/// Should the user be allowed to import configuration plugin archives from disk?
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a second gate on top of AllowUserToImportPlugins, and both must allow the import.
|
||||
/// Configuration plugins deserve their own switch because they are far more powerful than an
|
||||
/// assistant: they define LLM providers and data sources, and they lock settings.
|
||||
/// </remarks>
|
||||
public bool AllowUserToImportConfigurationPlugins { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportConfigurationPlugins, true);
|
||||
|
||||
/// <summary>
|
||||
/// Should the user be allowed to share or export plugins as archives?
|
||||
/// </summary>
|
||||
|
||||
@ -913,6 +913,13 @@ public static partial class ManagedConfiguration
|
||||
if (!MayManageSetting(configPluginId, configMeta))
|
||||
return false;
|
||||
|
||||
//
|
||||
// Remember the value the user had chosen before any configuration plugin took this setting
|
||||
// over. Once no plugin manages it anymore, we hand that value back to the user:
|
||||
//
|
||||
if (successful)
|
||||
configMeta.CaptureUserValueSnapshot();
|
||||
|
||||
switch (successful)
|
||||
{
|
||||
case true:
|
||||
@ -967,6 +974,15 @@ public static partial class ManagedConfiguration
|
||||
if (!MayManageSetting(configPluginId, configMeta))
|
||||
return false;
|
||||
|
||||
//
|
||||
// Remember the value the user had chosen before any configuration plugin took this setting
|
||||
// over. Once no plugin manages it anymore, we hand that value back to the user. This has to
|
||||
// happen before the managed state below changes, because only an unmanaged setting holds a
|
||||
// value which belongs to the user:
|
||||
//
|
||||
if (successful)
|
||||
configMeta.CaptureUserValueSnapshot();
|
||||
|
||||
switch (successful)
|
||||
{
|
||||
case true when managedMode is ManagedConfigurationMode.LOCKED:
|
||||
@ -1008,7 +1024,7 @@ public static partial class ManagedConfiguration
|
||||
case false when configMeta.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT
|
||||
&& TryGetEditableDefaultState(settingName, out var editableDefaultStateToRemove)
|
||||
&& editableDefaultStateToRemove.ConfigPluginId == configPluginId:
|
||||
configMeta.ClearEditableDefaultConfiguration();
|
||||
configMeta.ResetEditableDefaultConfiguration(HasUserChangedEditableDefault(configMeta, editableDefaultStateToRemove));
|
||||
ClearEditableDefaultState(settingName);
|
||||
break;
|
||||
}
|
||||
@ -1033,7 +1049,7 @@ public static partial class ManagedConfiguration
|
||||
return ManagedConfigurationMode.LOCKED;
|
||||
}
|
||||
|
||||
private static string SerializeManagedScalarValue<TValue>(TValue value) => value switch
|
||||
internal static string SerializeManagedScalarValue<TValue>(TValue value) => value switch
|
||||
{
|
||||
null => string.Empty,
|
||||
string text => text,
|
||||
|
||||
@ -277,10 +277,10 @@ public static partial class ManagedConfiguration
|
||||
if (owningConfigPluginId == Guid.Empty || owningConfigPluginId == configPluginId)
|
||||
return true;
|
||||
|
||||
if (!PluginFactory.IsEnterpriseConfigurationPlugin(owningConfigPluginId))
|
||||
if (!PluginFactory.IsOrganizationConfigurationPlugin(owningConfigPluginId))
|
||||
return true;
|
||||
|
||||
if (PluginFactory.IsEnterpriseConfigurationPlugin(configPluginId))
|
||||
if (PluginFactory.IsOrganizationConfigurationPlugin(configPluginId))
|
||||
return true;
|
||||
|
||||
Log.LogWarning($"The configuration plugin '{configPluginId}' tried to manage the setting '{configMeta.SettingName}', which is managed by the configuration plugin '{owningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.");
|
||||
@ -361,6 +361,19 @@ public static partial class ManagedConfiguration
|
||||
configMeta.RemovePluginContribution(contributingConfigPluginId);
|
||||
wasChanged = true;
|
||||
}
|
||||
|
||||
//
|
||||
// Finally, drop any snapshot of the user's value which nobody claims anymore. Without
|
||||
// this, a setting which stopped being managed outside of the paths above would keep its
|
||||
// snapshot in the settings file forever. The persisted editable default counts as a
|
||||
// claim as well: it survives a configuration plugin which is deployed but could not be
|
||||
// loaded, and that plugin is still in charge:
|
||||
//
|
||||
if (configMeta.ManagedMode is null && !TryGetEditableDefaultState(configMeta.SettingName, out _) && configMeta.ClearUserValueSnapshot())
|
||||
{
|
||||
Log.LogInformation($"Dropping the snapshot of the user's value for the setting '{configMeta.SettingName}': no configuration plugin manages it anymore.");
|
||||
wasChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove persisted states which belong to settings that do not exist anymore:
|
||||
@ -405,6 +418,13 @@ public static partial class ManagedConfiguration
|
||||
wasChanged = true;
|
||||
}
|
||||
|
||||
foreach (var settingName in configurationData.ManagedUserValueSnapshots.Keys.Where(x => !registeredSettingNames.Contains(x)).ToList())
|
||||
{
|
||||
Log.LogInformation($"Removing the snapshot of the user's value for the setting '{settingName}': this setting does not exist anymore.");
|
||||
configurationData.ManagedUserValueSnapshots.Remove(settingName);
|
||||
wasChanged = true;
|
||||
}
|
||||
|
||||
return wasChanged;
|
||||
}
|
||||
|
||||
@ -447,7 +467,7 @@ public static partial class ManagedConfiguration
|
||||
if (configMeta.ManagedMode is not ManagedConfigurationMode.EDITABLE_DEFAULT)
|
||||
return false;
|
||||
|
||||
configMeta.ClearEditableDefaultConfiguration();
|
||||
configMeta.ResetEditableDefaultConfiguration(keepCurrentValue: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -455,7 +475,17 @@ public static partial class ManagedConfiguration
|
||||
return false;
|
||||
|
||||
Log.LogInformation($"Clearing the editable default of the setting '{configMeta.SettingName}': the configuration plugin '{editableDefaultState.ConfigPluginId}' is not available anymore.");
|
||||
configMeta.ClearEditableDefaultConfiguration();
|
||||
configMeta.ResetEditableDefaultConfiguration(HasUserChangedEditableDefault(configMeta, editableDefaultState));
|
||||
return ClearEditableDefaultState(configMeta.SettingName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the user has changed an editable default themselves.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The user may change an editable default at any time. When the current value is not the one
|
||||
/// the configuration plugin applied last, the user decided against that value, and their
|
||||
/// decision outlives the plugin.
|
||||
/// </remarks>
|
||||
private static bool HasUserChangedEditableDefault(ConfigMetaBase configMeta, ManagedEditableDefaultState editableDefaultState) => !string.Equals(configMeta.SerializeCurrentValue(), editableDefaultState.LastAppliedValue, StringComparison.Ordinal);
|
||||
}
|
||||
@ -23,7 +23,7 @@ public sealed class SettingsManager
|
||||
|
||||
private readonly record struct CurrentSettingsReadResult(Data? SettingsData, SettingsWriteBlockReason FailureReason);
|
||||
|
||||
private static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||||
internal static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new TolerantEnumConverter() },
|
||||
|
||||
@ -85,21 +85,4 @@ public static class AssistantVisibilityExtensions
|
||||
|
||||
return !isHidden;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any assistant in a category should be visible.
|
||||
/// </summary>
|
||||
/// <param name="settingsManager">The settings manager to check configuration against.</param>
|
||||
/// <param name="categoryName">The name of the assistant category (for logging purposes).</param>
|
||||
/// <param name="assistants">The assistants in the category with their optional preview feature requirements.</param>
|
||||
/// <returns>True if at least one assistant in the category should be visible, false otherwise.</returns>
|
||||
public static bool IsAnyCategoryAssistantVisible(this SettingsManager settingsManager, string categoryName, params (Components Component, PreviewFeatures RequiredPreviewFeature)[] assistants)
|
||||
{
|
||||
foreach (var (component, requiredPreviewFeature) in assistants)
|
||||
if (settingsManager.IsAssistantVisible(component, withLogging: false, requiredPreviewFeature: requiredPreviewFeature))
|
||||
return true;
|
||||
|
||||
LOGGER.LogInformation("No assistants in category '{CategoryName}' are visible.", categoryName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
12
app/MindWork AI Studio/Tools/ContentStreamDocumentDetails.cs
Normal file
12
app/MindWork AI Studio/Tools/ContentStreamDocumentDetails.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public sealed class ContentStreamDocumentDetails
|
||||
{
|
||||
[JsonPropertyName("page_number")]
|
||||
public int? PageNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public ContentStreamPptxImageData? Image { get; init; }
|
||||
}
|
||||
@ -1,4 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
public sealed class ContentStreamDocumentMetadata : ContentStreamSseMetadata;
|
||||
public sealed class ContentStreamDocumentMetadata : ContentStreamSseMetadata
|
||||
{
|
||||
[JsonPropertyName("Document")]
|
||||
public ContentStreamDocumentDetails? Document { get; init; }
|
||||
}
|
||||
|
||||
55
app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs
Normal file
55
app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
// ReSharper disable UnusedAutoPropertyAccessor.Global
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
public sealed class ContentStreamErrorDetails
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string? Code { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The page the failure belongs to, when the failure affects a single page only.
|
||||
/// </summary>
|
||||
[JsonPropertyName("page_number")]
|
||||
public int? PageNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The format the runtime identified by looking at the content, e.g. when it contradicts the
|
||||
/// file extension.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detected_format")]
|
||||
public string? DetectedFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parsed error code.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Codes this version does not know map to <see cref="FileExtractionErrorCode.UNKNOWN"/>
|
||||
/// instead of failing the deserialization. A failed deserialization would turn the reported
|
||||
/// error back into empty file content, which is exactly what we want to avoid here.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public FileExtractionErrorCode ParsedCode => Enum.TryParse<FileExtractionErrorCode>(this.Code, ignoreCase: true, out var parsedCode) ? parsedCode : FileExtractionErrorCode.UNKNOWN;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this failure affects one part of the file only, while the
|
||||
/// remaining content is still usable.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsPartialFailure => this.ParsedCode is FileExtractionErrorCode.PAGE_EXTRACTION_FAILED;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this is a notice rather than a failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A notice tells the user something worth knowing about the file, while the content itself
|
||||
/// was read completely. It must therefore never degrade the outcome of an extraction.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public bool IsNotice => this.ParsedCode is FileExtractionErrorCode.EXTENSION_MISMATCH;
|
||||
}
|
||||
11
app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs
Normal file
11
app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
// ReSharper disable UnusedAutoPropertyAccessor.Global
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
public sealed class ContentStreamErrorMetadata : ContentStreamSseMetadata
|
||||
{
|
||||
[JsonPropertyName("Error")]
|
||||
public ContentStreamErrorDetails? Error { get; init; }
|
||||
}
|
||||
@ -23,7 +23,8 @@ public sealed class ContentStreamMetadataJsonConverter : JsonConverter<ContentSt
|
||||
"Presentation" => JsonSerializer.Deserialize<ContentStreamPresentationMetadata?>(rawText, options),
|
||||
"Image" => JsonSerializer.Deserialize<ContentStreamImageMetadata?>(rawText, options),
|
||||
"Document" => JsonSerializer.Deserialize<ContentStreamDocumentMetadata?>(rawText, options),
|
||||
|
||||
"Error" => JsonSerializer.Deserialize<ContentStreamErrorMetadata?>(rawText, options),
|
||||
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@ -15,4 +15,7 @@ public sealed class ContentStreamPptxImageData
|
||||
|
||||
[JsonPropertyName("is_end")]
|
||||
public bool IsEnd { get; init; }
|
||||
}
|
||||
|
||||
[JsonPropertyName("media_type")]
|
||||
public string? MediaType { get; init; }
|
||||
}
|
||||
|
||||
23
app/MindWork AI Studio/Tools/ContentStreamProcessedEvent.cs
Normal file
23
app/MindWork AI Studio/Tools/ContentStreamProcessedEvent.cs
Normal file
@ -0,0 +1,23 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of processing one content stream event: either content to append, or a reported
|
||||
/// failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Content and error are kept apart on purpose. A reported failure must never be appended as
|
||||
/// content, because that would hand the failure to the AI as if it were part of the document.
|
||||
/// </remarks>
|
||||
/// <param name="Content">The content to append, or null when this event carries none.</param>
|
||||
/// <param name="Error">The reported failure, or null when the event was processed successfully.</param>
|
||||
public readonly record struct ContentStreamProcessedEvent(string? Content, ContentStreamErrorDetails? Error)
|
||||
{
|
||||
/// <summary>
|
||||
/// An event which neither produced content nor reported a failure.
|
||||
/// </summary>
|
||||
public static readonly ContentStreamProcessedEvent NOTHING = new(null, null);
|
||||
|
||||
public static ContentStreamProcessedEvent FromContent(string? content) => new(content, null);
|
||||
|
||||
public static ContentStreamProcessedEvent FromError(ContentStreamErrorDetails? error) => new(null, error);
|
||||
}
|
||||
@ -7,8 +7,9 @@ public static class ContentStreamSseHandler
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, List<ContentStreamPptxImageData>> CHUNKED_IMAGES = new();
|
||||
private static readonly ConcurrentDictionary<string, SlideManager> SLIDE_MANAGERS = new();
|
||||
private static readonly ConcurrentDictionary<string, DocumentManager> DOCUMENT_MANAGERS = new();
|
||||
|
||||
public static string? ProcessEvent(ContentStreamSseEvent? sseEvent, bool extractImages = true)
|
||||
public static ContentStreamProcessedEvent ProcessEvent(ContentStreamSseEvent? sseEvent, bool extractImages = true)
|
||||
{
|
||||
switch (sseEvent)
|
||||
{
|
||||
@ -16,16 +17,16 @@ public static class ContentStreamSseHandler
|
||||
switch (sseEvent.Metadata)
|
||||
{
|
||||
case ContentStreamTextMetadata:
|
||||
return sseEvent.Content;
|
||||
|
||||
return ContentStreamProcessedEvent.FromContent(sseEvent.Content);
|
||||
|
||||
case ContentStreamPdfMetadata pdfMetadata:
|
||||
var pageNumber = pdfMetadata.Pdf?.PageNumber ?? 0;
|
||||
return $"""
|
||||
return ContentStreamProcessedEvent.FromContent($"""
|
||||
# Page {pageNumber}
|
||||
{sseEvent.Content}
|
||||
|
||||
""";
|
||||
|
||||
|
||||
""");
|
||||
|
||||
case ContentStreamSpreadsheetMetadata spreadsheetMetadata:
|
||||
var sheetName = spreadsheetMetadata.Spreadsheet?.SheetName;
|
||||
var rowNumber = spreadsheetMetadata.Spreadsheet?.RowNumber;
|
||||
@ -37,30 +38,50 @@ public static class ContentStreamSseHandler
|
||||
}
|
||||
|
||||
spreadSheetResult.Append(sseEvent.Content);
|
||||
return spreadSheetResult.ToString();
|
||||
|
||||
case ContentStreamDocumentMetadata:
|
||||
return ContentStreamProcessedEvent.FromContent(spreadSheetResult.ToString());
|
||||
|
||||
//
|
||||
// Documents which the runtime reads page by page are buffered, so the images of
|
||||
// a page can follow its Markdown. Documents converted as a whole, e.g. by Pandoc,
|
||||
// carry no page number and are passed on unchanged.
|
||||
//
|
||||
case ContentStreamDocumentMetadata documentMetadata:
|
||||
if (documentMetadata.Document?.PageNumber is not > 0)
|
||||
return ContentStreamProcessedEvent.FromContent(sseEvent.Content);
|
||||
|
||||
var documentManager = DOCUMENT_MANAGERS.GetOrAdd(sseEvent.StreamId!, _ => new());
|
||||
var documentContent = documentManager.AddPage(documentMetadata, sseEvent.Content, extractImages);
|
||||
return documentContent is null ? ContentStreamProcessedEvent.NOTHING : ContentStreamProcessedEvent.FromContent(documentContent);
|
||||
|
||||
case ContentStreamImageMetadata:
|
||||
return sseEvent.Content;
|
||||
return ContentStreamProcessedEvent.FromContent(sseEvent.Content);
|
||||
|
||||
case ContentStreamPresentationMetadata presentationMetadata:
|
||||
var slideManager = SLIDE_MANAGERS.GetOrAdd(
|
||||
sseEvent.StreamId!,
|
||||
_ => new()
|
||||
);
|
||||
|
||||
|
||||
slideManager.AddSlide(presentationMetadata, sseEvent.Content, extractImages);
|
||||
return null;
|
||||
|
||||
return ContentStreamProcessedEvent.NOTHING;
|
||||
|
||||
//
|
||||
// The runtime reported a failure. It must not contribute any content: an empty
|
||||
// or partial document would otherwise be handed to the AI as if it were the
|
||||
// real file content.
|
||||
//
|
||||
case ContentStreamErrorMetadata errorMetadata:
|
||||
return ContentStreamProcessedEvent.FromError(errorMetadata.Error);
|
||||
|
||||
default:
|
||||
return sseEvent.Content;
|
||||
return ContentStreamProcessedEvent.FromContent(sseEvent.Content);
|
||||
}
|
||||
|
||||
|
||||
case { Content: not null, Metadata: null }:
|
||||
return sseEvent.Content;
|
||||
|
||||
return ContentStreamProcessedEvent.FromContent(sseEvent.Content);
|
||||
|
||||
default:
|
||||
return null;
|
||||
return ContentStreamProcessedEvent.NOTHING;
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,6 +100,7 @@ public static class ContentStreamSseHandler
|
||||
Content = content,
|
||||
Segment = segment,
|
||||
IsEnd = isEnd,
|
||||
MediaType = contentStreamPptxImageData.MediaType,
|
||||
};
|
||||
|
||||
CHUNKED_IMAGES.AddOrUpdate(
|
||||
@ -110,7 +132,32 @@ public static class ContentStreamSseHandler
|
||||
CHUNKED_IMAGES.Remove(id, out _);
|
||||
return base64Image;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the collected segments of an image into a Markdown image.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Handing the naked Base64 data to the AI says nothing: it is neither readable text nor an
|
||||
/// image it could look at. Only the data URI makes it one, so every reader must embed its
|
||||
/// images this way.
|
||||
/// </remarks>
|
||||
/// <param name="id">The ID of the image to assemble.</param>
|
||||
/// <param name="mediaType">The media type the runtime reported, if any.</param>
|
||||
/// <returns>The Markdown image, or null when no data was collected for that ID.</returns>
|
||||
public static string? BuildImageMarkdown(string id, string? mediaType)
|
||||
{
|
||||
var base64Image = BuildImage(id);
|
||||
if (string.IsNullOrWhiteSpace(base64Image))
|
||||
return null;
|
||||
|
||||
//
|
||||
// Both readers compress their images, and that compression produces JPEG. A runtime which
|
||||
// does not report the media type therefore delivered JPEG as well.
|
||||
//
|
||||
var imageMediaType = string.IsNullOrWhiteSpace(mediaType) ? "image/jpeg" : mediaType;
|
||||
return $"";
|
||||
}
|
||||
|
||||
public static string? Clear(string streamId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(streamId))
|
||||
@ -123,12 +170,20 @@ public static class ContentStreamSseHandler
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
finalContentChunk.Append(result);
|
||||
}
|
||||
|
||||
if (DOCUMENT_MANAGERS.TryGetValue(streamId, out var documentManager))
|
||||
{
|
||||
var result = documentManager.Flush();
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
finalContentChunk.Append(result);
|
||||
}
|
||||
|
||||
SLIDE_MANAGERS.TryRemove(streamId, out _);
|
||||
DOCUMENT_MANAGERS.TryRemove(streamId, out _);
|
||||
var imageIdPrefix = $"{streamId}-";
|
||||
foreach (var key in CHUNKED_IMAGES.Keys.Where(k => k.StartsWith(imageIdPrefix, StringComparison.InvariantCultureIgnoreCase)))
|
||||
CHUNKED_IMAGES.TryRemove(key, out _);
|
||||
|
||||
return finalContentChunk.Length > 0 ? finalContentChunk.ToString() : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
61
app/MindWork AI Studio/Tools/DocumentManager.cs
Normal file
61
app/MindWork AI Studio/Tools/DocumentManager.cs
Normal file
@ -0,0 +1,61 @@
|
||||
using System.Text;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Buffers only the active document page so that its image segments can follow
|
||||
/// the page Markdown without retaining the complete document in memory.
|
||||
/// </summary>
|
||||
public sealed class DocumentManager
|
||||
{
|
||||
private StringBuilder? currentPageContent;
|
||||
|
||||
public string? AddPage(ContentStreamDocumentMetadata metadata, string? content, bool extractImages)
|
||||
{
|
||||
var pageNumber = metadata.Document?.PageNumber ?? 0;
|
||||
if (pageNumber == 0)
|
||||
return content;
|
||||
|
||||
var image = metadata.Document?.Image;
|
||||
if (image is null)
|
||||
{
|
||||
var completedPage = this.Flush();
|
||||
this.currentPageContent = new StringBuilder();
|
||||
|
||||
//
|
||||
// Sections, not pages: a Word or OpenDocument file carries no fixed page layout, so the
|
||||
// runtime derives these boundaries from page breaks and heuristics. Calling them pages,
|
||||
// as the PDF reader does with its real ones, would invite the AI to cite page numbers
|
||||
// which do not exist in the document.
|
||||
//
|
||||
this.currentPageContent.AppendLine($"# Section {pageNumber}");
|
||||
this.currentPageContent.Append(content);
|
||||
return completedPage;
|
||||
}
|
||||
|
||||
if (!extractImages || this.currentPageContent is null || string.IsNullOrWhiteSpace(image.Id))
|
||||
return null;
|
||||
|
||||
if (ContentStreamSseHandler.ProcessImageSegment(image.Id, image))
|
||||
{
|
||||
var markdownImage = ContentStreamSseHandler.BuildImageMarkdown(image.Id, image.MediaType);
|
||||
if (markdownImage is not null)
|
||||
{
|
||||
this.currentPageContent.AppendLine();
|
||||
this.currentPageContent.AppendLine(markdownImage);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public string? Flush()
|
||||
{
|
||||
if (this.currentPageContent is null)
|
||||
return null;
|
||||
|
||||
var result = this.currentPageContent.ToString();
|
||||
this.currentPageContent = null;
|
||||
return string.IsNullOrWhiteSpace(result) ? null : result;
|
||||
}
|
||||
}
|
||||
86
app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs
Normal file
86
app/MindWork AI Studio/Tools/FileExtractionErrorCode.cs
Normal file
@ -0,0 +1,86 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Why reading a file failed. The Rust runtime reports these codes as part of the content
|
||||
/// stream, so the app can tell the user what happened instead of showing an empty document.
|
||||
/// </summary>
|
||||
public enum FileExtractionErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// No failure happened.
|
||||
/// </summary>
|
||||
NONE,
|
||||
|
||||
/// <summary>
|
||||
/// A code this version does not know, e.g. from a newer runtime.
|
||||
/// </summary>
|
||||
UNKNOWN,
|
||||
|
||||
//
|
||||
// Codes reported by the Rust runtime:
|
||||
//
|
||||
|
||||
INVALID_REQUEST,
|
||||
FILE_NOT_FOUND,
|
||||
FILE_NOT_READABLE,
|
||||
|
||||
/// <summary>
|
||||
/// Another program holds the file open and denies reading it.
|
||||
/// </summary>
|
||||
FILE_LOCKED,
|
||||
|
||||
FORMAT_DETECTION_FAILED,
|
||||
NOT_A_VALID_PDF,
|
||||
NOT_A_VALID_SPREADSHEET,
|
||||
PDFIUM_UNAVAILABLE,
|
||||
PDF_ENCRYPTED,
|
||||
PAGE_EXTRACTION_FAILED,
|
||||
NO_TEXT_EXTRACTED,
|
||||
|
||||
/// <summary>
|
||||
/// The content does not match the file extension. This is a notice, not a failure: the file
|
||||
/// was read according to its content.
|
||||
/// </summary>
|
||||
EXTENSION_MISMATCH,
|
||||
|
||||
/// <summary>
|
||||
/// The file was read as text, but its bytes are not text.
|
||||
/// </summary>
|
||||
NOT_TEXT_CONTENT,
|
||||
|
||||
/// <summary>
|
||||
/// The file is an executable, no matter what its extension claims.
|
||||
/// </summary>
|
||||
EXECUTABLE_REJECTED,
|
||||
UNSUPPORTED,
|
||||
INTERNAL,
|
||||
|
||||
//
|
||||
// Codes reported by the app itself:
|
||||
//
|
||||
|
||||
/// <summary>
|
||||
/// Reading the file needs Pandoc, which is not available.
|
||||
/// </summary>
|
||||
PANDOC_UNAVAILABLE,
|
||||
|
||||
/// <summary>
|
||||
/// The runtime answered with an unsuccessful HTTP status.
|
||||
/// </summary>
|
||||
REQUEST_FAILED,
|
||||
|
||||
/// <summary>
|
||||
/// Reading the file took longer than the app is willing to wait.
|
||||
/// </summary>
|
||||
TIMEOUT,
|
||||
|
||||
/// <summary>
|
||||
/// The runtime sent something the app could not deserialize.
|
||||
/// </summary>
|
||||
INVALID_RESPONSE,
|
||||
|
||||
/// <summary>
|
||||
/// The extraction finished without reporting a failure, but produced no content at all.
|
||||
/// </summary>
|
||||
NO_CONTENT,
|
||||
}
|
||||
23
app/MindWork AI Studio/Tools/FileExtractionOutcome.cs
Normal file
23
app/MindWork AI Studio/Tools/FileExtractionOutcome.cs
Normal file
@ -0,0 +1,23 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// How reading a file ended.
|
||||
/// </summary>
|
||||
public enum FileExtractionOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// The whole file was read.
|
||||
/// </summary>
|
||||
SUCCESS,
|
||||
|
||||
/// <summary>
|
||||
/// Parts of the file could not be read, e.g. single pages of a PDF, while the remaining
|
||||
/// content is still usable.
|
||||
/// </summary>
|
||||
PARTIAL,
|
||||
|
||||
/// <summary>
|
||||
/// The file could not be read. There is no content the app is allowed to use.
|
||||
/// </summary>
|
||||
FAILED,
|
||||
}
|
||||
47
app/MindWork AI Studio/Tools/FileExtractionResult.cs
Normal file
47
app/MindWork AI Studio/Tools/FileExtractionResult.cs
Normal file
@ -0,0 +1,47 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// The result of reading a file through the Rust runtime.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Content and failure travel together on purpose. When reading a file returns a bare string, a
|
||||
/// failed extraction is indistinguishable from an empty document, and the empty document reaches
|
||||
/// the AI as if that were the content of the user's file.
|
||||
/// </remarks>
|
||||
/// <param name="Outcome">How the extraction ended.</param>
|
||||
/// <param name="Content">The extracted content. Empty when the extraction failed.</param>
|
||||
/// <param name="ErrorCode">Why the extraction failed or lost parts of the file.</param>
|
||||
/// <param name="ErrorMessage">The technical failure description, meant for logs and diagnostics.</param>
|
||||
/// <param name="FailedPages">The pages which could not be read, when known.</param>
|
||||
/// <param name="DetectedFormat">The format the runtime identified by looking at the content, when it is worth naming.</param>
|
||||
public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome, string Content, FileExtractionErrorCode ErrorCode, string? ErrorMessage, IReadOnlyList<int> FailedPages, string? DetectedFormat)
|
||||
{
|
||||
private static readonly int[] NO_FAILED_PAGES = [];
|
||||
|
||||
public static FileExtractionResult Success(string content, string? detectedFormat = null) => new(FileExtractionOutcome.SUCCESS, content, FileExtractionErrorCode.NONE, null, NO_FAILED_PAGES, detectedFormat);
|
||||
|
||||
public static FileExtractionResult Partial(string content, IReadOnlyList<int> failedPages, string? detectedFormat = null) => new(FileExtractionOutcome.PARTIAL, content, FileExtractionErrorCode.PAGE_EXTRACTION_FAILED, null, failedPages, detectedFormat);
|
||||
|
||||
public static FileExtractionResult Failed(FileExtractionErrorCode errorCode, string? errorMessage, string? detectedFormat = null) => new(FileExtractionOutcome.FAILED, string.Empty, errorCode, errorMessage, NO_FAILED_PAGES, detectedFormat);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the whole file was read.
|
||||
/// </summary>
|
||||
public bool IsSuccess => this.Outcome is FileExtractionOutcome.SUCCESS;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the content may be handed to the AI, i.e. the extraction
|
||||
/// either succeeded or lost only parts of the file.
|
||||
/// </summary>
|
||||
public bool HasUsableContent => this.Outcome is FileExtractionOutcome.SUCCESS or FileExtractionOutcome.PARTIAL;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the file was read, but its content did not match its file
|
||||
/// extension.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On a readable file, only the mismatch notice names a detected format, which is why no
|
||||
/// separate flag is needed here.
|
||||
/// </remarks>
|
||||
public bool HasExtensionMismatch => this.HasUsableContent && this.DetectedFormat is not null;
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Translates the stable failure codes of a file extraction into user-facing text.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The message which travels with a result is technical: it comes from the runtime, names the
|
||||
/// library which failed, and belongs into the log. The texts here are the counterpart for the
|
||||
/// user, and they name what the user can act on, such as an unavailable network drive.
|
||||
/// </remarks>
|
||||
internal static class FileExtractionResultExtensions
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(FileExtractionResultExtensions).Namespace, nameof(FileExtractionResultExtensions));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized message which explains why a file could not be read.
|
||||
/// </summary>
|
||||
/// <param name="result">The extraction result.</param>
|
||||
/// <param name="fileName">The name of the file, as shown to the user.</param>
|
||||
/// <returns>The localized message.</returns>
|
||||
internal static string ToUserMessage(this FileExtractionResult result, string fileName)
|
||||
{
|
||||
// When we know what the file really is, naming it beats a generic "not supported":
|
||||
if (result.ErrorCode is FileExtractionErrorCode.UNSUPPORTED && result.DetectedFormat is not null)
|
||||
return string.Format(TB("The file '{0}' is a {1}, which AI Studio cannot read, so it was not sent."), fileName, result.DetectedFormat);
|
||||
|
||||
return result.ErrorCode.ToUserMessage(fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized message for a file whose content does not match its file extension.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a notice, not a failure: the file was read according to its content. We still tell
|
||||
/// the user, because a wrong extension is a real problem for every other program as well.
|
||||
/// </remarks>
|
||||
/// <param name="result">The extraction result.</param>
|
||||
/// <param name="fileName">The name of the file, as shown to the user.</param>
|
||||
/// <returns>The localized message.</returns>
|
||||
internal static string ToExtensionMismatchUserMessage(this FileExtractionResult result, string fileName) => string.Format(
|
||||
TB("The file '{0}' is actually a {1} and was read as such. Please correct its file extension."),
|
||||
fileName,
|
||||
result.DetectedFormat);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized message which explains why a file could not be read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This overload exists for the places which know the reason before an extraction was even
|
||||
/// attempted, so both ways of skipping a file tell the user the same thing.
|
||||
/// </remarks>
|
||||
/// <param name="code">The stable failure code.</param>
|
||||
/// <param name="fileName">The name of the file, as shown to the user.</param>
|
||||
/// <returns>The localized message.</returns>
|
||||
internal static string ToUserMessage(this FileExtractionErrorCode code, string fileName) => string.Format(ToUserMessageFormat(code), fileName);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized message for a file which was read, but lost some of its pages.
|
||||
/// </summary>
|
||||
/// <param name="result">The extraction result.</param>
|
||||
/// <param name="fileName">The name of the file, as shown to the user.</param>
|
||||
/// <returns>The localized message.</returns>
|
||||
internal static string ToPartialUserMessage(this FileExtractionResult result, string fileName)
|
||||
{
|
||||
if (result.FailedPages.Count == 0)
|
||||
return string.Format(TB("Parts of the file '{0}' could not be read. The remaining content was sent."), fileName);
|
||||
|
||||
return string.Format(TB("The pages {1} of the file '{0}' could not be read. The remaining content was sent."), fileName, string.Join(", ", result.FailedPages));
|
||||
}
|
||||
|
||||
private static string ToUserMessageFormat(FileExtractionErrorCode code) => code switch
|
||||
{
|
||||
FileExtractionErrorCode.FILE_NOT_FOUND => TB("The file '{0}' does not exist anymore and was not sent."),
|
||||
FileExtractionErrorCode.FILE_NOT_READABLE => TB("The file '{0}' could not be read and was not sent. When the file is stored on a network drive, the drive might be unavailable, or another program might be blocking the file."),
|
||||
FileExtractionErrorCode.FILE_LOCKED => TB("The file '{0}' is currently open in another program, which is why it was not sent. Please close the file and try again. When the file is stored on a shared network drive, a colleague might have it open."),
|
||||
FileExtractionErrorCode.TIMEOUT => TB("Reading the file '{0}' took too long and was stopped, so the file was not sent. When the file is stored on a network drive, the connection might be slow or interrupted."),
|
||||
FileExtractionErrorCode.NOT_A_VALID_PDF => TB("The file '{0}' is not a readable PDF and was not sent. It might be damaged or transferred incompletely."),
|
||||
FileExtractionErrorCode.NOT_A_VALID_SPREADSHEET => TB("The file '{0}' is not a readable spreadsheet and was not sent. It might be damaged or transferred incompletely."),
|
||||
FileExtractionErrorCode.PDF_ENCRYPTED => TB("The file '{0}' is protected and could not be opened, so it was not sent."),
|
||||
FileExtractionErrorCode.PDFIUM_UNAVAILABLE => TB("AI Studio was not able to start its PDF engine, so the file '{0}' was not sent."),
|
||||
FileExtractionErrorCode.PANDOC_UNAVAILABLE => TB("Reading the file '{0}' needs Pandoc, which is not available, so the file was not sent."),
|
||||
FileExtractionErrorCode.NO_TEXT_EXTRACTED => TB("No text could be read from the file '{0}', so it was not sent. It might contain images only, such as a scanned PDF without a text layer, or no readable text at all."),
|
||||
FileExtractionErrorCode.NO_CONTENT => TB("The file '{0}' did not provide any content and was not sent."),
|
||||
|
||||
FileExtractionErrorCode.NOT_TEXT_CONTENT => TB("The file '{0}' is not a text file and was not sent. Its content could not be read as text, so it might have a wrong file extension."),
|
||||
|
||||
FileExtractionErrorCode.EXECUTABLE_REJECTED => TB("The file '{0}' is an executable program and was not sent, regardless of its file extension."),
|
||||
FileExtractionErrorCode.FORMAT_DETECTION_FAILED => TB("The file type of '{0}' could not be determined, so the file was not sent."),
|
||||
FileExtractionErrorCode.UNSUPPORTED => TB("The file type of '{0}' is not supported, so the file was not sent."),
|
||||
|
||||
_ => TB("The file '{0}' could not be read and was not sent."),
|
||||
};
|
||||
}
|
||||
@ -30,9 +30,21 @@ public static partial class Pandoc
|
||||
private static readonly Version FALLBACK_VERSION = new (3, 7, 0, 2);
|
||||
|
||||
/// <summary>
|
||||
/// Tracks whether the first availability check log has been written to avoid log spam on repeated calls.
|
||||
/// Tracks whether the executable AI Studio checks was already logged.
|
||||
/// </summary>
|
||||
private static bool HAS_LOGGED_AVAILABILITY_CHECK_ONCE;
|
||||
/// <remarks>
|
||||
/// Only informational logs are written once, because they describe a stable state and would
|
||||
/// otherwise spam the log on repeated calls. Failures are always logged: they are usually
|
||||
/// transient, e.g. an executable which is temporarily blocked or unreachable. Suppressing
|
||||
/// repeated failures hid exactly the interesting case, where the check succeeded during
|
||||
/// startup and started failing later on.
|
||||
/// </remarks>
|
||||
private static bool HAS_LOGGED_EXECUTABLE_ONCE;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks whether a successful availability check was already logged.
|
||||
/// </summary>
|
||||
private static bool HAS_LOGGED_SUCCESSFUL_CHECK_ONCE;
|
||||
|
||||
private static readonly HttpClient WEB_CLIENT = new();
|
||||
private static readonly SemaphoreSlim INSTALLATION_LOCK = new(1, 1);
|
||||
@ -52,11 +64,6 @@ public static partial class Pandoc
|
||||
/// <returns>True, if pandoc is available and the minimum required version is met, else false.</returns>
|
||||
public static async Task<PandocInstallation> CheckAvailabilityAsync(RustService rustService, bool showMessages = true, bool showSuccessMessage = true)
|
||||
{
|
||||
//
|
||||
// Determine if we should log (only on the first call):
|
||||
//
|
||||
var shouldLog = !HAS_LOGGED_AVAILABILITY_CHECK_ONCE;
|
||||
|
||||
try
|
||||
{
|
||||
//
|
||||
@ -64,7 +71,7 @@ public static partial class Pandoc
|
||||
// This can happen on dev machines where the metadata.txt contains stale values.
|
||||
// We always use the runtime-detected RID for correct behavior.
|
||||
//
|
||||
if (shouldLog && CPU_ARCHITECTURE != METADATA_ARCHITECTURE)
|
||||
if (!HAS_LOGGED_EXECUTABLE_ONCE && CPU_ARCHITECTURE != METADATA_ARCHITECTURE)
|
||||
{
|
||||
LOG.LogWarning(
|
||||
"Runtime-detected RID '{RuntimeRID}' differs from metadata RID '{MetadataRID}'. Using runtime-detected RID. This is expected on dev machines where metadata.txt may be outdated.",
|
||||
@ -73,8 +80,11 @@ public static partial class Pandoc
|
||||
}
|
||||
|
||||
var preparedProcess = await PreparePandocProcess().AddArgument("--version").BuildAsync(rustService);
|
||||
if (shouldLog)
|
||||
if (!HAS_LOGGED_EXECUTABLE_ONCE)
|
||||
{
|
||||
LOG.LogInformation("Checking Pandoc availability using executable: '{Executable}' (IsLocal: {IsLocal}).", preparedProcess.StartInfo.FileName, preparedProcess.IsLocal);
|
||||
HAS_LOGGED_EXECUTABLE_ONCE = true;
|
||||
}
|
||||
|
||||
using var process = Process.Start(preparedProcess.StartInfo);
|
||||
if (process == null)
|
||||
@ -82,9 +92,8 @@ public static partial class Pandoc
|
||||
if (showMessages)
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Help, TB("Was not able to check the Pandoc installation.")));
|
||||
|
||||
if (shouldLog)
|
||||
LOG.LogError("The Pandoc process was not started, it was null. Executable path: '{Executable}'.", preparedProcess.StartInfo.FileName);
|
||||
|
||||
LOG.LogError("The Pandoc process was not started, it was null. Executable path: '{Executable}'.", preparedProcess.StartInfo.FileName);
|
||||
|
||||
return new(false, TB("Was not able to check the Pandoc installation."), false, string.Empty, preparedProcess.IsLocal);
|
||||
}
|
||||
|
||||
@ -102,9 +111,8 @@ public static partial class Pandoc
|
||||
if (showMessages)
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("Pandoc is not available on the system or the process had issues.")));
|
||||
|
||||
if (shouldLog)
|
||||
LOG.LogError("The Pandoc process exited with code {ProcessExitCode}. Error output: '{ErrorText}'", process.ExitCode, error);
|
||||
|
||||
LOG.LogError("The Pandoc process exited with code {ProcessExitCode}. Error output: '{ErrorText}'", process.ExitCode, error);
|
||||
|
||||
return new(false, TB("Pandoc is not available on the system or the process had issues."), false, string.Empty, preparedProcess.IsLocal);
|
||||
}
|
||||
|
||||
@ -114,9 +122,8 @@ public static partial class Pandoc
|
||||
if (showMessages)
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Terminal, TB("Was not able to validate the Pandoc installation.")));
|
||||
|
||||
if (shouldLog)
|
||||
LOG.LogError("Pandoc --version returned an invalid format: '{Output}'.", output);
|
||||
|
||||
LOG.LogError("Pandoc --version returned an invalid format: '{Output}'.", output);
|
||||
|
||||
return new(false, TB("Was not able to validate the Pandoc installation."), false, string.Empty, preparedProcess.IsLocal);
|
||||
}
|
||||
|
||||
@ -129,8 +136,11 @@ public static partial class Pandoc
|
||||
if (showMessages && showSuccessMessage)
|
||||
await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, string.Format(TB("Pandoc v{0} is installed."), installedVersionString)));
|
||||
|
||||
if (shouldLog)
|
||||
if (!HAS_LOGGED_SUCCESSFUL_CHECK_ONCE)
|
||||
{
|
||||
LOG.LogInformation("Pandoc v{0} is installed and matches the required version (v{1}).", installedVersionString, MINIMUM_REQUIRED_VERSION.ToString());
|
||||
HAS_LOGGED_SUCCESSFUL_CHECK_ONCE = true;
|
||||
}
|
||||
|
||||
return new(true, string.Empty, true, installedVersionString, preparedProcess.IsLocal);
|
||||
}
|
||||
@ -138,9 +148,8 @@ public static partial class Pandoc
|
||||
if (showMessages)
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Build, string.Format(TB("Pandoc v{0} is installed, but it doesn't match the required version (v{1})."), installedVersionString, MINIMUM_REQUIRED_VERSION.ToString())));
|
||||
|
||||
if (shouldLog)
|
||||
LOG.LogWarning("Pandoc v{0} is installed, but it does not match the required version (v{1}).", installedVersionString, MINIMUM_REQUIRED_VERSION.ToString());
|
||||
|
||||
LOG.LogWarning("Pandoc v{0} is installed, but it does not match the required version (v{1}).", installedVersionString, MINIMUM_REQUIRED_VERSION.ToString());
|
||||
|
||||
return new(true, string.Format(TB("Pandoc v{0} is installed, but it does not match the required version (v{1})."), installedVersionString, MINIMUM_REQUIRED_VERSION.ToString()), false, installedVersionString, preparedProcess.IsLocal);
|
||||
}
|
||||
catch (Exception e)
|
||||
@ -148,15 +157,10 @@ public static partial class Pandoc
|
||||
if (showMessages)
|
||||
await MessageBus.INSTANCE.SendError(new(@Icons.Material.Filled.AppsOutage, TB("Pandoc doesn't seem to be installed.")));
|
||||
|
||||
if(shouldLog)
|
||||
LOG.LogError(e, "Pandoc availability check failed. This usually means Pandoc is not installed or not in the system PATH.");
|
||||
|
||||
LOG.LogError(e, "Pandoc availability check failed. This usually means Pandoc is not installed or not in the system PATH.");
|
||||
|
||||
return new(false, TB("Pandoc doesn't seem to be installed."), false, string.Empty, false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
HAS_LOGGED_AVAILABILITY_CHECK_ONCE = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -216,8 +216,12 @@ public sealed class PandocProcessBuilder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (shouldLog)
|
||||
LOGGER.LogWarning(ex, "Error while searching for a local Pandoc installation in: '{LocalInstallationRootDirectory}'.", localInstallationRootDirectory);
|
||||
//
|
||||
// Always logged, in contrast to the lines above: those describe a stable setup,
|
||||
// while this one is a transient fault, e.g. an unreachable data directory on a
|
||||
// network drive. Suppressing repeats would hide it after the first call.
|
||||
//
|
||||
LOGGER.LogWarning(ex, "Error while searching for a local Pandoc installation in: '{LocalInstallationRootDirectory}'.", localInstallationRootDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -49,6 +49,17 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
/// refine it, e.g. per department.
|
||||
/// </remarks>
|
||||
public int Priority { get; } = ReadPriority(state);
|
||||
|
||||
/// <summary>
|
||||
/// How many settings this configuration plugin declares.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This counts the entries of the Lua SETTINGS table, without the <c>.AllowUserOverride</c>
|
||||
/// companions. We need it for the import preview: a dry run does not lock anything, so the
|
||||
/// number of settings the plugin would take over cannot be read from the managed configuration
|
||||
/// at that point.
|
||||
/// </remarks>
|
||||
public int DeclaredSettingsCount { get; private set; }
|
||||
|
||||
public async Task InitializeAsync(bool dryRun)
|
||||
{
|
||||
@ -148,6 +159,26 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the settings a configuration plugin declares, ignoring the <c>.AllowUserOverride</c>
|
||||
/// companion keys: those refine a setting instead of adding one.
|
||||
/// </summary>
|
||||
private static int CountDeclaredSettings(LuaTable settingsTable)
|
||||
{
|
||||
const string USER_OVERRIDE_SUFFIX = ".AllowUserOverride";
|
||||
|
||||
var count = 0;
|
||||
var previousKey = LuaValue.Nil;
|
||||
while (settingsTable.TryGetNext(previousKey, out var pair))
|
||||
{
|
||||
previousKey = pair.Key;
|
||||
if (pair.Key.TryRead<string>(out var settingName) && !settingName.EndsWith(USER_OVERRIDE_SUFFIX, StringComparison.Ordinal))
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to initialize the UI text content of the plugin.
|
||||
/// </summary>
|
||||
@ -173,6 +204,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
message = TB("The SETTINGS table does not exist or is not a valid table.");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.DeclaredSettingsCount = CountDeclaredSettings(settingsTable);
|
||||
|
||||
// Config: check for updates, and if so, how often?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UpdateInterval, this.Id, settingsTable, dryRun);
|
||||
@ -201,6 +234,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
// Config: allow the user to import plugin archives?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportPlugins, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import configuration plugin archives?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportConfigurationPlugins, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to share or export plugins?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToSharePlugins, this.Id, settingsTable, dryRun);
|
||||
|
||||
@ -355,6 +391,28 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
if (dryRun)
|
||||
return;
|
||||
|
||||
//
|
||||
// Only a configuration which speaks for an organization may approve assistant plugins: one
|
||||
// deployed by a configuration server, or one staged in the test directory. An approval marks
|
||||
// a plugin as safe without any security audit, and the user interface states that the
|
||||
// organization approved it. No local configuration plugin may make that claim: it would
|
||||
// disable the security audit for arbitrary assistant plugins while telling the user that
|
||||
// their organization vouched for them.
|
||||
//
|
||||
// We decide by the plugin path. The self-declared DEPLOYED_USING_CONFIG_SERVER field would
|
||||
// not do, because any plugin can set it to true.
|
||||
//
|
||||
if (!PluginFactory.IsOrganizationConfigurationPath(this.PluginPath))
|
||||
{
|
||||
if (successful)
|
||||
LOG.LogWarning("The configuration plugin '{ConfigPluginId}' at '{PluginPath}' declares enterprise approvals for assistant plugins, but your organization's IT did not deploy it. Ignoring these approvals: only configuration plugins from a configuration server or from the test directory may approve assistant plugins.", this.Id, this.PluginPath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (PluginFactory.IsEnterpriseTestConfigurationPath(this.PluginPath))
|
||||
LOG.LogWarning("The test configuration plugin '{ConfigPluginId}' at '{PluginPath}' approves assistant plugins. These approvals are valid for this session only: AI Studio empties the test directory on every start.", this.Id, this.PluginPath);
|
||||
|
||||
switch (successful)
|
||||
{
|
||||
case true:
|
||||
|
||||
@ -34,6 +34,41 @@ public sealed record PluginConfigurationObject
|
||||
/// </summary>
|
||||
public required PluginConfigurationObjectType Type { get; init; } = PluginConfigurationObjectType.NONE;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the configuration object, e.g. the name of a provider.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Where this configuration object sends data to: the host of a self-hosted provider or data
|
||||
/// source, or the name of the cloud provider. Empty for objects without a destination, such as
|
||||
/// chat templates or profiles.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We keep this next to the object metadata so the import preview can tell users where a
|
||||
/// configuration would send their prompts before its providers are stored.
|
||||
/// </remarks>
|
||||
public string Endpoint { get; private init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Determines the destination of a configuration object for the import preview.
|
||||
/// </summary>
|
||||
private static string DescribeEndpoint(IConfigurationObject configObject) => configObject switch
|
||||
{
|
||||
Settings.Provider { IsSelfHosted: true } provider => provider.Hostname,
|
||||
Settings.Provider provider => Provider.LLMProvidersExtensions.ToName(provider.UsedLLMProvider),
|
||||
|
||||
EmbeddingProvider { IsSelfHosted: true } embeddingProvider => embeddingProvider.Hostname,
|
||||
EmbeddingProvider embeddingProvider => Provider.LLMProvidersExtensions.ToName(embeddingProvider.UsedLLMProvider),
|
||||
|
||||
TranscriptionProvider { IsSelfHosted: true } transcriptionProvider => transcriptionProvider.Hostname,
|
||||
TranscriptionProvider transcriptionProvider => Provider.LLMProvidersExtensions.ToName(transcriptionProvider.UsedLLMProvider),
|
||||
|
||||
DataSourceERI_V1 dataSource => dataSource.Hostname,
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses Lua table entries into configuration objects of the specified type, populating the
|
||||
/// provided list with results.
|
||||
@ -125,6 +160,8 @@ public sealed record PluginConfigurationObject
|
||||
ConfigPluginId = configPluginId,
|
||||
Id = Guid.Parse(configObject.Id),
|
||||
Type = configObjectType,
|
||||
Name = configObject.Name,
|
||||
Endpoint = DescribeEndpoint(configObject),
|
||||
});
|
||||
|
||||
if (dryRun)
|
||||
@ -214,6 +251,8 @@ public sealed record PluginConfigurationObject
|
||||
ConfigPluginId = configPluginId,
|
||||
Id = Guid.Parse(configObject.Id),
|
||||
Type = PluginConfigurationObjectType.DATA_SOURCE,
|
||||
Name = configObject.Name,
|
||||
Endpoint = DescribeEndpoint(configObject),
|
||||
});
|
||||
|
||||
if (dryRun)
|
||||
@ -273,10 +312,10 @@ public sealed record PluginConfigurationObject
|
||||
if (!existingObject.IsEnterpriseConfiguration || existingObject.EnterpriseConfigurationPluginId == configPluginId)
|
||||
return true;
|
||||
|
||||
if (!PluginFactory.IsEnterpriseConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId))
|
||||
if (!PluginFactory.IsOrganizationConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId))
|
||||
return true;
|
||||
|
||||
if (PluginFactory.IsEnterpriseConfigurationPlugin(configPluginId))
|
||||
if (PluginFactory.IsOrganizationConfigurationPlugin(configPluginId))
|
||||
return true;
|
||||
|
||||
LOG.LogWarning("The configuration plugin '{ConfigPluginId}' tried to replace the object '{ConfigObjectName}' (id={ConfigObjectId}), which belongs to the configuration plugin '{OwningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.", configPluginId, existingObject.Name, existingObject.Id, existingObject.EnterpriseConfigurationPluginId);
|
||||
|
||||
@ -119,13 +119,17 @@ public static partial class PluginFactory
|
||||
//
|
||||
if (AVAILABLE_PLUGINS.FirstOrDefault(candidate => candidate.Id == plugin.Id) is { } duplicatePlugin)
|
||||
{
|
||||
if (!IsEnterpriseConfigurationPath(pluginPath) || IsEnterpriseConfigurationPath(duplicatePlugin.LocalPath))
|
||||
if (GetConfigurationAuthority(pluginPath) <= GetConfigurationAuthority(duplicatePlugin.LocalPath))
|
||||
{
|
||||
LOG.LogWarning($"Ignoring the plugin '{pluginMainFile}': its ID ('{plugin.Id}') is already used by the plugin at '{duplicatePlugin.LocalPath}'. Plugin IDs must be unique. Please remove one of these plugins.");
|
||||
continue;
|
||||
}
|
||||
|
||||
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the enterprise configuration plugin at '{pluginPath}'. Plugins deployed by your organization's IT take precedence.");
|
||||
if (IsEnterpriseTestConfigurationPath(pluginPath))
|
||||
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the test configuration plugin at '{pluginPath}'. A test configuration takes precedence until AI Studio is restarted.");
|
||||
else
|
||||
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the enterprise configuration plugin at '{pluginPath}'. Plugins deployed by your organization's IT take precedence.");
|
||||
|
||||
AVAILABLE_PLUGINS.Remove(duplicatePlugin);
|
||||
}
|
||||
|
||||
@ -199,6 +203,15 @@ public static partial class PluginFactory
|
||||
// one broken configuration plugin would wipe the entire organization configuration:
|
||||
//
|
||||
var deployedEnterpriseConfigPluginIds = GetDeployedEnterpriseConfigPluginIds();
|
||||
|
||||
//
|
||||
// Test configurations manage settings and objects like a deployed configuration, so those must
|
||||
// not be treated as left over while the test runs. They are only ever loaded, never merely
|
||||
// present: the test directory is emptied on every start.
|
||||
//
|
||||
foreach (var testConfigurationPlugin in AVAILABLE_PLUGINS.Where(plugin => plugin.Type is PluginType.CONFIGURATION && IsEnterpriseTestConfigurationPath(plugin.LocalPath)))
|
||||
deployedEnterpriseConfigPluginIds.Add(testConfigurationPlugin.Id);
|
||||
|
||||
var unloadedEnterpriseConfigPluginIds = deployedEnterpriseConfigPluginIds.Where(x => AVAILABLE_PLUGINS.All(plugin => plugin.Id != x)).ToList();
|
||||
foreach (var unloadedEnterpriseConfigPluginId in unloadedEnterpriseConfigPluginIds)
|
||||
LOG.LogWarning($"The configuration plugin '{unloadedEnterpriseConfigPluginId}' is deployed, but was not loaded. Everything it manages stays unchanged, because the plugin was not removed. Please check the errors above and fix the plugin.");
|
||||
@ -345,7 +358,10 @@ public static partial class PluginFactory
|
||||
if(type is PluginType.NONE)
|
||||
return new NoPlugin($"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues<PluginType>()}");
|
||||
|
||||
var isInternal = !string.IsNullOrWhiteSpace(pluginPath) && pluginPath.StartsWith(INTERNAL_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase);
|
||||
// Whether a plugin is internal is decided by its path, never by the plugin itself. We use the
|
||||
// same nesting check as everywhere else, so that a directory like `.internal-old` next to the
|
||||
// internal plugins does not count as internal:
|
||||
var isInternal = IsPathInside(INTERNAL_PLUGINS_ROOT, pluginPath);
|
||||
switch (type)
|
||||
{
|
||||
case PluginType.LANGUAGE:
|
||||
|
||||
@ -1,129 +1,90 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static partial class PluginFactory
|
||||
{
|
||||
private const string REASON_NO_LONGER_REFERENCED = "no longer referenced by active enterprise environments";
|
||||
|
||||
/// <summary>
|
||||
/// Removes the configuration plugins an organization deployed once but does not reference anymore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is how an organization withdraws a configuration: it removes the configuration ID from the
|
||||
/// devices, e.g. through a group policy. The next time AI Studio syncs, the local copy has to go.
|
||||
/// A device which was offline while the policy changed applies the withdrawal when it starts again.
|
||||
/// <br/><br/>
|
||||
/// What an organization deployed is decided by the plugin path alone. We must not ask the plugin
|
||||
/// itself: `DEPLOYED_USING_CONFIG_SERVER` is part of the plugin, so a configuration declaring
|
||||
/// `false` could never be withdrawn again once it was deployed, while it would keep every right of
|
||||
/// an organization configuration, including the approval of assistant plugins.
|
||||
/// </remarks>
|
||||
/// <param name="activeConfigurationIds">The IDs of the enterprise configurations which are currently referenced.</param>
|
||||
public static void RemoveUnreferencedManagedConfigurationPlugins(ISet<Guid> activeConfigurationIds)
|
||||
{
|
||||
if (!IsInitialized)
|
||||
if (!IsInitialized || !Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
return;
|
||||
|
||||
var pluginIdsToRemove = new HashSet<Guid>();
|
||||
|
||||
// Case 1: Plugins are already loaded and metadata is available.
|
||||
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin =>
|
||||
plugin.Type is PluginType.CONFIGURATION &&
|
||||
plugin.IsManagedByConfigServer &&
|
||||
!activeConfigurationIds.Contains(plugin.Id)))
|
||||
pluginIdsToRemove.Add(plugin.Id);
|
||||
|
||||
// Case 2: Startup cleanup before the initial plugin load.
|
||||
// In this case, we inspect the .config directories directly.
|
||||
if (Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
foreach (var configurationDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
foreach (var pluginDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
var directoryName = Path.GetFileName(pluginDirectory);
|
||||
if (!Guid.TryParse(directoryName, out var pluginId))
|
||||
continue;
|
||||
var directoryName = Path.GetFileName(configurationDirectory);
|
||||
|
||||
if (activeConfigurationIds.Contains(pluginId))
|
||||
continue;
|
||||
// A download in flight stages and backs up next to the configuration directories. Those
|
||||
// directories belong to a running update, not to a withdrawn configuration:
|
||||
if (IsTransientDownloadDirectory(directoryName))
|
||||
continue;
|
||||
|
||||
var deployFlag = ReadDeployFlagFromPluginFile(pluginDirectory);
|
||||
var isManagedByConfigServer = deployFlag ?? true;
|
||||
if (!deployFlag.HasValue)
|
||||
LOG.LogWarning($"Configuration plugin '{pluginId}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{ENTERPRISE_CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
//
|
||||
// A configuration server downloads each configuration into a directory named after its
|
||||
// ID. Any other directory name cannot be referenced by an enterprise environment, so it
|
||||
// has no place here either:
|
||||
//
|
||||
if (Guid.TryParse(directoryName, out var configurationId) && activeConfigurationIds.Contains(configurationId))
|
||||
continue;
|
||||
|
||||
if (isManagedByConfigServer)
|
||||
pluginIdsToRemove.Add(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pluginId in pluginIdsToRemove)
|
||||
RemovePluginAsync(pluginId, REASON_NO_LONGER_REFERENCED);
|
||||
}
|
||||
|
||||
private static void RemovePluginAsync(Guid pluginId, string reason)
|
||||
{
|
||||
if (!IsInitialized)
|
||||
return;
|
||||
|
||||
LOG.LogWarning("Removing plugin with ID '{PluginId}'. Reason: {Reason}.", pluginId, reason);
|
||||
|
||||
//
|
||||
// Remove the plugin from the available plugins list:
|
||||
//
|
||||
var availablePluginToRemove = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||
if (availablePluginToRemove != null)
|
||||
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
|
||||
else
|
||||
LOG.LogWarning("No available plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
|
||||
|
||||
//
|
||||
// Remove the plugin from the running plugins list:
|
||||
//
|
||||
var runningPluginToRemove = RUNNING_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||
if (runningPluginToRemove == null)
|
||||
LOG.LogWarning("No running plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
|
||||
else
|
||||
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
||||
|
||||
//
|
||||
// Delete the plugin directory:
|
||||
//
|
||||
DeleteConfigurationPluginDirectory(pluginId);
|
||||
|
||||
LOG.LogInformation("Plugin with ID '{PluginId}' removed successfully. Reason: {Reason}.", pluginId, reason);
|
||||
}
|
||||
|
||||
private static bool? ReadDeployFlagFromPluginFile(string pluginDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginFile = Path.Join(pluginDirectory, "plugin.lua");
|
||||
if (!File.Exists(pluginFile))
|
||||
return null;
|
||||
|
||||
var pluginCode = File.ReadAllText(pluginFile);
|
||||
var match = DeployedByConfigServerRegex().Match(pluginCode);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
return bool.TryParse(match.Groups[1].Value, out var deployFlag)
|
||||
? deployFlag
|
||||
: null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LOG.LogWarning(ex, $"Failed to parse deployment flag from plugin directory '{pluginDirectory}'.");
|
||||
return null;
|
||||
RemoveConfigurationDirectory(configurationDirectory, REASON_NO_LONGER_REFERENCED);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteConfigurationPluginDirectory(Guid pluginId)
|
||||
/// <summary>
|
||||
/// Checks whether a directory below the enterprise configuration directory belongs to a running
|
||||
/// download instead of to an installed configuration.
|
||||
/// </summary>
|
||||
private static bool IsTransientDownloadDirectory(string directoryName) =>
|
||||
directoryName.Contains(".staging-", StringComparison.OrdinalIgnoreCase) ||
|
||||
directoryName.Contains(".backup-", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Unloads every plugin stored in the given directory and deletes the directory afterwards.
|
||||
/// </summary>
|
||||
private static void RemoveConfigurationDirectory(string configurationDirectory, string reason)
|
||||
{
|
||||
var pluginDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, pluginId.ToString());
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
LOG.LogWarning("Removing the enterprise configuration directory '{Directory}'. Reason: {Reason}.", configurationDirectory, reason);
|
||||
|
||||
//
|
||||
// We collect the plugins by path, not by the ID the directory is named after: a plugin may
|
||||
// declare an ID which differs from its directory name, and a single directory may even hold
|
||||
// several plugins:
|
||||
//
|
||||
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin => IsPathInside(configurationDirectory, plugin.LocalPath)).ToList())
|
||||
{
|
||||
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
|
||||
return;
|
||||
AVAILABLE_PLUGINS.Remove(plugin);
|
||||
|
||||
if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove)
|
||||
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
||||
|
||||
LOG.LogInformation("Unloaded the plugin '{PluginName}' ({PluginId}). Reason: {Reason}.", plugin.Name, plugin.Id, reason);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(configurationDirectory))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(pluginDirectory, true);
|
||||
LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully.");
|
||||
Directory.Delete(configurationDirectory, true);
|
||||
LOG.LogInformation($"Plugin directory '{configurationDirectory}' deleted successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'.");
|
||||
LOG.LogError(e, $"Failed to delete plugin directory '{configurationDirectory}'.");
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\s*DEPLOYED_USING_CONFIG_SERVER\s*=\s*(true|false)\s*(?:--.*)?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
|
||||
private static partial Regex DeployedByConfigServerRegex();
|
||||
}
|
||||
@ -110,9 +110,10 @@ public static partial class PluginFactory
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The configuration plugins an organization deployed go first: they are the baseline for
|
||||
/// everything else. Local configuration plugins follow, so they can add to that baseline instead
|
||||
/// of replacing parts of it. All remaining plugin types write no settings at all, so their rank
|
||||
/// is irrelevant for the outcome.<br/><br/>
|
||||
/// everything else. A test configuration follows, so that an administrator sees their draft take
|
||||
/// effect over the deployed baseline. Local configuration plugins come last, so they can add to
|
||||
/// that baseline instead of replacing parts of it. All remaining plugin types write no settings at
|
||||
/// all, so their rank is irrelevant for the outcome.<br/><br/>
|
||||
/// The rank comes before the declared priority on purpose: a local configuration plugin must not
|
||||
/// be able to jump ahead of an organization by declaring a high priority.
|
||||
/// </remarks>
|
||||
@ -121,9 +122,10 @@ public static partial class PluginFactory
|
||||
private static int GetStartupRank(IAvailablePlugin plugin) => plugin.Type switch
|
||||
{
|
||||
PluginType.CONFIGURATION when IsEnterpriseConfigurationPath(plugin.LocalPath) => 0,
|
||||
PluginType.CONFIGURATION => 1,
|
||||
PluginType.CONFIGURATION when IsEnterpriseTestConfigurationPath(plugin.LocalPath) => 1,
|
||||
PluginType.CONFIGURATION => 2,
|
||||
|
||||
_ => 2,
|
||||
_ => 3,
|
||||
};
|
||||
|
||||
private static void LogAssistantPluginStartupState()
|
||||
|
||||
@ -21,9 +21,31 @@ public static partial class PluginFactory
|
||||
/// deploys plugins here, each in a directory named after its configuration ID.
|
||||
/// </remarks>
|
||||
private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The directory administrators use to try out a configuration before their organization deploys it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything stored here acts on behalf of the organization, so that a test behaves like the
|
||||
/// later rollout, including the approval of assistant plugins. In exchange, the directory is
|
||||
/// emptied on every start: a test configuration lives for one session only. It also never gets
|
||||
/// the protection of a deployed configuration, so users can remove or replace it through the user
|
||||
/// interface.
|
||||
/// </remarks>
|
||||
private static string ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
||||
|
||||
private static string HOT_RELOAD_LOCK_FILE = string.Empty;
|
||||
private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
|
||||
|
||||
/// <summary>
|
||||
/// How many test configurations were removed while AI Studio was starting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The user interface reports this: an administrator who placed a test configuration and restarted
|
||||
/// AI Studio would otherwise face an empty directory without any explanation.
|
||||
/// </remarks>
|
||||
public static int RemovedTestConfigurationsAtStartup { get; private set; }
|
||||
|
||||
public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE;
|
||||
|
||||
public static bool IsInitialized { get; private set; }
|
||||
@ -75,10 +97,12 @@ public static partial class PluginFactory
|
||||
HOT_RELOAD_LOCK_FILE = Path.Join(PLUGINS_ROOT, ".lock");
|
||||
INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal");
|
||||
ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config");
|
||||
|
||||
ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config-tests");
|
||||
|
||||
if (!Directory.Exists(PLUGINS_ROOT))
|
||||
Directory.CreateDirectory(PLUGINS_ROOT);
|
||||
|
||||
|
||||
ClearTestConfigurationPlugins();
|
||||
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
|
||||
IsInitialized = true;
|
||||
LOG.LogInformation("Plugin factory initialized successfully.");
|
||||
@ -96,20 +120,130 @@ public static partial class PluginFactory
|
||||
/// </remarks>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory is nested in the enterprise configuration directory.</returns>
|
||||
private static bool IsEnterpriseConfigurationPath(string? pluginPath)
|
||||
public static bool IsEnterpriseConfigurationPath(string? pluginPath) => IsPathInside(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, pluginPath);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin directory belongs to the test configuration area.
|
||||
/// </summary>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory is nested in the test configuration directory.</returns>
|
||||
public static bool IsEnterpriseTestConfigurationPath(string? pluginPath) => IsPathInside(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT, pluginPath);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin acts on behalf of an organization, either deployed by a configuration
|
||||
/// server or staged for a test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this wherever a configuration speaks for the organization, e.g. when it approves assistant
|
||||
/// plugins or claims a setting against a local configuration plugin. Do not use it where a
|
||||
/// deployed configuration is protected against the user, e.g. against deletion: an administrator
|
||||
/// must be able to get rid of their own test configuration.
|
||||
/// </remarks>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory belongs to the enterprise or the test configuration area.</returns>
|
||||
public static bool IsOrganizationConfigurationPath(string? pluginPath) => IsEnterpriseConfigurationPath(pluginPath) || IsEnterpriseTestConfigurationPath(pluginPath);
|
||||
|
||||
/// <summary>
|
||||
/// Ranks how much say a configuration plugin has, based on where it is stored. The higher rank
|
||||
/// wins when two configuration plugins claim the same plugin ID.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A test configuration outranks a deployed one on purpose: an administrator tries out the next
|
||||
/// version of a configuration under the ID it will have later. Local configuration plugins rank
|
||||
/// lowest, so nobody can push aside what an organization deployed.
|
||||
/// </remarks>
|
||||
private static int GetConfigurationAuthority(string? pluginPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginPath) || string.IsNullOrWhiteSpace(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
if (IsEnterpriseTestConfigurationPath(pluginPath))
|
||||
return 2;
|
||||
|
||||
return IsEnterpriseConfigurationPath(pluginPath) ? 1 : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empties the test configuration directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A test configuration carries the rights of an organization configuration without anybody having
|
||||
/// deployed it. It must therefore never outlive the session it was placed in, and administrators
|
||||
/// get a predictable lifetime instead of a configuration which is swept away at some point.
|
||||
/// </remarks>
|
||||
private static void ClearTestConfigurationPlugins()
|
||||
{
|
||||
RemovedTestConfigurationsAtStartup = 0;
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
var removedTestConfigurations = Directory.EnumerateDirectories(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT).Count();
|
||||
Directory.Delete(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT, true);
|
||||
RemovedTestConfigurationsAtStartup = removedTestConfigurations;
|
||||
|
||||
if (removedTestConfigurations > 0)
|
||||
LOG.LogWarning($"Removed {removedTestConfigurations} test configuration(s) from '{ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT}'. Test configurations are valid for one session only.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, $"Failed to empty the test configuration directory '{ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin directory is stored below the plugins directory of AI Studio.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything that removes or replaces plugin files checks this first, so a plugin directory
|
||||
/// which points somewhere else can never be touched.
|
||||
/// </remarks>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory is nested in the plugins directory.</returns>
|
||||
public static bool IsInsidePluginsRoot(string? pluginPath) => IsPathInside(PLUGINS_ROOT, pluginPath);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin directory is the plugins directory itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A `plugin.lua` placed directly in the plugins directory makes that directory the plugin
|
||||
/// directory. Removing or replacing such a plugin means touching its directory, which would take
|
||||
/// every other plugin with it.
|
||||
/// </remarks>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory is the plugins directory.</returns>
|
||||
public static bool IsPluginsRoot(string? pluginPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginPath) || string.IsNullOrWhiteSpace(PLUGINS_ROOT))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var configurationRoot = Path.GetFullPath(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var pluginDirectory = Path.GetFullPath(pluginPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return pluginDirectory.StartsWith(configurationRoot, StringComparison.OrdinalIgnoreCase);
|
||||
var root = Path.GetFullPath(PLUGINS_ROOT).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var pluginDirectory = Path.GetFullPath(pluginPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(root, pluginDirectory, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogWarning(e, $"Was not able to check whether the plugin directory '{pluginPath}' belongs to the enterprise configuration directory. Treating it as a local plugin.");
|
||||
LOG.LogWarning(e, $"Was not able to check whether the plugin directory '{pluginPath}' is the plugins directory. Treating it as the plugins directory.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPathInside(string rootDirectory, string? pluginPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginPath) || string.IsNullOrWhiteSpace(rootDirectory))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var root = Path.GetFullPath(rootDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var pluginDirectory = Path.GetFullPath(pluginPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return pluginDirectory.StartsWith(root, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogWarning(e, $"Was not able to check whether the plugin directory '{pluginPath}' is nested in '{rootDirectory}'. Treating it as unrelated.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -135,6 +269,27 @@ public static partial class PluginFactory
|
||||
return Directory.Exists(Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPluginId.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a configuration plugin speaks for an organization: either deployed by its IT
|
||||
/// department, or staged as a test configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A test configuration is only ever loaded, never merely present: it is emptied on every start,
|
||||
/// so there is no unloadable leftover to account for.
|
||||
/// </remarks>
|
||||
/// <param name="configPluginId">The ID of the configuration plugin.</param>
|
||||
/// <returns>True when the plugin speaks for an organization, false when it is local or unknown.</returns>
|
||||
public static bool IsOrganizationConfigurationPlugin(Guid configPluginId)
|
||||
{
|
||||
if (configPluginId == Guid.Empty || !IsInitialized)
|
||||
return false;
|
||||
|
||||
if (IsEnterpriseConfigurationPlugin(configPluginId))
|
||||
return true;
|
||||
|
||||
return AVAILABLE_PLUGINS.Any(plugin => plugin.Id == configPluginId && plugin.Type is PluginType.CONFIGURATION && IsEnterpriseTestConfigurationPath(plugin.LocalPath));
|
||||
}
|
||||
|
||||
private static async Task LockHotReloadAsync()
|
||||
{
|
||||
if (!IsInitialized)
|
||||
|
||||
@ -51,17 +51,21 @@ public static class FileTypes
|
||||
// Document hierarchy
|
||||
public static readonly FileTypeFilter PDF = FileTypeFilter.Leaf("PDF", "pdf");
|
||||
public static readonly FileTypeFilter TEXT = FileTypeFilter.Leaf(TB("Text"), "txt", "md", "rtf");
|
||||
public static readonly FileTypeFilter TABULAR = FileTypeFilter.Leaf(TB("Tabular text"), "csv", "tsv");
|
||||
public static readonly FileTypeFilter MS_WORD = FileTypeFilter.Leaf("Microsoft Word", "docx");
|
||||
public static readonly FileTypeFilter WORD = FileTypeFilter.Composite("Word", ["odt"], MS_WORD);
|
||||
public static readonly FileTypeFilter EXCEL = FileTypeFilter.Leaf("Excel", "xls", "xlsx");
|
||||
public static readonly FileTypeFilter POWER_POINT = FileTypeFilter.Leaf("PowerPoint", "ppt", "pptx", "odp");
|
||||
|
||||
// The legacy binary ".ppt" is missing on purpose: AI Studio has no reader for it, so offering
|
||||
// it would only let users attach a file which cannot be read.
|
||||
public static readonly FileTypeFilter POWER_POINT = FileTypeFilter.Leaf("PowerPoint", "pptx", "odp");
|
||||
public static readonly FileTypeFilter MAIL = FileTypeFilter.Leaf(TB("Mail"), "eml", "msg", "mbox");
|
||||
public static readonly FileTypeFilter LATEX = FileTypeFilter.Leaf("LaTeX", "tex", "bib", "sty", "cls", "log");
|
||||
|
||||
public static readonly FileTypeFilter OFFICE_FILES = FileTypeFilter.Parent(TB("Office Files"),
|
||||
WORD, EXCEL, POWER_POINT, PDF);
|
||||
public static readonly FileTypeFilter DOCUMENT = FileTypeFilter.Parent(TB("Document"),
|
||||
TEXT, OFFICE_FILES, SOURCE_CODE, LATEX);
|
||||
TEXT, TABULAR, OFFICE_FILES, SOURCE_CODE, LATEX);
|
||||
|
||||
// Media hierarchy
|
||||
public static readonly FileTypeFilter IMAGE = FileTypeFilter.Leaf(TB("Image"),
|
||||
@ -84,6 +88,25 @@ public static class FileTypes
|
||||
public static readonly FileTypeFilter EXECUTABLES = FileTypeFilter.Leaf(TB("Executable"), "exe", "app", "bin", "appimage");
|
||||
public static readonly FileTypeFilter PLUGIN_ARCHIVE = FileTypeFilter.Leaf(TB("Plugin archive"), PluginArchive.PLUGIN_FILE_EXTENSION.TrimStart('.'), "zip");
|
||||
|
||||
/// <summary>
|
||||
/// The file types AI Studio converts using Pandoc.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is not a user-selectable type, it mirrors the formats the Rust runtime hands to
|
||||
/// Pandoc. Every other document type is read by the runtime itself, so it must never depend
|
||||
/// on a Pandoc installation. Word and OpenDocument text files (.docx, .odt) used to be listed
|
||||
/// here as well; the runtime reads them on its own now. The name is not localized because it
|
||||
/// is never shown.
|
||||
/// </remarks>
|
||||
private static readonly FileTypeFilter PANDOC_CONVERTED = FileTypeFilter.Leaf("Pandoc conversion", "html", "htm");
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether reading the given file needs Pandoc.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The path of the file to check.</param>
|
||||
/// <returns>True, when reading the file needs Pandoc.</returns>
|
||||
public static bool RequiresPandoc(string filePath) => IsAllowedPath(filePath, PANDOC_CONVERTED);
|
||||
|
||||
public static FileTypeFilter? AsOneFileType(params FileTypeFilter[]? types)
|
||||
{
|
||||
if (types == null || types.Length == 0)
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
|
||||
@ -1,852 +0,0 @@
|
||||
using System.Text;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.PluginSystem.Assistants;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed class AssistantPluginInstallService
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AssistantPluginInstallService).Namespace, nameof(AssistantPluginInstallService));
|
||||
|
||||
private const string PLUGIN_FILE_NAME = "plugin.lua";
|
||||
private const string ASSISTANT_BUILDER_DIRECTORY_PREFIX = "assistant-builder";
|
||||
private const string DELETE_BACKUP_DIRECTORY = ".plugin-delete-backups";
|
||||
private const string INSTALL_BACKUP_DIRECTORY = ".plugin-install-backups";
|
||||
private const int DIRECTORY_PREFIX_MAX_LEN = 80;
|
||||
|
||||
private readonly ILogger<AssistantPluginInstallService> logger;
|
||||
private readonly SettingsManager settingsManager;
|
||||
private readonly AssistantSessionService assistantSessionService;
|
||||
private readonly MediaTranscriptionService mediaTranscriptionService;
|
||||
private readonly SemaphoreSlim installSemaphore = new(1, 1);
|
||||
|
||||
private static AssistantPluginInstallResult Error(string issue) => new(false, Guid.Empty, string.Empty, string.Empty, false, issue);
|
||||
|
||||
private static AssistantPluginInstallResult CancelledByUser() => new(false, Guid.Empty, string.Empty, string.Empty, false, string.Empty, true);
|
||||
|
||||
private static AssistantPluginCheckResult CheckError(string issue) => new(false, Guid.Empty, string.Empty, issue);
|
||||
|
||||
private static AssistantPluginDeleteResult DeleteError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue);
|
||||
|
||||
private static AssistantPluginUpdateResult UpdateError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue);
|
||||
|
||||
public AssistantPluginInstallService(ILogger<AssistantPluginInstallService> logger, SettingsManager settingsManager, AssistantSessionService assistantSessionService, MediaTranscriptionService mediaTranscriptionService)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.settingsManager = settingsManager;
|
||||
this.assistantSessionService = assistantSessionService;
|
||||
this.mediaTranscriptionService = mediaTranscriptionService;
|
||||
this.logger.LogInformation("The assistant plugin install service has been initialized.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a local plugin is an Assistant Builder generated assistant that users may delete.
|
||||
/// </summary>
|
||||
public static bool CanDeleteInstalledAssistant(IAvailablePlugin plugin) => string.IsNullOrWhiteSpace(GetAssistantDeletionEligibilityIssue(plugin));
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether an assistant still owns running or canceling background work.
|
||||
/// </summary>
|
||||
public bool HasActiveAssistantWork(Guid pluginId)
|
||||
{
|
||||
var instanceId = pluginId.ToString();
|
||||
if (this.assistantSessionService.GetSnapshots().Any(snapshot => snapshot.IsActive && string.Equals(snapshot.Key.InstanceId, instanceId, StringComparison.Ordinal)))
|
||||
return true;
|
||||
|
||||
var ownerIdSuffix = $":{instanceId}";
|
||||
return this.mediaTranscriptionService.GetSnapshots().Any(snapshot =>
|
||||
snapshot is { IsBusy: true, Owner.Kind: MediaImportOwnerKind.ASSISTANT } &&
|
||||
snapshot.Owner.Id.EndsWith(ownerIdSuffix, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether generated Lua assistant plugin code can be loaded and installed.
|
||||
/// The plugin is written to a temporary staging directory and validated through the
|
||||
/// normal plugin loader, but it is not moved into the user plugin directory.
|
||||
/// </summary>
|
||||
/// <param name="lua">The full generated <c>plugin.lua</c> content.</param>
|
||||
/// <param name="token">A cancellation token for file IO and Lua validation.</param>
|
||||
/// <returns>
|
||||
/// Check result that contains success state, plugin metadata, and a user-facing issue when validation failed.
|
||||
/// </returns>
|
||||
public async Task<AssistantPluginCheckResult> CheckInstallabilityAsync(string lua, CancellationToken token)
|
||||
{
|
||||
if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
|
||||
return CheckError(rootIssue);
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
var stagingDirectory = string.Empty;
|
||||
try
|
||||
{
|
||||
var validation = await this.ValidateIntoStagingAsync(lua, token);
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return CheckError(validation.Issue);
|
||||
|
||||
stagingDirectory = validation.StagingDirectory;
|
||||
var finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, validation.AssistantPlugin);
|
||||
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
|
||||
return CheckError(TB("The resolved plugin directory is outside the assistant plugin directory."));
|
||||
|
||||
return new(true, validation.AssistantPlugin.Id, validation.AssistantPlugin.Name, string.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs generated Lua assistant plugin code into the user plugin directory.
|
||||
/// Writes the plugin into a temporary staging directory first, validates it through the
|
||||
/// normal plugin loader, then moves into <c>data/plugins/assistants</c>.
|
||||
/// If plugin with same ID already exists, the existing directory is moved
|
||||
/// aside as backup and restored when replacement fails.
|
||||
/// </summary>
|
||||
/// <param name="lua">The full generated <c>plugin.lua</c> content.</param>
|
||||
/// <param name="token">A cancellation token for file IO, Lua validation, and plugin reload.</param>
|
||||
/// <returns>
|
||||
/// Installation result that contains success state, installed plugin metadata, final directory,
|
||||
/// whether an existing plugin was replaced, and user-facing issue when installation failed.
|
||||
/// </returns>
|
||||
public async Task<AssistantPluginInstallResult> InstallAsync(string lua, CancellationToken token)
|
||||
{
|
||||
if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
|
||||
return Error(rootIssue);
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
var validation = await this.ValidateIntoStagingAsync(lua, token);
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return Error(validation.Issue);
|
||||
|
||||
return await this.InstallStagedAssistantAsync(assistantPluginsRoot, validation, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs an assistant plugin archive that contains exactly one <c>plugin.lua</c> file.
|
||||
/// Companion files are validated from and moved with the same staging directory.
|
||||
/// </summary>
|
||||
/// <param name="archivePath">The local <c>.mwplugin</c> or <c>.zip</c> archive path.</param>
|
||||
/// <param name="confirmAsync">
|
||||
/// Asks the user whether the validated archive may be installed. It is called after all checks
|
||||
/// passed and before anything gets written. Returning false aborts the installation.
|
||||
/// </param>
|
||||
/// <param name="token">Cancellation token for extraction, validation, file IO, and plugin reload.</param>
|
||||
/// <returns>Installation result that contains success state, installed plugin metadata, and a user-facing issue when installation failed.</returns>
|
||||
public async Task<AssistantPluginInstallResult> InstallArchiveAsync(string archivePath, Func<PluginImportPreview, Task<bool>> confirmAsync, CancellationToken token)
|
||||
{
|
||||
if (!this.settingsManager.ConfigurationData.App.AllowUserToImportPlugins)
|
||||
return Error(TB("Your organization has disabled importing plugins."));
|
||||
|
||||
if (!FileTypes.IsAllowedPath(archivePath, FileTypes.PLUGIN_ARCHIVE))
|
||||
return Error(TB("Please select a plugin archive with the extension .mwplugin or .zip."));
|
||||
|
||||
if (!File.Exists(archivePath))
|
||||
return Error(TB("The selected plugin archive does not exist."));
|
||||
|
||||
if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
|
||||
return Error(rootIssue);
|
||||
|
||||
if (!PluginFactory.IsInitialized)
|
||||
return Error(TB("The plugin system is not initialized yet."));
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
var stagingDirectory = Path.Join(Path.GetTempPath(), $"assistant-plugin-import.staging-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
PluginArchive.Extract(archivePath, stagingDirectory);
|
||||
|
||||
var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray();
|
||||
if (pluginFiles.Length != 1)
|
||||
return Error(TB("The plugin archive must contain exactly one plugin.lua file."));
|
||||
|
||||
var pluginFile = pluginFiles[0];
|
||||
var pluginDirectory = Path.GetDirectoryName(pluginFile)!;
|
||||
var pluginCode = await File.ReadAllTextAsync(pluginFile, Encoding.UTF8, token);
|
||||
var validation = await ValidateAssistantPluginCodeAsync(
|
||||
pluginDirectory,
|
||||
pluginCode.Trim(),
|
||||
TB("Currently, only assistant plugins can be imported."),
|
||||
TB("The imported assistant plugin is invalid. Issue: {0}"),
|
||||
TB("The imported assistant plugin uses the ID of another installed plugin."),
|
||||
token);
|
||||
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return Error(validation.Issue);
|
||||
|
||||
// A plugin the user imports by hand never comes from a config server. We reject such
|
||||
// archives because AI Studio trusts this self-declared flag: an imported plugin
|
||||
// claiming it would be neither replaceable nor deletable through the user interface:
|
||||
if (validation.AssistantPlugin.IsManagedByConfigServer)
|
||||
return Error(TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."));
|
||||
|
||||
// The archive would replace an existing plugin: reject it when that plugin belongs
|
||||
// to the IT department. We check this before asking the user, so that the
|
||||
// confirmation never offers something we would refuse afterwards anyway:
|
||||
var replacementIssue = GetAssistantReplacementIssue(validation.AssistantPlugin.Id);
|
||||
if (!string.IsNullOrEmpty(replacementIssue))
|
||||
return Error(replacementIssue);
|
||||
|
||||
// Everything is validated, but nothing was written yet. This is the point where the
|
||||
// user decides, because the plugin code comes from an untrusted source:
|
||||
if (!await confirmAsync(CreateImportPreview(validation.AssistantPlugin)))
|
||||
return CancelledByUser();
|
||||
|
||||
return await this.InstallStagedAssistantAsync(assistantPluginsRoot, validation with { StagingDirectory = pluginDirectory }, token);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
this.logger.LogError(e, "Failed to extract or validate assistant plugin archive '{ArchivePath}'.", archivePath);
|
||||
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether edited assistant plugin code can replace an installed local assistant plugin
|
||||
/// without writing the file.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The installed local assistant plugin to validate against.</param>
|
||||
/// <param name="lua">The edited <c>plugin.lua</c> content.</param>
|
||||
/// <param name="token">Cancellation token for Lua validation.</param>
|
||||
/// <returns>Check result that contains success state, plugin metadata, and a user-facing issue when validation failed.</returns>
|
||||
public async Task<AssistantPluginCheckResult> CheckInstalledAssistantUpdateAsync(IAvailablePlugin plugin, string lua, CancellationToken token)
|
||||
{
|
||||
if (plugin.Type is not PluginType.ASSISTANT)
|
||||
return CheckError(TB("Only assistant plugins can be edited."));
|
||||
|
||||
if (plugin.IsInternal)
|
||||
return CheckError(TB("Internal assistant plugins cannot be edited."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(plugin.LocalPath))
|
||||
return CheckError(TB("The assistant plugin has no local directory."));
|
||||
|
||||
if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
|
||||
return CheckError(rootIssue);
|
||||
|
||||
var pluginDirectory = plugin.LocalPath;
|
||||
if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory))
|
||||
return CheckError(TB("The assistant plugin directory is outside the local assistant plugin directory."));
|
||||
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
return CheckError(TB("The assistant plugin directory does not exist."));
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token);
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return CheckError(validation.Issue);
|
||||
|
||||
var assistantPlugin = validation.AssistantPlugin;
|
||||
return assistantPlugin.Id != plugin.Id
|
||||
? CheckError(TB("The edited assistant plugin must keep the same plugin ID."))
|
||||
: new(true, assistantPlugin.Id, assistantPlugin.Name, string.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes installed local assistant plugin directories.
|
||||
/// The directory gets moved to a backup dir outside the plugin root so the
|
||||
/// plugin loader cannot discover it during reload. On failure, the directory
|
||||
/// and related assistant settings are restored.
|
||||
/// </summary>
|
||||
/// <param name="plugin">Assistant plugin metadata</param>
|
||||
/// <param name="token">Cancellation token for settings storage and plugin reload</param>
|
||||
/// <returns>
|
||||
/// Delete result that contains success state, deleted plugin metadata, the original plugin directory,
|
||||
/// and a user-facing issue when deletion failed.
|
||||
/// </returns>
|
||||
public async Task<AssistantPluginDeleteResult> DeleteInstalledAssistantAsync(IAvailablePlugin plugin, CancellationToken token)
|
||||
{
|
||||
var eligibilityIssue = GetAssistantDeletionEligibilityIssue(plugin);
|
||||
if (!string.IsNullOrEmpty(eligibilityIssue))
|
||||
return DeleteError(plugin, plugin.LocalPath, eligibilityIssue);
|
||||
|
||||
if (this.HasActiveAssistantWork(plugin.Id))
|
||||
return DeleteError(plugin, plugin.LocalPath, TB("The assistant cannot be deleted while background work is still running."));
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
var pluginDirectory = plugin.LocalPath;
|
||||
var backupDirectory = string.Empty;
|
||||
var wasEnabled = false;
|
||||
var removedAudits = new List<PluginAssistantAudit>();
|
||||
|
||||
try
|
||||
{
|
||||
eligibilityIssue = GetAssistantDeletionEligibilityIssue(plugin);
|
||||
if (!string.IsNullOrEmpty(eligibilityIssue))
|
||||
return DeleteError(plugin, pluginDirectory, eligibilityIssue);
|
||||
|
||||
if (this.HasActiveAssistantWork(plugin.Id))
|
||||
return DeleteError(plugin, pluginDirectory, TB("The assistant cannot be deleted while background work is still running."));
|
||||
|
||||
backupDirectory = CreateDeleteBackupDirectory(plugin);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!);
|
||||
Directory.Move(pluginDirectory, backupDirectory);
|
||||
|
||||
wasEnabled = this.settingsManager.ConfigurationData.EnabledPlugins.Remove(plugin.Id);
|
||||
removedAudits =
|
||||
[
|
||||
.. this.settingsManager.ConfigurationData.AssistantPluginAudits.Where(audit => audit.PluginId == plugin.Id)
|
||||
];
|
||||
|
||||
if (removedAudits.Count > 0)
|
||||
this.settingsManager.ConfigurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id);
|
||||
|
||||
await this.settingsManager.StoreSettings();
|
||||
await PluginFactory.LoadAll(token);
|
||||
|
||||
TryDeleteDirectory(backupDirectory, "assistant plugin delete backup", this.logger);
|
||||
this.logger.LogInformation($"Deleted assistant plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'.");
|
||||
return new(true, plugin.Id, plugin.Name, pluginDirectory, string.Empty);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, $"Failed to delete assistant plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'.");
|
||||
|
||||
await this.TryRestoreDeletedAssistantPluginAsync(plugin, pluginDirectory, backupDirectory, wasEnabled, removedAudits, token);
|
||||
return DeleteError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates installed assistant plugin <c>plugin.lua</c> file.
|
||||
/// The edited Lua code is validated from the provided string before it is written,
|
||||
/// but validation uses existing plugin directory as loader context so
|
||||
/// <c>require(...)</c> can resolve companion files such as <c>icon.lua</c>.
|
||||
/// After successful validation, the current <c>plugin.lua</c> is backed up,
|
||||
/// replaced atomically through a temporary file in the plugin directory, and
|
||||
/// restored when the plugin reload fails.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The installed local assistant plugin to update.</param>
|
||||
/// <param name="lua">The edited <c>plugin.lua</c> content.</param>
|
||||
/// <param name="token">Cancellation token for Lua validation, file IO, and plugin reload.</param>
|
||||
/// <returns>
|
||||
/// Update result that contains success state, updated plugin metadata, the plugin directory,
|
||||
/// and a user-facing issue when the update failed.
|
||||
/// </returns>
|
||||
public async Task<AssistantPluginUpdateResult> UpdateInstalledAssistantAsync(IAvailablePlugin plugin, string lua, CancellationToken token)
|
||||
{
|
||||
if (plugin.Type is not PluginType.ASSISTANT)
|
||||
return UpdateError(plugin, plugin.LocalPath, TB("Only assistant plugins can be edited."));
|
||||
|
||||
if (plugin.IsInternal)
|
||||
return UpdateError(plugin, plugin.LocalPath, TB("Internal assistant plugins cannot be edited."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(plugin.LocalPath))
|
||||
return UpdateError(plugin, string.Empty, TB("The assistant plugin has no local directory."));
|
||||
|
||||
if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
|
||||
return UpdateError(plugin, plugin.LocalPath, rootIssue);
|
||||
|
||||
var pluginDirectory = plugin.LocalPath;
|
||||
if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory))
|
||||
return UpdateError(plugin, pluginDirectory, TB("The assistant plugin directory is outside the local assistant plugin directory."));
|
||||
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
return UpdateError(plugin, pluginDirectory, TB("The assistant plugin directory does not exist."));
|
||||
|
||||
var pluginFile = Path.Join(pluginDirectory, PLUGIN_FILE_NAME);
|
||||
if (!IsPathInsideDirectory(pluginDirectory, pluginFile))
|
||||
return UpdateError(plugin, pluginDirectory, TB("The plugin file is outside the assistant plugin directory."));
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
var tempFile = string.Empty;
|
||||
var backupFile = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token);
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return UpdateError(plugin, pluginDirectory, validation.Issue);
|
||||
|
||||
var assistantPlugin = validation.AssistantPlugin;
|
||||
if (assistantPlugin.Id != plugin.Id)
|
||||
return UpdateError(plugin, pluginDirectory, TB("The edited assistant plugin must keep the same plugin ID."));
|
||||
|
||||
var pluginCode = lua.Trim();
|
||||
tempFile = Path.Join(pluginDirectory, $"{PLUGIN_FILE_NAME}.tmp-{Guid.NewGuid():N}");
|
||||
backupFile = Path.Join(pluginDirectory, $"{PLUGIN_FILE_NAME}.backup-{Guid.NewGuid():N}");
|
||||
|
||||
await File.WriteAllTextAsync(tempFile, pluginCode, Encoding.UTF8, token);
|
||||
|
||||
if (File.Exists(pluginFile))
|
||||
File.Replace(tempFile, pluginFile, backupFile);
|
||||
else
|
||||
File.Move(tempFile, pluginFile);
|
||||
|
||||
try
|
||||
{
|
||||
await PluginFactory.LoadAll(token);
|
||||
if (File.Exists(backupFile))
|
||||
File.Delete(backupFile);
|
||||
|
||||
this.logger.LogInformation($"Updated assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) at '{pluginFile}'.");
|
||||
return new(true, assistantPlugin.Id, assistantPlugin.Name, pluginDirectory, string.Empty);
|
||||
}
|
||||
catch (Exception reloadException)
|
||||
{
|
||||
this.logger.LogError(reloadException, $"Failed to reload plugins after editing assistant plugin '{plugin.Name}' ({plugin.Id}).");
|
||||
await this.TryRestoreEditedAssistantPluginAsync(pluginFile, backupFile, token);
|
||||
return UpdateError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), reloadException.Message));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, $"Failed to update assistant plugin '{plugin.Name}' ({plugin.Id}) at '{pluginDirectory}'.");
|
||||
await this.TryRestoreEditedAssistantPluginAsync(pluginFile, backupFile, token);
|
||||
return UpdateError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteFile(tempFile, "assistant plugin edit temp file");
|
||||
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AssistantPluginInstallResult> InstallStagedAssistantAsync(string assistantPluginsRoot, AssistantPluginValidationResult validation, CancellationToken token)
|
||||
{
|
||||
var stagingDirectory = validation.StagingDirectory;
|
||||
var assistantPlugin = validation.AssistantPlugin!;
|
||||
string? backupDirectory = null;
|
||||
string? finalDirectory = null;
|
||||
var replacedExisting = false;
|
||||
var movedIntoPlace = false;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(assistantPluginsRoot);
|
||||
finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin);
|
||||
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
|
||||
return Error(TB("The resolved plugin directory is outside the assistant plugin directory."));
|
||||
|
||||
var replacementIssue = GetAssistantReplacementIssue(assistantPlugin.Id);
|
||||
if (!string.IsNullOrWhiteSpace(replacementIssue))
|
||||
return Error(replacementIssue);
|
||||
|
||||
if (Directory.Exists(finalDirectory))
|
||||
{
|
||||
replacedExisting = true;
|
||||
|
||||
// The backup goes to a directory outside the plugin root, so the plugin loader
|
||||
// cannot discover it during the reload below. Otherwise, the previous version
|
||||
// would be loaded a second time, next to the version we are installing:
|
||||
backupDirectory = CreateInstallBackupDirectory(assistantPlugin);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!);
|
||||
Directory.Move(finalDirectory, backupDirectory);
|
||||
}
|
||||
|
||||
Directory.Move(stagingDirectory, finalDirectory);
|
||||
movedIntoPlace = true;
|
||||
await PluginFactory.LoadAll(token);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory))
|
||||
TryDeleteDirectory(backupDirectory, "assistant plugin backup", this.logger);
|
||||
|
||||
this.logger.LogInformation("Installed assistant plugin '{PluginName}' ({PluginId}) to '{PluginDirectory}'.", assistantPlugin.Name, assistantPlugin.Id, finalDirectory);
|
||||
return new(true, assistantPlugin.Id, assistantPlugin.Name, finalDirectory, replacedExisting, string.Empty);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, "Failed to install assistant plugin.");
|
||||
|
||||
// Only remove the target directory when this installation actually moved the plugin
|
||||
// there. Otherwise, when moving the previous plugin into the backup directory failed,
|
||||
// we would delete the still intact previous plugin:
|
||||
if (movedIntoPlace && !string.IsNullOrWhiteSpace(finalDirectory) && Directory.Exists(finalDirectory))
|
||||
TryDeleteDirectory(finalDirectory, "failed assistant plugin installation", this.logger);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !string.IsNullOrWhiteSpace(finalDirectory) && !Directory.Exists(finalDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(backupDirectory, finalDirectory);
|
||||
await PluginFactory.LoadAll(CancellationToken.None);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
this.logger.LogError(restoreException, "Failed to restore the previous assistant plugin after a failed installation.");
|
||||
}
|
||||
}
|
||||
|
||||
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AssistantPluginValidationResult> ValidateIntoStagingAsync(string lua, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lua))
|
||||
return AssistantPluginValidationResult.Failure(TB("No Lua plugin code was generated."));
|
||||
|
||||
if (!PluginFactory.IsInitialized)
|
||||
return AssistantPluginValidationResult.Failure(TB("The plugin system is not initialized yet."));
|
||||
|
||||
var pluginCode = lua.Trim();
|
||||
var stagingDirectory = Path.Join(Path.GetTempPath(), $"{ASSISTANT_BUILDER_DIRECTORY_PREFIX}.staging-{Guid.NewGuid():N}");
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(stagingDirectory);
|
||||
var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME);
|
||||
await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token);
|
||||
|
||||
var validation = await ValidateAssistantPluginCodeAsync(
|
||||
stagingDirectory,
|
||||
pluginCode,
|
||||
TB("The generated plugin is not an assistant plugin. Issue: {0}"),
|
||||
TB("The generated assistant plugin is invalid. Issue: {0}"),
|
||||
TB("The generated assistant plugin uses the ID of another installed plugin."),
|
||||
token);
|
||||
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
|
||||
return validation with { StagingDirectory = stagingDirectory };
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, "Failed to validate generated assistant plugin.");
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
return AssistantPluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AssistantPluginValidationResult> ValidateInPluginDirectoryAsync(string lua, string pluginDirectory, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lua))
|
||||
return AssistantPluginValidationResult.Failure(TB("No Lua plugin code was generated."));
|
||||
|
||||
if (!PluginFactory.IsInitialized)
|
||||
return AssistantPluginValidationResult.Failure(TB("The plugin system is not initialized yet."));
|
||||
|
||||
try
|
||||
{
|
||||
return await ValidateAssistantPluginCodeAsync(
|
||||
pluginDirectory,
|
||||
lua.Trim(),
|
||||
TB("The edited plugin is not an assistant plugin. Issue: {0}"),
|
||||
TB("The edited assistant plugin is invalid. Issue: {0}"),
|
||||
TB("The edited assistant plugin uses the ID of another installed plugin."),
|
||||
token);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, "Failed to validate edited assistant plugin.");
|
||||
return AssistantPluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AssistantPluginValidationResult> ValidateAssistantPluginCodeAsync(string pluginDirectory, string pluginCode,
|
||||
string notAssistantIssue, string invalidAssistantIssue, string conflictingPluginIdIssue, CancellationToken token)
|
||||
{
|
||||
// The plugin is not installed yet: it sits in a staging directory outside the installed
|
||||
// plugins directory. We allow that directory as the module base, so the plugin can load its
|
||||
// own Lua modules, e.g., an icon.lua, while we validate it:
|
||||
var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token, pluginDirectory);
|
||||
if (plugin is not PluginAssistants assistantPlugin)
|
||||
return AssistantPluginValidationResult.Failure(string.Format(notAssistantIssue, string.Join("; ", plugin.Issues)));
|
||||
|
||||
if (!assistantPlugin.IsValid)
|
||||
return AssistantPluginValidationResult.Failure(string.Format(invalidAssistantIssue, string.Join("; ", assistantPlugin.Issues)));
|
||||
|
||||
// Plugin IDs must be unique across all plugin types: several lookups resolve a plugin by its
|
||||
// ID alone, e.g., the base language plugin in PluginFactory.Starting. An assistant plugin
|
||||
// carrying the ID of a language or configuration plugin would break those lookups. Reusing
|
||||
// the ID of another local assistant plugin stays allowed: that is how updating one works.
|
||||
if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Id == assistantPlugin.Id && (availablePlugin.IsInternal || availablePlugin.Type is not PluginType.ASSISTANT)))
|
||||
return AssistantPluginValidationResult.Failure(conflictingPluginIdIssue);
|
||||
|
||||
return new(true, string.Empty, assistantPlugin, string.Empty);
|
||||
}
|
||||
|
||||
private static bool TryGetAssistantPluginsRoot(out string assistantPluginsRoot, out string issue)
|
||||
{
|
||||
assistantPluginsRoot = string.Empty;
|
||||
issue = string.Empty;
|
||||
|
||||
var dataDirectory = SettingsManager.DataDirectory;
|
||||
if (string.IsNullOrWhiteSpace(dataDirectory))
|
||||
{
|
||||
issue = TB("The AI Studio data directory is not initialized yet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
assistantPluginsRoot = Path.Join(dataDirectory, "plugins", PluginType.ASSISTANT.GetDirectory());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string GetAssistantDeletionEligibilityIssue(IAvailablePlugin plugin)
|
||||
{
|
||||
if (plugin.Type is not PluginType.ASSISTANT)
|
||||
return TB("Only assistant plugins can be deleted.");
|
||||
|
||||
if (plugin.IsInternal)
|
||||
return TB("Internal assistant plugins cannot be deleted.");
|
||||
|
||||
if (plugin.IsManagedByConfigServer)
|
||||
return TB("Config Server managed assistant plugins cannot be deleted.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(plugin.LocalPath))
|
||||
return TB("The assistant plugin has no local directory.");
|
||||
|
||||
var assistantPlugin = PluginFactory.RunningPlugins
|
||||
.OfType<PluginAssistants>()
|
||||
.FirstOrDefault(candidate => candidate.Id == plugin.Id && IsSameDirectory(candidate.PluginPath, plugin.LocalPath));
|
||||
|
||||
if (assistantPlugin is null || assistantPlugin.IsInternal || !assistantPlugin.IsAssistantBuilderGenerated)
|
||||
return TB("Only assistants generated by the Assistant Builder can be deleted.");
|
||||
|
||||
if (assistantPlugin.IsManagedByConfigServer)
|
||||
return TB("Config Server managed assistant plugins cannot be deleted.");
|
||||
|
||||
if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
|
||||
return rootIssue;
|
||||
|
||||
if (!IsPathInsideDirectory(assistantPluginsRoot, plugin.LocalPath) || IsSameDirectory(assistantPluginsRoot, plugin.LocalPath))
|
||||
return TB("The assistant plugin directory is outside the local assistant plugin directory.");
|
||||
|
||||
return Directory.Exists(plugin.LocalPath)
|
||||
? string.Empty
|
||||
: TB("The assistant plugin directory does not exist.");
|
||||
}
|
||||
|
||||
private void TryDeleteStagingDirectory(string stagingDirectory) => TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger);
|
||||
|
||||
private static string DetermineFinalDirectory(string assistantPluginsRoot, PluginAssistants assistantPlugin)
|
||||
{
|
||||
var existingPlugin = FindReplaceableAssistantPlugin(assistantPlugin.Id);
|
||||
return existingPlugin is not null
|
||||
? existingPlugin.LocalPath
|
||||
: Path.Join(assistantPluginsRoot, CreatePluginDirectoryName(assistantPlugin));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the local assistant plugin that an installation with the given ID would replace.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The ID of the assistant plugin about to be installed.</param>
|
||||
/// <returns>The plugin that would be replaced, or null when the installation adds a new plugin.</returns>
|
||||
private static IAvailablePlugin? FindReplaceableAssistantPlugin(Guid pluginId) => PluginFactory.AvailablePlugins
|
||||
.OfType<IAvailablePlugin>()
|
||||
.FirstOrDefault(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == pluginId && !plugin.IsInternal);
|
||||
|
||||
/// <summary>
|
||||
/// Collects the metadata an archive declares about itself, together with the information about
|
||||
/// the installed plugin it would replace.
|
||||
/// </summary>
|
||||
/// <param name="assistantPlugin">The validated assistant plugin from the archive.</param>
|
||||
/// <returns>The preview shown to the user before the installation starts.</returns>
|
||||
private static PluginImportPreview CreateImportPreview(PluginAssistants assistantPlugin) => new(assistantPlugin, FindReplaceableAssistantPlugin(assistantPlugin.Id));
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether an installation may replace the assistant plugin that currently uses the given ID.
|
||||
/// Plugins deployed by a Config Server belong to the organization's IT, so neither an import nor
|
||||
/// the Assistant Builder may overwrite them.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The ID of the assistant plugin about to be installed.</param>
|
||||
/// <returns>A user-facing issue when the existing plugin must not be replaced, an empty string otherwise.</returns>
|
||||
private static string GetAssistantReplacementIssue(Guid pluginId)
|
||||
{
|
||||
var existingPlugin = FindReplaceableAssistantPlugin(pluginId);
|
||||
if (existingPlugin is null)
|
||||
return string.Empty;
|
||||
|
||||
if (existingPlugin.IsManagedByConfigServer)
|
||||
return TB("Config server managed assistant plugins cannot be replaced.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existingPlugin.LocalPath))
|
||||
return string.Empty;
|
||||
|
||||
// The metadata above and the running plugin read the same Lua field. We check both, though,
|
||||
// just like the deletion path does:
|
||||
var runningPlugin = PluginFactory.RunningPlugins
|
||||
.OfType<PluginAssistants>()
|
||||
.FirstOrDefault(candidate => candidate.Id == pluginId && IsSameDirectory(candidate.PluginPath, existingPlugin.LocalPath));
|
||||
|
||||
return runningPlugin?.IsManagedByConfigServer is true
|
||||
? TB("Config server managed assistant plugins cannot be replaced.")
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static string CreatePluginDirectoryName(PluginAssistants assistantPlugin)
|
||||
{
|
||||
var safeName = CreateSafeDirectoryNamePart(assistantPlugin.Name);
|
||||
return $"{safeName}-{assistantPlugin.Id:N}";
|
||||
}
|
||||
|
||||
private static string CreateSafeDirectoryNamePart(string name)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var invalidChars = Path.GetInvalidFileNameChars().ToHashSet();
|
||||
|
||||
foreach (var character in name.Trim())
|
||||
{
|
||||
if (char.IsLetterOrDigit(character))
|
||||
{
|
||||
sb.Append(char.ToLowerInvariant(character));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character is '-' or '_' or '.' && !invalidChars.Contains(character))
|
||||
{
|
||||
sb.Append(character);
|
||||
continue;
|
||||
}
|
||||
|
||||
AppendSeparator();
|
||||
}
|
||||
|
||||
var safeName = sb.ToString().Trim('-', '.');
|
||||
if (safeName.Length > DIRECTORY_PREFIX_MAX_LEN)
|
||||
safeName = safeName[..DIRECTORY_PREFIX_MAX_LEN].Trim('-', '.');
|
||||
|
||||
return string.IsNullOrWhiteSpace(safeName)
|
||||
? ASSISTANT_BUILDER_DIRECTORY_PREFIX
|
||||
: safeName;
|
||||
|
||||
void AppendSeparator()
|
||||
{
|
||||
if (sb.Length == 0 || sb[^1] == '-')
|
||||
return;
|
||||
|
||||
sb.Append('-');
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPathInsideDirectory(string parentDirectory, string path)
|
||||
{
|
||||
var parentPath = Path.GetFullPath(parentDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsSameDirectory(string firstDirectory, string secondDirectory)
|
||||
{
|
||||
var firstPath = Path.GetFullPath(firstDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var secondPath = Path.GetFullPath(secondDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(firstPath, secondPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string CreateDeleteBackupDirectory(IAvailablePlugin plugin)
|
||||
{
|
||||
var backupRoot = Path.Join(SettingsManager.DataDirectory, DELETE_BACKUP_DIRECTORY);
|
||||
return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
private static string CreateInstallBackupDirectory(IPluginMetadata plugin)
|
||||
{
|
||||
var backupRoot = Path.Join(SettingsManager.DataDirectory, INSTALL_BACKUP_DIRECTORY);
|
||||
return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
private async Task TryRestoreDeletedAssistantPluginAsync(IAvailablePlugin plugin, string pluginDirectory, string backupDirectory, bool wasEnabled, List<PluginAssistantAudit> removedAudits, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(pluginDirectory) && Directory.Exists(backupDirectory))
|
||||
Directory.Move(backupDirectory, pluginDirectory);
|
||||
|
||||
if (wasEnabled && !this.settingsManager.ConfigurationData.EnabledPlugins.Contains(plugin.Id))
|
||||
this.settingsManager.ConfigurationData.EnabledPlugins.Add(plugin.Id);
|
||||
|
||||
if (removedAudits.Count > 0)
|
||||
{
|
||||
this.settingsManager.ConfigurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id);
|
||||
this.settingsManager.ConfigurationData.AssistantPluginAudits.AddRange(removedAudits);
|
||||
}
|
||||
|
||||
await this.settingsManager.StoreSettings();
|
||||
await PluginFactory.LoadAll(token);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
this.logger.LogError(restoreException, $"Failed to restore assistant plugin '{plugin.Name}' ({plugin.Id}) after a failed delete.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryRestoreEditedAssistantPluginAsync(string pluginFile, string backupFile, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(backupFile) || !File.Exists(backupFile))
|
||||
return;
|
||||
|
||||
if (File.Exists(pluginFile))
|
||||
File.Delete(pluginFile);
|
||||
|
||||
File.Move(backupFile, pluginFile);
|
||||
await PluginFactory.LoadAll(token);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
this.logger.LogError(restoreException, $"Failed to restore assistant plugin file '{pluginFile}' after a failed edit.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string directory, string directoryDescription, ILogger logger)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, $"Failed to delete {directoryDescription} directory '{directory}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteFile(string filePath, string fileDescription)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, $"Failed to delete {fileDescription} '{filePath}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AssistantPluginValidationResult(bool Success, string StagingDirectory, PluginAssistants? AssistantPlugin, string Issue)
|
||||
{
|
||||
public static AssistantPluginValidationResult Failure(string issue) => new(false, string.Empty, null, issue);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// What deleting a local configuration plugin takes with it, besides the plugin directory itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A configuration plugin owns everything it configured. Removing it therefore removes its providers,
|
||||
/// data sources, chat templates, and profiles, and it resets the settings it had locked. Users cannot
|
||||
/// see any of that on the plugins page, so we show it before they confirm the deletion.
|
||||
/// </remarks>
|
||||
public sealed record ConfigurationPluginDeleteSummary(
|
||||
int LlmProviders,
|
||||
int TranscriptionProviders,
|
||||
int EmbeddingProviders,
|
||||
int DataSources,
|
||||
int ChatTemplates,
|
||||
int Profiles,
|
||||
int DocumentAnalysisPolicies,
|
||||
int LockedSettings,
|
||||
int MandatoryInfos,
|
||||
int Introductions)
|
||||
{
|
||||
/// <summary>
|
||||
/// An empty summary, used when the configuration plugin is not running and we cannot tell what it configured.
|
||||
/// </summary>
|
||||
public static readonly ConfigurationPluginDeleteSummary EMPTY = new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// True when the deletion affects anything beyond the plugin directory.
|
||||
/// </summary>
|
||||
public bool HasAnyConsequence =>
|
||||
this.LlmProviders > 0 ||
|
||||
this.TranscriptionProviders > 0 ||
|
||||
this.EmbeddingProviders > 0 ||
|
||||
this.DataSources > 0 ||
|
||||
this.ChatTemplates > 0 ||
|
||||
this.Profiles > 0 ||
|
||||
this.DocumentAnalysisPolicies > 0 ||
|
||||
this.LockedSettings > 0 ||
|
||||
this.MandatoryInfos > 0 ||
|
||||
this.Introductions > 0;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A provider or data source a configuration plugin brings, and where it sends data to.
|
||||
/// </summary>
|
||||
/// <param name="Type">The kind of configuration object.</param>
|
||||
/// <param name="Name">The name the configuration gives it.</param>
|
||||
/// <param name="Endpoint">The host of a self-hosted destination, or the name of the cloud provider.</param>
|
||||
public sealed record ConfigurationPluginDestination(PluginConfigurationObjectType Type, string Name, string Endpoint);
|
||||
@ -0,0 +1,38 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// What a configuration plugin would set up, read from the archive before anything is installed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A configuration takes effect the moment it is installed, and it has no on/off switch. The import
|
||||
/// dialog is therefore the only place where users can see what they are about to accept, which is
|
||||
/// why this carries the destinations of providers and data sources and not just their number.
|
||||
/// </remarks>
|
||||
/// <param name="Destinations">The providers and data sources, together with where they send data to.</param>
|
||||
/// <param name="ChatTemplates">How many chat templates the configuration adds.</param>
|
||||
/// <param name="Profiles">How many profiles the configuration adds.</param>
|
||||
/// <param name="DocumentAnalysisPolicies">How many document analysis policies the configuration adds.</param>
|
||||
/// <param name="DeclaredSettings">How many settings the configuration takes over.</param>
|
||||
/// <param name="MandatoryInfos">How many mandatory information texts users must accept.</param>
|
||||
/// <param name="Introductions">How many introductions the configuration adds to the welcome page.</param>
|
||||
public sealed record ConfigurationPluginImportSummary(
|
||||
IReadOnlyList<ConfigurationPluginDestination> Destinations,
|
||||
int ChatTemplates,
|
||||
int Profiles,
|
||||
int DocumentAnalysisPolicies,
|
||||
int DeclaredSettings,
|
||||
int MandatoryInfos,
|
||||
int Introductions)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the configuration sets up anything at all.
|
||||
/// </summary>
|
||||
public bool HasAnyContent =>
|
||||
this.Destinations.Count > 0 ||
|
||||
this.ChatTemplates > 0 ||
|
||||
this.Profiles > 0 ||
|
||||
this.DocumentAnalysisPolicies > 0 ||
|
||||
this.DeclaredSettings > 0 ||
|
||||
this.MandatoryInfos > 0 ||
|
||||
this.Introductions > 0;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed record PluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
|
||||
@ -8,7 +8,10 @@ namespace AIStudio.Tools.Services;
|
||||
/// </summary>
|
||||
/// <param name="Plugin">The plugin from the archive, with the metadata it declares about itself.</param>
|
||||
/// <param name="ExistingPlugin">The installed plugin that gets replaced or null when the archive adds a new plugin.</param>
|
||||
public sealed record PluginImportPreview(IPluginMetadata Plugin, IAvailablePlugin? ExistingPlugin)
|
||||
/// <param name="ConfigurationSummary">
|
||||
/// What a configuration plugin would set up. Null for every other plugin type.
|
||||
/// </param>
|
||||
public sealed record PluginImportPreview(IPluginMetadata Plugin, IAvailablePlugin? ExistingPlugin, ConfigurationPluginImportSummary? ConfigurationSummary = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when an installed plugin with the same ID gets replaced.
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
using System.Text;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class PluginInstallService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks whether generated Lua assistant plugin code can be loaded and installed.
|
||||
/// The plugin is written to a temporary staging directory and validated through the
|
||||
/// normal plugin loader, but it is not moved into the user plugin directory.
|
||||
/// </summary>
|
||||
/// <param name="lua">The full generated <c>plugin.lua</c> content.</param>
|
||||
/// <param name="token">A cancellation token for file IO and Lua validation.</param>
|
||||
/// <returns>
|
||||
/// Check result that contains success state, plugin metadata, and a user-facing issue when validation failed.
|
||||
/// </returns>
|
||||
public async Task<AssistantPluginCheckResult> CheckInstallabilityAsync(string lua, CancellationToken token)
|
||||
{
|
||||
if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue))
|
||||
return CheckError(rootIssue);
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
var stagingDirectory = string.Empty;
|
||||
try
|
||||
{
|
||||
var validation = await this.ValidateIntoStagingAsync(lua, token);
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return CheckError(validation.Issue);
|
||||
|
||||
stagingDirectory = validation.StagingDirectory;
|
||||
var finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, validation.AssistantPlugin, PluginType.ASSISTANT);
|
||||
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
|
||||
return CheckError(TB("The resolved plugin directory is outside the plugin directory."));
|
||||
|
||||
return new(true, validation.AssistantPlugin.Id, validation.AssistantPlugin.Name, string.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs generated Lua assistant plugin code into the user plugin directory.
|
||||
/// Writes the plugin into a temporary staging directory first, validates it through the
|
||||
/// normal plugin loader, then moves into <c>data/plugins/assistants</c>.
|
||||
/// If plugin with same ID already exists, the existing directory is moved
|
||||
/// aside as backup and restored when replacement fails.
|
||||
/// </summary>
|
||||
/// <param name="lua">The full generated <c>plugin.lua</c> content.</param>
|
||||
/// <param name="token">A cancellation token for file IO, Lua validation, and plugin reload.</param>
|
||||
/// <returns>
|
||||
/// Installation result that contains success state, installed plugin metadata, final directory,
|
||||
/// whether an existing plugin was replaced, and user-facing issue when installation failed.
|
||||
/// </returns>
|
||||
public async Task<AssistantPluginInstallResult> InstallAsync(string lua, CancellationToken token)
|
||||
{
|
||||
if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue))
|
||||
return Error(rootIssue);
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
var validation = await this.ValidateIntoStagingAsync(lua, token);
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return Error(validation.Issue);
|
||||
|
||||
return await this.InstallStagedPluginAsync(assistantPluginsRoot, validation, PluginType.ASSISTANT, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PluginValidationResult> ValidateIntoStagingAsync(string lua, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lua))
|
||||
return PluginValidationResult.Failure(TB("No Lua plugin code was generated."));
|
||||
|
||||
if (!PluginFactory.IsInitialized)
|
||||
return PluginValidationResult.Failure(TB("The plugin system is not initialized yet."));
|
||||
|
||||
var pluginCode = lua.Trim();
|
||||
var stagingDirectory = Path.Join(Path.GetTempPath(), $"{ASSISTANT_BUILDER_DIRECTORY_PREFIX}.staging-{Guid.NewGuid():N}");
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(stagingDirectory);
|
||||
var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME);
|
||||
await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token);
|
||||
|
||||
var validation = await ValidatePluginCodeAsync(
|
||||
stagingDirectory,
|
||||
pluginCode,
|
||||
[PluginType.ASSISTANT],
|
||||
TB("The generated plugin is not an assistant plugin. Issue: {0}"),
|
||||
TB("The generated assistant plugin is invalid. Issue: {0}"),
|
||||
TB("The generated assistant plugin uses the ID of another installed plugin."),
|
||||
token);
|
||||
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
|
||||
return validation with { StagingDirectory = stagingDirectory };
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, "Failed to validate generated assistant plugin.");
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
return PluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,283 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.PluginSystem.Assistants;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class PluginInstallService
|
||||
{
|
||||
/// <summary>
|
||||
/// The plugin types users may remove through the user interface.
|
||||
/// </summary>
|
||||
private static readonly PluginType[] DELETABLE_PLUGIN_TYPES = [PluginType.ASSISTANT, PluginType.CONFIGURATION, PluginType.LANGUAGE];
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin is one that users may delete.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This decides whether the delete action is offered at all. Whether it may run right now is a
|
||||
/// different question: an assistant with running background work stays visible but blocked.
|
||||
/// </remarks>
|
||||
public static bool CanDeletePlugin(IAvailablePlugin plugin) => string.IsNullOrWhiteSpace(GetDeletionEligibilityIssue(plugin));
|
||||
|
||||
/// <summary>
|
||||
/// Collects what deleting a local configuration plugin removes besides the plugin directory.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The configuration plugin about to be deleted.</param>
|
||||
/// <returns>
|
||||
/// The summary shown to the user before the deletion starts. It is empty when the plugin is not
|
||||
/// running, because we cannot tell what an unloadable plugin had configured.
|
||||
/// </returns>
|
||||
public ConfigurationPluginDeleteSummary BuildConfigurationDeleteSummary(IAvailablePlugin plugin)
|
||||
{
|
||||
var configurationPlugin = PluginFactory.RunningPlugins.OfType<PluginConfiguration>().FirstOrDefault(candidate => candidate.Id == plugin.Id);
|
||||
if (configurationPlugin is null)
|
||||
return ConfigurationPluginDeleteSummary.EMPTY;
|
||||
|
||||
var configObjects = configurationPlugin.ConfigObjects.ToList();
|
||||
var configurationData = this.settingsManager.ConfigurationData;
|
||||
|
||||
// Both maps record which configuration plugin manages a setting. Everything this plugin owns
|
||||
// returns to its default value once the plugin is gone:
|
||||
var lockedSettings =
|
||||
configurationData.ManagedLockedConfigurations.Count(entry => entry.Value == plugin.Id) +
|
||||
configurationData.ManagedEditableDefaults.Count(entry => entry.Value.ConfigPluginId == plugin.Id);
|
||||
|
||||
return new(
|
||||
LlmProviders: CountObjects(PluginConfigurationObjectType.LLM_PROVIDER),
|
||||
TranscriptionProviders: CountObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER),
|
||||
EmbeddingProviders: CountObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER),
|
||||
DataSources: CountObjects(PluginConfigurationObjectType.DATA_SOURCE),
|
||||
ChatTemplates: CountObjects(PluginConfigurationObjectType.CHAT_TEMPLATE),
|
||||
Profiles: CountObjects(PluginConfigurationObjectType.PROFILE),
|
||||
DocumentAnalysisPolicies: CountObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY),
|
||||
LockedSettings: lockedSettings,
|
||||
MandatoryInfos: configurationPlugin.MandatoryInfos.Count,
|
||||
Introductions: configurationPlugin.Introductions.Count);
|
||||
|
||||
int CountObjects(PluginConfigurationObjectType type) => configObjects.Count(configObject => configObject.Type == type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether an assistant still owns running or canceling background work.
|
||||
/// </summary>
|
||||
public bool HasActiveAssistantWork(Guid pluginId)
|
||||
{
|
||||
var instanceId = pluginId.ToString();
|
||||
if (this.assistantSessionService.GetSnapshots().Any(snapshot => snapshot.IsActive && string.Equals(snapshot.Key.InstanceId, instanceId, StringComparison.Ordinal)))
|
||||
return true;
|
||||
|
||||
var ownerIdSuffix = $":{instanceId}";
|
||||
return this.mediaTranscriptionService.GetSnapshots().Any(snapshot =>
|
||||
snapshot is { IsBusy: true, Owner.Kind: MediaImportOwnerKind.ASSISTANT } &&
|
||||
snapshot.Owner.Id.EndsWith(ownerIdSuffix, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the directory of a plugin the user installed or placed themselves.
|
||||
/// The directory gets moved to a backup dir outside the plugin root so the plugin loader cannot
|
||||
/// discover it during reload. On failure, the directory and the related settings are restored.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For a configuration plugin, we do not remove its providers, data sources, chat templates,
|
||||
/// profiles, or locked settings ourselves. The reload does that: it recognizes them as left over
|
||||
/// once their configuration plugin is gone, and it also deletes the related secrets from the OS
|
||||
/// keyring.<br/><br/>
|
||||
/// What the reload cannot recognize as left over is everything the user decided about the plugin
|
||||
/// itself: its activation state, the language choice of a language plugin, and the security audit
|
||||
/// of an assistant. Those are removed here, see ApplyDeleteSideEffects.
|
||||
/// </remarks>
|
||||
/// <param name="plugin">Metadata of the plugin to delete.</param>
|
||||
/// <param name="token">Cancellation token for settings storage and plugin reload.</param>
|
||||
/// <returns>
|
||||
/// Delete result that contains a success state, deleted plugin metadata, the original plugin directory,
|
||||
/// and a user-facing issue when deletion failed.
|
||||
/// </returns>
|
||||
public async Task<PluginDeleteResult> DeletePluginAsync(IAvailablePlugin plugin, CancellationToken token)
|
||||
{
|
||||
var deletionIssue = this.GetDeletionIssue(plugin);
|
||||
if (!string.IsNullOrWhiteSpace(deletionIssue))
|
||||
return DeleteError(plugin, plugin.LocalPath, deletionIssue);
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
var pluginDirectory = plugin.LocalPath;
|
||||
var backupDirectory = string.Empty;
|
||||
var sideEffects = PluginDeleteSideEffects.NONE;
|
||||
|
||||
try
|
||||
{
|
||||
// Check again under the semaphore: another operation might have changed the plugin state
|
||||
// while we were waiting:
|
||||
deletionIssue = this.GetDeletionIssue(plugin);
|
||||
if (!string.IsNullOrWhiteSpace(deletionIssue))
|
||||
return DeleteError(plugin, pluginDirectory, deletionIssue);
|
||||
|
||||
backupDirectory = CreateDeleteBackupDirectory(plugin);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!);
|
||||
Directory.Move(pluginDirectory, backupDirectory);
|
||||
|
||||
sideEffects = this.ApplyDeleteSideEffects(plugin);
|
||||
if (sideEffects.HasChanges)
|
||||
await this.settingsManager.StoreSettings();
|
||||
|
||||
await PluginFactory.LoadAll(token);
|
||||
|
||||
TryDeleteDirectory(backupDirectory, "plugin delete backup", this.logger);
|
||||
this.logger.LogInformation($"Deleted {plugin.Type} plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'.");
|
||||
return new(true, plugin.Id, plugin.Name, pluginDirectory, string.Empty);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, $"Failed to delete {plugin.Type} plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'.");
|
||||
|
||||
await this.TryRestoreDeletedPluginAsync(plugin, pluginDirectory, backupDirectory, sideEffects, token);
|
||||
return DeleteError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks everything that prevents deleting a plugin right now.
|
||||
/// </summary>
|
||||
private string GetDeletionIssue(IAvailablePlugin plugin)
|
||||
{
|
||||
var eligibilityIssue = GetDeletionEligibilityIssue(plugin);
|
||||
if (!string.IsNullOrWhiteSpace(eligibilityIssue))
|
||||
return eligibilityIssue;
|
||||
|
||||
// An assistant must not be pulled away from under a user while it is still working:
|
||||
if (plugin.Type is PluginType.ASSISTANT && this.HasActiveAssistantWork(plugin.Id))
|
||||
return TB("The assistant cannot be deleted while background work is still running.");
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin is one users may delete at all, regardless of its current state.
|
||||
/// </summary>
|
||||
private static string GetDeletionEligibilityIssue(IAvailablePlugin plugin)
|
||||
{
|
||||
if (!DELETABLE_PLUGIN_TYPES.Contains(plugin.Type))
|
||||
return TB("Only assistant, configuration, and language plugins can be deleted.");
|
||||
|
||||
if (plugin.IsInternal)
|
||||
return TB("Plugins shipped with AI Studio cannot be deleted.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(plugin.LocalPath))
|
||||
return TB("The plugin has no local directory.");
|
||||
|
||||
//
|
||||
// We decide by the plugin path, not by what a plugin declares about itself. Both
|
||||
// DEPLOYED_USING_CONFIG_SERVER and the Assistant Builder metadata are self-declared: a
|
||||
// locally placed plugin could claim to be deployed by an organization, or simply omit the
|
||||
// builder metadata, and would then be impossible to remove through the user interface, which
|
||||
// is exactly the situation this deletion is meant to resolve.
|
||||
//
|
||||
if (PluginFactory.IsEnterpriseConfigurationPath(plugin.LocalPath))
|
||||
return TB("Plugins deployed by your organization cannot be deleted.");
|
||||
|
||||
if (!PluginFactory.IsInsidePluginsRoot(plugin.LocalPath) || PluginFactory.IsPluginsRoot(plugin.LocalPath))
|
||||
return TB("This individual plugin’s directory is outside the expected plugins directory.");
|
||||
|
||||
return Directory.Exists(plugin.LocalPath) ? string.Empty : TB("The plugin directory does not exist.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes everything the user decided about the plugin, and reports what was removed so a failed
|
||||
/// deletion can put it back.
|
||||
/// </summary>
|
||||
private PluginDeleteSideEffects ApplyDeleteSideEffects(IAvailablePlugin plugin)
|
||||
{
|
||||
var configurationData = this.settingsManager.ConfigurationData;
|
||||
|
||||
//
|
||||
// Nothing removes the activation state of a plugin which is gone. Should the user install
|
||||
// a plugin with the same ID again later, it would start enabled without ever having been
|
||||
// switched on. We ask for removal regardless of the plugin type: a configuration plugin
|
||||
// is never listed there, so this simply does nothing for it:
|
||||
//
|
||||
var wasEnabled = configurationData.EnabledPlugins.Remove(plugin.Id);
|
||||
|
||||
//
|
||||
// When the user had chosen this language plugin, the app would silently fall back to
|
||||
// English while the settings still point to the deleted plugin. We return the language
|
||||
// choice to automatic instead, so the settings stay truthful:
|
||||
//
|
||||
var wasChosenLanguage = plugin.Type is PluginType.LANGUAGE && configurationData.App.LanguagePluginId == plugin.Id;
|
||||
if (wasChosenLanguage)
|
||||
{
|
||||
configurationData.App.LanguageBehavior = LangBehavior.AUTO;
|
||||
configurationData.App.LanguagePluginId = Guid.Empty;
|
||||
}
|
||||
|
||||
//
|
||||
// The security audit belongs to the assistant code we checked. Another assistant installed
|
||||
// under the same ID later is different code, so it must be audited again:
|
||||
//
|
||||
List<PluginAssistantAudit> removedAudits = [];
|
||||
if (plugin.Type is PluginType.ASSISTANT)
|
||||
{
|
||||
removedAudits = [.. configurationData.AssistantPluginAudits.Where(audit => audit.PluginId == plugin.Id)];
|
||||
if (removedAudits.Count > 0)
|
||||
configurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id);
|
||||
}
|
||||
|
||||
return new(wasEnabled, wasChosenLanguage, removedAudits);
|
||||
}
|
||||
|
||||
private static string CreateDeleteBackupDirectory(IAvailablePlugin plugin)
|
||||
{
|
||||
var backupRoot = Path.Join(SettingsManager.DataDirectory, DELETE_BACKUP_DIRECTORY);
|
||||
return Path.Join(backupRoot, $"{plugin.Type.GetDirectory()}-{plugin.Id:N}-{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
private async Task TryRestoreDeletedPluginAsync(IAvailablePlugin plugin, string pluginDirectory, string backupDirectory, PluginDeleteSideEffects sideEffects, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(pluginDirectory) && Directory.Exists(backupDirectory))
|
||||
Directory.Move(backupDirectory, pluginDirectory);
|
||||
|
||||
var configurationData = this.settingsManager.ConfigurationData;
|
||||
if (sideEffects.WasEnabled && !configurationData.EnabledPlugins.Contains(plugin.Id))
|
||||
configurationData.EnabledPlugins.Add(plugin.Id);
|
||||
|
||||
if (sideEffects.WasChosenLanguage)
|
||||
{
|
||||
configurationData.App.LanguageBehavior = LangBehavior.MANUAL;
|
||||
configurationData.App.LanguagePluginId = plugin.Id;
|
||||
}
|
||||
|
||||
if (sideEffects.RemovedAudits.Count > 0)
|
||||
{
|
||||
configurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id);
|
||||
configurationData.AssistantPluginAudits.AddRange(sideEffects.RemovedAudits);
|
||||
}
|
||||
|
||||
if (sideEffects.HasChanges)
|
||||
await this.settingsManager.StoreSettings();
|
||||
|
||||
// The reload restores everything the plugin configured, because it is back in place:
|
||||
await PluginFactory.LoadAll(token);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
this.logger.LogError(restoreException, $"Failed to restore {plugin.Type} plugin '{plugin.Name}' ({plugin.Id}) after a failed delete.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What deleting a plugin changed in the settings, so a failed deletion can undo it.
|
||||
/// </summary>
|
||||
private sealed record PluginDeleteSideEffects(bool WasEnabled, bool WasChosenLanguage, List<PluginAssistantAudit> RemovedAudits)
|
||||
{
|
||||
public static readonly PluginDeleteSideEffects NONE = new(false, false, []);
|
||||
|
||||
public bool HasChanges => this.WasEnabled || this.WasChosenLanguage || this.RemovedAudits.Count > 0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,195 @@
|
||||
using System.Text;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class PluginInstallService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks whether edited assistant plugin code can replace an installed local assistant plugin
|
||||
/// without writing the file.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The installed local assistant plugin to validate against.</param>
|
||||
/// <param name="lua">The edited <c>plugin.lua</c> content.</param>
|
||||
/// <param name="token">Cancellation token for Lua validation.</param>
|
||||
/// <returns>Check result that contains success state, plugin metadata, and a user-facing issue when validation failed.</returns>
|
||||
public async Task<AssistantPluginCheckResult> CheckInstalledAssistantUpdateAsync(IAvailablePlugin plugin, string lua, CancellationToken token)
|
||||
{
|
||||
if (plugin.Type is not PluginType.ASSISTANT)
|
||||
return CheckError(TB("Only assistant plugins can be edited."));
|
||||
|
||||
if (plugin.IsInternal)
|
||||
return CheckError(TB("Internal assistant plugins cannot be edited."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(plugin.LocalPath))
|
||||
return CheckError(TB("The assistant plugin has no local directory."));
|
||||
|
||||
if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue))
|
||||
return CheckError(rootIssue);
|
||||
|
||||
var pluginDirectory = plugin.LocalPath;
|
||||
if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory))
|
||||
return CheckError(TB("The assistant plugin directory is outside the local assistant plugin directory."));
|
||||
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
return CheckError(TB("The assistant plugin directory does not exist."));
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token);
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return CheckError(validation.Issue);
|
||||
|
||||
var assistantPlugin = validation.AssistantPlugin;
|
||||
return assistantPlugin.Id != plugin.Id
|
||||
? CheckError(TB("The edited assistant plugin must keep the same plugin ID."))
|
||||
: new(true, assistantPlugin.Id, assistantPlugin.Name, string.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates installed assistant plugin <c>plugin.lua</c> file.
|
||||
/// The edited Lua code is validated from the provided string before it is written,
|
||||
/// but validation uses existing plugin directory as loader context so
|
||||
/// <c>require(...)</c> can resolve companion files such as <c>icon.lua</c>.
|
||||
/// After successful validation, the current <c>plugin.lua</c> is backed up,
|
||||
/// replaced atomically through a temporary file in the plugin directory, and
|
||||
/// restored when the plugin reload fails.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The installed local assistant plugin to update.</param>
|
||||
/// <param name="lua">The edited <c>plugin.lua</c> content.</param>
|
||||
/// <param name="token">Cancellation token for Lua validation, file IO, and plugin reload.</param>
|
||||
/// <returns>
|
||||
/// Update result that contains success state, updated plugin metadata, the plugin directory,
|
||||
/// and a user-facing issue when the update failed.
|
||||
/// </returns>
|
||||
public async Task<AssistantPluginUpdateResult> UpdateInstalledAssistantAsync(IAvailablePlugin plugin, string lua, CancellationToken token)
|
||||
{
|
||||
if (plugin.Type is not PluginType.ASSISTANT)
|
||||
return UpdateError(plugin, plugin.LocalPath, TB("Only assistant plugins can be edited."));
|
||||
|
||||
if (plugin.IsInternal)
|
||||
return UpdateError(plugin, plugin.LocalPath, TB("Internal assistant plugins cannot be edited."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(plugin.LocalPath))
|
||||
return UpdateError(plugin, string.Empty, TB("The assistant plugin has no local directory."));
|
||||
|
||||
if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue))
|
||||
return UpdateError(plugin, plugin.LocalPath, rootIssue);
|
||||
|
||||
var pluginDirectory = plugin.LocalPath;
|
||||
if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory))
|
||||
return UpdateError(plugin, pluginDirectory, TB("The assistant plugin directory is outside the local assistant plugin directory."));
|
||||
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
return UpdateError(plugin, pluginDirectory, TB("The assistant plugin directory does not exist."));
|
||||
|
||||
var pluginFile = Path.Join(pluginDirectory, PLUGIN_FILE_NAME);
|
||||
if (!IsPathInsideDirectory(pluginDirectory, pluginFile))
|
||||
return UpdateError(plugin, pluginDirectory, TB("The plugin file is outside the assistant plugin directory."));
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
var tempFile = string.Empty;
|
||||
var backupFile = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token);
|
||||
if (!validation.Success || validation.AssistantPlugin is null)
|
||||
return UpdateError(plugin, pluginDirectory, validation.Issue);
|
||||
|
||||
var assistantPlugin = validation.AssistantPlugin;
|
||||
if (assistantPlugin.Id != plugin.Id)
|
||||
return UpdateError(plugin, pluginDirectory, TB("The edited assistant plugin must keep the same plugin ID."));
|
||||
|
||||
var pluginCode = lua.Trim();
|
||||
tempFile = Path.Join(pluginDirectory, $"{PLUGIN_FILE_NAME}.tmp-{Guid.NewGuid():N}");
|
||||
backupFile = Path.Join(pluginDirectory, $"{PLUGIN_FILE_NAME}.backup-{Guid.NewGuid():N}");
|
||||
|
||||
await File.WriteAllTextAsync(tempFile, pluginCode, Encoding.UTF8, token);
|
||||
|
||||
if (File.Exists(pluginFile))
|
||||
File.Replace(tempFile, pluginFile, backupFile);
|
||||
else
|
||||
File.Move(tempFile, pluginFile);
|
||||
|
||||
try
|
||||
{
|
||||
await PluginFactory.LoadAll(token);
|
||||
if (File.Exists(backupFile))
|
||||
File.Delete(backupFile);
|
||||
|
||||
this.logger.LogInformation($"Updated assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) at '{pluginFile}'.");
|
||||
return new(true, assistantPlugin.Id, assistantPlugin.Name, pluginDirectory, string.Empty);
|
||||
}
|
||||
catch (Exception reloadException)
|
||||
{
|
||||
this.logger.LogError(reloadException, $"Failed to reload plugins after editing assistant plugin '{plugin.Name}' ({plugin.Id}).");
|
||||
await this.TryRestoreEditedAssistantPluginAsync(pluginFile, backupFile, token);
|
||||
return UpdateError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), reloadException.Message));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, $"Failed to update assistant plugin '{plugin.Name}' ({plugin.Id}) at '{pluginDirectory}'.");
|
||||
await this.TryRestoreEditedAssistantPluginAsync(pluginFile, backupFile, token);
|
||||
return UpdateError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteFile(tempFile, "assistant plugin edit temp file");
|
||||
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PluginValidationResult> ValidateInPluginDirectoryAsync(string lua, string pluginDirectory, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lua))
|
||||
return PluginValidationResult.Failure(TB("No Lua plugin code was generated."));
|
||||
|
||||
if (!PluginFactory.IsInitialized)
|
||||
return PluginValidationResult.Failure(TB("The plugin system is not initialized yet."));
|
||||
|
||||
try
|
||||
{
|
||||
return await ValidatePluginCodeAsync(
|
||||
pluginDirectory,
|
||||
lua.Trim(),
|
||||
[PluginType.ASSISTANT],
|
||||
TB("The edited plugin is not an assistant plugin. Issue: {0}"),
|
||||
TB("The edited assistant plugin is invalid. Issue: {0}"),
|
||||
TB("The edited assistant plugin uses the ID of another installed plugin."),
|
||||
token);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, "Failed to validate edited assistant plugin.");
|
||||
return PluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryRestoreEditedAssistantPluginAsync(string pluginFile, string backupFile, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(backupFile) || !File.Exists(backupFile))
|
||||
return;
|
||||
|
||||
if (File.Exists(pluginFile))
|
||||
File.Delete(pluginFile);
|
||||
|
||||
File.Move(backupFile, pluginFile);
|
||||
await PluginFactory.LoadAll(token);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
this.logger.LogError(restoreException, $"Failed to restore assistant plugin file '{pluginFile}' after a failed edit.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class PluginInstallService
|
||||
{
|
||||
private static bool IsPathInsideDirectory(string parentDirectory, string path)
|
||||
{
|
||||
var parentPath = Path.GetFullPath(parentDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsSameDirectory(string firstDirectory, string secondDirectory)
|
||||
{
|
||||
var firstPath = Path.GetFullPath(firstDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var secondPath = Path.GetFullPath(secondDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(firstPath, secondPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void TryDeleteStagingDirectory(string stagingDirectory) => TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger);
|
||||
|
||||
private static void TryDeleteDirectory(string directory, string directoryDescription, ILogger logger)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, $"Failed to delete {directoryDescription} directory '{directory}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteFile(string filePath, string fileDescription)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, $"Failed to delete {fileDescription} '{filePath}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
using System.Text;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.PluginSystem.Assistants;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class PluginInstallService
|
||||
{
|
||||
/// <summary>
|
||||
/// The plugin types a user may import from an archive.
|
||||
/// </summary>
|
||||
private static readonly PluginType[] IMPORTABLE_PLUGIN_TYPES = [PluginType.ASSISTANT, PluginType.CONFIGURATION, PluginType.LANGUAGE];
|
||||
|
||||
/// <summary>
|
||||
/// Installs a plugin archive that contains exactly one <c>plugin.lua</c> file.
|
||||
/// Companion files are validated from and moved with the same staging directory.
|
||||
/// </summary>
|
||||
/// <param name="archivePath">The local <c>.mwplugin</c> or <c>.zip</c> archive path.</param>
|
||||
/// <param name="confirmAsync">
|
||||
/// Asks the user whether the validated archive may be installed. It is called after all checks
|
||||
/// passed and before anything gets written. Returning false aborts the installation.
|
||||
/// </param>
|
||||
/// <param name="token">Cancellation token for extraction, validation, file IO, and plugin reload.</param>
|
||||
/// <returns>Installation result that contains success state, installed plugin metadata, and a user-facing issue when installation failed.</returns>
|
||||
public async Task<AssistantPluginInstallResult> InstallArchiveAsync(string archivePath, Func<PluginImportPreview, Task<bool>> confirmAsync, CancellationToken token)
|
||||
{
|
||||
if (!this.settingsManager.ConfigurationData.App.AllowUserToImportPlugins)
|
||||
return Error(TB("Your organization has disabled importing plugins."));
|
||||
|
||||
if (!FileTypes.IsAllowedPath(archivePath, FileTypes.PLUGIN_ARCHIVE))
|
||||
return Error(TB("Please select a plugin archive with the extension .mwplugin or .zip."));
|
||||
|
||||
if (!File.Exists(archivePath))
|
||||
return Error(TB("The selected plugin archive does not exist."));
|
||||
|
||||
if (!PluginFactory.IsInitialized)
|
||||
return Error(TB("The plugin system is not initialized yet."));
|
||||
|
||||
await this.installSemaphore.WaitAsync(token);
|
||||
var stagingDirectory = Path.Join(Path.GetTempPath(), $"plugin-import.staging-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
PluginArchive.Extract(archivePath, stagingDirectory);
|
||||
|
||||
var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray();
|
||||
if (pluginFiles.Length != 1)
|
||||
return Error(TB("The plugin archive must contain exactly one plugin.lua file."));
|
||||
|
||||
var pluginFile = pluginFiles[0];
|
||||
var pluginDirectory = Path.GetDirectoryName(pluginFile)!;
|
||||
var pluginCode = await File.ReadAllTextAsync(pluginFile, Encoding.UTF8, token);
|
||||
var validation = await ValidatePluginCodeAsync(
|
||||
pluginDirectory,
|
||||
pluginCode.Trim(),
|
||||
IMPORTABLE_PLUGIN_TYPES,
|
||||
TB("Only assistant, configuration, and language plugins can be imported."),
|
||||
TB("The imported plugin is invalid. Issue: {0}"),
|
||||
TB("The imported plugin uses the ID of another installed plugin."),
|
||||
token);
|
||||
|
||||
if (!validation.Success || validation.Plugin is null)
|
||||
return Error(validation.Issue);
|
||||
|
||||
var plugin = validation.Plugin;
|
||||
var eligibilityIssue = this.GetImportEligibilityIssue(plugin);
|
||||
if (!string.IsNullOrEmpty(eligibilityIssue))
|
||||
return Error(eligibilityIssue);
|
||||
|
||||
// The archive would replace an existing plugin: reject it when that plugin belongs
|
||||
// to the IT department. We check this before asking the user, so that the
|
||||
// confirmation never offers something we would refuse afterwards anyway:
|
||||
var replacementIssue = GetReplacementIssue(plugin.Id, plugin.Type);
|
||||
if (!string.IsNullOrEmpty(replacementIssue))
|
||||
return Error(replacementIssue);
|
||||
|
||||
// Local plugins live in the directory of their type, never in the enterprise
|
||||
// configuration directory. Only a config server deploys plugins there:
|
||||
if (!TryGetPluginRoot(plugin.Type, out var pluginRoot, out var rootIssue))
|
||||
return Error(rootIssue);
|
||||
|
||||
// Everything is validated, but nothing was written yet. This is the point where the
|
||||
// user decides, because the plugin code comes from an untrusted source:
|
||||
if (!await confirmAsync(CreateImportPreview(plugin)))
|
||||
return CancelledByUser();
|
||||
|
||||
return await this.InstallStagedPluginAsync(pluginRoot, validation with { StagingDirectory = pluginDirectory }, plugin.Type, token);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
this.logger.LogError(e, "Failed to extract or validate plugin archive '{ArchivePath}'.", archivePath);
|
||||
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
this.installSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the rules that depend on the type of the plugin inside the archive.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The validated plugin from the archive.</param>
|
||||
/// <returns>A user-facing issue when the archive must not be installed, an empty string otherwise.</returns>
|
||||
private string GetImportEligibilityIssue(PluginBase plugin) => plugin switch
|
||||
{
|
||||
// A plugin the user imports by hand never comes from a config server. We reject such
|
||||
// archives because AI Studio trusts this self-declared flag: an imported plugin claiming it
|
||||
// would be neither replaceable nor deletable through the user interface:
|
||||
PluginAssistants { IsManagedByConfigServer: true } => TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."),
|
||||
|
||||
PluginConfiguration configurationPlugin => this.GetConfigurationImportEligibilityIssue(configurationPlugin),
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks the additional rules for importing a configuration plugin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A configuration takes effect immediately and has no on/off switch, so it gets its own
|
||||
/// organization permission on top of the general import permission.
|
||||
/// </remarks>
|
||||
private string GetConfigurationImportEligibilityIssue(PluginConfiguration configurationPlugin)
|
||||
{
|
||||
if (!this.settingsManager.ConfigurationData.App.AllowUserToImportConfigurationPlugins)
|
||||
return TB("Your organization has disabled importing configuration plugins.");
|
||||
|
||||
if (configurationPlugin.DeployedUsingConfigServer is true)
|
||||
return TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.");
|
||||
|
||||
// Never let an imported configuration take the place of one the organization deployed. This
|
||||
// also covers a deployed configuration which currently cannot be loaded, e.g. because of an
|
||||
// error in its Lua code:
|
||||
if (PluginFactory.IsEnterpriseConfigurationPlugin(configurationPlugin.Id))
|
||||
return TB("Your organization deployed a configuration with the same ID. An imported configuration must not take its place.");
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
using System.Text;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.PluginSystem.Assistants;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class PluginInstallService
|
||||
{
|
||||
private async Task<AssistantPluginInstallResult> InstallStagedPluginAsync(string pluginRoot, PluginValidationResult validation, PluginType pluginType, CancellationToken token)
|
||||
{
|
||||
var stagingDirectory = validation.StagingDirectory;
|
||||
var plugin = validation.Plugin!;
|
||||
string? backupDirectory = null;
|
||||
string? finalDirectory = null;
|
||||
var replacedExisting = false;
|
||||
var movedIntoPlace = false;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(pluginRoot);
|
||||
finalDirectory = DetermineFinalDirectory(pluginRoot, plugin, pluginType);
|
||||
if (!IsPathInsideDirectory(pluginRoot, finalDirectory))
|
||||
return Error(TB("The resolved plugin directory is outside the plugin directory."));
|
||||
|
||||
var replacementIssue = GetReplacementIssue(plugin.Id, pluginType);
|
||||
if (!string.IsNullOrWhiteSpace(replacementIssue))
|
||||
return Error(replacementIssue);
|
||||
|
||||
if (Directory.Exists(finalDirectory))
|
||||
{
|
||||
replacedExisting = true;
|
||||
|
||||
// The backup goes to a directory outside the plugin root, so the plugin loader
|
||||
// cannot discover it during the reload below. Otherwise, the previous version
|
||||
// would be loaded a second time, next to the version we are installing:
|
||||
backupDirectory = CreateInstallBackupDirectory(plugin);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!);
|
||||
Directory.Move(finalDirectory, backupDirectory);
|
||||
}
|
||||
|
||||
Directory.Move(stagingDirectory, finalDirectory);
|
||||
movedIntoPlace = true;
|
||||
await PluginFactory.LoadAll(token);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory))
|
||||
TryDeleteDirectory(backupDirectory, "plugin backup", this.logger);
|
||||
|
||||
this.logger.LogInformation("Installed plugin '{PluginName}' ({PluginId}, {PluginType}) to '{PluginDirectory}'.", plugin.Name, plugin.Id, pluginType, finalDirectory);
|
||||
return new(true, plugin.Id, plugin.Name, finalDirectory, replacedExisting, string.Empty);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger.LogError(e, "Failed to install plugin.");
|
||||
|
||||
// Only remove the target directory when this installation actually moved the plugin
|
||||
// there. Otherwise, when moving the previous plugin into the backup directory failed,
|
||||
// we would delete the still intact previous plugin:
|
||||
if (movedIntoPlace && !string.IsNullOrWhiteSpace(finalDirectory) && Directory.Exists(finalDirectory))
|
||||
TryDeleteDirectory(finalDirectory, "failed assistant plugin installation", this.logger);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !string.IsNullOrWhiteSpace(finalDirectory) && !Directory.Exists(finalDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(backupDirectory, finalDirectory);
|
||||
await PluginFactory.LoadAll(CancellationToken.None);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
this.logger.LogError(restoreException, "Failed to restore the previous assistant plugin after a failed installation.");
|
||||
}
|
||||
}
|
||||
|
||||
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteStagingDirectory(stagingDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads and validates plugin code that is not installed yet.
|
||||
/// </summary>
|
||||
/// <param name="pluginDirectory">The staging directory the plugin currently lives in.</param>
|
||||
/// <param name="pluginCode">The <c>plugin.lua</c> content to validate.</param>
|
||||
/// <param name="acceptedTypes">The plugin types the caller accepts.</param>
|
||||
/// <param name="wrongTypeIssue">Issue when the plugin has another type. Gets the plugin issues as {0}.</param>
|
||||
/// <param name="invalidPluginIssue">Issue when the plugin is of an accepted type, but invalid. Gets the plugin issues as {0}.</param>
|
||||
/// <param name="conflictingPluginIdIssue">Issue when another plugin already uses this plugin ID.</param>
|
||||
/// <param name="token">Cancellation token for running the Lua code.</param>
|
||||
/// <returns>The validation result, including the loaded plugin when it passed.</returns>
|
||||
private static async Task<PluginValidationResult> ValidatePluginCodeAsync(string pluginDirectory, string pluginCode, IReadOnlyCollection<PluginType> acceptedTypes,
|
||||
string wrongTypeIssue, string invalidPluginIssue, string conflictingPluginIdIssue, CancellationToken token)
|
||||
{
|
||||
// The plugin is not installed yet: it sits in a staging directory outside the installed
|
||||
// plugins directory. We allow that directory as the module base, so the plugin can load its
|
||||
// own Lua modules, e.g., an icon.lua, while we validate it:
|
||||
var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token, pluginDirectory);
|
||||
if (!acceptedTypes.Contains(plugin.Type))
|
||||
return PluginValidationResult.Failure(string.Format(wrongTypeIssue, string.Join("; ", plugin.Issues)));
|
||||
|
||||
if (!plugin.IsValid)
|
||||
return PluginValidationResult.Failure(string.Format(invalidPluginIssue, string.Join("; ", plugin.Issues)));
|
||||
|
||||
// Plugin IDs must be unique across all plugin types: several lookups resolve a plugin by its
|
||||
// ID alone, e.g., the base language plugin in PluginFactory.Starting. A plugin carrying the
|
||||
// ID of a plugin of another type would break those lookups. Reusing the ID of another local
|
||||
// plugin of the same type stays allowed: that is how updating one works.
|
||||
if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Id == plugin.Id && (availablePlugin.IsInternal || availablePlugin.Type != plugin.Type)))
|
||||
return PluginValidationResult.Failure(conflictingPluginIdIssue);
|
||||
|
||||
return new(true, string.Empty, plugin, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the directory local plugins of the given type are installed into.
|
||||
/// </summary>
|
||||
private static bool TryGetPluginRoot(PluginType pluginType, out string pluginRoot, out string issue)
|
||||
{
|
||||
pluginRoot = string.Empty;
|
||||
issue = string.Empty;
|
||||
|
||||
var dataDirectory = SettingsManager.DataDirectory;
|
||||
if (string.IsNullOrWhiteSpace(dataDirectory))
|
||||
{
|
||||
issue = TB("The AI Studio data directory is not initialized yet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
pluginRoot = Path.Join(dataDirectory, "plugins", pluginType.GetDirectory());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string DetermineFinalDirectory(string pluginRoot, IPluginMetadata plugin, PluginType pluginType)
|
||||
{
|
||||
var existingPlugin = FindReplaceablePlugin(plugin.Id, pluginType);
|
||||
return existingPlugin is not null
|
||||
? existingPlugin.LocalPath
|
||||
: Path.Join(pluginRoot, CreatePluginDirectoryName(plugin));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the local plugin that an installation with the given ID and type would replace.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The ID of the plugin about to be installed.</param>
|
||||
/// <param name="pluginType">The type of the plugin about to be installed.</param>
|
||||
/// <returns>The plugin that would be replaced, or null when the installation adds a new plugin.</returns>
|
||||
private static IAvailablePlugin? FindReplaceablePlugin(Guid pluginId, PluginType pluginType) => PluginFactory.AvailablePlugins
|
||||
.OfType<IAvailablePlugin>()
|
||||
.FirstOrDefault(plugin => plugin.Type == pluginType && plugin.Id == pluginId && !plugin.IsInternal);
|
||||
|
||||
/// <summary>
|
||||
/// Collects the metadata an archive declares about itself, together with the information about
|
||||
/// the installed plugin it would replace.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The validated plugin from the archive.</param>
|
||||
/// <returns>The preview shown to the user before the installation starts.</returns>
|
||||
private static PluginImportPreview CreateImportPreview(PluginBase plugin) => new(
|
||||
plugin,
|
||||
FindReplaceablePlugin(plugin.Id, plugin.Type),
|
||||
plugin is PluginConfiguration configurationPlugin ? CreateConfigurationImportSummary(configurationPlugin) : null);
|
||||
|
||||
/// <summary>
|
||||
/// Collects what a configuration plugin would set up once it is installed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The plugin was loaded as a dry run, so nothing of this is stored yet. The destinations come
|
||||
/// from the parsed configuration objects, which is why the preview can name the host a provider
|
||||
/// would talk to.
|
||||
/// </remarks>
|
||||
private static ConfigurationPluginImportSummary CreateConfigurationImportSummary(PluginConfiguration configurationPlugin)
|
||||
{
|
||||
var configObjects = configurationPlugin.ConfigObjects.ToList();
|
||||
var destinations = configObjects
|
||||
.Where(configObject => configObject.Type is PluginConfigurationObjectType.LLM_PROVIDER
|
||||
or PluginConfigurationObjectType.EMBEDDING_PROVIDER
|
||||
or PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER
|
||||
or PluginConfigurationObjectType.DATA_SOURCE)
|
||||
.Select(configObject => new ConfigurationPluginDestination(configObject.Type, configObject.Name, configObject.Endpoint))
|
||||
.ToList();
|
||||
|
||||
return new(
|
||||
Destinations: destinations,
|
||||
ChatTemplates: CountObjects(PluginConfigurationObjectType.CHAT_TEMPLATE),
|
||||
Profiles: CountObjects(PluginConfigurationObjectType.PROFILE),
|
||||
DocumentAnalysisPolicies: CountObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY),
|
||||
DeclaredSettings: configurationPlugin.DeclaredSettingsCount,
|
||||
MandatoryInfos: configurationPlugin.MandatoryInfos.Count,
|
||||
Introductions: configurationPlugin.Introductions.Count);
|
||||
|
||||
int CountObjects(PluginConfigurationObjectType type) => configObjects.Count(configObject => configObject.Type == type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether an installation may replace the plugin that currently uses the given ID.
|
||||
/// Plugins deployed by a Config Server belong to the organization's IT, so neither an import nor
|
||||
/// the Assistant Builder may overwrite them.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The ID of the plugin about to be installed.</param>
|
||||
/// <param name="pluginType">The type of the plugin about to be installed.</param>
|
||||
/// <returns>A user-facing issue when the existing plugin must not be replaced, an empty string otherwise.</returns>
|
||||
private static string GetReplacementIssue(Guid pluginId, PluginType pluginType)
|
||||
{
|
||||
var existingPlugin = FindReplaceablePlugin(pluginId, pluginType);
|
||||
if (existingPlugin is null)
|
||||
return string.Empty;
|
||||
|
||||
if (existingPlugin.IsManagedByConfigServer)
|
||||
return TB("Plugins deployed by your organization cannot be replaced.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existingPlugin.LocalPath))
|
||||
return string.Empty;
|
||||
|
||||
// The metadata above and the running plugin read the same Lua field. We check both, though,
|
||||
// just like the deletion path does:
|
||||
var runningPlugin = PluginFactory.RunningPlugins
|
||||
.FirstOrDefault(candidate => candidate.Id == pluginId && IsSameDirectory(candidate.PluginPath, existingPlugin.LocalPath));
|
||||
|
||||
var isManagedByConfigServer = runningPlugin switch
|
||||
{
|
||||
PluginAssistants assistantPlugin => assistantPlugin.IsManagedByConfigServer,
|
||||
PluginConfiguration configurationPlugin => configurationPlugin.DeployedUsingConfigServer ?? false,
|
||||
|
||||
_ => false,
|
||||
};
|
||||
|
||||
return isManagedByConfigServer
|
||||
? TB("Plugins deployed by your organization cannot be replaced.")
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static string CreateInstallBackupDirectory(IPluginMetadata plugin)
|
||||
{
|
||||
var backupRoot = Path.Join(SettingsManager.DataDirectory, INSTALL_BACKUP_DIRECTORY);
|
||||
return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
private static string CreatePluginDirectoryName(IPluginMetadata plugin)
|
||||
{
|
||||
var safeName = CreateSafeDirectoryNamePart(plugin.Name);
|
||||
return $"{safeName}-{plugin.Id:N}";
|
||||
}
|
||||
|
||||
private static string CreateSafeDirectoryNamePart(string name)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var invalidChars = Path.GetInvalidFileNameChars().ToHashSet();
|
||||
|
||||
foreach (var character in name.Trim())
|
||||
{
|
||||
if (char.IsLetterOrDigit(character))
|
||||
{
|
||||
sb.Append(char.ToLowerInvariant(character));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character is '-' or '_' or '.' && !invalidChars.Contains(character))
|
||||
{
|
||||
sb.Append(character);
|
||||
continue;
|
||||
}
|
||||
|
||||
AppendSeparator();
|
||||
}
|
||||
|
||||
var safeName = sb.ToString().Trim('-', '.');
|
||||
if (safeName.Length > DIRECTORY_PREFIX_MAX_LEN)
|
||||
safeName = safeName[..DIRECTORY_PREFIX_MAX_LEN].Trim('-', '.');
|
||||
|
||||
// Fallback for a plugin name without any usable character. The plugin ID is appended by the
|
||||
// caller, so the directory stays unique either way:
|
||||
return string.IsNullOrWhiteSpace(safeName)
|
||||
? "plugin"
|
||||
: safeName;
|
||||
|
||||
void AppendSeparator()
|
||||
{
|
||||
if (sb.Length == 0 || sb[^1] == '-')
|
||||
return;
|
||||
|
||||
sb.Append('-');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.PluginSystem.Assistants;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Installs, updates, and removes the plugins AI Studio manages locally.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The implementation is split across several files:<br/>
|
||||
/// - <c>PluginInstallService.AssistantBuilder.cs</c>: installing generated assistant plugin code<br/>
|
||||
/// - <c>PluginInstallService.Editing.cs</c>: editing an installed assistant plugin<br/>
|
||||
/// - <c>PluginInstallService.Import.cs</c>: importing plugin archives<br/>
|
||||
/// - <c>PluginInstallService.Delete.cs</c>: removing installed plugins<br/>
|
||||
/// - <c>PluginInstallService.Installation.cs</c>: the shared validation and installation steps<br/>
|
||||
/// - <c>PluginInstallService.FileSystem.cs</c>: the shared path and directory helpers
|
||||
/// </remarks>
|
||||
public sealed partial class PluginInstallService
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginInstallService).Namespace, nameof(PluginInstallService));
|
||||
|
||||
private const string PLUGIN_FILE_NAME = "plugin.lua";
|
||||
private const string ASSISTANT_BUILDER_DIRECTORY_PREFIX = "assistant-builder";
|
||||
private const string DELETE_BACKUP_DIRECTORY = ".plugin-delete-backups";
|
||||
private const string INSTALL_BACKUP_DIRECTORY = ".plugin-install-backups";
|
||||
private const int DIRECTORY_PREFIX_MAX_LEN = 80;
|
||||
|
||||
private readonly ILogger<PluginInstallService> logger;
|
||||
private readonly SettingsManager settingsManager;
|
||||
private readonly AssistantSessionService assistantSessionService;
|
||||
private readonly MediaTranscriptionService mediaTranscriptionService;
|
||||
private readonly SemaphoreSlim installSemaphore = new(1, 1);
|
||||
|
||||
private static AssistantPluginInstallResult Error(string issue) => new(false, Guid.Empty, string.Empty, string.Empty, false, issue);
|
||||
|
||||
private static AssistantPluginInstallResult CancelledByUser() => new(false, Guid.Empty, string.Empty, string.Empty, false, string.Empty, true);
|
||||
|
||||
private static AssistantPluginCheckResult CheckError(string issue) => new(false, Guid.Empty, string.Empty, issue);
|
||||
|
||||
private static PluginDeleteResult DeleteError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue);
|
||||
|
||||
private static AssistantPluginUpdateResult UpdateError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue);
|
||||
|
||||
public PluginInstallService(ILogger<PluginInstallService> logger, SettingsManager settingsManager, AssistantSessionService assistantSessionService, MediaTranscriptionService mediaTranscriptionService)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.settingsManager = settingsManager;
|
||||
this.assistantSessionService = assistantSessionService;
|
||||
this.mediaTranscriptionService = mediaTranscriptionService;
|
||||
this.logger.LogInformation("The plugin install service has been initialized.");
|
||||
}
|
||||
|
||||
private sealed record PluginValidationResult(bool Success, string StagingDirectory, PluginBase? Plugin, string Issue)
|
||||
{
|
||||
public static PluginValidationResult Failure(string issue) => new(false, string.Empty, null, issue);
|
||||
|
||||
/// <summary>
|
||||
/// The validated plugin as an assistant plugin, or null when it has another type.
|
||||
/// </summary>
|
||||
public PluginAssistants? AssistantPlugin => this.Plugin as PluginAssistants;
|
||||
}
|
||||
}
|
||||
@ -5,36 +5,61 @@ namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class RustService
|
||||
{
|
||||
public async Task<string> ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false)
|
||||
/// <summary>
|
||||
/// How long one file extraction may take.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reading a large file from a slow network share is legitimately slow, so this is well above
|
||||
/// the default HTTP client timeout. It still bounds the operation, because an unbounded read
|
||||
/// would keep the caller waiting forever.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan EXTRACTION_TIMEOUT = TimeSpan.FromMinutes(10);
|
||||
|
||||
public async Task<FileExtractionResult> ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false)
|
||||
{
|
||||
var streamId = Guid.NewGuid().ToString();
|
||||
var requestUri = $"/retrieval/fs/extract?path={Uri.EscapeDataString(path)}&stream_id={streamId}&extract_images={extractImages}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
var response = await this.http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
this.logger?.LogError(
|
||||
"Failed to read arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'",
|
||||
response.StatusCode,
|
||||
response.ReasonPhrase,
|
||||
path,
|
||||
responseBody);
|
||||
return string.Empty;
|
||||
}
|
||||
using var timeoutTokenSource = new CancellationTokenSource(EXTRACTION_TIMEOUT);
|
||||
var cancellationToken = timeoutTokenSource.Token;
|
||||
|
||||
var resultBuilder = new StringBuilder();
|
||||
var failedPages = new List<int>();
|
||||
var hasPartialFailure = false;
|
||||
var failureCode = FileExtractionErrorCode.NONE;
|
||||
string? failureMessage = null;
|
||||
string? detectedFormat = null;
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
using var response = await this.extractionHttp.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
this.logger?.LogError(
|
||||
"Failed to read arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'",
|
||||
response.StatusCode,
|
||||
response.ReasonPhrase,
|
||||
path,
|
||||
responseBody);
|
||||
|
||||
return FileExtractionResult.Failed(FileExtractionErrorCode.REQUEST_FAILED, $"The runtime answered with the status {(int)response.StatusCode} ({response.ReasonPhrase}).");
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var reader = new StreamReader(stream);
|
||||
var chunkCount = 0;
|
||||
|
||||
while (!reader.EndOfStream && chunkCount < maxChunks)
|
||||
while (chunkCount < maxChunks)
|
||||
{
|
||||
var line = await reader.ReadLineAsync();
|
||||
// We read line by line instead of checking EndOfStream: the latter blocks on a
|
||||
// network stream and cannot be cancelled, which would defeat the timeout above.
|
||||
var line = await reader.ReadLineAsync(cancellationToken);
|
||||
if (line is null)
|
||||
break;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
@ -46,24 +71,85 @@ public sealed partial class RustService
|
||||
try
|
||||
{
|
||||
var sseEvent = JsonSerializer.Deserialize<ContentStreamSseEvent>(jsonContent);
|
||||
if (sseEvent is not null)
|
||||
{
|
||||
var content = ContentStreamSseHandler.ProcessEvent(sseEvent, extractImages);
|
||||
if (content is not null)
|
||||
resultBuilder.AppendLine(content);
|
||||
if (sseEvent is null)
|
||||
continue;
|
||||
|
||||
chunkCount++;
|
||||
var processedEvent = ContentStreamSseHandler.ProcessEvent(sseEvent, extractImages);
|
||||
if (processedEvent.Error is not null)
|
||||
{
|
||||
var error = processedEvent.Error;
|
||||
|
||||
//
|
||||
// A notice is not a failure: the file was read completely, we only learned
|
||||
// something about it worth telling the user. It must not change the outcome.
|
||||
//
|
||||
if (error.IsNotice)
|
||||
{
|
||||
this.logger?.LogInformation(
|
||||
"The runtime reported a notice while reading '{Path}': code={ErrorCode}, detectedFormat='{DetectedFormat}', message='{Message}'",
|
||||
path,
|
||||
error.ParsedCode,
|
||||
error.DetectedFormat,
|
||||
error.Message);
|
||||
|
||||
detectedFormat ??= error.DetectedFormat;
|
||||
chunkCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger?.LogError(
|
||||
"The runtime reported a failure while reading '{Path}': code={ErrorCode}, page={PageNumber}, partial={IsPartialFailure}, detectedFormat='{DetectedFormat}', message='{Message}'",
|
||||
path,
|
||||
error.ParsedCode,
|
||||
error.PageNumber,
|
||||
error.IsPartialFailure,
|
||||
error.DetectedFormat,
|
||||
error.Message);
|
||||
|
||||
//
|
||||
// A partial failure costs us one part of the file, e.g. a single PDF page,
|
||||
// but keeps the rest usable. Any other failure means what we collected is
|
||||
// not the document the user picked, so we must not pass it on as content.
|
||||
//
|
||||
if (error.IsPartialFailure)
|
||||
{
|
||||
hasPartialFailure = true;
|
||||
if (error.PageNumber is { } pageNumber)
|
||||
failedPages.Add(pageNumber);
|
||||
}
|
||||
else if (failureCode is FileExtractionErrorCode.NONE)
|
||||
{
|
||||
failureCode = error.ParsedCode;
|
||||
failureMessage = error.Message;
|
||||
detectedFormat = error.DetectedFormat;
|
||||
}
|
||||
}
|
||||
else if (processedEvent.Content is not null)
|
||||
resultBuilder.AppendLine(processedEvent.Content);
|
||||
|
||||
chunkCount++;
|
||||
}
|
||||
catch (JsonException)
|
||||
catch (JsonException e)
|
||||
{
|
||||
this.logger?.LogError("Failed to deserialize SSE event: {JsonContent}", jsonContent);
|
||||
this.logger?.LogError(e, "Failed to deserialize SSE event while reading '{Path}': {JsonContent}", path, jsonContent);
|
||||
|
||||
if (failureCode is FileExtractionErrorCode.NONE)
|
||||
{
|
||||
failureCode = FileExtractionErrorCode.INVALID_RESPONSE;
|
||||
failureMessage = "The runtime sent a response the app was not able to read.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
catch (OperationCanceledException) when (timeoutTokenSource.IsCancellationRequested)
|
||||
{
|
||||
this.logger?.LogError("Reading the file '{Path}' timed out after {Timeout}.", path, EXTRACTION_TIMEOUT);
|
||||
return FileExtractionResult.Failed(FileExtractionErrorCode.TIMEOUT, $"Reading the file timed out after {EXTRACTION_TIMEOUT.TotalMinutes:0} minutes.");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.logger?.LogError(e, "Error reading file data from stream: {Path}", path);
|
||||
return FileExtractionResult.Failed(FileExtractionErrorCode.INTERNAL, e.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -71,7 +157,25 @@ public sealed partial class RustService
|
||||
if (!string.IsNullOrWhiteSpace(finalContentChunk))
|
||||
resultBuilder.AppendLine(finalContentChunk);
|
||||
}
|
||||
|
||||
return resultBuilder.ToString();
|
||||
|
||||
if (failureCode is not FileExtractionErrorCode.NONE)
|
||||
return FileExtractionResult.Failed(failureCode, failureMessage, detectedFormat);
|
||||
|
||||
var content = resultBuilder.ToString();
|
||||
|
||||
//
|
||||
// Nothing failed, yet nothing came out either. We report this as a failure as well:
|
||||
// handing an empty document to the AI looks like a file without content, and the user
|
||||
// would never learn that reading the file did not work.
|
||||
//
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
this.logger?.LogWarning("Reading the file '{Path}' produced no content at all.", path);
|
||||
return FileExtractionResult.Failed(FileExtractionErrorCode.NO_CONTENT, "Reading the file produced no content.");
|
||||
}
|
||||
|
||||
return hasPartialFailure
|
||||
? FileExtractionResult.Partial(content, failedPages, detectedFormat)
|
||||
: FileExtractionResult.Success(content, detectedFormat);
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,19 @@ public sealed partial class RustService : BackgroundService
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(RustService).Namespace, nameof(RustService));
|
||||
|
||||
private readonly HttpClient http;
|
||||
|
||||
/// <summary>
|
||||
/// A dedicated client for file extraction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Extraction needs its own client because <see cref="HttpClient.Timeout"/> is a client-wide
|
||||
/// setting which also covers reading the streamed response body. A per-request cancellation
|
||||
/// token can only shorten that limit, never extend it. Reading a large file from a slow
|
||||
/// network share legitimately exceeds the default limit, so this client has no timeout of its
|
||||
/// own and the extraction bounds each request itself.
|
||||
/// </remarks>
|
||||
private readonly HttpClient extractionHttp;
|
||||
|
||||
private readonly SemaphoreSlim fileDialogLock = new(1, 1);
|
||||
private readonly SemaphoreSlim userLanguageLock = new(1, 1);
|
||||
private readonly SemaphoreSlim userNameLock = new(1, 1);
|
||||
@ -42,26 +55,37 @@ public sealed partial class RustService : BackgroundService
|
||||
{
|
||||
this.apiPort = apiPort;
|
||||
this.certificateFingerprint = certificateFingerprint;
|
||||
|
||||
// The default timeout of HttpClient, kept explicit so the difference to the
|
||||
// extraction client below is visible:
|
||||
this.http = CreateHttpClient(apiPort, certificateFingerprint, TimeSpan.FromSeconds(100));
|
||||
this.extractionHttp = CreateHttpClient(apiPort, certificateFingerprint, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(string apiPort, string certificateFingerprint, TimeSpan timeout)
|
||||
{
|
||||
var certificateValidationHandler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
|
||||
{
|
||||
if(certificate is null)
|
||||
return false;
|
||||
|
||||
|
||||
var currentCertificateFingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256);
|
||||
return currentCertificateFingerprint == certificateFingerprint;
|
||||
},
|
||||
};
|
||||
|
||||
this.http = new HttpClient(certificateValidationHandler)
|
||||
|
||||
var client = new HttpClient(certificateValidationHandler)
|
||||
{
|
||||
BaseAddress = new Uri($"https://127.0.0.1:{apiPort}"),
|
||||
DefaultRequestVersion = Version.Parse("2.0"),
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
|
||||
Timeout = timeout,
|
||||
};
|
||||
|
||||
this.http.DefaultRequestHeaders.AddApiToken();
|
||||
|
||||
client.DefaultRequestHeaders.AddApiToken();
|
||||
return client;
|
||||
}
|
||||
|
||||
public void SetLogger(ILogger<RustService> logService)
|
||||
@ -93,6 +117,7 @@ public sealed partial class RustService : BackgroundService
|
||||
public override void Dispose()
|
||||
{
|
||||
this.http.Dispose();
|
||||
this.extractionHttp.Dispose();
|
||||
this.userLanguageLock.Dispose();
|
||||
this.userNameLock.Dispose();
|
||||
base.Dispose();
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
using System.Text;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public sealed class SlideImageContent(string base64Image) : ISlideContent
|
||||
/// <summary>
|
||||
/// An image of a slide, ready to be appended to the slide's Markdown.
|
||||
/// </summary>
|
||||
/// <param name="markdownImage">The image as a Markdown image with an embedded data URI.</param>
|
||||
public sealed class SlideImageContent(string markdownImage) : ISlideContent
|
||||
{
|
||||
public StringBuilder Base64Image => new(base64Image);
|
||||
public string MarkdownImage => markdownImage;
|
||||
}
|
||||
@ -52,11 +52,11 @@ public sealed class SlideManager
|
||||
//
|
||||
if (addImage)
|
||||
{
|
||||
var img = ContentStreamSseHandler.BuildImage(image!.Id!);
|
||||
var slideImage = new SlideImageContent(img);
|
||||
createdSlide.Content.Add(slideImage);
|
||||
var markdownImage = ContentStreamSseHandler.BuildImageMarkdown(image!.Id!, image.MediaType);
|
||||
if (markdownImage is not null)
|
||||
createdSlide.Content.Add(new SlideImageContent(markdownImage));
|
||||
}
|
||||
|
||||
|
||||
this.slides[slideNumber] = createdSlide;
|
||||
}
|
||||
else
|
||||
@ -75,9 +75,9 @@ public sealed class SlideManager
|
||||
// Add any image content?
|
||||
if (addImage)
|
||||
{
|
||||
var img = ContentStreamSseHandler.BuildImage(image!.Id!);
|
||||
var slideImage = new SlideImageContent(img);
|
||||
slide.Content.Add(slideImage);
|
||||
var markdownImage = ContentStreamSseHandler.BuildImageMarkdown(image!.Id!, image.MediaType);
|
||||
if (markdownImage is not null)
|
||||
slide.Content.Add(new SlideImageContent(markdownImage));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -96,7 +96,7 @@ public sealed class SlideManager
|
||||
|
||||
foreach (var image in slide.Content.OfType<SlideImageContent>())
|
||||
{
|
||||
content.AppendLine(image.Base64Image.ToString());
|
||||
content.AppendLine(image.MarkdownImage);
|
||||
content.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,5 +4,10 @@ namespace AIStudio.Tools;
|
||||
|
||||
public sealed class SlideTextContent(string textContent) : ISlideContent
|
||||
{
|
||||
public StringBuilder Text => new(textContent);
|
||||
//
|
||||
// One builder per slide, created once: an expression-bodied property would hand out a fresh
|
||||
// builder on every access, so appending further text to a slide would write into a throwaway
|
||||
// object and the text would never reach the slide.
|
||||
//
|
||||
public StringBuilder Text { get; } = new(textContent);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
|
||||
@ -14,38 +15,71 @@ public static class UserFile
|
||||
/// <summary>
|
||||
/// Attempts to load the content of a file at the specified path, ensuring Pandoc is installed and available before proceeding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the one place which reports a failed load to the user, so callers neither have to
|
||||
/// repeat that nor may they treat a failure as an empty file.
|
||||
/// </remarks>
|
||||
/// <param name="filePath">The full path to the file to be read. Must not be null or empty.</param>
|
||||
/// <param name="rustService">Rust service used to read file content.</param>
|
||||
/// <param name="dialogService">Dialogservice used to display the Pandoc installation dialog if needed.</param>
|
||||
public static async Task<string> LoadFileData(string filePath, RustService rustService, IDialogService dialogService)
|
||||
/// <returns>The result of reading the file.</returns>
|
||||
public static async Task<FileExtractionResult> LoadFileData(string filePath, RustService rustService, IDialogService dialogService)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
LOGGER.LogError("Can't load from an empty or null file path.");
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("The file path is null or empty and the file therefore can not be loaded.")));
|
||||
return FileExtractionResult.Failed(FileExtractionErrorCode.INVALID_REQUEST, "The file path is null or empty.");
|
||||
}
|
||||
|
||||
// Ensure that Pandoc is installed and ready:
|
||||
var pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: false);
|
||||
if (!pandocState.IsAvailable)
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
//
|
||||
// Ensure that Pandoc is installed and ready. This is only needed for the formats we
|
||||
// convert with it: PDFs and the other document types are read by the Rust runtime itself.
|
||||
//
|
||||
if (FileTypes.RequiresPandoc(filePath))
|
||||
{
|
||||
var dialogParameters = new DialogParameters<PandocDialog>
|
||||
{
|
||||
{ x => x.ShowInitialResultInSnackbar, false },
|
||||
};
|
||||
|
||||
var dialogReference = await dialogService.ShowAsync<PandocDialog>(TB("Pandoc Installation"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
await dialogReference.Result;
|
||||
|
||||
pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: true);
|
||||
var pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: false);
|
||||
if (!pandocState.IsAvailable)
|
||||
{
|
||||
LOGGER.LogError("Pandoc is not available after installation attempt.");
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Pandoc may be required for importing files.")));
|
||||
var dialogParameters = new DialogParameters<PandocDialog>
|
||||
{
|
||||
{ x => x.ShowInitialResultInSnackbar, false },
|
||||
};
|
||||
|
||||
var dialogReference = await dialogService.ShowAsync<PandocDialog>(TB("Pandoc Installation"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
await dialogReference.Result;
|
||||
|
||||
pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showSuccessMessage: true);
|
||||
if (!pandocState.IsAvailable)
|
||||
{
|
||||
LOGGER.LogError("Pandoc is not available after installation attempt, so '{FilePath}' cannot be read.", filePath);
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, FileExtractionErrorCode.PANDOC_UNAVAILABLE.ToUserMessage(fileName)));
|
||||
return FileExtractionResult.Failed(FileExtractionErrorCode.PANDOC_UNAVAILABLE, "Pandoc is required to read this file, but it is not available.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fileContent = await rustService.ReadArbitraryFileData(filePath, int.MaxValue);
|
||||
return fileContent;
|
||||
|
||||
var result = await rustService.ReadArbitraryFileData(filePath, int.MaxValue);
|
||||
if (!result.HasUsableContent)
|
||||
{
|
||||
LOGGER.LogError("Reading the file '{FilePath}' failed: code={ErrorCode}, message='{ErrorMessage}'.", filePath, result.ErrorCode, result.ErrorMessage);
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Description, result.ToUserMessage(fileName)));
|
||||
}
|
||||
else if (result.Outcome is FileExtractionOutcome.PARTIAL)
|
||||
{
|
||||
LOGGER.LogWarning("Parts of the file '{FilePath}' could not be read: pages={FailedPages}.", filePath, string.Join(", ", result.FailedPages));
|
||||
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, result.ToPartialUserMessage(fileName)));
|
||||
}
|
||||
|
||||
// The file was read correctly, but its extension lies about what it contains:
|
||||
if (result.HasExtensionMismatch)
|
||||
{
|
||||
LOGGER.LogWarning("The file '{FilePath}' is actually a '{DetectedFormat}'.", filePath, result.DetectedFormat);
|
||||
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.RuleFolder, result.ToExtensionMismatchUserMessage(fileName)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,40 @@
|
||||
# v26.8.1, build 251 (2026-08-xx xx:xx UTC)
|
||||
- Added a prototype Visual Briefing Assistant that turns documents, data, images, audio, and video into self-contained interactive HTML briefings.
|
||||
- Added a prototype Visual Briefing Assistant that turns documents, data, images, audio, and video into self-contained interactive HTML briefings. When you want to test it, you have to enable this preview feature in your app settings.
|
||||
- Added organization-configurable defaults and visibility controls for the Visual Briefing Assistant.
|
||||
- Added a share button for plugins, which uses the native share dialog on Windows and macOS. For Linux, we added an export option for plugins, which stores the plugin archive at a location of your choice.
|
||||
- Added an import button on the plugin page to install plugin archives directly from your files.
|
||||
- Added the option to import plugins by dropping a plugin archive onto the plugin page.
|
||||
- Added the dedicated file extension `.mwplugin` for plugin archives.
|
||||
- Added an option for organizations to disable importing, sharing, and exporting plugins.
|
||||
- Added a share button for assistants, configurations, and language plugins. It uses the native share dialog on Windows and macOS. For Linux, we added an export option, which stores the plugin archive at a location of your choice. When you work on a translation for a new language, you can now hand your current state to testers or to us with one click.
|
||||
- Added the option to install plugin archives from your files: use the import button on the plugin page or simply drop an archive onto that page. Assistants, configurations, and language plugins are supported, and plugin archives now have their own file extension `.mwplugin`. Before installing a configuration, AI Studio shows what it sets up: which LLM providers and data sources it adds and where each of them sends your data, plus how many settings it takes control of. A configuration takes effect right away and has no on/off switch, so please install one only when you trust its source. You can remove it again at any time.
|
||||
- Added a delete button for assistants, configurations, and language plugins you installed or placed yourself. Until now, such a plugin could only be removed from the data directory by hand, which was especially painful for configurations because they have no on/off switch. Before deleting a configuration, AI Studio lists what disappears with it, such as providers, data sources, and settings that return to their default. When you delete the language plugin you had chosen, AI Studio returns to choosing your language automatically. Plugins shipped with AI Studio and plugins deployed by your IT department cannot be deleted.
|
||||
- Added options for organizations to disable importing, sharing, and exporting plugins, with a separate option for configuration plugins. Organizations can now let people import assistants while keeping configurations to their IT department.
|
||||
- Added a priority for configuration plugins. Organizations that deploy several configurations can now decide which one wins: a configuration with a higher priority overrides the settings and providers of a lower one. This allows a company-wide base configuration that each department refines for itself.
|
||||
- Added the Batch Processing Assistant: process all documents of a folder in one run. Each document is sent to the AI along with your instructions - either a free prompt, one of your document analysis policies, or instructions you import from a file. You choose whether each answer is stored as its own Markdown file or whether all answers are collected in one CSV results table, which you can name yourself. Every run writes a log that lists each document with its processing time, the model, the status, and the reason for any error. When you start another run on the same output folder, we ask whether you want to continue it: documents that failed or are missing from the log are processed again, which is helpful after a crash or when documents exceeded the context window of your model. A single failing document never stops the run, and you can cancel at any time.
|
||||
- Added a way for IT departments to try out a configuration before rolling it out. A configuration placed in the new `.config-tests` directory below the plugins directory acts like one your organization deployed, including the approval of assistant plugins, so a test shows exactly what colleagues will see later. No configuration server is needed for this. AI Studio empties that directory every time it starts, so a test configuration is valid for one session, and the information page reports it while it is active. The Enterprise IT documentation describes the whole procedure.
|
||||
- Added CSV and TSV files to the file types you can attach. AI Studio was already able to read them, but they could not be selected.
|
||||
- Improved how your organization's configuration behaves when a configuration plugin is present but cannot be loaded, e.g. because of an error in the plugin. Such a plugin still manages your app, so its settings, providers, data sources, profiles, and chat templates now stay in place instead of being removed.
|
||||
- Improved reading large files from slow locations such as network drives. AI Studio now waits considerably longer before it gives up, and it tells you when it does.
|
||||
- Improved how Word documents (`.docx`) and OpenDocument text files (`.odt`) are read. AI Studio now reads them itself instead of handing them to Pandoc, so these documents no longer need a Pandoc installation. It reads them section by section, which keeps even large documents responsive, and it now picks up more of the document: the title, the author, headers and footers, footnotes, endnotes, and comments. This was contributed by Nils Kruthoff (`nilskruthoff`), who also wrote the library behind it. Thank you, Nils, for this great contribution.
|
||||
- Changed how approvals for assistant plugins combine when your organization deploys several configurations. They now add up, so a department can approve additional assistant plugins without repeating the approvals of the company-wide configuration. Previously, the last configuration replaced all earlier approvals, which silently required a new security check for those assistants.
|
||||
- Fixed attached files reaching the AI as empty documents when AI Studio could not read them. The AI then answered as if your file had no content, and nothing pointed to a problem. AI Studio now names the cause instead, for example, an unavailable network drive, a file another program is blocking, a protected PDF, or a scanned PDF without a text layer, and it no longer attaches such a file.
|
||||
- Fixed files that are open in another program being reported as an unrecognized file type. AI Studio now tells you that the file is currently open elsewhere and asks you to close it. This also works for files on shared network drives, where a colleague might have the file open.
|
||||
- Fixed files with a wrong file extension being reported as empty. AI Studio now recognizes what a file really is by looking at its content, for example, a PowerPoint presentation that was renamed to `.txt`, and reads it accordingly. It also points out the wrong extension, so you can correct it.
|
||||
- Fixed files whose content is not text being sent as an empty document. AI Studio now tells you that the file is not readable as text, which usually means it carries a wrong file extension.
|
||||
- Fixed executable programs with a harmless file extension being read as text. They are now recognized by their content and refused.
|
||||
- Fixed a single unreadable page of a PDF silently cutting off the rest of the document. The remaining pages are now used, and AI Studio tells you which pages are missing.
|
||||
- Fixed a single unreadable sheet of a spreadsheet silently dropping all remaining sheets.
|
||||
- Fixed PDFs, text files, spreadsheets, and presentations requiring Pandoc. Only HTML files need Pandoc now, so every other file can be attached and read without it.
|
||||
- Fixed attached files that are temporarily unavailable, disappearing from your message without a word. This could happen when a file was stored on a network drive.
|
||||
- Fixed the file preview showing an empty document when reading the file failed. It now shows what went wrong, so the preview again answers what AI Studio will hand to the AI.
|
||||
- Fixed the file preview looking like an empty file while AI Studio was still reading it. Larger documents and PDFs need a moment to be read, and until now that moment looked like a file without any content. The preview now says that it is still loading and shows the content as soon as it is ready.
|
||||
- Fixed problems while reading files being missing from the log file after the first one. This made exactly those issues hard to track down that only appeared later on.
|
||||
- Fixed reset buttons in assistants. As you may have noticed in the Document Analysis Assistant, resetting it could leave content from the previous analysis visible. Reset buttons now clear previous results completely.
|
||||
- Fixed dropping files after you closed a dialog that accepts files itself. Such a dialog takes over dropped files while it is open, but never handed that role back when you closed it. Afterwards, the chat and the assistants silently ignored dropped files until you switched to another page. Each time you opened such a dialog again, the problem got worse.
|
||||
- Fixed configuration-managed settings remaining active after their configuration plugin was removed.
|
||||
- Fixed dropping files after you closed a dialog that accepts files itself. Such a dialog takes over dropped files while it is open, but never handed that role back when you closed it. Afterward, the chat and the assistants silently ignored dropped files until you switched to another page. Each time you opened such a dialog again, the problem got worse.
|
||||
- Fixed configuration-managed settings, remaining active after their configuration plugin was removed.
|
||||
- Fixed settings not returning to your own value after a configuration was removed. When a configuration takes control of a setting, AI Studio now remembers the value you had chosen before and hands it back once no configuration manages that setting anymore. This covers an IT department withdrawing a configuration, deleting one yourself, and an administrator ending a test configuration. When a configuration only suggested a value, and you changed it afterward, your choice stays as it is.
|
||||
- Fixed the integrated code editor to keep errors and other issues in plugin code visible in the footer while scrolling.
|
||||
- Fixed the trusted badge so you can now see at a glance which models are trusted. It is shown consistently for self-hosted models and models from trusted providers.
|
||||
- Fixed approvals for assistant plugins being accepted from any configuration plugin. An approval marks an assistant as safe without a security check, and the app states that your organization approved it. Only configurations your IT department deploys, or that an administrator stages for a test, can do that now; approvals from any other locally placed configuration plugin are ignored and reported in the log.
|
||||
- Fixed withdrawing a configuration your organization deployed. A configuration that declared itself as locally managed stayed on the device even after the IT department stopped deploying it, and it kept every right of an organization configuration, such as approving assistant plugins. Where a configuration is stored now decides this instead of what the configuration says about itself, so withdrawing one always takes effect. This also applies to a device that was offline while the organization changed its policy: the withdrawal is applied when AI Studio starts again.
|
||||
- Fixed preview features contributed by several configuration plugins at once. Only the most recent contribution was recognized as coming from your organization, so features enabled by another configuration looked as if you had switched them on yourself. Each configuration is now tracked separately, which lets your organization enable one preview feature company-wide and another one for a single department.
|
||||
- Fixed which configuration wins when two configuration plugins collide, e.g. by claiming the same plugin ID, by managing the same setting, or by defining the same provider. Previously, this was down to chance, so a local configuration plugin could take over parts of the configuration your IT department deployed. Configurations from your organization now always win, and every ignored attempt is reported in the log.
|
||||
- Upgraded dependencies to their latest versions to improve security and stability.
|
||||
- Fixed the assistant categories when your organization hides individual assistants. A category heading could stay visible above an empty area, and the Log Viewer could disappear together with the Localization assistant. Each heading now follows the assistants actually shown below it.
|
||||
- Removed the legacy PowerPoint format (`.ppt`) from the selectable file types. AI Studio has no reader for it, so such a file could be attached but never read. The modern `.pptx` format is not affected.
|
||||
- Upgraded dependencies to their latest versions to improve security and stability.
|
||||
@ -284,6 +284,8 @@ DEPLOYED_USING_CONFIG_SERVER = true
|
||||
|
||||
Local, manually managed configuration plugins should set this to `false`. If the field is missing, AI Studio falls back to the plugin path (`.config`) to determine whether the plugin is managed and logs a warning.
|
||||
|
||||
The field describes a plugin, it does not grant it anything. Which configurations belong to your organization is always decided by the plugin path: which approvals for assistant plugins are honored, which configuration wins a conflict, and which configuration AI Studio withdraws once you stop referencing it. A configuration stored under `.config` is therefore removed when your organization no longer references its ID, whatever this field says.
|
||||
|
||||
## Priority of configuration plugins
|
||||
|
||||
When you deploy more than one configuration, two of your configuration plugins may manage the same setting or define the same object, e.g. the same LLM provider. The optional `PRIORITY` field decides which one wins:
|
||||
@ -302,7 +304,7 @@ A typical layered setup:
|
||||
| Department | `100` | Refines the base, e.g. a different default model |
|
||||
| Project or lab | `200` | Refines the department configuration |
|
||||
|
||||
A configuration only overrides what it actually defines. Everything it does not mention keeps the value of the configuration below it. The same applies when you remove a configuration later: its settings fall back to the configuration below, not to the AI Studio defaults.
|
||||
A configuration only overrides what it actually defines. Everything it does not mention keeps the value of the configuration below it. The same applies when you remove a configuration later: its settings fall back to the configuration below, not to the AI Studio defaults. Once no configuration manages a setting anymore, see [Withdrawing a configuration](#withdrawing-a-configuration).
|
||||
|
||||
Give two configurations that must override each other different priorities. With an equal priority, the order is stable across restarts but arbitrary, so the outcome is not the one you designed.
|
||||
|
||||
@ -311,6 +313,8 @@ Two guarantees are independent of the priority:
|
||||
- A local configuration plugin never wins against one your IT department deployed, whatever priority it declares. Local plugins are always applied afterwards, and they may not take over a setting or an object that belongs to one of your configurations.
|
||||
- Two plugins must not share the same plugin ID. If that happens, AI Studio keeps the one your IT department deployed and logs a warning for the other.
|
||||
|
||||
The single exception is a configuration you stage for a test under `.config-tests`. It is applied after your deployed configurations and wins a shared plugin ID, so that you can try out the next version of a configuration under its final ID. See [Local staging and testing](#local-staging-and-testing).
|
||||
|
||||
### Settings that hold a list or a table
|
||||
|
||||
For a setting that holds a list or a table, the winning configuration replaces the whole collection. It does not merge the entries. A department configuration that lists a single entry drops every entry the base configuration had set for that setting.
|
||||
@ -338,6 +342,19 @@ In both cases each configuration keeps its own contribution, so removing one of
|
||||
|
||||
One clarification for `DataChat.PreselectedDataSourceIds`: the IDs are not limited to the data sources of the same configuration. They are resolved against every known data source, including those of your other configurations and the ones a user configured. IDs that resolve to nothing are ignored.
|
||||
|
||||
## Withdrawing a configuration
|
||||
|
||||
A configuration does not have to stay forever: you stop deploying it, a user deletes a configuration they installed themselves, or a test configuration ends with the next restart. AI Studio then removes what that configuration brought along, such as its providers, data sources, profiles, chat templates, and its approvals for assistant plugins.
|
||||
|
||||
Settings go one step further. AI Studio remembers the value each setting had before a configuration took it over and hands it back once no configuration manages that setting anymore. Somebody who had chosen a start page before your configuration set one therefore gets their own start page back, not the AI Studio default.
|
||||
|
||||
Two cases differ:
|
||||
|
||||
- **There is nothing to hand back.** When a setting still had its AI Studio default at the moment your configuration took it over, that default returns. The same applies to settings which a configuration already managed before AI Studio v26.8.1, because nothing was remembered back then.
|
||||
- **Somebody used `AllowUserOverride`.** A setting you offered as an organization default, and which the user changed afterwards, keeps the user's value. Their decision outlives your configuration.
|
||||
|
||||
A configuration that is deployed but cannot be loaded, e.g. because of an error in its Lua code, is not withdrawn. It still manages the device, so everything it brought along stays untouched until you actually stop deploying it.
|
||||
|
||||
## Example AI Studio configuration
|
||||
The latest example of an AI Studio configuration via configuration plugin can always be found in the repository in the `app/MindWork AI Studio/Plugins/configuration` folder. Here are the links to the files:
|
||||
|
||||
@ -369,6 +386,16 @@ AI Studio computes the approval hash as a SHA-256 digest over all `.lua` files i
|
||||
|
||||
If any Lua file changes, the hash changes automatically and the enterprise approval no longer applies.
|
||||
|
||||
### Only your configurations may approve
|
||||
|
||||
Approvals are honored only in configuration plugins that speak for your organization: plugins a configuration server deployed, meaning plugins stored under the `.config` directory, and plugins you staged for a test under `.config-tests`. AI Studio ignores the approvals of any other locally placed configuration plugin and writes a warning to the log.
|
||||
|
||||
The reason is what an approval does: it marks an assistant plugin as safe without any security audit, and AI Studio then tells the user that their organization approved it. Anyone who can drop a file into the plugin directory could otherwise disable the security audit for an assistant plugin of their choosing while the app vouches for it in your name.
|
||||
|
||||
This is decided by where the plugin is stored, not by its `DEPLOYED_USING_CONFIG_SERVER` field. That field is part of the plugin itself, so any plugin could claim it.
|
||||
|
||||
If you want to test approvals before rolling a configuration out, see [Local staging and testing](#local-staging-and-testing).
|
||||
|
||||
### Configuration example
|
||||
|
||||
Add the approval list to `CONFIG["SETTINGS"]` in your configuration plugin:
|
||||
@ -397,6 +424,66 @@ dotnet run --project app/Build -- assistant-plugin-hash "<plugin-dir>" --lua-sni
|
||||
|
||||
This prints the canonical hash and, with `--lua-snippet`, also prints a ready-to-paste Lua snippet for `CONFIG["SETTINGS"]`.
|
||||
|
||||
## Local staging and testing
|
||||
|
||||
Before you roll a configuration out through a configuration web server, you can stage it on a device and test it end to end, including the enterprise approvals for assistant plugins described above. This needs no configuration web server, no registry, policy, or environment entry, and no encryption secret.
|
||||
|
||||
AI Studio has a dedicated directory for this: `.config-tests`. A configuration stored there speaks for your organization exactly like a deployed one. In exchange, AI Studio empties the directory on every start, so a test configuration is valid for one session.
|
||||
|
||||
Do not use the `.config` directory for this. It belongs to your configuration web server, and AI Studio removes everything there that your organization does not reference anymore.
|
||||
|
||||
### The data directory
|
||||
|
||||
Plugins live in the data directory of AI Studio:
|
||||
|
||||
| Platform | Data directory |
|
||||
| --- | --- |
|
||||
| Windows | `%LOCALAPPDATA%\com.github.mindwork-ai.ai-studio\data` |
|
||||
| macOS | `~/Library/Application Support/com.github.mindwork-ai.ai-studio/data` |
|
||||
| Linux | `$XDG_DATA_HOME/com.github.mindwork-ai.ai-studio/data`, usually `~/.local/share/com.github.mindwork-ai.ai-studio/data` |
|
||||
| Linux (Flatpak) | `~/.var/app/org.mindworkai.AIStudio/data/com.github.mindwork-ai.ai-studio/data` |
|
||||
|
||||
### Staging a configuration
|
||||
|
||||
Place the files **while AI Studio is running**: the test directory is emptied whenever the app starts.
|
||||
|
||||
1. Start AI Studio. It creates `<data directory>/plugins/.config-tests/` if it does not exist yet.
|
||||
2. Create a directory below it and place your `plugin.lua` there, e.g. `.config-tests/my-department-draft/`. The directory name is up to you here: a test configuration is identified by the `ID` field inside the plugin, not by the directory it lives in.
|
||||
3. Place the assistant plugin you want to test in `<data directory>/plugins/assistants/<any name>/`.
|
||||
4. AI Studio watches the plugin directory and picks both up without a restart. The security card of the assistant then states that your organization approved it, exactly as it will after the rollout.
|
||||
|
||||
While a test configuration is loaded, the Information page reports it, including the directory it was staged in. After a restart, that same page tells you that a test configuration was removed, so nobody has to wonder where the directory went.
|
||||
|
||||
What behaves like the later rollout:
|
||||
|
||||
- The approvals for assistant plugins are honored.
|
||||
- Settings and configuration objects the test configuration manages are protected against local configuration plugins.
|
||||
- When the test configuration declares the same plugin `ID` as one your organization deployed, the test configuration wins. This is how you try out the next version of an existing configuration under its final ID.
|
||||
|
||||
What deliberately does not:
|
||||
|
||||
- A test configuration has no protection against the user. You can remove it on the plugin page and replace it by importing a new version.
|
||||
- It does not survive a restart.
|
||||
|
||||
### Testing with a small group
|
||||
|
||||
To let colleagues take part in the test, place the same two directories on each of their devices while AI Studio runs, for example through a script, your MDM solution, or a login script. A configuration web server is not involved, and nothing has to be enabled inside AI Studio. Ordinary user accounts can take part: the data directory belongs to the user, so no administrator rights are needed to place the files.
|
||||
|
||||
Keep in mind that everybody in the group loses the test configuration the next time they start AI Studio. Either repeat the step, or let your script place the files at every login.
|
||||
|
||||
### Cleaning up
|
||||
|
||||
Restart AI Studio: the test directory is emptied, the approvals are gone, and the assistant requires a security audit again. Every setting your test configuration had taken over returns to the value it had before the test, as described in [Withdrawing a configuration](#withdrawing-a-configuration). To end a test without restarting, delete the configuration on the plugin page.
|
||||
|
||||
### Security note
|
||||
|
||||
A test configuration carries the rights of an organization configuration without anybody having deployed it. Two properties keep that in check, and you should not work around either of them:
|
||||
|
||||
- The directory is emptied on every start, so nothing staged for a test can settle in unnoticed.
|
||||
- No feature inside AI Studio writes into that directory. Importing, sharing, and deleting plugins never touch it, so a user cannot be talked into staging a configuration by opening a file.
|
||||
|
||||
The data directory belongs to the user account, so whoever can write there can approve assistant plugins in the name of your organization until the next restart. Treat write access to the data directory as equivalent to deploying a configuration, and protect it accordingly on managed devices.
|
||||
|
||||
## Encrypted API Keys
|
||||
|
||||
You can include encrypted API keys in your configuration plugins for cloud providers (like OpenAI, Anthropic) or secured on-premise models. This feature provides obfuscation to prevent casual exposure of API keys in configuration files.
|
||||
|
||||
@ -102,7 +102,7 @@ Confirm the installation of the required GNOME runtime from Flathub when Flatpak
|
||||
|
||||
#### Pandoc Extension (Strongly Recommended)
|
||||
|
||||
Pandoc is required for essential file features, including regular file attachments in chats, importing and converting Office documents, and other document-based functionality. We therefore strongly recommend installing the Pandoc extension. AI Studio checks whether a compatible Pandoc version is already available.
|
||||
Pandoc is required for some file features, namely attaching HTML files and exporting chats as a Word document. Every other file type, PDFs, Word and OpenDocument text files, spreadsheets, and presentations among them, is read by AI Studio itself and works without Pandoc. We still recommend installing the Pandoc extension so that all file features are available. AI Studio checks whether a compatible Pandoc version is already available.
|
||||
|
||||
For Intel/AMD, download `MindWork.AI.Studio.Plugin.Pandoc_x86_64.flatpak` and run:
|
||||
|
||||
|
||||
2
runtime/.codex/config.toml
Normal file
2
runtime/.codex/config.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[mcp_servers.rustrover]
|
||||
url = "http://127.0.0.1:64522/stream"
|
||||
34
runtime/Cargo.lock
generated
34
runtime/Cargo.lock
generated
@ -1251,6 +1251,17 @@ dependencies = [
|
||||
"whatlang",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chardetng"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"encoding_rs",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@ -1990,6 +2001,20 @@ dependencies = [
|
||||
"strsim 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docx-to-md"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ff66168dc94c192d9372fa8fa8201900bd6f2b1edfbbdd2674a2e69c98073b6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
"quick-xml 0.41.0",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"zip 8.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@ -2160,9 +2185,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.34"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
@ -4062,7 +4087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-targets 0.48.5",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4249,9 +4274,12 @@ dependencies = [
|
||||
"calamine",
|
||||
"cbc 0.2.1",
|
||||
"cfg-if",
|
||||
"chardetng",
|
||||
"dbus-secret-service",
|
||||
"dbus-secret-service-keyring-store",
|
||||
"dirs",
|
||||
"docx-to-md",
|
||||
"encoding_rs",
|
||||
"file-format",
|
||||
"flexi_logger",
|
||||
"futures",
|
||||
|
||||
@ -39,7 +39,19 @@ pbkdf2 = "0.13.0"
|
||||
hmac = "0.13.0"
|
||||
sha2 = "0.11.0"
|
||||
rcgen = { version = "0.14.8", features = ["pem"] }
|
||||
file-format = "0.29.0"
|
||||
|
||||
# The readers are needed to identify a file by its content instead of its extension: zip covers
|
||||
# OOXML and ODF, cfb the legacy Office formats, txt tells actual text from binary data, and exe
|
||||
# recognizes executables which carry a harmless extension. Without them, every ZIP-based document
|
||||
# is only detected as a plain archive.
|
||||
file-format = { version = "0.29.0", features = ["reader-zip", "reader-cfb", "reader-txt", "reader-exe"] }
|
||||
|
||||
# Text files are not always UTF-8: on Windows they are frequently encoded in Windows-1252, whose
|
||||
# umlauts are single bytes and therefore invalid UTF-8. chardetng guesses the encoding, encoding_rs
|
||||
# decodes it.
|
||||
chardetng = "1.0.0"
|
||||
encoding_rs = "0.8.35"
|
||||
|
||||
symphonia = { version = "0.6", default-features = false, features = ["aac", "aiff", "alac", "caf", "flac", "isomp4", "mkv", "mp1", "mp2", "mp3", "ogg", "pcm", "vorbis", "wav"] }
|
||||
ropus = "=0.12.18"
|
||||
rubato = { version = "4", default-features = false, features = ["fft_resampler"] }
|
||||
@ -50,6 +62,7 @@ sys-locale = "0.3.2"
|
||||
whoami = "2.1.2"
|
||||
cfg-if = "1.0.4"
|
||||
pptx-to-md = "1.0.0"
|
||||
docx-to-md = "0.1.0"
|
||||
tempfile = "3.27.0"
|
||||
strum_macros = "0.28.0"
|
||||
sysinfo = "0.39.6"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user