Improved local plugin handling (share, import, delete) (#900)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer 2026-08-09 19:01:58 +02:00 committed by GitHub
parent 0eb747b386
commit 6e143aafaa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 3108 additions and 1480 deletions

View File

@ -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.
@ -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
View File

@ -0,0 +1,2 @@
[mcp_servers.rider]
url = "http://127.0.0.1:64482/stream"

View 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)
{

View File

@ -2914,24 +2914,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."
@ -3379,6 +3361,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."
@ -4750,6 +4765,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"
@ -5461,6 +5554,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"
@ -5476,6 +5572,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."
@ -5485,12 +5587,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}."
@ -5500,18 +5626,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."
@ -7585,6 +7738,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:"
@ -7750,6 +7906,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."
@ -7765,6 +7924,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"
@ -7888,6 +8050,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."
@ -7957,6 +8122,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"
@ -7969,18 +8137,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"
@ -7990,9 +8170,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"
@ -8014,9 +8191,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."
@ -8047,9 +8221,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."
@ -9589,102 +9760,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."
@ -9736,6 +9811,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 plugins directory is outside the expected plugins directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2486199999"] = "This individual plugins 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."

View File

@ -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();
}
}

View File

@ -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>
}

View 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);
}
}

View File

@ -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}'.");

View File

@ -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}'.");

View File

@ -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>

View File

@ -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));
}

View File

@ -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">

View File

@ -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));

View File

@ -58,7 +58,7 @@
<AdditionalActions>
@if (availablePlugin is not null)
{
<AssistantPluginDeleteAction Plugin="@availablePlugin" />
<PluginDeleteAction Plugin="@availablePlugin" />
}
</AdditionalActions>
<SecurityBadge>

View File

@ -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)"

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -278,6 +278,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 +468,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,

View File

@ -2916,24 +2916,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."
@ -3381,6 +3363,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."
@ -4752,6 +4767,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"
@ -5463,6 +5556,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"
@ -5478,6 +5574,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."
@ -5487,12 +5589,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."
@ -5502,18 +5628,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."
@ -7587,6 +7740,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:"
@ -7752,6 +7908,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."
@ -7767,6 +7926,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"
@ -7890,6 +8052,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."
@ -7959,6 +8124,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"
@ -7971,18 +8139,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"
@ -7992,9 +8172,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"
@ -8016,9 +8193,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."
@ -8049,9 +8223,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."
@ -9591,102 +9762,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."
@ -9738,6 +9813,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 plugins 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."

View File

@ -2916,24 +2916,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."
@ -3381,6 +3363,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."
@ -4752,6 +4767,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"
@ -5463,6 +5556,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"
@ -5478,6 +5574,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."
@ -5487,12 +5589,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}."
@ -5502,18 +5628,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."
@ -7587,6 +7740,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:"
@ -7752,6 +7908,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."
@ -7767,6 +7926,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"
@ -7890,6 +8052,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."
@ -7959,6 +8124,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"
@ -7971,18 +8139,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"
@ -7992,9 +8172,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"
@ -8016,9 +8193,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."
@ -8049,9 +8223,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."
@ -9591,102 +9762,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."
@ -9738,6 +9813,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 plugins directory is outside the expected plugins directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2486199999"] = "This individual plugins 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."

View File

@ -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>();

View File

@ -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>

View File

@ -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.");

View File

@ -50,6 +50,17 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
/// </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)
{
if(!this.TryProcessConfiguration(dryRun, out var issue))
@ -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>
@ -174,6 +205,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
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:

View File

@ -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);

View File

@ -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;
}
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:

View File

@ -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))
var directoryName = Path.GetFileName(configurationDirectory);
// 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;
if (activeConfigurationIds.Contains(pluginId))
//
// 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;
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}'.");
if (isManagedByConfigServer)
pluginIdsToRemove.Add(pluginId);
RemoveConfigurationDirectory(configurationDirectory, REASON_NO_LONGER_REFERENCED);
}
}
foreach (var pluginId in pluginIdsToRemove)
RemovePluginAsync(pluginId, REASON_NO_LONGER_REFERENCED);
}
/// <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);
private static void RemovePluginAsync(Guid pluginId, string reason)
/// <summary>
/// Unloads every plugin stored in the given directory and deletes the directory afterwards.
/// </summary>
private static void RemoveConfigurationDirectory(string configurationDirectory, string reason)
{
if (!IsInitialized)
return;
LOG.LogWarning("Removing plugin with ID '{PluginId}'. Reason: {Reason}.", pluginId, reason);
LOG.LogWarning("Removing the enterprise configuration directory '{Directory}'. Reason: {Reason}.", configurationDirectory, reason);
//
// Remove the plugin from the available plugins list:
// 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:
//
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);
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin => IsPathInside(configurationDirectory, plugin.LocalPath)).ToList())
{
AVAILABLE_PLUGINS.Remove(plugin);
//
// 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
if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove)
RUNNING_PLUGINS.Remove(runningPluginToRemove);
//
// Delete the plugin directory:
//
DeleteConfigurationPluginDirectory(pluginId);
LOG.LogInformation("Plugin with ID '{PluginId}' removed successfully. Reason: {Reason}.", pluginId, reason);
LOG.LogInformation("Unloaded the plugin '{PluginName}' ({PluginId}). Reason: {Reason}.", plugin.Name, plugin.Id, 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;
}
}
private static void DeleteConfigurationPluginDirectory(Guid pluginId)
{
var pluginDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, pluginId.ToString());
if (!Directory.Exists(pluginDirectory))
{
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
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();
}

View File

@ -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()

View File

@ -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)

View File

@ -1,3 +0,0 @@
namespace AIStudio.Tools.Services;
public sealed record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record PluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);

View File

@ -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.

View File

@ -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));
}
}
}

View File

@ -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 plugins 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;
}
}

View File

@ -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.");
}
}
}

View File

@ -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}'.");
}
}
}

View File

@ -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;
}
}

View File

@ -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('-');
}
}
}

View File

@ -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;
}
}

View File

@ -1,12 +1,12 @@
# 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 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.
- 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.
- 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 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.
@ -14,6 +14,8 @@
- Fixed configuration-managed settings remaining active after their configuration plugin was removed.
- 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.

View File

@ -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:
@ -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.
@ -369,6 +373,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 +411,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. 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.

View File

@ -0,0 +1,2 @@
[mcp_servers.rustrover]
url = "http://127.0.0.1:64522/stream"