From 22065783c22d80444eb460636975c418d8fc1062 Mon Sep 17 00:00:00 2001 From: Peer Hogeterp Date: Fri, 16 Oct 2026 17:22:23 +0200 Subject: [PATCH] Added importing of configurations exported from AI Studio (#1007) Co-authored-by: Thorsten Sommer --- .../DocumentAnalysisAssistant.razor | 4 +- .../DocumentAnalysisAssistant.razor.cs | 75 +-- .../Assistants/I18N/allTexts.lua | 165 +++++ .../Components/DataSourceManagement.razor | 38 +- .../Components/DataSourceManagement.razor.cs | 38 +- .../Settings/SettingsPanelEmbeddings.razor | 8 +- .../Settings/SettingsPanelEmbeddings.razor.cs | 71 +- .../Settings/SettingsPanelProviders.razor | 8 +- .../Settings/SettingsPanelProviders.razor.cs | 71 +- .../Settings/SettingsPanelTranscription.razor | 8 +- .../SettingsPanelTranscription.razor.cs | 65 +- .../Dialogs/ChatTemplateDialog.razor | 29 +- .../Dialogs/ChatTemplateDialog.razor.cs | 51 +- .../ConfigurationSnippetImportDialog.razor | 32 + .../ConfigurationSnippetImportDialog.razor.cs | 97 +++ .../Dialogs/DataSourceERI_V1Dialog.razor | 6 +- .../Dialogs/DataSourceERI_V1Dialog.razor.cs | 55 +- .../Dialogs/EmbeddingProviderDialog.razor.cs | 30 +- .../Dialogs/ProviderDialog.razor.cs | 32 +- .../Settings/SettingsDialogChatTemplate.razor | 10 +- .../SettingsDialogChatTemplate.razor.cs | 76 ++- .../Settings/SettingsDialogProfiles.razor | 10 +- .../Settings/SettingsDialogProfiles.razor.cs | 59 +- .../TranscriptionProviderDialog.razor.cs | 16 +- .../Plugins/configuration/plugin.lua | 17 +- .../plugin.lua | 165 +++++ .../plugin.lua | 165 +++++ .../Settings/DataModel/DataApp.cs | 75 ++- .../DataModel/DataDocumentAnalysisPolicy.cs | 55 +- .../Settings/DataModel/DataSourceERI_V1.cs | 33 +- .../Settings/EmbeddingProvider.cs | 4 +- app/MindWork AI Studio/Settings/Provider.cs | 4 +- .../Tools/PluginSystem/AGENTS.md | 24 + .../ConfigurationBaseObjectDescriptor.cs | 15 +- .../ConfigurationSnippetImport.cs | 286 ++++++++ .../ConfigurationSnippetImportResult.cs | 10 + .../ConfigurationSnippetParser.cs | 326 ++++++++++ ...DataSourceConfigurationObjectDescriptor.cs | 15 +- .../Tools/PluginSystem/PluginConfiguration.cs | 26 +- .../PluginSystem/PluginConfigurationObject.cs | 15 +- .../PluginConfigurationObjectDescriptor.cs | 20 + .../PluginConfigurationObjectDescriptors.cs | 22 + ...PluginConfigurationObjectTypeExtensions.cs | 22 + .../wwwroot/changelog/v26.10.1.md | 5 + .../Settings/DataAppImportPermissionTests.cs | 130 ++++ .../ConfigurationSnippetImportTests.cs | 609 ++++++++++++++++++ .../ConfigurationSnippetParserTests.cs | 358 ++++++++++ ...uginConfigurationObjectDescriptorsTests.cs | 33 + documentation/Enterprise IT.md | 32 +- 49 files changed, 3257 insertions(+), 263 deletions(-) create mode 100644 app/MindWork AI Studio/Dialogs/ConfigurationSnippetImportDialog.razor create mode 100644 app/MindWork AI Studio/Dialogs/ConfigurationSnippetImportDialog.razor.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImport.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImportResult.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetParser.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectTypeExtensions.cs create mode 100644 app/Tests/Settings/DataAppImportPermissionTests.cs create mode 100644 app/Tests/Tools/PluginSystem/ConfigurationSnippetImportTests.cs create mode 100644 app/Tests/Tools/PluginSystem/ConfigurationSnippetParserTests.cs diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor index b43a5cb7e..f1c5e2f96 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor @@ -2,6 +2,7 @@ @inherits AssistantBaseCore @using AIStudio.Settings @using AIStudio.Settings.DataModel +@using AIStudio.Tools.PluginSystem
@@ -47,6 +48,7 @@ else @T("Add policy") + @T("Delete this policy") @@ -180,4 +182,4 @@ else @* The warning sits right at the provider selection, because choosing another provider resolves it: *@ - + \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index 7987a7974..2f99f115b 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -7,11 +7,10 @@ using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.AssistantSessions; +using AIStudio.Tools.PluginSystem; using Microsoft.AspNetCore.Components; -using SharedTools; - using DialogOptions = AIStudio.Dialogs.DialogOptions; using AIStudio.Tools.Security; @@ -483,6 +482,29 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore + /// Like a policy added by hand, an imported policy is stored right away and edited here + /// afterward. With no form in between, the permission check of the import dialog suffices. + /// + private async Task ImportPolicy() + { + if (this.ArePolicyControlsDisabled) + return; + + var import = await ConfigurationSnippetImportDialog.ShowAsync(this.DialogService, PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, T("Import policy")); + if (import?.ConfigurationObject is not DataDocumentAnalysisPolicy policy || this.ArePolicyControlsDisabled) + return; + + var addedPolicy = policy with + { + Num = this.SettingsManager.ConfigurationData.NextDocumentAnalysisPolicyNum++, + }; + + this.SettingsManager.ConfigurationData.DocumentAnalysis.Policies.Add(addedPolicy); + await this.SettingsManager.StoreSettings(); + this.SelectedPolicyChanged(addedPolicy); + } + private async Task AddPolicy() { if (this.ArePolicyControlsDisabled) @@ -985,7 +1007,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore x, StringComparer.Ordinal).Select(x => LuaTools.ToLuaStringLiteral(x))); - - return $$""" - CONFIG["DOCUMENT_ANALYSIS_POLICIES"][#CONFIG["DOCUMENT_ANALYSIS_POLICIES"]+1] = { - ["Id"] = "{{id}}", - ["PolicyName"] = {{LuaTools.ToLuaStringLiteral(this.selectedPolicy.PolicyName.Trim())}}, - ["PolicyDescription"] = {{LuaTools.ToLuaStringLiteral(this.selectedPolicy.PolicyDescription.Trim())}}, - - ["AnalysisRules"] = {{LuaTools.ToLuaStringLiteral(this.selectedPolicy.AnalysisRules.Trim(), forceLongString: true)}}, - - ["OutputRules"] = {{LuaTools.ToLuaStringLiteral(this.selectedPolicy.OutputRules.Trim(), forceLongString: true)}}, - - -- Optional: minimum provider confidence required for this policy. - -- Allowed values are: NONE, VERY_LOW, LOW, MODERATE, MEDIUM, HIGH - ["MinimumProviderConfidence"] = "{{this.selectedPolicy.MinimumProviderConfidence}}", - - -- The tools an analysis with this policy may use, by tool ID. - -- This is a limit, not a preselection: a tool which is not listed here cannot - -- be used for this policy. An empty list means no tools. A listed tool must - -- still meet the confidence requirements of the provider in use. - ["AllowedToolIds"] = { {{allowedToolIds}} }, - - -- Optional: preselect a provider or profile by ID. - -- The IDs must exist in CONFIG["LLM_PROVIDERS"] or CONFIG["PROFILES"]. - ["PreselectedProvider"] = "{{preselectedProvider}}", - ["PreselectedProfile"] = "{{preselectedProfile}}", - - -- Optional: hide the policy definition section in the UI. - -- When set to true, users will only see the document selection interface - -- and cannot view or modify the policy settings. - -- This is useful for enterprise configurations where policy details should remain hidden. - -- Allowed values are: true, false (default: false) - ["HidePolicyDefinition"] = {{this.selectedPolicy.HidePolicyDefinition.ToString().ToLowerInvariant()}}, - } - """; - } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 32a444ced..5ed07216c 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -1120,6 +1120,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Common settings UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1963959073"] = "Common settings" +-- Import policy +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1965764758"] = "Import policy" + -- Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1984494439"] = "Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create." @@ -4168,6 +4171,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3687976654"] = "Lo -- Not available UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3706935413"] = "Not available" +-- Import Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T381231449"] = "Import Data Source" + -- Export ERI Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3831281036"] = "Export ERI Data Source" @@ -5206,6 +5212,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T16449 -- Test Embedding Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1655784761"] = "Test Embedding Provider" +-- Import Embedding +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1681763001"] = "Import Embedding" + -- Add Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1738753945"] = "Add Embedding" @@ -5332,6 +5341,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T301987 -- What we call a provider is the combination of an LLM provider such as OpenAI and a model like GPT-6 Luna. You can configure as many providers as you want. This way, you can use the appropriate model for each task. As an LLM provider, you can also choose local providers. However, to use this app, you must configure at least one provider. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3032087963"] = "What we call a provider is the combination of an LLM provider such as OpenAI and a model like GPT-6 Luna. You can configure as many providers as you want. This way, you can use the appropriate model for each task. As an LLM provider, you can also choose local providers. However, to use this app, you must configure at least one provider." +-- Import Provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3160005705"] = "Import Provider" + -- Edit UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3267849393"] = "Edit" @@ -5443,6 +5455,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T58 -- This transcription provider is trusted by your organization for data source security checks. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T601264181"] = "This transcription provider is trusted by your organization for data source security checks." +-- Import transcription provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T644492997"] = "Import transcription provider" + -- This transcription provider is managed by your organization. You can set your own API key. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T690752279"] = "This transcription provider is managed by your organization. You can set your own API key." @@ -6082,6 +6097,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1548747185"] = "Please -- Yes, this template decides which data a chat starts with UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T17861006"] = "Yes, this template decides which data a chat starts with" +-- Enter the full path of a file on this computer, or remove the attachment. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1798848159"] = "Enter the full path of a file on this computer, or remove the attachment." + -- Load predefined user input from file UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1837026610"] = "Load predefined user input from file" @@ -6094,6 +6112,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T204496403"] = "The chat -- Profile Usage UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2147062613"] = "Profile Usage" +-- Relink attachment: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2234793579"] = "Relink attachment: {0}" + -- Add messages of an example conversation (user prompt followed by assistant prompt) to demonstrate the desired interaction pattern. These examples help the AI understand your expectations by showing it the correct format, style, and content of responses before it receives actual user inputs. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2292424657"] = "Add messages of an example conversation (user prompt followed by assistant prompt) to demonstrate the desired interaction pattern. These examples help the AI understand your expectations by showing it the correct format, style, and content of responses before it receives actual user inputs." @@ -6136,6 +6157,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2719480499"] = "The mes -- Chat Template Name UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T275026390"] = "Chat Template Name" +-- Please relink the attachment '{0}' to a file on this computer or remove it before you save. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2884684079"] = "Please relink the attachment '{0}' to a file on this computer or remove it before you save." + -- Commit Changes UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2996302517"] = "Commit Changes" @@ -6202,6 +6226,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4051106111"] = "Use the -- Tell the AI your predefined user input. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4052406705"] = "Tell the AI your predefined user input." +-- Some attachments of this chat template are not on this computer. Please relink or remove them before you add the template. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4194591607"] = "Some attachments of this chat template are not on this computer. Please relink or remove them before you add the template." + -- Create your custom chat template to tailor the LLM's behavior for specific tasks or domains. Define a custom system prompt and provide an example conversation to design an AI experience perfectly suited to your requirements. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4199560726"] = "Create your custom chat template to tailor the LLM's behavior for specific tasks or domains. Define a custom system prompt and provide an example conversation to design an AI experience perfectly suited to your requirements." @@ -6217,6 +6244,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T628396066"] = "System P -- A chat started with this template begins with these data sources and options. All of it stays changeable in the chat itself. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T650711085"] = "A chat started with this template begins with these data sources and options. All of it stays changeable in the chat itself." +-- Remove this attachment +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T70791272"] = "Remove this attachment" + -- The selection stays changeable in the chat, and every tool still has to meet the confidence requirements of the provider in use. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T722788866"] = "The selection stays changeable in the chat, and every tool still has to meet the confidence requirements of the provider in use." @@ -6313,6 +6343,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T767586087" -- {0} embedding provider UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T818101181"] = "{0} embedding provider" +-- Exported configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T1297128668"] = "Exported configuration" + +-- Your organization does not allow importing this type of configuration. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T2349213174"] = "Your organization does not allow importing this type of configuration." + +-- Paste a configuration which was exported from AI Studio, for example by a colleague. AI Studio only reads its values and never runs the pasted text. You can review and change everything afterward. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T2598895568"] = "Paste a configuration which was exported from AI Studio, for example by a colleague. AI Studio only reads its values and never runs the pasted text. You can review and change everything afterward." + +-- Continue anyway +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T2843058989"] = "Continue anyway" + +-- Cancel +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T900713019"] = "Cancel" + -- No UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T1642511898"] = "No" @@ -6451,6 +6496,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T449168182"] = "Fail -- Username UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T470340825"] = "Username" +-- The server does not offer the imported retrieval process '{0}'. Please select another one before you save. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T653836675"] = "The server does not offer the imported retrieval process '{0}'. Please select another one before you save." + -- Password UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T750979128"] = "Password" @@ -8599,6 +8647,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T23198 -- This chat template preselects data sources which exist on this machine only: {0}. They cannot be rolled out, so a chat started with this template elsewhere begins without them. Do you want to export the template anyway? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T2563631533"] = "This chat template preselects data sources which exist on this machine only: {0}. They cannot be rolled out, so a chat started with this template elsewhere begins without them. Do you want to export the template anyway?" +-- Import Chat Template +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T263213584"] = "Import Chat Template" + -- Chat Template Name UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T275026390"] = "Chat Template Name" @@ -8920,6 +8971,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGMYTASKS::T711745239" -- Edit Profile UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T1143111468"] = "Edit Profile" +-- Import Profile +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T1215374025"] = "Import Profile" + -- No profiles configured yet. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T1433534732"] = "No profiles configured yet." @@ -12574,6 +12628,93 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECUR -- The plugin code changed after the last security audit. The stored result no longer matches the current code, so this assistant plugin must be audited again before it may be enabled or used. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T995107927"] = "The plugin code changed after the last security audit. The stored result no longer matches the current code, so this assistant plugin must be audited again before it may be enabled or used." +-- The token or password of the data source cannot be decrypted on this computer. Such data sources can only be imported where the encryption secret of your organization is configured. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T186946778"] = "The token or password of the data source cannot be decrypted on this computer. Such data sources can only be imported where the encryption secret of your organization is configured." + +-- Data sources which sign in with Kerberos cannot be imported yet. Please add them yourself. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T206419407"] = "Data sources which sign in with Kerberos cannot be imported yet. Please add them yourself." + +-- The tokenizer '{0}' is not on this computer, so the provider is imported without it. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T2556276619"] = "The tokenizer '{0}' is not on this computer, so the provider is imported without it." + +-- The chat template preselects data sources which do not exist on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T2608225446"] = "The chat template preselects data sources which do not exist on this computer: {0}." + +-- The chat template preselects tools which are not available on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T2693686582"] = "The chat template preselects tools which are not available on this computer: {0}." + +-- The API key in the pasted configuration cannot be decrypted on this computer. Please enter your API key before you save the provider. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T3589070261"] = "The API key in the pasted configuration cannot be decrypted on this computer. Please enter your API key before you save the provider." + +-- The chat template attaches these files from this computer to every chat started with it: {0}. Please check that you want to share them. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T3702015680"] = "The chat template attaches these files from this computer to every chat started with it: {0}. Please check that you want to share them." + +-- The policy preselects a provider which does not exist on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T3849646238"] = "The policy preselects a provider which does not exist on this computer: {0}." + +-- The pasted configuration is not a valid configuration of the type '{0}'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T4231596123"] = "The pasted configuration is not a valid configuration of the type '{0}'." + +-- The policy permits tools which are not available on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T582643438"] = "The policy permits tools which are not available on this computer: {0}." + +-- The policy preselects a profile which does not exist on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T600255824"] = "The policy preselects a profile which does not exist on this computer: {0}." + +-- The pasted configuration contains an API key which is not encrypted, so it was left out. Please enter your API key before you save the provider. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T639320638"] = "The pasted configuration contains an API key which is not encrypted, so it was left out. Please enter your API key before you save the provider." + +-- Expected a quoted string at character {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T1235695745"] = "Expected a quoted string at character {0}." + +-- The pasted configuration describes the type '{0}', but only the type '{1}' can be imported here. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T1957713080"] = "The pasted configuration describes the type '{0}', but only the type '{1}' can be imported here." + +-- Expected '{0}' at character {1}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2092509274"] = "Expected '{0}' at character {1}." + +-- A quoted string contains a line break, which Lua does not allow. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2204852708"] = "A quoted string contains a line break, which Lua does not allow." + +-- Please paste one exported configuration of at most 1 MB. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2312733854"] = "Please paste one exported configuration of at most 1 MB." + +-- The pasted configuration does not describe the type '{0}', which can be imported here. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2537838739"] = "The pasted configuration does not describe the type '{0}', which can be imported here." + +-- A quoted string is not closed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2729613384"] = "A quoted string is not closed." + +-- The block comment at character {0} is not supported. Please use line comments, which start with -- and end at the end of the line. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2765542892"] = "The block comment at character {0} is not supported. Please use line comments, which start with -- and end at the end of the line." + +-- The pasted configuration names two different types of configuration. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2938094482"] = "The pasted configuration names two different types of configuration." + +-- The pasted configuration contains too many nested tables. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3064118429"] = "The pasted configuration contains too many nested tables." + +-- Expected a comma or closing brace at character {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3466555352"] = "Expected a comma or closing brace at character {0}." + +-- The value nil at character {0} is not supported. Leave the field out instead. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3903177263"] = "The value nil at character {0} is not supported. Leave the field out instead." + +-- At character {0}, the pasted configuration contains code. Only plain values such as texts, numbers, true, and false are allowed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3962550160"] = "At character {0}, the pasted configuration contains code. Only plain values such as texts, numbers, true, and false are allowed." + +-- A long string is not closed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T39989546"] = "A long string is not closed." + +-- The escape sequence {0} is not supported. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T459116273"] = "The escape sequence {0} is not supported." + +-- The field '{0}' occurs more than once. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T560836334"] = "The field '{0}' occurs more than once." + +-- The pasted configuration must consist of exactly one exported item, without any further code. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T949973671"] = "The pasted configuration must consist of exactly one exported item, without any further code." + -- The table AUTHORS does not exist or is using an invalid syntax. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T1068328139"] = "The table AUTHORS does not exist or is using an invalid syntax." @@ -12736,6 +12877,30 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATION::T1148682011 -- The CONFIG table does not exist or is not a valid table. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATION::T3331620576"] = "The CONFIG table does not exist or is not a valid table." +-- Document Analysis Policy +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T16338172"] = "Document Analysis Policy" + +-- Chat Template +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T2515475479"] = "Chat Template" + +-- Unknown configuration +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T3189461731"] = "Unknown configuration" + +-- Profile +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T3440849550"] = "Profile" + +-- LLM Provider +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T3612415205"] = "LLM Provider" + +-- Transcription Provider +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T4094182084"] = "Transcription Provider" + +-- Data Source +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T858938618"] = "Data Source" + +-- Embedding Provider +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T981047123"] = "Embedding Provider" + -- The field IETF_TAG does not exist or is not a valid string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T1796010240"] = "The field IETF_TAG does not exist or is not a valid string." diff --git a/app/MindWork AI Studio/Components/DataSourceManagement.razor b/app/MindWork AI Studio/Components/DataSourceManagement.razor index 3b23922ac..f31322e8c 100644 --- a/app/MindWork AI Studio/Components/DataSourceManagement.razor +++ b/app/MindWork AI Studio/Components/DataSourceManagement.razor @@ -1,5 +1,6 @@ @using AIStudio.Settings @using AIStudio.Settings.DataModel +@using AIStudio.Tools.PluginSystem @inherits MSGComponentBase @@ -115,21 +116,24 @@ } - - - @T("External Data (ERI-Server v1)") - - - @T("Local Directory") - - - @T("Local File") - - @if (PreviewFeatures.PRE_MAILBOXES_2026.IsEnabled(this.SettingsManager)) - { - @* Shown with a lock rather than left out, like the locked buttons for adding providers: *@ - - @(this.MayAddMailbox ? T("Mailbox (IMAP)") : T("Mailbox (IMAP), not allowed by your organization")) + + + + @T("External Data (ERI-Server v1)") - } - \ No newline at end of file + + @T("Local Directory") + + + @T("Local File") + + @if (PreviewFeatures.PRE_MAILBOXES_2026.IsEnabled(this.SettingsManager)) + { + @* Shown with a lock rather than left out, like the locked buttons for adding providers: *@ + + @(this.MayAddMailbox ? T("Mailbox (IMAP)") : T("Mailbox (IMAP), not allowed by your organization")) + + } + + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/DataSourceManagement.razor.cs b/app/MindWork AI Studio/Components/DataSourceManagement.razor.cs index f3d607f24..131ce6613 100644 --- a/app/MindWork AI Studio/Components/DataSourceManagement.razor.cs +++ b/app/MindWork AI Studio/Components/DataSourceManagement.razor.cs @@ -185,6 +185,37 @@ public partial class DataSourceManagement : MSGComponentBase await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + private async Task ImportERIDataSource() + { + var import = await ConfigurationSnippetImportDialog.ShowAsync(this.DialogService, PluginConfigurationObjectType.DATA_SOURCE, T("Import Data Source")); + if (import?.ConfigurationObject is not DataSourceERI_V1 dataSource) + return; + + var eriDialogParameters = new DialogParameters + { + { x => x.IsEditing, false }, + { x => x.IsImport, true }, + { x => x.DataSource, dataSource }, + { x => x.ImportedSecret, import.Secret }, + }; + + var eriDialogReference = await this.DialogService.ShowAsync(T("Add ERI v1 Data Source"), eriDialogParameters, DialogOptions.FULLSCREEN); + var eriDialogResult = await eriDialogReference.Result; + if (eriDialogResult is null || eriDialogResult.Canceled) + return; + + // + // An organization can lock the import while the user is still filling in the form, e.g., + // with a new version of its configuration plugin. Hence, the permission is checked once + // more before the imported data source is stored: + // + if (!this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.DATA_SOURCE)) + return; + + var eriDataSource = (DataSourceERI_V1)eriDialogResult.Data!; + await this.StoreAddedDataSource(eriDataSource with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }); + } + private async Task AddDataSource(DataSourceType type) { IDataSource? addedDataSource = null; @@ -244,6 +275,11 @@ public partial class DataSourceManagement : MSGComponentBase if(addedDataSource is null) return; + await this.StoreAddedDataSource(addedDataSource); + } + + private async Task StoreAddedDataSource(IDataSource addedDataSource) + { this.SettingsManager.ConfigurationData.DataSources.Add(addedDataSource); await this.SettingsManager.StoreSettings(); await this.DataSourceEmbeddingService.QueueDataSourceAsync(addedDataSource); @@ -616,4 +652,4 @@ public partial class DataSourceManagement : MSGComponentBase break; } } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor index 31db52cbe..88bd9afb3 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor @@ -1,6 +1,7 @@ @using AIStudio.Provider @using AIStudio.Settings @using AIStudio.Settings.DataModel +@using AIStudio.Tools.PluginSystem @inherits SettingsPanelProviderBase @if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager)) @@ -93,6 +94,9 @@ } - + + + + -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs index a48b3f7d5..3e3b86c6c 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs @@ -2,6 +2,7 @@ using System.Globalization; using AIStudio.Dialogs; using AIStudio.Provider; using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -59,12 +60,36 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase { { x => x.IsEditing, false }, }; - + + await this.AddEmbeddingProvider(dialogParameters, isImport: false); + } + + private async Task ImportEmbeddingProvider() + { + var import = await ConfigurationSnippetImportDialog.ShowAsync(this.DialogService, PluginConfigurationObjectType.EMBEDDING_PROVIDER, T("Import Embedding")); + if (import?.ConfigurationObject is not EmbeddingProvider embeddingProvider) + return; + + var dialogParameters = CreateDialogParameters(embeddingProvider, isEditing: false); + dialogParameters.Add(x => x.ImportedAPIKey, import.Secret); + await this.AddEmbeddingProvider(dialogParameters, isImport: true); + } + + private async Task AddEmbeddingProvider(DialogParameters dialogParameters, bool isImport) + { var dialogReference = await this.DialogService.ShowAsync(T("Add Embedding Provider"), dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; + // + // An organization can lock the import while the user is still filling in the form, e.g., + // with a new version of its configuration plugin. Hence, the permission is checked once + // more before the imported embedding provider is stored: + // + if (isImport && !this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.EMBEDDING_PROVIDER)) + return; + var addedEmbedding = (EmbeddingProvider)dialogResult.Data!; addedEmbedding = addedEmbedding with { Num = this.SettingsManager.ConfigurationData.NextEmbeddingNum++ }; @@ -81,27 +106,7 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase if (embeddingProvider.IsEnterpriseConfiguration && !embeddingProvider.AllowUserProvidedAPIKey) return; - var dialogParameters = new DialogParameters - { - { x => x.DataNum, embeddingProvider.Num }, - { x => x.DataId, embeddingProvider.Id }, - { x => x.DataName, embeddingProvider.Name }, - { x => x.DataLLMProvider, embeddingProvider.UsedLLMProvider }, - { x => x.DataCustomIconDataUrl, embeddingProvider.CustomIconDataUrl }, - { x => x.DataModel, embeddingProvider.Model }, - { x => x.DataHostname, embeddingProvider.Hostname }, - { x => x.IsSelfHosted, embeddingProvider.IsSelfHosted }, - { x => x.IsEditing, true }, - { x => x.DataHost, embeddingProvider.Host }, - { x => x.DataTokenizerPath, embeddingProvider.TokenizerPath }, - { x => x.DataTokenizerFingerprint, embeddingProvider.TokenizerFingerprint }, - { x => x.DataTokenLimit, embeddingProvider.EffectiveTokenLimit }, - { x => x.DataEmbeddingBatchSize, embeddingProvider.EffectiveEmbeddingBatchSize }, - { x => x.HFInferenceProviderId, embeddingProvider.HFInferenceProvider }, - { x => x.IsEnterpriseConfiguration, embeddingProvider.IsEnterpriseConfiguration }, - }; - - var dialogReference = await this.DialogService.ShowAsync(T("Edit Embedding Provider"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogReference = await this.DialogService.ShowAsync(T("Edit Embedding Provider"), CreateDialogParameters(embeddingProvider, isEditing: true), DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; @@ -131,6 +136,26 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + private static DialogParameters CreateDialogParameters(EmbeddingProvider embeddingProvider, bool isEditing) => new() + { + { x => x.DataNum, embeddingProvider.Num }, + { x => x.DataId, embeddingProvider.Id }, + { x => x.DataName, embeddingProvider.Name }, + { x => x.DataLLMProvider, embeddingProvider.UsedLLMProvider }, + { x => x.DataCustomIconDataUrl, embeddingProvider.CustomIconDataUrl }, + { x => x.DataModel, embeddingProvider.Model }, + { x => x.DataHostname, embeddingProvider.Hostname }, + { x => x.IsSelfHosted, embeddingProvider.IsSelfHosted }, + { x => x.IsEditing, isEditing }, + { x => x.DataHost, embeddingProvider.Host }, + { x => x.DataTokenizerPath, embeddingProvider.TokenizerPath }, + { x => x.DataTokenizerFingerprint, embeddingProvider.TokenizerFingerprint }, + { x => x.DataTokenLimit, embeddingProvider.EffectiveTokenLimit }, + { x => x.DataEmbeddingBatchSize, embeddingProvider.EffectiveEmbeddingBatchSize }, + { x => x.HFInferenceProviderId, embeddingProvider.HFInferenceProvider }, + { x => x.IsEnterpriseConfiguration, embeddingProvider.IsEnterpriseConfiguration }, + }; + private async Task DeleteEmbeddingProvider(EmbeddingProvider provider) { var question = string.Format(T("Are you sure you want to delete the embedding provider '{0}'?"), provider.Name); @@ -261,4 +286,4 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase await this.DialogService.ShowAsync(T("Embedding Result"), resultParameters, DialogOptions.FULLSCREEN); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor index b6b532e0d..13272b726 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor @@ -1,5 +1,6 @@ @using AIStudio.Provider @using AIStudio.Settings +@using AIStudio.Tools.PluginSystem @inherits SettingsPanelProviderBase @@ -78,5 +79,8 @@ } - - + + + + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs index 64464e243..0934b69e3 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using AIStudio.Dialogs; using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -39,19 +40,43 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase #endregion - [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed", Justification = "Managing the provider list is the purpose of this settings panel. Reading providers goes through the settings manager, but adding, editing, and removing them stays here on purpose.")] private async Task AddLLMProvider() { var dialogParameters = new DialogParameters { { x => x.IsEditing, false }, }; - + + await this.AddLLMProvider(dialogParameters, isImport: false); + } + + private async Task ImportProvider() + { + var import = await ConfigurationSnippetImportDialog.ShowAsync(this.DialogService, PluginConfigurationObjectType.LLM_PROVIDER, T("Import Provider")); + if (import?.ConfigurationObject is not AIStudio.Settings.Provider provider) + return; + + var dialogParameters = CreateDialogParameters(provider, isEditing: false); + dialogParameters.Add(x => x.ImportedAPIKey, import.Secret); + await this.AddLLMProvider(dialogParameters, isImport: true); + } + + [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed", Justification = "Managing the provider list is the purpose of this settings panel. Reading providers goes through the settings manager, but adding, editing, and removing them stays here on purpose.")] + private async Task AddLLMProvider(DialogParameters dialogParameters, bool isImport) + { var dialogReference = await this.DialogService.ShowAsync(T("Add LLM Provider"), dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; + // + // An organization can lock the import while the user is still filling in the form, e.g., + // with a new version of its configuration plugin. Hence, the permission is checked once + // more before the imported provider is stored: + // + if (isImport && !this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.LLM_PROVIDER)) + return; + var addedProvider = (AIStudio.Settings.Provider)dialogResult.Data!; addedProvider = addedProvider with { Num = this.SettingsManager.ConfigurationData.NextProviderNum++ }; @@ -71,26 +96,7 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase if (provider.IsEnterpriseConfiguration && !provider.AllowUserProvidedAPIKey) return; - var dialogParameters = new DialogParameters - { - { x => x.DataNum, provider.Num }, - { x => x.DataId, provider.Id }, - { x => x.DataInstanceName, provider.InstanceName }, - { x => x.DataLLMProvider, provider.UsedLLMProvider }, - { x => x.DataCustomIconDataUrl, provider.CustomIconDataUrl }, - { x => x.DataModel, provider.Model }, - { x => x.DataHostname, provider.Hostname }, - { x => x.IsSelfHosted, provider.IsSelfHosted }, - { x => x.IsEditing, true }, - { x => x.DataHost, provider.Host }, - { x => x.HFInferenceProviderId, provider.HFInferenceProvider }, - { x => x.AdditionalJsonApiParameters, provider.AdditionalJsonApiParameters }, - { x => x.DataTokenizerPath, provider.TokenizerPath }, - { x => x.DataCapabilityOverrides, provider.CapabilityOverrides }, - { x => x.IsEnterpriseConfiguration, provider.IsEnterpriseConfiguration }, - }; - - var dialogReference = await this.DialogService.ShowAsync(T("Edit LLM Provider"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogReference = await this.DialogService.ShowAsync(T("Edit LLM Provider"), CreateDialogParameters(provider, isEditing: true), DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; @@ -119,6 +125,25 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + private static DialogParameters CreateDialogParameters(AIStudio.Settings.Provider provider, bool isEditing) => new() + { + { x => x.DataNum, provider.Num }, + { x => x.DataId, provider.Id }, + { x => x.DataInstanceName, provider.InstanceName }, + { x => x.DataLLMProvider, provider.UsedLLMProvider }, + { x => x.DataCustomIconDataUrl, provider.CustomIconDataUrl }, + { x => x.DataModel, provider.Model }, + { x => x.DataHostname, provider.Hostname }, + { x => x.IsSelfHosted, provider.IsSelfHosted }, + { x => x.IsEditing, isEditing }, + { x => x.DataHost, provider.Host }, + { x => x.HFInferenceProviderId, provider.HFInferenceProvider }, + { x => x.AdditionalJsonApiParameters, provider.AdditionalJsonApiParameters }, + { x => x.DataTokenizerPath, provider.TokenizerPath }, + { x => x.DataCapabilityOverrides, provider.CapabilityOverrides }, + { x => x.IsEnterpriseConfiguration, provider.IsEnterpriseConfiguration }, + }; + [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed", Justification = "Managing the provider list is the purpose of this settings panel. Reading providers goes through the settings manager, but adding, editing, and removing them stays here on purpose.")] private async Task DeleteLLMProvider(AIStudio.Settings.Provider provider) { @@ -197,4 +222,4 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase await this.AvailableLLMProvidersChanged.InvokeAsync(this.AvailableLLMProviders); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor index 4ad4488f5..11dc52c69 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor @@ -1,6 +1,7 @@ @using AIStudio.Provider @using AIStudio.Settings @using AIStudio.Settings.DataModel +@using AIStudio.Tools.PluginSystem @inherits SettingsPanelProviderBase @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) @@ -83,6 +84,9 @@ } - + + + + -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs index 1db253798..466e5818f 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs @@ -1,5 +1,6 @@ using AIStudio.Dialogs; using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; using Microsoft.AspNetCore.Components; @@ -53,12 +54,36 @@ public partial class SettingsPanelTranscription : SettingsPanelProviderBase { { x => x.IsEditing, false }, }; - + + await this.AddTranscriptionProvider(dialogParameters, isImport: false); + } + + private async Task ImportTranscriptionProvider() + { + var import = await ConfigurationSnippetImportDialog.ShowAsync(this.DialogService, PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, T("Import transcription provider")); + if (import?.ConfigurationObject is not TranscriptionProvider transcriptionProvider) + return; + + var dialogParameters = CreateDialogParameters(transcriptionProvider, isEditing: false); + dialogParameters.Add(x => x.ImportedAPIKey, import.Secret); + await this.AddTranscriptionProvider(dialogParameters, isImport: true); + } + + private async Task AddTranscriptionProvider(DialogParameters dialogParameters, bool isImport) + { var dialogReference = await this.DialogService.ShowAsync(T("Add Transcription Provider"), dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; - + + // + // An organization can lock the import while the user is still filling in the form, e.g., + // with a new version of its configuration plugin. Hence, the permission is checked once + // more before the imported transcription provider is stored: + // + if (isImport && !this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)) + return; + var addedTranscription = (TranscriptionProvider)dialogResult.Data!; addedTranscription = addedTranscription with { Num = this.SettingsManager.ConfigurationData.NextTranscriptionNum++ }; @@ -74,23 +99,7 @@ public partial class SettingsPanelTranscription : SettingsPanelProviderBase if (transcriptionProvider.IsEnterpriseConfiguration && !transcriptionProvider.AllowUserProvidedAPIKey) return; - var dialogParameters = new DialogParameters - { - { x => x.DataNum, transcriptionProvider.Num }, - { x => x.DataId, transcriptionProvider.Id }, - { x => x.DataName, transcriptionProvider.Name }, - { x => x.DataLLMProvider, transcriptionProvider.UsedLLMProvider }, - { x => x.DataCustomIconDataUrl, transcriptionProvider.CustomIconDataUrl }, - { x => x.DataModel, transcriptionProvider.Model }, - { x => x.DataHostname, transcriptionProvider.Hostname }, - { x => x.IsSelfHosted, transcriptionProvider.IsSelfHosted }, - { x => x.IsEditing, true }, - { x => x.DataHost, transcriptionProvider.Host }, - { x => x.HFInferenceProviderId, transcriptionProvider.HFInferenceProvider }, - { x => x.IsEnterpriseConfiguration, transcriptionProvider.IsEnterpriseConfiguration }, - }; - - var dialogReference = await this.DialogService.ShowAsync(T("Edit Transcription Provider"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogReference = await this.DialogService.ShowAsync(T("Edit Transcription Provider"), CreateDialogParameters(transcriptionProvider, isEditing: true), DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; @@ -119,6 +128,22 @@ public partial class SettingsPanelTranscription : SettingsPanelProviderBase await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + private static DialogParameters CreateDialogParameters(TranscriptionProvider transcriptionProvider, bool isEditing) => new() + { + { x => x.DataNum, transcriptionProvider.Num }, + { x => x.DataId, transcriptionProvider.Id }, + { x => x.DataName, transcriptionProvider.Name }, + { x => x.DataLLMProvider, transcriptionProvider.UsedLLMProvider }, + { x => x.DataCustomIconDataUrl, transcriptionProvider.CustomIconDataUrl }, + { x => x.DataModel, transcriptionProvider.Model }, + { x => x.DataHostname, transcriptionProvider.Hostname }, + { x => x.IsSelfHosted, transcriptionProvider.IsSelfHosted }, + { x => x.IsEditing, isEditing }, + { x => x.DataHost, transcriptionProvider.Host }, + { x => x.HFInferenceProviderId, transcriptionProvider.HFInferenceProvider }, + { x => x.IsEnterpriseConfiguration, transcriptionProvider.IsEnterpriseConfiguration }, + }; + private async Task DeleteTranscriptionProvider(TranscriptionProvider provider) { var dialogParameters = new DialogParameters @@ -161,4 +186,4 @@ public partial class SettingsPanelTranscription : SettingsPanelProviderBase await this.AvailableTranscriptionProvidersChanged.InvokeAsync(this.AvailableTranscriptionProviders); } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor b/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor index c26d48725..40c1d07a6 100644 --- a/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor +++ b/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor @@ -94,6 +94,33 @@ @T("You can attach files that will be automatically included when using this chat template. These files will be added to the first message sent in any chat using this template.") + @if (!this.IsEditing && !this.IsReadOnly) + { + @if (this.HasUnresolvedAttachments) + { + + @T("Some attachments of this chat template are not on this computer. Please relink or remove them before you add the template.") + + } + @if (!string.IsNullOrWhiteSpace(this.relinkIssue)) + { + @this.relinkIssue + } + @for (var index = 0; index < this.attachmentsToRelink.Count; index++) + { + var currentIndex = index; + var attachment = this.attachmentsToRelink[index]; + + + + + + + } + } + @if (this.IsEditing) { @T("Update") diff --git a/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor.cs index 942d27271..0d199bae7 100644 --- a/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor.cs @@ -57,6 +57,13 @@ public partial class ChatTemplateDialog : MSGComponentBase [Parameter] public IReadOnlyCollection FileAttachments { get; init; } = []; + /// + /// The attachments of an imported chat template which are not on this computer, as the + /// configuration snippet names them. The user relinks or removes each one before saving. + /// + [Parameter] + public IReadOnlyList AttachmentsToRelink { get; init; } = []; + [Parameter] public bool AllowProfileUsage { get; set; } = true; @@ -91,6 +98,9 @@ public partial class ChatTemplateDialog : MSGComponentBase private bool dataIsValid; private List dataExampleConversation = []; private HashSet fileAttachments = []; + private List<(string OriginalPath, string ReplacementPath)> attachmentsToRelink = []; + private string relinkIssue = string.Empty; + private bool HasUnresolvedAttachments => this.attachmentsToRelink.Any(attachment => !IsExistingFile(attachment.ReplacementPath)); private bool preselectTools; private HashSet selectedToolIds = new(StringComparer.Ordinal); private bool preselectDataSources; @@ -130,11 +140,12 @@ public partial class ChatTemplateDialog : MSGComponentBase // When editing, we need to load the data: if(this.IsEditing) - { this.dataEditingPreviousName = this.DataName.ToLowerInvariant(); - this.dataExampleConversation = this.ExampleConversation.Select(n => n.DeepClone()).ToList(); - this.fileAttachments = [..this.FileAttachments]; - } + + // An edited template brings its conversation and attachments along, and so does an imported one: + this.dataExampleConversation = this.ExampleConversation.Select(n => n.DeepClone()).ToList(); + this.fileAttachments = [..this.FileAttachments]; + this.attachmentsToRelink = this.AttachmentsToRelink.Select(path => (path, string.Empty)).ToList(); if (this.CreateFromExistingChatThread && this.ExistingChatThread is not null) { @@ -176,6 +187,22 @@ public partial class ChatTemplateDialog : MSGComponentBase IsEnterpriseConfiguration = false, }; + private static bool IsExistingFile(string path) => Path.IsPathFullyQualified(path) && File.Exists(path); + + private void UpdateRelinkPath(int index, string? path) + { + this.attachmentsToRelink[index] = (this.attachmentsToRelink[index].OriginalPath, path ?? string.Empty); + if (!this.HasUnresolvedAttachments) + this.relinkIssue = string.Empty; + } + + private void RemoveAttachmentToRelink(int index) + { + this.attachmentsToRelink.RemoveAt(index); + if (!this.HasUnresolvedAttachments) + this.relinkIssue = string.Empty; + } + private void SetSelectedToolIds(HashSet toolIds) => this.selectedToolIds = toolIds; private void RemoveMessage(ContentBlock item) @@ -274,6 +301,18 @@ public partial class ChatTemplateDialog : MSGComponentBase if (this.IsReadOnly) return; + // Only check the relinked attachments here. They are added right before closing, so that a + // failed save does not leave a path behind which the user changes afterward: + this.relinkIssue = string.Empty; + foreach (var (originalPath, replacementPath) in this.attachmentsToRelink) + { + if (!IsExistingFile(replacementPath)) + { + this.relinkIssue = string.Format(T("Please relink the attachment '{0}' to a file on this computer or remove it before you save."), originalPath); + return; + } + } + await this.form.Validate(); // When the data is not valid, we don't store it: @@ -284,6 +323,10 @@ public partial class ChatTemplateDialog : MSGComponentBase if (this.isInlineEditOnGoing) return; + foreach (var (_, replacementPath) in this.attachmentsToRelink) + this.fileAttachments.Add(FileAttachment.FromPath(replacementPath)); + this.attachmentsToRelink.Clear(); + // Use the data model to store the chat template. // We just return this data to the parent component: var addedChatTemplateSettings = this.CreateChatTemplateSettings(); diff --git a/app/MindWork AI Studio/Dialogs/ConfigurationSnippetImportDialog.razor b/app/MindWork AI Studio/Dialogs/ConfigurationSnippetImportDialog.razor new file mode 100644 index 000000000..090ca7607 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/ConfigurationSnippetImportDialog.razor @@ -0,0 +1,32 @@ +@inherits MSGComponentBase + + + + + @T("Paste a configuration which was exported from AI Studio, for example by a colleague. AI Studio only reads its values and never runs the pasted text. You can review and change everything afterward.") + + + @if (!string.IsNullOrWhiteSpace(this.issue)) + { + @this.issue + } + @if (this.importWithHints is not null) + { + @foreach (var hint in this.importWithHints.Hints) + { + @hint + } + } + + + @T("Cancel") + @if (this.importWithHints is null) + { + @this.ImportLabel + } + else + { + @T("Continue anyway") + } + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/ConfigurationSnippetImportDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ConfigurationSnippetImportDialog.razor.cs new file mode 100644 index 000000000..1d423adb6 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/ConfigurationSnippetImportDialog.razor.cs @@ -0,0 +1,97 @@ +using AIStudio.Components; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.ToolCallingSystem; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Dialogs; + +public partial class ConfigurationSnippetImportDialog : MSGComponentBase +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public PluginConfigurationObjectType Type { get; set; } = PluginConfigurationObjectType.NONE; + + [Parameter] + public string ImportLabel { get; set; } = string.Empty; + + [Inject] + private ToolRegistry ToolRegistry { get; init; } = null!; + + private string snippet = string.Empty; + private string issue = string.Empty; + + /// + /// What the snippet imports, while the user still has to read its hints. + /// + private ConfigurationSnippetImportResult? importWithHints; + + private void SnippetChanged(string text) + { + // The issue and the hints belong to the snippet they were found in: + this.snippet = text; + this.issue = string.Empty; + this.importWithHints = null; + } + + private async Task Import() + { + if (!ConfigurationSnippetImport.TryImport(this.snippet, this.Type, out var imported, out this.issue)) + return; + + imported = await ConfigurationSnippetImport.ResolveReferencesAsync(imported, this.SettingsManager, this.ToolRegistry); + if (imported.Hints.Count > 0) + { + this.importWithHints = imported; + return; + } + + this.Close(imported); + } + + private void ContinueAnyway() + { + if (this.importWithHints is not null) + this.Close(this.importWithHints); + } + + private void Close(ConfigurationSnippetImportResult imported) + { + // + // An organization can lock the import at any time, e.g., with a new version of its + // configuration plugin, and the user may take a while to read the hints. Hence, the + // permission is checked when the dialog hands over the imported object: + // + if (!this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(this.Type)) + { + this.issue = T("Your organization does not allow importing this type of configuration."); + this.importWithHints = null; + return; + } + + this.MudDialog.Close(DialogResult.Ok(imported)); + } + + private void Cancel() => this.MudDialog.Cancel(); + + /// + /// Lets the user paste a configuration snippet and imports it as an object of the user's own. + /// + /// The dialog service. + /// The type of configuration object to import. + /// The title of the dialog, which also labels its import button. + /// The imported object, or null when the user canceled. + public static async Task ShowAsync(IDialogService service, PluginConfigurationObjectType type, string title) + { + var parameters = new DialogParameters + { + { x => x.Type, type }, + { x => x.ImportLabel, title }, + }; + var dialog = await service.ShowAsync(title, parameters, DialogOptions.FULLSCREEN); + var result = await dialog.Result; + return result is { Canceled: false, Data: ConfigurationSnippetImportResult imported } ? imported : null; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor index 3a3d4b00e..5a07d5306 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor +++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor @@ -5,6 +5,10 @@ + @if (!string.IsNullOrWhiteSpace(this.importRetrievalIssue)) + { + @this.importRetrievalIssue + } @* ReSharper disable once CSharpWarnings::CS8974 *@ 0) { - + @foreach (var retrievalProcess in this.availableRetrievalProcesses) { diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs index ca32a9700..a07b44fc6 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs @@ -23,7 +23,20 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId [Parameter] public DataSourceERI_V1 DataSource { get; set; } - + + /// + /// Whether the data source was imported. It then fills the form like an edited one, but the + /// dialog does not connect to its server before the user tests the connection. + /// + [Parameter] + public bool IsImport { get; set; } + + /// + /// The token or password an imported data source brought along. + /// + [Parameter] + public string ImportedSecret { get; set; } = string.Empty; + [Inject] private ILogger Logger { get; init; } = null!; @@ -43,6 +56,8 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId private bool dataIsValid; private string[] dataIssues = []; private string dataSecretStorageIssue = string.Empty; + private string importRetrievalIssue = string.Empty; + private string importedRetrievalId = string.Empty; private string dataEditingPreviousInstanceName = string.Empty; private List availableAuthMethods = []; private DataSourceSecurity dataSecurityPolicy; @@ -92,13 +107,12 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId .Concat(this.SettingsManager.ConfigurationData.Mailboxes.Select(x => x.Name.ToLowerInvariant())) .ToList(); - // When editing, we need to load the data: - if(this.IsEditing) + // An edited data source fills the form, and so does an imported one: + if(this.IsEditing || this.IsImport) { // // Assign the data to the form fields: // - this.dataEditingPreviousInstanceName = this.DataSource.Name.ToLowerInvariant(); this.dataNum = this.DataSource.Num; this.dataId = this.DataSource.Id; this.dataName = this.DataSource.Name; @@ -114,7 +128,12 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId // restore the selected retrieval process id. That's why we // assign the selected retrieval id to the default retrieval process: this.dataSelectedRetrievalProcess = this.dataSelectedRetrievalProcess with { Id = this.DataSource.SelectedRetrievalId }; + } + // When editing, we need to load the data: + if(this.IsEditing) + { + this.dataEditingPreviousInstanceName = this.DataSource.Name.ToLowerInvariant(); if (this.dataAuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD) { // Load the secret: @@ -135,7 +154,13 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId // Select the retrieval process: this.dataSelectedRetrievalProcess = this.availableRetrievalProcesses.FirstOrDefault(n => n.Id == this.DataSource.SelectedRetrievalId); } - + else if (this.IsImport) + { + // The first connection test, which the user starts, selects the imported retrieval process: + this.dataSecret = this.ImportedSecret; + this.importedRetrievalId = this.DataSource.SelectedRetrievalId; + } + await base.OnInitializedAsync(); } @@ -179,6 +204,8 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId }; } + private void ClearImportRetrievalIssue() => this.importRetrievalIssue = string.Empty; + private bool IsConnectionEncrypted() => this.dataHostname.StartsWith("https://", StringComparison.InvariantCultureIgnoreCase); private bool IsConnectionPossible() @@ -253,7 +280,23 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId } this.availableRetrievalProcesses = retrievalInfoRequest.Data ?? []; - + + // Only the first successful test after an import resolves the imported retrieval ID; + // afterward, the selection belongs to the user: + if (!string.IsNullOrWhiteSpace(this.importedRetrievalId)) + { + var importedRetrieval = this.availableRetrievalProcesses.FirstOrDefault(item => item.Id == this.importedRetrievalId); + if (importedRetrieval != default) + this.dataSelectedRetrievalProcess = importedRetrieval; + else + { + this.importRetrievalIssue = string.Format(T("The server does not offer the imported retrieval process '{0}'. Please select another one before you save."), this.importedRetrievalId); + this.dataSelectedRetrievalProcess = default; + } + + this.importedRetrievalId = string.Empty; + } + this.connectionTested = true; this.connectionSuccessfulTested = true; this.Logger.LogInformation("Connection to the ERI v1 server was successful tested."); diff --git a/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs index 765e7aa55..13db40ccf 100644 --- a/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs @@ -110,6 +110,12 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId [Parameter] public bool IsEnterpriseConfiguration { get; set; } + /// + /// The API key an imported embedding provider brought along. When editing, the dialog loads the stored key instead. + /// + [Parameter] + public string ImportedAPIKey { get; set; } = string.Empty; + [Inject] private RustService RustService { get; init; } = null!; @@ -204,16 +210,18 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId // Load the used instance names: this.UsedInstanceNames = this.SettingsManager.ConfigurationData.EmbeddingProviders.Select(x => x.Name.ToLowerInvariant()).ToList(); + // An edited embedding provider fills the form, and so does an imported one: + this.dataFilePath = this.DataTokenizerPath; + this.dataTokenizerFingerprint = this.DataTokenizerFingerprint; + this.showExpertSettings = !string.IsNullOrWhiteSpace(this.DataTokenizerPath) + || this.DataTokenLimit != EmbeddingProvider.DEFAULT_TOKEN_LIMIT + || this.DataEmbeddingBatchSize != EmbeddingProvider.DEFAULT_EMBEDDING_BATCH_SIZE; + // When editing, we need to load the data: if(this.IsEditing) { this.dataEditingPreviousInstanceName = this.DataName.ToLowerInvariant(); - this.dataFilePath = this.DataTokenizerPath; - this.dataTokenizerFingerprint = this.DataTokenizerFingerprint; - this.showExpertSettings = !string.IsNullOrWhiteSpace(this.DataTokenizerPath) - || this.DataTokenLimit != EmbeddingProvider.DEFAULT_TOKEN_LIMIT - || this.DataEmbeddingBatchSize != EmbeddingProvider.DEFAULT_EMBEDDING_BATCH_SIZE; - + // Load the API key. A self-hosted server may well need one: LM Studio can ask for a // token of its own, and any of these servers can sit behind an authenticating proxy. // So we try for every host and treat a missing key as the normal case (isTrying). @@ -239,6 +247,14 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId await this.ReloadModels(); } + else + { + this.dataAPIKey = this.ImportedAPIKey; + + // An import does not contact the server, so its model is the only one to choose until the user loads the models: + if (!string.IsNullOrWhiteSpace(this.DataModel.Id)) + this.availableModels.Add(this.DataModel); + } } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -603,4 +619,4 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId private void ToggleExpertSettings() => this.showExpertSettings = !this.showExpertSettings; private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty; -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs index 26666691c..e761d2297 100644 --- a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs @@ -117,7 +117,13 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId [Parameter] public ProviderCapabilityOverrides? DataCapabilityOverrides { get; set; } - + + /// + /// The API key an imported provider brought along. When editing, the dialog loads the stored key instead. + /// + [Parameter] + public string ImportedAPIKey { get; set; } = string.Empty; + [Inject] private RustService RustService { get; init; } = null!; @@ -254,16 +260,18 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId || this.capabilityOverrides.HasOverrides || (this.ShowTokenizerSettings && !string.IsNullOrWhiteSpace(this.DataTokenizerPath)); + // An edited provider fills the form, and so does an imported one: + this.dataFilePath = this.DataTokenizerPath; + + // When using Fireworks, we must copy the model name: + if (this.DataLLMProvider.IsLLMModelProvidedManually()) + this.dataManuallyModel = this.DataModel.Id; + // When editing, we need to load the data: if(this.IsEditing) { this.dataEditingPreviousInstanceName = this.DataInstanceName.ToLowerInvariant(); - this.dataFilePath = this.DataTokenizerPath; - - // When using Fireworks, we must copy the model name: - if (this.DataLLMProvider.IsLLMModelProvidedManually()) - this.dataManuallyModel = this.DataModel.Id; - + // Load the API key. A self-hosted server may well need one: LM Studio can ask for a // token of its own, and any of these servers can sit behind an authenticating proxy. // So we try for every host and treat a missing key as the normal case (isTrying). @@ -289,6 +297,14 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId await this.ReloadModels(); } + else + { + this.dataAPIKey = this.ImportedAPIKey; + + // An import does not contact the server, so its model is the only one to choose until the user loads the models: + if (!string.IsNullOrWhiteSpace(this.DataModel.Id)) + this.availableModels.Add(this.DataModel); + } } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -1053,4 +1069,4 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId "top_p": 0.9, "frequency_penalty": 0.0 """; -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor index 305f25e54..843be5427 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor @@ -1,3 +1,4 @@ +@using AIStudio.Tools.PluginSystem @inherits SettingsDialogBase @@ -81,9 +82,12 @@ } - - @T("Add Chat Template") - + + + @T("Add Chat Template") + + + diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs index b941beab2..c74b9b239 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs @@ -1,5 +1,6 @@ using AIStudio.Chat; using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; using Microsoft.AspNetCore.Components; namespace AIStudio.Dialogs.Settings; @@ -39,11 +40,35 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase dialogParameters.Add(x => x.ExistingChatThread, this.ExistingChatThread); } + await this.AddChatTemplate(dialogParameters, isImport: false); + } + + private async Task ImportChatTemplate() + { + var import = await ConfigurationSnippetImportDialog.ShowAsync(this.DialogService, PluginConfigurationObjectType.CHAT_TEMPLATE, T("Import Chat Template")); + if (import?.ConfigurationObject is not ChatTemplate chatTemplate) + return; + + var dialogParameters = CreateDialogParameters(chatTemplate, isEditing: false); + dialogParameters.Add(x => x.AttachmentsToRelink, import.AttachmentsToRelink); + await this.AddChatTemplate(dialogParameters, isImport: true); + } + + private async Task AddChatTemplate(DialogParameters dialogParameters, bool isImport) + { var dialogReference = await this.DialogService.ShowAsync(T("Add Chat Template"), dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; + // + // An organization can lock the import while the user is still filling in the form, e.g., + // with a new version of its configuration plugin. Hence, the permission is checked once + // more before the imported chat template is stored: + // + if (isImport && !this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.CHAT_TEMPLATE)) + return; + var addedChatTemplate = (ChatTemplate)dialogResult.Data!; addedChatTemplate = addedChatTemplate with { Num = this.SettingsManager.ConfigurationData.NextChatTemplateNum++ }; @@ -58,22 +83,7 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase if (chatTemplate == ChatTemplate.NO_CHAT_TEMPLATE || chatTemplate.IsEnterpriseConfiguration) return; - var dialogParameters = new DialogParameters - { - { x => x.DataNum, chatTemplate.Num }, - { x => x.DataId, chatTemplate.Id }, - { x => x.DataName, chatTemplate.Name }, - { x => x.DataSystemPrompt, chatTemplate.SystemPrompt }, - { x => x.PredefinedUserPrompt, chatTemplate.PredefinedUserPrompt }, - { x => x.IsEditing, true }, - { x => x.ExampleConversation, chatTemplate.ExampleConversation }, - { x => x.FileAttachments, chatTemplate.FileAttachments }, - { x => x.AllowProfileUsage, chatTemplate.AllowProfileUsage }, - { x => x.ToolIds, chatTemplate.ToolIds }, - { x => x.DataSourceOptions, chatTemplate.DataSourceOptions }, - }; - - var dialogReference = await this.DialogService.ShowAsync(T("Edit Chat Template"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogReference = await this.DialogService.ShowAsync(T("Edit Chat Template"), CreateDialogParameters(chatTemplate, isEditing: true), DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; @@ -87,25 +97,25 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase private async Task ViewChatTemplate(ChatTemplate chatTemplate) { - var dialogParameters = new DialogParameters - { - { x => x.DataNum, chatTemplate.Num }, - { x => x.DataId, chatTemplate.Id }, - { x => x.DataName, chatTemplate.Name }, - { x => x.DataSystemPrompt, chatTemplate.SystemPrompt }, - { x => x.PredefinedUserPrompt, chatTemplate.PredefinedUserPrompt }, - { x => x.IsEditing, true }, - { x => x.IsReadOnly, true }, - { x => x.ExampleConversation, chatTemplate.ExampleConversation }, - { x => x.FileAttachments, chatTemplate.FileAttachments }, - { x => x.AllowProfileUsage, chatTemplate.AllowProfileUsage }, - { x => x.ToolIds, chatTemplate.ToolIds }, - { x => x.DataSourceOptions, chatTemplate.DataSourceOptions }, - }; - - await this.DialogService.ShowAsync(T("View Chat Template"), dialogParameters, DialogOptions.FULLSCREEN); + await this.DialogService.ShowAsync(T("View Chat Template"), CreateDialogParameters(chatTemplate, isEditing: true, isReadOnly: true), DialogOptions.FULLSCREEN); } + private static DialogParameters CreateDialogParameters(ChatTemplate chatTemplate, bool isEditing, bool isReadOnly = false) => new() + { + { x => x.DataNum, chatTemplate.Num }, + { x => x.DataId, chatTemplate.Id }, + { x => x.DataName, chatTemplate.Name }, + { x => x.DataSystemPrompt, chatTemplate.SystemPrompt }, + { x => x.PredefinedUserPrompt, chatTemplate.PredefinedUserPrompt }, + { x => x.IsEditing, isEditing }, + { x => x.IsReadOnly, isReadOnly }, + { x => x.ExampleConversation, chatTemplate.ExampleConversation }, + { x => x.FileAttachments, chatTemplate.FileAttachments }, + { x => x.AllowProfileUsage, chatTemplate.AllowProfileUsage }, + { x => x.ToolIds, chatTemplate.ToolIds }, + { x => x.DataSourceOptions, chatTemplate.DataSourceOptions }, + }; + private async Task DeleteChatTemplate(ChatTemplate chatTemplate) { var dialogParameters = new DialogParameters diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor index f84a170b4..b7d66a437 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor @@ -1,3 +1,4 @@ +@using AIStudio.Tools.PluginSystem @inherits SettingsDialogBase @@ -64,9 +65,12 @@ } - - @T("Add Profile") - + + + @T("Add Profile") + + + diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor.cs index 531583a05..ef4980e29 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; namespace AIStudio.Dialogs.Settings; @@ -11,11 +12,33 @@ public partial class SettingsDialogProfiles : SettingsDialogBase { x => x.IsEditing, false }, }; + await this.AddProfile(dialogParameters, isImport: false); + } + + private async Task ImportProfile() + { + var import = await ConfigurationSnippetImportDialog.ShowAsync(this.DialogService, PluginConfigurationObjectType.PROFILE, T("Import Profile")); + if (import?.ConfigurationObject is not Profile profile) + return; + + await this.AddProfile(CreateDialogParameters(profile, isEditing: false), isImport: true); + } + + private async Task AddProfile(DialogParameters dialogParameters, bool isImport) + { var dialogReference = await this.DialogService.ShowAsync(T("Add Profile"), dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; + // + // An organization can lock the import while the user is still filling in the form, e.g., + // with a new version of its configuration plugin. Hence, the permission is checked once + // more before the imported profile is stored: + // + if (isImport && !this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.PROFILE)) + return; + var addedProfile = (Profile)dialogResult.Data!; addedProfile = addedProfile with { Num = this.SettingsManager.ConfigurationData.NextProfileNum++ }; @@ -27,17 +50,7 @@ public partial class SettingsDialogProfiles : SettingsDialogBase private async Task EditProfile(Profile profile) { - var dialogParameters = new DialogParameters - { - { x => x.DataNum, profile.Num }, - { x => x.DataId, profile.Id }, - { x => x.DataName, profile.Name }, - { x => x.DataNeedToKnow, profile.NeedToKnow }, - { x => x.DataActions, profile.Actions }, - { x => x.IsEditing, true }, - }; - - var dialogReference = await this.DialogService.ShowAsync(T("Edit Profile"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogReference = await this.DialogService.ShowAsync(T("Edit Profile"), CreateDialogParameters(profile, isEditing: true), DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; @@ -51,20 +64,20 @@ public partial class SettingsDialogProfiles : SettingsDialogBase private async Task ViewProfile(Profile profile) { - var dialogParameters = new DialogParameters - { - { x => x.DataNum, profile.Num }, - { x => x.DataId, profile.Id }, - { x => x.DataName, profile.Name }, - { x => x.DataNeedToKnow, profile.NeedToKnow }, - { x => x.DataActions, profile.Actions }, - { x => x.IsEditing, true }, - { x => x.IsReadOnly, true }, - }; - - await this.DialogService.ShowAsync(T("View Profile"), dialogParameters, DialogOptions.FULLSCREEN); + await this.DialogService.ShowAsync(T("View Profile"), CreateDialogParameters(profile, isEditing: true, isReadOnly: true), DialogOptions.FULLSCREEN); } + private static DialogParameters CreateDialogParameters(Profile profile, bool isEditing, bool isReadOnly = false) => new() + { + { x => x.DataNum, profile.Num }, + { x => x.DataId, profile.Id }, + { x => x.DataName, profile.Name }, + { x => x.DataNeedToKnow, profile.NeedToKnow }, + { x => x.DataActions, profile.Actions }, + { x => x.IsEditing, isEditing }, + { x => x.IsReadOnly, isReadOnly }, + }; + private async Task ExportProfile(Profile profile) { if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) diff --git a/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs index dd463a06e..7d497000d 100644 --- a/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs @@ -89,6 +89,12 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId [Parameter] public bool IsEnterpriseConfiguration { get; set; } + /// + /// The API key an imported transcription provider brought along. When editing, the dialog loads the stored key instead. + /// + [Parameter] + public string ImportedAPIKey { get; set; } = string.Empty; + [Inject] private RustService RustService { get; init; } = null!; @@ -197,6 +203,14 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId await this.ReloadModels(); } + else + { + this.dataAPIKey = this.ImportedAPIKey; + + // An import does not contact the server, so its model is the only one to choose until the user loads the models: + if (!string.IsNullOrWhiteSpace(this.DataModel.Id)) + this.availableModels.Add(this.DataModel); + } } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -346,4 +360,4 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId }; private bool IsNoneProvider => this.DataLLMProvider is LLMProviders.NONE; -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 9baf0bdee..7f8dbf9c5 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -436,6 +436,21 @@ CONFIG["SETTINGS"] = {} -- the AI from reading them, switch off the tool collection mailboxes in DataTools.DisabledToolIds. -- CONFIG["SETTINGS"]["DataApp.AllowUserToAddMailbox"] = false +-- Configure the permission to import an item which somebody exported from AI Studio, such as +-- a chat template a colleague shares. The first setting allows or locks every import, the +-- others one type each; an import needs both. Importing a provider also needs the permission +-- to add it by hand: DataApp.AllowUserToAddProvider and the setting for its kind, see above. +-- When set to false, the import button stays visible but is locked. Importing plugin archives +-- has a setting of its own, see below. +-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportConfigurationSnippets"] = false +-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportProfile"] = false +-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportLLMProvider"] = false +-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportEmbeddingProvider"] = false +-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportTranscriptionProvider"] = false +-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportChatTemplate"] = false +-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportERIDataSource"] = false +-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportDocumentAnalysisPolicy"] = false + -- Configure the user permission to import plugin archives from disk. -- When set to false, the import button on the plugins page stays visible but is disabled. -- CONFIG["SETTINGS"]["DataApp.AllowUserToImportPlugins"] = false @@ -1356,4 +1371,4 @@ CONFIG["PROFILES"] = {} -- ["Name"] = "", -- ["NeedToKnow"] = "I like to cook in my free time. My favorite meal is ...", -- ["Actions"] = "Please always ensure the portion size is ..." --- } +-- } \ No newline at end of file diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 2be934879..f99ec7cc9 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -1122,6 +1122,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Common settings UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1963959073"] = "Allgemeine Einstellungen" +-- Import policy +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1965764758"] = "Regelwerk importieren" + -- Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1984494439"] = "Hinweis: Diese Einstellung wird nur wirksam, wenn dieses Regelwerk exportiert und über ein Konfigurations-Plugin an andere Nutzer verteilt wird. Wenn sie aktiviert ist, sehen Nutzer nur die Oberfläche zur Dokumentauswahl und können die Details des Regelwerks weder anzeigen noch ändern. Diese Einstellung wirkt sich NICHT auf Ihre lokale Ansicht aus – Sie sehen bei von Ihnen erstellten Regelwerken immer die Definition." @@ -4170,6 +4173,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3687976654"] = "Lo -- Not available UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3706935413"] = "Nicht verfügbar" +-- Import Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T381231449"] = "Datenquelle importieren" + -- Export ERI Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3831281036"] = "ERI-Datenquelle exportieren" @@ -5208,6 +5214,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T16449 -- Test Embedding Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1655784761"] = "Anbieter für Einbettung testen" +-- Import Embedding +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1681763001"] = "Einbettung importieren" + -- Add Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1738753945"] = "Einbettung hinzufügen" @@ -5334,6 +5343,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T301987 -- What we call a provider is the combination of an LLM provider such as OpenAI and a model like GPT-6 Luna. You can configure as many providers as you want. This way, you can use the appropriate model for each task. As an LLM provider, you can also choose local providers. However, to use this app, you must configure at least one provider. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3032087963"] = "Was wir als „Anbieter“ bezeichnen, ist die Kombination aus einem LLM-Anbieter wie OpenAI und einem Modell wie GPT-6 Luna. Sie können beliebig viele Anbieter einrichten. So können Sie für jede Aufgabe das passende Modell nutzen. Als LLM-Anbieter können Sie auch lokale Anbieter auswählen. Um diese App zu verwenden, müssen Sie jedoch mindestens einen Anbieter konfigurieren." +-- Import Provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3160005705"] = "Anbieter importieren" + -- Edit UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3267849393"] = "Bearbeiten" @@ -5445,6 +5457,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T58 -- This transcription provider is trusted by your organization for data source security checks. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T601264181"] = "Ihre Organisation vertraut diesem Anbieter für Transkriptionen bei der Sicherheitsprüfung von Datenquellen." +-- Import transcription provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T644492997"] = "Anbieter für Transkriptionen importieren" + -- This transcription provider is managed by your organization. You can set your own API key. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T690752279"] = "Dieser Anbieter für Transkriptionen wird von Ihrer Organisation verwaltet. Sie können Ihren eigenen API-Schlüssel festlegen." @@ -6084,6 +6099,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1548747185"] = "Bitte g -- Yes, this template decides which data a chat starts with UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T17861006"] = "Ja, diese Vorlage legt fest, mit welchen Daten ein Chat startet" +-- Enter the full path of a file on this computer, or remove the attachment. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1798848159"] = "Geben Sie den vollständigen Pfad zu einer Datei auf diesem Computer ein oder entfernen Sie den Anhang." + -- Load predefined user input from file UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1837026610"] = "Vordefinierte Benutzereingabe aus Datei laden" @@ -6096,6 +6114,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T204496403"] = "Der Name -- Profile Usage UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2147062613"] = "Profilnutzung" +-- Relink attachment: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2234793579"] = "Anhang erneut verknüpfen: {0}" + -- Add messages of an example conversation (user prompt followed by assistant prompt) to demonstrate the desired interaction pattern. These examples help the AI understand your expectations by showing it the correct format, style, and content of responses before it receives actual user inputs. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2292424657"] = "Fügen Sie Nachrichten einer Beispiel-Konversation hinzu (Nutzereingabe, gefolgt von einer Antwort des Assistenten), um das gewünschte Interaktionsmuster zu demonstrieren. Diese Beispiele helfen der KI, Ihre Erwartungen zu verstehen, indem Sie das korrekte Format, den Stil und den Inhalt von Antworten zeigen, bevor tatsächliche Nutzereingaben erfolgen." @@ -6138,6 +6159,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2719480499"] = "Die Nac -- Chat Template Name UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T275026390"] = "Name der Chat-Vorlage" +-- Please relink the attachment '{0}' to a file on this computer or remove it before you save. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2884684079"] = "Bitte verknüpfen Sie den Anhang „{0}“ vor dem Speichern erneut mit einer Datei auf diesem Computer oder entfernen Sie ihn." + -- Commit Changes UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2996302517"] = "Änderungen übernehmen" @@ -6204,6 +6228,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4051106111"] = "Verwend -- Tell the AI your predefined user input. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4052406705"] = "Teilen Sie der KI Ihre vordefinierte Benutzereingabe mit." +-- Some attachments of this chat template are not on this computer. Please relink or remove them before you add the template. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4194591607"] = "Einige Anhänge dieser Chat-Vorlage sind auf diesem Computer nicht vorhanden. Bitte verknüpfen Sie sie erneut oder entfernen Sie sie, bevor Sie die Vorlage hinzufügen." + -- Create your custom chat template to tailor the LLM's behavior for specific tasks or domains. Define a custom system prompt and provide an example conversation to design an AI experience perfectly suited to your requirements. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4199560726"] = "Erstellen Sie Ihre eigene Chat-Vorlage, um das Verhalten des LLMs für bestimmte Aufgaben oder Bereiche anzupassen. Definieren Sie einen individuellen System-Prompt und geben Sie eine Beispiel-Konversation vor, um eine KI-Erfahrung zu gestalten, die genau auf Ihre Anforderungen zugeschnitten ist." @@ -6219,6 +6246,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T628396066"] = "System-P -- A chat started with this template begins with these data sources and options. All of it stays changeable in the chat itself. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T650711085"] = "Ein mit dieser Vorlage gestarteter Chat beginnt mit diesen Datenquellen und Optionen. Alles davon lässt sich im Chat selbst weiterhin ändern." +-- Remove this attachment +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T70791272"] = "Diesen Anhang entfernen" + -- The selection stays changeable in the chat, and every tool still has to meet the confidence requirements of the provider in use. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T722788866"] = "Die Auswahl lässt sich im Chat weiterhin ändern, und jedes Werkzeug muss die Vertrauensanforderungen des verwendeten Anbieters erfüllen." @@ -6315,6 +6345,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T767586087" -- {0} embedding provider UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T818101181"] = "{0} Anbieter für Einbettungen" +-- Exported configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T1297128668"] = "Exportierte Konfiguration" + +-- Your organization does not allow importing this type of configuration. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T2349213174"] = "Ihre Organisation erlaubt den Import dieser Konfigurationsart nicht." + +-- Paste a configuration which was exported from AI Studio, for example by a colleague. AI Studio only reads its values and never runs the pasted text. You can review and change everything afterward. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T2598895568"] = "Fügen Sie eine Konfiguration ein, die aus AI Studio exportiert wurde, zum Beispiel von einer Kollegin oder einem Kollegen. AI Studio liest daraus nur die Werte aus und führt den eingefügten Text niemals aus. Anschließend können Sie alles überprüfen und ändern." + +-- Continue anyway +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T2843058989"] = "Trotzdem fortfahren" + +-- Cancel +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T900713019"] = "Abbrechen" + -- No UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T1642511898"] = "Nein" @@ -6453,6 +6498,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T449168182"] = "Verb -- Username UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T470340825"] = "Benutzername" +-- The server does not offer the imported retrieval process '{0}'. Please select another one before you save. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T653836675"] = "Der Server bietet den importierten Abrufprozess „{0}“ nicht an. Bitte wählen Sie vor dem Speichern einen anderen aus." + -- Password UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T750979128"] = "Passwort" @@ -8601,6 +8649,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T23198 -- This chat template preselects data sources which exist on this machine only: {0}. They cannot be rolled out, so a chat started with this template elsewhere begins without them. Do you want to export the template anyway? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T2563631533"] = "Diese Chat-Vorlage wählt Datenquellen vorab aus, die es nur auf diesem Rechner gibt: {0}. Solche Quellen lassen sich nicht bereitstellen; ein Chat, der auf einem anderen Rechner mit dieser Vorlage startet, beginnt daher ohne sie. Möchten Sie die Vorlage trotzdem exportieren?" +-- Import Chat Template +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T263213584"] = "Chat-Vorlage importieren" + -- Chat Template Name UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T275026390"] = "Name der Chat-Vorlage" @@ -8922,6 +8973,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGMYTASKS::T711745239" -- Edit Profile UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T1143111468"] = "Profil bearbeiten" +-- Import Profile +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T1215374025"] = "Profil importieren" + -- No profiles configured yet. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T1433534732"] = "Noch keine Profile eingerichtet." @@ -12576,6 +12630,93 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECUR -- The plugin code changed after the last security audit. The stored result no longer matches the current code, so this assistant plugin must be audited again before it may be enabled or used. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T995107927"] = "Der Plugin-Code wurde nach der letzten Sicherheitsprüfung geändert. Das gespeicherte Ergebnis stimmt nicht mehr mit dem aktuellen Code überein, daher muss dieses Assistenten-Plugin erneut geprüft werden, bevor es aktiviert oder verwendet werden darf." +-- The token or password of the data source cannot be decrypted on this computer. Such data sources can only be imported where the encryption secret of your organization is configured. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T186946778"] = "Das Token oder Passwort der Datenquelle lässt sich auf diesem Computer nicht entschlüsseln. Datenquellen dieser Art können nur auf einem Computer importiert werden, auf dem das Verschlüsselungsgeheimnis Ihrer Organisation eingerichtet ist." + +-- Data sources which sign in with Kerberos cannot be imported yet. Please add them yourself. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T206419407"] = "Datenquellen, die Kerberos zur Anmeldung verwenden, können noch nicht importiert werden. Bitte fügen Sie sie selbst hinzu." + +-- The tokenizer '{0}' is not on this computer, so the provider is imported without it. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T2556276619"] = "Der Tokenizer „{0}“ ist auf diesem Computer nicht vorhanden. Daher wird der Anbieter ohne ihn importiert." + +-- The chat template preselects data sources which do not exist on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T2608225446"] = "Die Chat-Vorlage wählt Datenquellen vorab aus, die auf diesem Computer nicht vorhanden sind: {0}." + +-- The chat template preselects tools which are not available on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T2693686582"] = "Die Chat-Vorlage wählt Werkzeuge vorab aus, die auf diesem Computer nicht verfügbar sind: {0}." + +-- The API key in the pasted configuration cannot be decrypted on this computer. Please enter your API key before you save the provider. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T3589070261"] = "Der API-Schlüssel in der eingefügten Konfiguration kann auf diesem Computer nicht entschlüsselt werden. Bitte geben Sie Ihren API-Schlüssel ein, bevor Sie den Anbieter speichern." + +-- The chat template attaches these files from this computer to every chat started with it: {0}. Please check that you want to share them. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T3702015680"] = "Die Chat-Vorlage fügt jedem damit gestarteten Chat folgende Dateien von diesem Computer hinzu: {0}. Bitte vergewissern Sie sich, dass Sie diese Dateien teilen möchten." + +-- The policy preselects a provider which does not exist on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T3849646238"] = "Das Regelwerk wählt einen Anbieter vorab aus, der auf diesem Computer nicht vorhanden ist: {0}." + +-- The pasted configuration is not a valid configuration of the type '{0}'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T4231596123"] = "Die eingefügte Konfiguration ist keine gültige Konfiguration vom Typ „{0}“." + +-- The policy permits tools which are not available on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T582643438"] = "Das Regelwerk erlaubt Werkzeuge, die auf diesem Computer nicht verfügbar sind: {0}." + +-- The policy preselects a profile which does not exist on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T600255824"] = "Das Regelwerk wählt ein Profil vorab aus, das auf diesem Computer nicht vorhanden ist: {0}." + +-- The pasted configuration contains an API key which is not encrypted, so it was left out. Please enter your API key before you save the provider. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T639320638"] = "Die eingefügte Konfiguration enthält einen unverschlüsselten API-Schlüssel. Dieser wurde daher ausgelassen. Bitte geben Sie Ihren API-Schlüssel ein, bevor Sie den Anbieter speichern." + +-- Expected a quoted string at character {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T1235695745"] = "An Position {0} wurde ein Text in Anführungszeichen erwartet." + +-- The pasted configuration describes the type '{0}', but only the type '{1}' can be imported here. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T1957713080"] = "Die eingefügte Konfiguration beschreibt den Typ „{0}“, aber hier kann nur der Typ „{1}“ importiert werden." + +-- Expected '{0}' at character {1}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2092509274"] = "An Position {1} wurde „{0}“ erwartet." + +-- A quoted string contains a line break, which Lua does not allow. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2204852708"] = "Eine Zeichenfolge in Anführungszeichen enthält einen Zeilenumbruch, der in Lua nicht zulässig ist." + +-- Please paste one exported configuration of at most 1 MB. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2312733854"] = "Bitte fügen Sie eine exportierte Konfiguration mit maximal 1 MB ein." + +-- The pasted configuration does not describe the type '{0}', which can be imported here. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2537838739"] = "Die eingefügte Konfiguration beschreibt nicht den Typ „{0}“, der hier importiert werden kann." + +-- A quoted string is not closed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2729613384"] = "Eine Zeichenfolge in Anführungszeichen ist nicht geschlossen." + +-- The block comment at character {0} is not supported. Please use line comments, which start with -- and end at the end of the line. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2765542892"] = "Der Blockkommentar an Position {0} wird nicht unterstützt. Bitte verwenden Sie Zeilenkommentare, die mit -- beginnen und am Zeilenende enden." + +-- The pasted configuration names two different types of configuration. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2938094482"] = "Die eingefügte Konfiguration gibt zwei unterschiedliche Konfigurationstypen an." + +-- The pasted configuration contains too many nested tables. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3064118429"] = "Die eingefügte Konfiguration enthält zu viele verschachtelte Tabellen." + +-- Expected a comma or closing brace at character {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3466555352"] = "An Position {0} wurde ein Komma oder eine schließende geschweifte Klammer erwartet." + +-- The value nil at character {0} is not supported. Leave the field out instead. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3903177263"] = "Der Wert nil an Position {0} wird nicht unterstützt. Lassen Sie das Feld stattdessen weg." + +-- At character {0}, the pasted configuration contains code. Only plain values such as texts, numbers, true, and false are allowed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3962550160"] = "An Position {0} enthält die eingefügte Konfiguration Code. Erlaubt sind nur einfache Werte wie Texte, Zahlen, true und false." + +-- A long string is not closed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T39989546"] = "Eine lange Zeichenfolge ist nicht geschlossen." + +-- The escape sequence {0} is not supported. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T459116273"] = "Die Escape-Sequenz {0} wird nicht unterstützt." + +-- The field '{0}' occurs more than once. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T560836334"] = "Das Feld „{0}“ kommt mehr als einmal vor." + +-- The pasted configuration must consist of exactly one exported item, without any further code. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T949973671"] = "Die eingefügte Konfiguration muss aus genau einem exportierten Element bestehen und darf keinen weiteren Code enthalten." + -- The table AUTHORS does not exist or is using an invalid syntax. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T1068328139"] = "Die Tabelle AUTHORS existiert nicht oder verwendet eine ungültige Syntax." @@ -12738,6 +12879,30 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATION::T1148682011 -- The CONFIG table does not exist or is not a valid table. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATION::T3331620576"] = "Die Tabelle CONFIG existiert nicht oder ist keine gültige Tabelle." +-- Document Analysis Policy +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T16338172"] = "Regelwerk für die Dokumentenanalyse" + +-- Chat Template +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T2515475479"] = "Chat-Vorlage" + +-- Unknown configuration +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T3189461731"] = "Unbekannte Konfiguration" + +-- Profile +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T3440849550"] = "Profil" + +-- LLM Provider +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T3612415205"] = "LLM-Anbieter" + +-- Transcription Provider +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T4094182084"] = "Transkriptionsanbieter" + +-- Data Source +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T858938618"] = "Datenquelle" + +-- Embedding Provider +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T981047123"] = "Einbettungsanbieter" + -- The field IETF_TAG does not exist or is not a valid string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T1796010240"] = "Das Feld IETF_TAG existiert nicht oder ist keine gültige Zeichenkette." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 9d67e6a87..bf35a5fd1 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -1122,6 +1122,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Common settings UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1963959073"] = "Common settings" +-- Import policy +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1965764758"] = "Import policy" + -- Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1984494439"] = "Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create." @@ -4170,6 +4173,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3687976654"] = "Lo -- Not available UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3706935413"] = "Not available" +-- Import Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T381231449"] = "Import Data Source" + -- Export ERI Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3831281036"] = "Export ERI Data Source" @@ -5208,6 +5214,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T16449 -- Test Embedding Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1655784761"] = "Test Embedding Provider" +-- Import Embedding +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1681763001"] = "Import Embedding" + -- Add Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1738753945"] = "Add Embedding" @@ -5334,6 +5343,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T301987 -- What we call a provider is the combination of an LLM provider such as OpenAI and a model like GPT-6 Luna. You can configure as many providers as you want. This way, you can use the appropriate model for each task. As an LLM provider, you can also choose local providers. However, to use this app, you must configure at least one provider. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3032087963"] = "What we call a provider is the combination of an LLM provider such as OpenAI and a model like GPT-6 Luna. You can configure as many providers as you want. This way, you can use the appropriate model for each task. As an LLM provider, you can also choose local providers. However, to use this app, you must configure at least one provider." +-- Import Provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3160005705"] = "Import Provider" + -- Edit UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3267849393"] = "Edit" @@ -5445,6 +5457,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T58 -- This transcription provider is trusted by your organization for data source security checks. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T601264181"] = "This transcription provider is trusted by your organization for data source security checks." +-- Import transcription provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T644492997"] = "Import transcription provider" + -- This transcription provider is managed by your organization. You can set your own API key. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T690752279"] = "This transcription provider is managed by your organization. You can set your own API key." @@ -6084,6 +6099,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1548747185"] = "Please -- Yes, this template decides which data a chat starts with UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T17861006"] = "Yes, this template decides which data a chat starts with" +-- Enter the full path of a file on this computer, or remove the attachment. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1798848159"] = "Enter the full path of a file on this computer, or remove the attachment." + -- Load predefined user input from file UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1837026610"] = "Load predefined user input from file" @@ -6096,6 +6114,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T204496403"] = "The chat -- Profile Usage UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2147062613"] = "Profile Usage" +-- Relink attachment: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2234793579"] = "Relink attachment: {0}" + -- Add messages of an example conversation (user prompt followed by assistant prompt) to demonstrate the desired interaction pattern. These examples help the AI understand your expectations by showing it the correct format, style, and content of responses before it receives actual user inputs. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2292424657"] = "Add messages of an example conversation (user prompt followed by assistant prompt) to demonstrate the desired interaction pattern. These examples help the AI understand your expectations by showing it the correct format, style, and content of responses before it receives actual user inputs." @@ -6138,6 +6159,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2719480499"] = "The mes -- Chat Template Name UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T275026390"] = "Chat Template Name" +-- Please relink the attachment '{0}' to a file on this computer or remove it before you save. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2884684079"] = "Please relink the attachment '{0}' to a file on this computer or remove it before you save." + -- Commit Changes UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2996302517"] = "Commit Changes" @@ -6204,6 +6228,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4051106111"] = "Use the -- Tell the AI your predefined user input. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4052406705"] = "Tell the AI your predefined user input." +-- Some attachments of this chat template are not on this computer. Please relink or remove them before you add the template. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4194591607"] = "Some attachments of this chat template are not on this computer. Please relink or remove them before you add the template." + -- Create your custom chat template to tailor the LLM's behavior for specific tasks or domains. Define a custom system prompt and provide an example conversation to design an AI experience perfectly suited to your requirements. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4199560726"] = "Create your custom chat template to tailor the LLM's behavior for specific tasks or domains. Define a custom system prompt and provide an example conversation to design an AI experience perfectly suited to your requirements." @@ -6219,6 +6246,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T628396066"] = "System P -- A chat started with this template begins with these data sources and options. All of it stays changeable in the chat itself. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T650711085"] = "A chat started with this template begins with these data sources and options. All of it stays changeable in the chat itself." +-- Remove this attachment +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T70791272"] = "Remove this attachment" + -- The selection stays changeable in the chat, and every tool still has to meet the confidence requirements of the provider in use. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T722788866"] = "The selection stays changeable in the chat, and every tool still has to meet the confidence requirements of the provider in use." @@ -6315,6 +6345,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T767586087" -- {0} embedding provider UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T818101181"] = "{0} embedding provider" +-- Exported configuration +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T1297128668"] = "Exported configuration" + +-- Your organization does not allow importing this type of configuration. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T2349213174"] = "Your organization does not allow importing this type of configuration." + +-- Paste a configuration which was exported from AI Studio, for example by a colleague. AI Studio only reads its values and never runs the pasted text. You can review and change everything afterward. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T2598895568"] = "Paste a configuration which was exported from AI Studio, for example by a colleague. AI Studio only reads its values and never runs the pasted text. You can review and change everything afterward." + +-- Continue anyway +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T2843058989"] = "Continue anyway" + +-- Cancel +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONSNIPPETIMPORTDIALOG::T900713019"] = "Cancel" + -- No UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T1642511898"] = "No" @@ -6453,6 +6498,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T449168182"] = "Fail -- Username UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T470340825"] = "Username" +-- The server does not offer the imported retrieval process '{0}'. Please select another one before you save. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T653836675"] = "The server does not offer the imported retrieval process '{0}'. Please select another one before you save." + -- Password UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T750979128"] = "Password" @@ -8601,6 +8649,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T23198 -- This chat template preselects data sources which exist on this machine only: {0}. They cannot be rolled out, so a chat started with this template elsewhere begins without them. Do you want to export the template anyway? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T2563631533"] = "This chat template preselects data sources which exist on this machine only: {0}. They cannot be rolled out, so a chat started with this template elsewhere begins without them. Do you want to export the template anyway?" +-- Import Chat Template +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T263213584"] = "Import Chat Template" + -- Chat Template Name UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T275026390"] = "Chat Template Name" @@ -8922,6 +8973,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGMYTASKS::T711745239" -- Edit Profile UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T1143111468"] = "Edit Profile" +-- Import Profile +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T1215374025"] = "Import Profile" + -- No profiles configured yet. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T1433534732"] = "No profiles configured yet." @@ -12576,6 +12630,93 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECUR -- The plugin code changed after the last security audit. The stored result no longer matches the current code, so this assistant plugin must be audited again before it may be enabled or used. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T995107927"] = "The plugin code changed after the last security audit. The stored result no longer matches the current code, so this assistant plugin must be audited again before it may be enabled or used." +-- The token or password of the data source cannot be decrypted on this computer. Such data sources can only be imported where the encryption secret of your organization is configured. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T186946778"] = "The token or password of the data source cannot be decrypted on this computer. Such data sources can only be imported where the encryption secret of your organization is configured." + +-- Data sources which sign in with Kerberos cannot be imported yet. Please add them yourself. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T206419407"] = "Data sources which sign in with Kerberos cannot be imported yet. Please add them yourself." + +-- The tokenizer '{0}' is not on this computer, so the provider is imported without it. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T2556276619"] = "The tokenizer '{0}' is not on this computer, so the provider is imported without it." + +-- The chat template preselects data sources which do not exist on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T2608225446"] = "The chat template preselects data sources which do not exist on this computer: {0}." + +-- The chat template preselects tools which are not available on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T2693686582"] = "The chat template preselects tools which are not available on this computer: {0}." + +-- The API key in the pasted configuration cannot be decrypted on this computer. Please enter your API key before you save the provider. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T3589070261"] = "The API key in the pasted configuration cannot be decrypted on this computer. Please enter your API key before you save the provider." + +-- The chat template attaches these files from this computer to every chat started with it: {0}. Please check that you want to share them. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T3702015680"] = "The chat template attaches these files from this computer to every chat started with it: {0}. Please check that you want to share them." + +-- The policy preselects a provider which does not exist on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T3849646238"] = "The policy preselects a provider which does not exist on this computer: {0}." + +-- The pasted configuration is not a valid configuration of the type '{0}'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T4231596123"] = "The pasted configuration is not a valid configuration of the type '{0}'." + +-- The policy permits tools which are not available on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T582643438"] = "The policy permits tools which are not available on this computer: {0}." + +-- The policy preselects a profile which does not exist on this computer: {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T600255824"] = "The policy preselects a profile which does not exist on this computer: {0}." + +-- The pasted configuration contains an API key which is not encrypted, so it was left out. Please enter your API key before you save the provider. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETIMPORT::T639320638"] = "The pasted configuration contains an API key which is not encrypted, so it was left out. Please enter your API key before you save the provider." + +-- Expected a quoted string at character {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T1235695745"] = "Expected a quoted string at character {0}." + +-- The pasted configuration describes the type '{0}', but only the type '{1}' can be imported here. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T1957713080"] = "The pasted configuration describes the type '{0}', but only the type '{1}' can be imported here." + +-- Expected '{0}' at character {1}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2092509274"] = "Expected '{0}' at character {1}." + +-- A quoted string contains a line break, which Lua does not allow. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2204852708"] = "A quoted string contains a line break, which Lua does not allow." + +-- Please paste one exported configuration of at most 1 MB. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2312733854"] = "Please paste one exported configuration of at most 1 MB." + +-- The pasted configuration does not describe the type '{0}', which can be imported here. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2537838739"] = "The pasted configuration does not describe the type '{0}', which can be imported here." + +-- A quoted string is not closed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2729613384"] = "A quoted string is not closed." + +-- The block comment at character {0} is not supported. Please use line comments, which start with -- and end at the end of the line. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2765542892"] = "The block comment at character {0} is not supported. Please use line comments, which start with -- and end at the end of the line." + +-- The pasted configuration names two different types of configuration. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T2938094482"] = "The pasted configuration names two different types of configuration." + +-- The pasted configuration contains too many nested tables. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3064118429"] = "The pasted configuration contains too many nested tables." + +-- Expected a comma or closing brace at character {0}. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3466555352"] = "Expected a comma or closing brace at character {0}." + +-- The value nil at character {0} is not supported. Leave the field out instead. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3903177263"] = "The value nil at character {0} is not supported. Leave the field out instead." + +-- At character {0}, the pasted configuration contains code. Only plain values such as texts, numbers, true, and false are allowed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T3962550160"] = "At character {0}, the pasted configuration contains code. Only plain values such as texts, numbers, true, and false are allowed." + +-- A long string is not closed. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T39989546"] = "A long string is not closed." + +-- The escape sequence {0} is not supported. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T459116273"] = "The escape sequence {0} is not supported." + +-- The field '{0}' occurs more than once. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T560836334"] = "The field '{0}' occurs more than once." + +-- The pasted configuration must consist of exactly one exported item, without any further code. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::CONFIGURATIONSNIPPETPARSER::T949973671"] = "The pasted configuration must consist of exactly one exported item, without any further code." + -- The table AUTHORS does not exist or is using an invalid syntax. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T1068328139"] = "The table AUTHORS does not exist or is using an invalid syntax." @@ -12738,6 +12879,30 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATION::T1148682011 -- The CONFIG table does not exist or is not a valid table. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATION::T3331620576"] = "The CONFIG table does not exist or is not a valid table." +-- Document Analysis Policy +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T16338172"] = "Document Analysis Policy" + +-- Chat Template +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T2515475479"] = "Chat Template" + +-- Unknown configuration +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T3189461731"] = "Unknown configuration" + +-- Profile +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T3440849550"] = "Profile" + +-- LLM Provider +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T3612415205"] = "LLM Provider" + +-- Transcription Provider +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T4094182084"] = "Transcription Provider" + +-- Data Source +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T858938618"] = "Data Source" + +-- Embedding Provider +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINCONFIGURATIONOBJECTTYPEEXTENSIONS::T981047123"] = "Embedding Provider" + -- The field IETF_TAG does not exist or is not a valid string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T1796010240"] = "The field IETF_TAG does not exist or is not a valid string." diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index 252da172a..bb01c20bd 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -1,5 +1,7 @@ using System.Linq.Expressions; +using AIStudio.Tools.PluginSystem; + namespace AIStudio.Settings.DataModel; public sealed class DataApp(Expression>? configSelection = null) @@ -199,6 +201,77 @@ public sealed class DataApp(Expression>? configSelection = n /// public bool AllowUserToAddMailbox { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToAddMailbox, true); + /// + /// Should the user be allowed to import configurations which others exported? + /// + /// + /// The master switch of the import: the switch of each type allows its import only while this + /// one does as well. Use CanImportConfigurationSnippet rather than reading the switches one by one. + /// + public bool AllowUserToImportConfigurationSnippets { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportConfigurationSnippets, true); + + /// + /// Should the user be allowed to import profiles? + /// + public bool AllowUserToImportProfile { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportProfile, true); + + /// + /// Should the user be allowed to import LLM providers? + /// + /// + /// An import adds a provider, so AllowUserToAddProvider and AllowUserToAddLLMProvider must allow it as well. + /// + public bool AllowUserToImportLLMProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportLLMProvider, true); + + /// + /// Should the user be allowed to import embedding providers? + /// + /// + /// An import adds a provider, so AllowUserToAddProvider and AllowUserToAddEmbeddingProvider must allow it as well. + /// + public bool AllowUserToImportEmbeddingProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportEmbeddingProvider, true); + + /// + /// Should the user be allowed to import transcription providers? + /// + /// + /// An import adds a provider, so AllowUserToAddProvider and AllowUserToAddTranscriptionProvider must allow it as well. + /// + public bool AllowUserToImportTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportTranscriptionProvider, true); + + /// + /// Should the user be allowed to import chat templates? + /// + public bool AllowUserToImportChatTemplate { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportChatTemplate, true); + + /// + /// Should the user be allowed to import external data sources (ERI servers)? + /// + public bool AllowUserToImportERIDataSource { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportERIDataSource, true); + + /// + /// Should the user be allowed to import document analysis policies? + /// + public bool AllowUserToImportDocumentAnalysisPolicy { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportDocumentAnalysisPolicy, true); + + /// + /// May the user import a configuration of this type, which somebody else exported? + /// + /// The type of configuration object to import. + /// True when the master switch and the switch of the type allow it, and for providers also the permission to add one. + public bool CanImportConfigurationSnippet(PluginConfigurationObjectType type) => this.AllowUserToImportConfigurationSnippets && type switch + { + PluginConfigurationObjectType.PROFILE => this.AllowUserToImportProfile, + PluginConfigurationObjectType.LLM_PROVIDER => this.AllowUserToImportLLMProvider && this.AllowUserToAddProvider && this.AllowUserToAddLLMProvider, + PluginConfigurationObjectType.EMBEDDING_PROVIDER => this.AllowUserToImportEmbeddingProvider && this.AllowUserToAddProvider && this.AllowUserToAddEmbeddingProvider, + PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => this.AllowUserToImportTranscriptionProvider && this.AllowUserToAddProvider && this.AllowUserToAddTranscriptionProvider, + PluginConfigurationObjectType.CHAT_TEMPLATE => this.AllowUserToImportChatTemplate, + PluginConfigurationObjectType.DATA_SOURCE => this.AllowUserToImportERIDataSource, + PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => this.AllowUserToImportDocumentAnalysisPolicy, + + _ => false, + }; + /// /// Should the user be allowed to import plugin archives from disk? /// @@ -251,4 +324,4 @@ public sealed class DataApp(Expression>? configSelection = n /// List of assistants that should be hidden from the UI. /// public HashSet HiddenAssistants { get; set; } = ManagedConfiguration.Register(configSelection, n => n.HiddenAssistants, []); -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataDocumentAnalysisPolicy.cs b/app/MindWork AI Studio/Settings/DataModel/DataDocumentAnalysisPolicy.cs index d2ee2347a..cb6bb2896 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataDocumentAnalysisPolicy.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataDocumentAnalysisPolicy.cs @@ -1,7 +1,9 @@ using AIStudio.Provider; using AIStudio.Tools.PluginSystem; -using Lua; +using SharedTools; + +using LuaTable = Lua.LuaTable; namespace AIStudio.Settings.DataModel; @@ -177,7 +179,56 @@ public sealed record DataDocumentAnalysisPolicy : ConfigurationBaseObject IsEnterpriseConfiguration = true, EnterpriseConfigurationPluginId = configPluginId, }; - + return true; } + + /// + /// Exports the policy as a Lua configuration section. + /// + /// + /// Unlike the other exports, this one keeps the ID of the policy. + /// + /// A Lua configuration section string. + public string ExportAsConfigurationSection() + { + var preselectedProvider = string.IsNullOrWhiteSpace(this.PreselectedProvider) ? string.Empty : this.PreselectedProvider; + var preselectedProfile = string.IsNullOrWhiteSpace(this.PreselectedProfile) ? string.Empty : this.PreselectedProfile; + var id = string.IsNullOrWhiteSpace(this.Id) ? Guid.NewGuid().ToString() : this.Id; + var allowedToolIds = string.Join(", ", this.AllowedToolIds.OrderBy(x => x, StringComparer.Ordinal).Select(x => LuaTools.ToLuaStringLiteral(x))); + + return $$""" + CONFIG["DOCUMENT_ANALYSIS_POLICIES"][#CONFIG["DOCUMENT_ANALYSIS_POLICIES"]+1] = { + ["Id"] = "{{id}}", + ["PolicyName"] = {{LuaTools.ToLuaStringLiteral(this.PolicyName.Trim())}}, + ["PolicyDescription"] = {{LuaTools.ToLuaStringLiteral(this.PolicyDescription.Trim())}}, + + ["AnalysisRules"] = {{LuaTools.ToLuaStringLiteral(this.AnalysisRules.Trim(), forceLongString: true)}}, + + ["OutputRules"] = {{LuaTools.ToLuaStringLiteral(this.OutputRules.Trim(), forceLongString: true)}}, + + -- Optional: minimum provider confidence required for this policy. + -- Allowed values are: NONE, VERY_LOW, LOW, MODERATE, MEDIUM, HIGH + ["MinimumProviderConfidence"] = "{{this.MinimumProviderConfidence}}", + + -- The tools an analysis with this policy may use, by tool ID. + -- This is a limit, not a preselection: a tool which is not listed here cannot + -- be used for this policy. An empty list means no tools. A listed tool must + -- still meet the confidence requirements of the provider in use. + ["AllowedToolIds"] = { {{allowedToolIds}} }, + + -- Optional: preselect a provider or profile by ID. + -- The IDs must exist in CONFIG["LLM_PROVIDERS"] or CONFIG["PROFILES"]. + ["PreselectedProvider"] = "{{preselectedProvider}}", + ["PreselectedProfile"] = "{{preselectedProfile}}", + + -- Optional: hide the policy definition section in the UI. + -- When set to true, users will only see the document selection interface + -- and cannot view or modify the policy settings. + -- This is useful for enterprise configurations where policy details should remain hidden. + -- Allowed values are: true, false (default: false) + ["HidePolicyDefinition"] = {{this.HidePolicyDefinition.ToString().ToLowerInvariant()}}, + } + """; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs index 333c81350..2abf99fce 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs @@ -24,6 +24,11 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); + /// + /// What an export writes as the shared username when the data source has none to export. + /// + public const string SHARED_USERNAME_PLACEHOLDER = ""; + public DataSourceERI_V1() { } @@ -319,7 +324,7 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource switch (this.AuthMethod) { case AuthMethod.TOKEN: - secretLine = CreateSecretLine("Token", encryptedSecret); + secretLine = CreateSecretLine(GetSecretFieldName(AuthMethod.TOKEN), encryptedSecret); break; case AuthMethod.USERNAME_PASSWORD: @@ -332,13 +337,13 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD) { - var username = string.IsNullOrWhiteSpace(this.Username) ? "" : this.Username; + var username = string.IsNullOrWhiteSpace(this.Username) ? SHARED_USERNAME_PLACEHOLDER : this.Username; usernameLine = $""" ["Username"] = "{LuaTools.EscapeLuaString(username)}", """; } - secretLine = CreateSecretLine("Password", encryptedSecret); + secretLine = CreateSecretLine(GetSecretFieldName(AuthMethod.USERNAME_PASSWORD), encryptedSecret); break; } @@ -360,15 +365,21 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource """; } + /// + /// The field of a configuration which holds the encrypted secret of an auth method. + /// + /// How the data source signs in. + /// The name of the field, or an empty string for an auth method without a secret. + public static string GetSecretFieldName(AuthMethod authMethod) => authMethod switch + { + AuthMethod.TOKEN => "Token", + AuthMethod.USERNAME_PASSWORD => "Password", + _ => string.Empty, + }; + private static bool TryQueueEnterpriseSecret(int idx, LuaTable table, Guid configPluginId, DataSourceERI_V1 dataSource, bool dryRun) { - var secretFieldName = dataSource.AuthMethod switch - { - AuthMethod.TOKEN => "Token", - AuthMethod.USERNAME_PASSWORD => "Password", - _ => string.Empty, - }; - + var secretFieldName = GetSecretFieldName(dataSource.AuthMethod); if (string.IsNullOrWhiteSpace(secretFieldName)) return true; @@ -428,4 +439,4 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource var cleanedHostname = hostname.Trim(); return cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname; } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs index 01842831e..b15504c86 100644 --- a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs +++ b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs @@ -263,7 +263,7 @@ public sealed record EmbeddingProvider( ["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}", ["UsedLLMProvider"] = "{{this.UsedLLMProvider}}", - ["TokenizerPath"] = "{{this.TokenizerPath}}", + ["TokenizerPath"] = "{{LuaTools.EscapeLuaString(this.TokenizerPath)}}", ["TokenLimit"] = {{this.EffectiveTokenLimit}}, ["EmbeddingBatchSize"] = {{this.EffectiveEmbeddingBatchSize}}, @@ -278,4 +278,4 @@ public sealed record EmbeddingProvider( } """; } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/Provider.cs b/app/MindWork AI Studio/Settings/Provider.cs index 77ef5c77f..fdd8dd242 100644 --- a/app/MindWork AI Studio/Settings/Provider.cs +++ b/app/MindWork AI Studio/Settings/Provider.cs @@ -286,7 +286,7 @@ public sealed record Provider( ["InstanceName"] = "{{LuaTools.EscapeLuaString(this.InstanceName)}}", ["UsedLLMProvider"] = "{{this.UsedLLMProvider}}", - ["TokenizerPath"] = "{{this.TokenizerPath}}", + ["TokenizerPath"] = "{{LuaTools.EscapeLuaString(this.TokenizerPath)}}", ["Host"] = "{{this.Host}}", ["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}", @@ -301,4 +301,4 @@ public sealed record Provider( } """; } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/AGENTS.md b/app/MindWork AI Studio/Tools/PluginSystem/AGENTS.md index 5afa3f3d1..a90aa34d7 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/AGENTS.md +++ b/app/MindWork AI Studio/Tools/PluginSystem/AGENTS.md @@ -41,3 +41,27 @@ When adding configuration plugin capabilities: - For managed configuration objects, add the type to `PluginConfigurationObjectType.cs`, persist the objects in the appropriate `ConfigurationData` collection, and add a descriptor to `PluginConfigurationObjectDescriptors` and its list `ALL`: its Lua table, its parser, the collection, the next number, and where secrets are kept. Parsing and cleanup need no extra call; `PluginConfigurationObjectDescriptorsTests` fails for a type without a descriptor. - For live plugin content, add a data type implementing `ILivePluginContent`, parse it in `PluginConfiguration`, expose it through `PluginFactory`, and add any required cleanup only for persistent side data. - Always document the new capability in `app/MindWork AI Studio/Plugins/configuration/plugin.lua`. + +## Importing exported configuration objects + +Users can paste a configuration object which somebody exported, such as a chat template, and keep it as one +of their own. Three parts carry this: + +- `ConfigurationSnippetParser` reads the pasted text as data and never creates a Lua runtime: the text comes + from somebody else, so it earns none of the trust a deployed plugin has. It accepts only what an export + writes, and it reads that the way Lua does. `ConfigurationSnippetParserTests` compares both for every + export; extend the parser only together with that test, and never toward running code. +- `ConfigurationSnippetImport` reads the table through `TryParseSnippet` of the descriptor, i.e., with the + parser a configuration plugin goes through, as a dry run. It then makes the object local: an ID of its own, + no plugin. Do not add a second description of the fields for the import. +- `DataApp.CanImportConfigurationSnippet` decides who may import which type. + +A snippet may name things outside of itself: a file, a server, another configuration object. None of them may +exist on this computer, and none of them may be touched before the user agreed to the import. The importer +therefore looks up no path on a network share and contacts no server; the forms do that once the user asks. + +A new type of configuration object needs a decision on its import: a switch in `DataApp`, its case in +`CanImportConfigurationSnippet`, and its documentation in `plugin.lua`, plus its export in +`ConfigurationSnippetParserTests` and its case in `ConfigurationSnippetImport.TryImport`. The coverage tests in +`DataAppImportPermissionTests`, `ConfigurationSnippetParserTests`, and `ConfigurationSnippetImportTests` fail +for a type without them. \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationBaseObjectDescriptor.cs b/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationBaseObjectDescriptor.cs index 9bed49c6b..160719a0a 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationBaseObjectDescriptor.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationBaseObjectDescriptor.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using AIStudio.Settings.DataModel; @@ -21,10 +22,8 @@ public sealed class ConfigurationBaseObjectDescriptor(PluginConfiguratio /// public override PluginConfigurationObjectType Type => type; - /// - /// The Lua table in which a configuration plugin defines these objects. - /// - public string LuaTableName => luaTableName; + /// + public override string LuaTableName => luaTableName; /// /// Where the settings keep these objects. @@ -44,6 +43,14 @@ public sealed class ConfigurationBaseObjectDescriptor(PluginConfiguratio /// public override bool TryParse(LuaTable mainTable, Guid configPluginId, List configObjects, bool dryRun, string pluginPath) => PluginConfigurationObject.TryParse(this, mainTable, configPluginId, configObjects, dryRun, pluginPath); + /// + public override bool TryParseSnippet(LuaTable table, [NotNullWhen(true)] out IConfigurationObject? configObject) + { + var isValid = parser(1, table, Guid.Empty, string.Empty, dryRun: true, out var parsedObject); + configObject = isValid ? parsedObject : null; + return isValid; + } + /// public override Task CleanLeftOverAsync(IList availablePlugins, IReadOnlySet deployedEnterpriseConfigPluginIds, IReadOnlySet notStartedConfigPluginIds, IList configObjectList) => PluginConfigurationObject.CleanLeftOverConfigurationObjects(type, selection, availablePlugins, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, secretStoreType); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImport.cs b/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImport.cs new file mode 100644 index 000000000..d651a1a8b --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImport.cs @@ -0,0 +1,286 @@ +using System.Diagnostics.CodeAnalysis; + +using AIStudio.Chat; +using AIStudio.Settings; +using AIStudio.Settings.DataModel; +using AIStudio.Tools.ERIClient.DataModel; +using AIStudio.Tools.ToolCallingSystem; + +using Lua; + +namespace AIStudio.Tools.PluginSystem; + +/// +/// Turns a configuration snippet which somebody exported into a configuration object of the user's own. +/// +/// +/// The snippet is read by the same parsers which read configuration plugins, so an import accepts +/// what a configuration plugin accepts, and there is no second description of the fields to keep in +/// step with the first. The parsers run as a dry run, which stores nothing. Afterward, the object is +/// made local: it gets an ID of its own and belongs to no configuration plugin. What the snippet +/// refers to beyond itself, a file or another configuration object, may not exist on this computer; +/// the user hears about it instead of losing the whole snippet. +/// +public static class ConfigurationSnippetImport +{ + private const string FILE_ATTACHMENTS_FIELD = "FileAttachments"; + + private const string API_KEY_FIELD = "APIKey"; + + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ConfigurationSnippetImport).Namespace, nameof(ConfigurationSnippetImport)); + + /// + /// Reads a pasted configuration snippet and makes its configuration object one of the user's own. + /// + /// + /// Secrets are decrypted with the encryption secret of the organization, if this computer has + /// one. What the object refers to in the settings is checked by ResolveReferencesAsync. + /// + /// The pasted snippet. + /// The type of configuration object the snippet must describe. + /// The imported configuration object, when the snippet could be imported. + /// Why the snippet could not be imported, otherwise empty. + /// True when the snippet could be imported. + public static bool TryImport(string snippet, PluginConfigurationObjectType type, [NotNullWhen(true)] out ConfigurationSnippetImportResult? result, out string issue) + { + result = null; + if (!ConfigurationSnippetParser.TryParse(snippet, type, out var table, out issue)) + return false; + + // + // The parser of chat templates looks up every attachment, on a network share as well. Since + // a snippet comes from somebody else, that lookup would reach out to whatever share they + // named as soon as the snippet was pasted. Hence, the attachments are taken out beforehand + // and checked here, without touching a share: + // + var attachmentPaths = type is PluginConfigurationObjectType.CHAT_TEMPLATE ? TakeAttachmentPaths(table) : []; + if (!PluginConfigurationObjectDescriptors.Get(type).TryParseSnippet(table, out var configObject)) + { + issue = DescribeInvalidSnippet(type, table); + return false; + } + + var hints = new List(); + switch (configObject) + { + case DataSourceERI_V1 dataSource: + if (dataSource.AuthMethod is AuthMethod.KERBEROS) + { + issue = TB("Data sources which sign in with Kerberos cannot be imported yet. Please add them yourself."); + return false; + } + + // The parser already decrypted the secret once, so it succeeds here as well: + TryReadSecret(table, DataSourceERI_V1.GetSecretFieldName(dataSource.AuthMethod), out var dataSourceSecret); + result = new(MakeLocal(dataSource), dataSourceSecret, hints, []); + return true; + + // + // A user-provided API key is what an organization grants for a provider it manages. A + // provider of the user's own has nothing else, so the flag carries no meaning here: + // + case Settings.Provider provider: + result = new(MakeLocal(provider) with { TokenizerPath = KeepLocalTokenizer(provider.TokenizerPath, hints), AllowUserProvidedAPIKey = false }, ReadAPIKey(table, hints), hints, []); + return true; + + case EmbeddingProvider provider: + result = new(MakeLocal(provider) with { TokenizerPath = KeepLocalTokenizer(provider.TokenizerPath, hints), AllowUserProvidedAPIKey = false }, ReadAPIKey(table, hints), hints, []); + return true; + + case TranscriptionProvider provider: + result = new(MakeLocal(provider) with { AllowUserProvidedAPIKey = false }, ReadAPIKey(table, hints), hints, []); + return true; + + // + // A snippet may name any file of this computer, a private key just as well as a report. + // Hence, the user gets to see which files the template would attach to every chat: + // + case ChatTemplate template: + var localPaths = attachmentPaths.Where(IsLocalFile).ToList(); + if (localPaths.Count > 0) + hints.Add(string.Format(TB("The chat template attaches these files from this computer to every chat started with it: {0}. Please check that you want to share them."), string.Join(", ", localPaths))); + + result = new(MakeLocal(template) with { FileAttachments = localPaths.Select(FileAttachment.FromPath).ToList() }, string.Empty, hints, attachmentPaths.Except(localPaths).ToList()); + return true; + + case DataDocumentAnalysisPolicy policy: + result = new(MakeLocal(policy) with { IsProtected = false }, string.Empty, hints, []); + return true; + + case Profile profile: + result = new(MakeLocal(profile), string.Empty, hints, []); + return true; + + default: + throw new NotSupportedException($"Configuration objects of the type {type} cannot be imported yet."); + } + } + + /// + /// Checks what an imported configuration object refers to in the settings: the providers, + /// profiles, data sources, and tools it preselects or permits. + /// + /// + /// The selected tools are normalized first, as every selection of tools is. A reference which + /// cannot be found stays in the object, as it would in a configuration plugin: the snippet may + /// stem from a computer which has more, and the user may add what is missing later. + /// + /// What TryImport read from the snippet. + /// The settings in which the references must exist. + /// The tools which are available. + /// The result with the tools normalized and a hint for each kind of missing reference. + public static async Task ResolveReferencesAsync(ConfigurationSnippetImportResult result, SettingsManager settingsManager, ToolRegistry toolRegistry) + { + var hints = new List(result.Hints); + switch (result.ConfigurationObject) + { + case ChatTemplate template: + var templateToolIds = template.ToolIds is null ? null : toolRegistry.NormalizeSelection(template.ToolIds); + var missingDataSourceIds = (template.DataSourceOptions?.PreselectedDataSourceIds ?? []) + .Where(id => settingsManager.ConfigurationData.DataSources.All(dataSource => dataSource.Id != id)) + .ToList(); + + if (missingDataSourceIds.Count > 0) + hints.Add(string.Format(TB("The chat template preselects data sources which do not exist on this computer: {0}."), string.Join(", ", missingDataSourceIds))); + + var missingChatToolIds = await FindMissingToolIdsAsync(templateToolIds ?? [], toolRegistry, Components.CHAT); + if (missingChatToolIds.Count > 0) + hints.Add(string.Format(TB("The chat template preselects tools which are not available on this computer: {0}."), string.Join(", ", missingChatToolIds))); + + return result with { ConfigurationObject = template with { ToolIds = templateToolIds }, Hints = hints }; + + case DataDocumentAnalysisPolicy policy: + var allowedToolIds = toolRegistry.NormalizeSelection(policy.AllowedToolIds); + if (!string.IsNullOrWhiteSpace(policy.PreselectedProvider) && settingsManager.GetProviderById(policy.PreselectedProvider) == Settings.Provider.NONE) + hints.Add(string.Format(TB("The policy preselects a provider which does not exist on this computer: {0}."), policy.PreselectedProvider)); + + if (!string.IsNullOrWhiteSpace(policy.PreselectedProfile) && policy.PreselectedProfile != Profile.NO_PROFILE.Id && settingsManager.ConfigurationData.Profiles.All(profile => profile.Id != policy.PreselectedProfile)) + hints.Add(string.Format(TB("The policy preselects a profile which does not exist on this computer: {0}."), policy.PreselectedProfile)); + + var missingPolicyToolIds = await FindMissingToolIdsAsync(allowedToolIds, toolRegistry, Components.DOCUMENT_ANALYSIS_ASSISTANT); + if (missingPolicyToolIds.Count > 0) + hints.Add(string.Format(TB("The policy permits tools which are not available on this computer: {0}."), string.Join(", ", missingPolicyToolIds))); + + return result with { ConfigurationObject = policy with { AllowedToolIds = allowedToolIds }, Hints = hints }; + + default: + return result; + } + } + + private static async Task> FindMissingToolIdsAsync(IReadOnlyCollection toolIds, ToolRegistry toolRegistry, Components component) + { + if (toolIds.Count == 0) + return []; + + var availableToolIds = (await toolRegistry.GetCatalogAsync(component)).Select(item => item.Id).ToHashSet(StringComparer.Ordinal); + return toolIds.Where(id => !availableToolIds.Contains(id)).Order(StringComparer.Ordinal).ToList(); + } + + private static T MakeLocal(T configObject) where T : ConfigurationBaseObject => (T)((ConfigurationBaseObject)configObject with + { + Id = Guid.NewGuid().ToString(), + Num = 0, + IsEnterpriseConfiguration = false, + EnterpriseConfigurationPluginId = Guid.Empty, + }); + + private static DataSourceERI_V1 MakeLocal(DataSourceERI_V1 dataSource) => dataSource with + { + Id = Guid.NewGuid().ToString(), + Num = 0, + IsEnterpriseConfiguration = false, + EnterpriseConfigurationPluginId = Guid.Empty, + + // Only an organization manages usernames and passwords for its users, so the user manages them here: + UsernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED, + Username = dataSource.Username == DataSourceERI_V1.SHARED_USERNAME_PLACEHOLDER ? string.Empty : dataSource.Username, + }; + + private static List TakeAttachmentPaths(LuaTable table) + { + var paths = new List(); + if (table.TryGetValue(FILE_ATTACHMENTS_FIELD, out var attachmentsValue) && attachmentsValue.TryRead(out var attachments)) + { + for (var index = 1; index <= attachments.ArrayLength; index++) + { + if (attachments[index].TryRead(out var path) && !string.IsNullOrWhiteSpace(path) && !paths.Contains(path)) + paths.Add(path); + } + } + + table[FILE_ATTACHMENTS_FIELD] = LuaValue.Nil; + return paths; + } + + private static string KeepLocalTokenizer(string tokenizerPath, List hints) + { + if (string.IsNullOrWhiteSpace(tokenizerPath) || IsLocalFile(tokenizerPath)) + return tokenizerPath; + + hints.Add(string.Format(TB("The tokenizer '{0}' is not on this computer, so the provider is imported without it."), tokenizerPath)); + return string.Empty; + } + + /// + /// Whether a path names a file on this computer. A path on a network share never does: looking + /// it up would already connect to the share. + /// + private static bool IsLocalFile(string path) => Path.IsPathFullyQualified(path) && !path.StartsWith(@"\\", StringComparison.Ordinal) && !path.StartsWith("//", StringComparison.Ordinal) && File.Exists(path); + + /// + /// Unlike the secret of a data source, an API key is no part of what makes a provider valid, so + /// a key which cannot be read leaves only a hint: the user enters a key of their own instead. + /// + private static string ReadAPIKey(LuaTable table, List hints) + { + var encryptedApiKey = ReadString(table, API_KEY_FIELD); + if (string.IsNullOrWhiteSpace(encryptedApiKey)) + return string.Empty; + + if (!EnterpriseEncryption.IsEncrypted(encryptedApiKey)) + { + hints.Add(TB("The pasted configuration contains an API key which is not encrypted, so it was left out. Please enter your API key before you save the provider.")); + return string.Empty; + } + + if (TryReadSecret(table, API_KEY_FIELD, out var apiKey)) + return apiKey; + + hints.Add(TB("The API key in the pasted configuration cannot be decrypted on this computer. Please enter your API key before you save the provider.")); + return string.Empty; + } + + private static bool TryReadSecret(LuaTable table, string fieldName, out string secret) + { + secret = string.Empty; + var encryptedSecret = ReadString(table, fieldName); + if (!EnterpriseEncryption.IsEncrypted(encryptedSecret)) + return false; + + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable != true || !encryption.TryDecrypt(encryptedSecret, out var decryptedSecret)) + return false; + + secret = decryptedSecret; + return true; + } + + private static string ReadString(LuaTable table, string fieldName) => !string.IsNullOrWhiteSpace(fieldName) && table.TryGetValue(fieldName, out var value) && value.TryRead(out var text) ? text : string.Empty; + + private static string DescribeInvalidSnippet(PluginConfigurationObjectType type, LuaTable table) + { + // + // A data source which signs in with a secret is only valid where its secret can be + // decrypted. That is no fault of the snippet, so the user learns the actual reason: + // + if (type is PluginConfigurationObjectType.DATA_SOURCE && Enum.TryParse(ReadString(table, "AuthMethod"), true, out var authMethod)) + { + var secretFieldName = DataSourceERI_V1.GetSecretFieldName(authMethod); + if (EnterpriseEncryption.IsEncrypted(ReadString(table, secretFieldName)) && !TryReadSecret(table, secretFieldName, out _)) + return TB("The token or password of the data source cannot be decrypted on this computer. Such data sources can only be imported where the encryption secret of your organization is configured."); + } + + return string.Format(TB("The pasted configuration is not a valid configuration of the type '{0}'."), type.GetName()); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImportResult.cs b/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImportResult.cs new file mode 100644 index 000000000..997023770 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetImportResult.cs @@ -0,0 +1,10 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// A configuration object read from a pasted configuration snippet, ready to become one of the user's own. +/// +/// The configuration object, with an ID of its own and belonging to no configuration plugin. +/// The decrypted API key, token, or password which the snippet brought along, or an empty string. +/// What the user should know before adding the object, each as a sentence of its own. +/// The file attachments of a chat template which are not on this computer, as the snippet names them. +public sealed record ConfigurationSnippetImportResult(IConfigurationObject ConfigurationObject, string Secret, IReadOnlyList Hints, IReadOnlyList AttachmentsToRelink); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetParser.cs b/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetParser.cs new file mode 100644 index 000000000..afa9ec1e5 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/ConfigurationSnippetParser.cs @@ -0,0 +1,326 @@ +using System.Globalization; +using System.Text; + +using Lua; + +namespace AIStudio.Tools.PluginSystem; + +/// Reads one exported configuration assignment as data. No Lua state is created. +/// +/// A snippet is text somebody else wrote, pasted by a user who cannot tell what it does. Running it, +/// even in the sandbox of the plugins, would let it do whatever Lua allows: loop forever, fill the +/// memory, or compute its values from anything within reach. Configuration plugins may run code +/// because an organization deploys them; a snippet earns no such trust. This parser therefore +/// accepts only what an export writes: one assignment of a table made of strings, numbers, +/// booleans, and tables, with comments in between. Whatever it accepts, it reads as Lua would, so a +/// snippet means the same here as in a configuration plugin. Anything else is refused, never +/// guessed: a construct which Lua would read differently must not slip through as data. +/// +public sealed class ConfigurationSnippetParser +{ + private readonly string source; + private int position; + + private ConfigurationSnippetParser(string source) => this.source = source; + + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ConfigurationSnippetParser).Namespace, nameof(ConfigurationSnippetParser)); + + /// + /// Reads a snippet of the form CONFIG["SECTION"][#CONFIG["SECTION"]+1] = { ... }. + /// + /// The pasted snippet. + /// The type of configuration object the snippet must describe. + /// The table the snippet assigns, when it could be read. + /// Why the snippet could not be read, otherwise empty. + /// True when the snippet could be read. + public static bool TryParse(string source, PluginConfigurationObjectType type, out LuaTable table, out string issue) + { + table = new LuaTable(); + issue = string.Empty; + if (string.IsNullOrWhiteSpace(source) || source.Length > 1_000_000) + { + issue = TB("Please paste one exported configuration of at most 1 MB."); + return false; + } + + try + { + var expectedSection = PluginConfigurationObjectDescriptors.Get(type).LuaTableName; + var parser = new ConfigurationSnippetParser(source); + parser.ExpectWord("CONFIG"); + var section = parser.ReadBracketedString(); + if (section != expectedSection) + throw new FormatException(PluginConfigurationObjectDescriptors.TryGet(section, out var otherDescriptor) + ? string.Format(TB("The pasted configuration describes the type '{0}', but only the type '{1}' can be imported here."), otherDescriptor.Type.GetName(), type.GetName()) + : string.Format(TB("The pasted configuration does not describe the type '{0}', which can be imported here."), type.GetName())); + + parser.Expect('['); + parser.Expect('#'); + parser.ExpectWord("CONFIG"); + if (parser.ReadBracketedString() != expectedSection) + throw new FormatException(TB("The pasted configuration names two different types of configuration.")); + parser.Expect('+'); + parser.Expect('1'); + parser.Expect(']'); + parser.Expect('='); + table = parser.ReadTable(0); + parser.SkipTrivia(); + if (parser.position != source.Length) + throw new FormatException(TB("The pasted configuration must consist of exactly one exported item, without any further code.")); + return true; + } + catch (FormatException exception) + { + issue = exception.Message; + return false; + } + } + + private LuaTable ReadTable(int depth) + { + if (depth > 32) + throw new FormatException(TB("The pasted configuration contains too many nested tables.")); + this.Expect('{'); + var table = new LuaTable(); + var arrayIndex = 1; + var names = new HashSet(StringComparer.Ordinal); + while (true) + { + this.SkipTrivia(); + if (this.Take('}')) + return table; + + // "[[" or "[==[" opens a long string, which is an array value rather than a bracketed key: + if (this.Peek() == '[' && !this.IsLongStringStart()) + { + this.position++; + var key = this.ReadString(); + this.Expect(']'); + this.Expect('='); + if (!names.Add(key)) + throw new FormatException(string.Format(TB("The field '{0}' occurs more than once."), key)); + table[key] = this.ReadValue(depth + 1); + } + else + table[arrayIndex++] = this.ReadValue(depth + 1); + + this.SkipTrivia(); + if (this.Take('}')) + return table; + if (!this.Take(',') && !this.Take(';')) + throw new FormatException(string.Format(TB("Expected a comma or closing brace at character {0}."), this.position + 1)); + } + } + + private LuaValue ReadValue(int depth) + { + this.SkipTrivia(); + if (this.Peek() == '{') + return this.ReadTable(depth); + if (this.Peek() is '"' or '\'' || this.Peek() == '[') + return this.ReadString(); + if (this.TakeWord("true")) + return true; + if (this.TakeWord("false")) + return false; + + // In Lua, nil leaves a field out, and in a list it ends what the parsers read of it: + if (this.TakeWord("nil")) + throw new FormatException(string.Format(TB("The value nil at character {0} is not supported. Leave the field out instead."), this.position - 2)); + + var start = this.position; + if (this.Peek() == '-') + this.position++; + while (char.IsAsciiDigit(this.Peek())) + this.position++; + if (this.Peek() == '.') + { + this.position++; + while (char.IsAsciiDigit(this.Peek())) + this.position++; + } + if (this.position > start && double.TryParse(this.source[start..this.position], NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && double.IsFinite(number)) + return number; + throw new FormatException(string.Format(TB("At character {0}, the pasted configuration contains code. Only plain values such as texts, numbers, true, and false are allowed."), start + 1)); + } + + private bool IsLongStringStart() => this.IsLongBracketAt(this.position); + + /// + /// Whether a long bracket opens at this index: '[', any number of '=', and '[' again. + /// + private bool IsLongBracketAt(int index) + { + if (index >= this.source.Length || this.source[index] != '[') + return false; + + index++; + while (index < this.source.Length && this.source[index] == '=') + index++; + + return index < this.source.Length && this.source[index] == '['; + } + + /// + /// Skips one line break, which Lua counts as one whether it is \n, \r, \r\n, or \n\r. + /// + private void SkipLineBreak() + { + var first = this.Peek(); + if (first is not ('\r' or '\n')) + return; + + this.position++; + var second = this.Peek(); + if (second is '\r' or '\n' && second != first) + this.position++; + } + + /// + /// Turns every line break of a long string into \n, as Lua does. + /// + private static string NormalizeLineBreaks(string text) + { + if (!text.Contains('\r')) + return text; + + var builder = new StringBuilder(text.Length); + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c is not ('\r' or '\n')) + { + builder.Append(c); + continue; + } + + builder.Append('\n'); + if (i + 1 < text.Length && text[i + 1] is '\r' or '\n' && text[i + 1] != c) + i++; + } + + return builder.ToString(); + } + + private string ReadBracketedString() + { + this.Expect('['); + var result = this.ReadString(); + this.Expect(']'); + return result; + } + + private string ReadString() + { + this.SkipTrivia(); + var quote = this.Peek(); + if (quote == '[') + { + this.position++; + var equalsStart = this.position; + while (this.Peek() == '=') + this.position++; + var equals = this.source[equalsStart..this.position]; + if (this.Peek() != '[') + throw new FormatException(string.Format(TB("Expected '{0}' at character {1}."), '[', this.position + 1)); + this.position++; + + // Like Lua, a line break right after the opening bracket is not part of the string: + this.SkipLineBreak(); + var end = this.source.IndexOf("]" + equals + "]", this.position, StringComparison.Ordinal); + if (end < 0) + throw new FormatException(TB("A long string is not closed.")); + var value = NormalizeLineBreaks(this.source[this.position..end]); + this.position = end + equals.Length + 2; + return value; + } + if (quote is not ('"' or '\'')) + throw new FormatException(string.Format(TB("Expected a quoted string at character {0}."), this.position + 1)); + this.position++; + var builder = new StringBuilder(); + while (this.position < this.source.Length) + { + var c = this.source[this.position++]; + if (c == quote) + return builder.ToString(); + if (c is '\r' or '\n') + throw new FormatException(TB("A quoted string contains a line break, which Lua does not allow.")); + if (c != '\\') + { + builder.Append(c); + continue; + } + if (this.position == this.source.Length) + break; + c = this.source[this.position++]; + builder.Append(c switch + { + 'n' => '\n', 'r' => '\r', 't' => '\t', 'a' => '\a', 'b' => '\b', 'f' => '\f', 'v' => '\v', + '\\' => '\\', '"' => '"', '\'' => '\'', + _ => throw new FormatException(string.Format(TB("The escape sequence {0} is not supported."), "\\" + c)), + }); + } + throw new FormatException(TB("A quoted string is not closed.")); + } + + private void SkipTrivia() + { + while (this.position < this.source.Length) + { + if (char.IsWhiteSpace(this.source[this.position])) + { + this.position++; + continue; + } + if (this.source.AsSpan(this.position).StartsWith("--")) + { + // + // A block comment may end in the middle of a line and continue with data, which a + // line comment would swallow. Rather than reading it differently than Lua does, it + // is refused: + // + if (this.IsLongBracketAt(this.position + 2)) + throw new FormatException(string.Format(TB("The block comment at character {0} is not supported. Please use line comments, which start with -- and end at the end of the line."), this.position + 1)); + + this.position += 2; + while (this.position < this.source.Length && this.source[this.position] != '\n') + this.position++; + continue; + } + break; + } + } + + private char Peek() => this.position < this.source.Length ? this.source[this.position] : '\0'; + + private bool Take(char c) + { + this.SkipTrivia(); + if (this.Peek() != c) + return false; + this.position++; + return true; + } + + private void Expect(char c) + { + if (!this.Take(c)) + throw new FormatException(string.Format(TB("Expected '{0}' at character {1}."), c, this.position + 1)); + } + + private bool TakeWord(string word) + { + this.SkipTrivia(); + if (!this.source.AsSpan(this.position).StartsWith(word) || + (this.position + word.Length < this.source.Length && (char.IsLetterOrDigit(this.source[this.position + word.Length]) || this.source[this.position + word.Length] == '_'))) + return false; + this.position += word.Length; + return true; + } + + private void ExpectWord(string word) + { + if (!this.TakeWord(word)) + throw new FormatException(string.Format(TB("Expected '{0}' at character {1}."), word, this.position + 1)); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/DataSourceConfigurationObjectDescriptor.cs b/app/MindWork AI Studio/Tools/PluginSystem/DataSourceConfigurationObjectDescriptor.cs index 126b46b06..0794db593 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/DataSourceConfigurationObjectDescriptor.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/DataSourceConfigurationObjectDescriptor.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + using AIStudio.Settings.DataModel; using Lua; @@ -18,7 +20,18 @@ public sealed class DataSourceConfigurationObjectDescriptor : PluginConfiguratio public override PluginConfigurationObjectType Type => PluginConfigurationObjectType.DATA_SOURCE; /// - public override bool TryParse(LuaTable mainTable, Guid configPluginId, List configObjects, bool dryRun, string pluginPath) => PluginConfigurationObject.TryParseDataSources(mainTable, configPluginId, configObjects, dryRun); + public override string LuaTableName => "DATA_SOURCES"; + + /// + public override bool TryParse(LuaTable mainTable, Guid configPluginId, List configObjects, bool dryRun, string pluginPath) => PluginConfigurationObject.TryParseDataSources(this, mainTable, configPluginId, configObjects, dryRun); + + /// + public override bool TryParseSnippet(LuaTable table, [NotNullWhen(true)] out IConfigurationObject? configObject) + { + var isValid = DataSourceERI_V1.TryParseConfiguration(1, table, Guid.Empty, dryRun: true, out var dataSource); + configObject = isValid ? dataSource : null; + return isValid; + } /// /// diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 3bd75a98a..6b592716d 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -263,6 +263,30 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Config: allow the user to add mailboxes? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddMailbox, this.Id, settingsTable, dryRun); + // Config: allow the user to import configurations which others exported? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportConfigurationSnippets, this.Id, settingsTable, dryRun); + + // Config: allow the user to import profiles? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportProfile, this.Id, settingsTable, dryRun); + + // Config: allow the user to import LLM providers? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportLLMProvider, this.Id, settingsTable, dryRun); + + // Config: allow the user to import embedding providers? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportEmbeddingProvider, this.Id, settingsTable, dryRun); + + // Config: allow the user to import transcription providers? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportTranscriptionProvider, this.Id, settingsTable, dryRun); + + // Config: allow the user to import chat templates? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportChatTemplate, this.Id, settingsTable, dryRun); + + // Config: allow the user to import external data sources (ERI servers)? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportERIDataSource, this.Id, settingsTable, dryRun); + + // Config: allow the user to import document analysis policies? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportDocumentAnalysisPolicy, this.Id, settingsTable, dryRun); + // Config: allow the user to import plugin archives? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportPlugins, this.Id, settingsTable, dryRun); @@ -819,4 +843,4 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT LOG.LogWarning("The table 'MAILBOX_PROVIDERS' entry at index {Index} does not contain a valid mail server (config plugin id: {ConfigPluginId}).", i, this.Id); } } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index e04a48fdc..d481e5910 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -282,17 +282,18 @@ public sealed record PluginConfigurationObject /// /// Parses configured data sources from a configuration plugin. /// + /// The descriptor of the data sources. /// The Lua table containing entries to parse into data sources. /// The unique identifier of the plugin associated with the data sources. /// The list to populate with the parsed configuration objects. /// Specifies whether to perform the operation as a dry run. /// True if the table was present and processed; otherwise false. - public static bool TryParseDataSources(LuaTable mainTable, Guid configPluginId, List configObjects, bool dryRun) + public static bool TryParseDataSources(DataSourceConfigurationObjectDescriptor descriptor, LuaTable mainTable, Guid configPluginId, List configObjects, bool dryRun) { - const string LUA_TABLE_NAME = "DATA_SOURCES"; - if (!mainTable.TryGetValue(LUA_TABLE_NAME, out var luaValue) || !luaValue.TryRead(out var luaTable)) + var luaTableName = descriptor.LuaTableName; + if (!mainTable.TryGetValue(luaTableName, out var luaValue) || !luaValue.TryRead(out var luaTable)) { - LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, configPluginId); + LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, configPluginId); return false; } @@ -305,13 +306,13 @@ public sealed record PluginConfigurationObject var luaObjectTableValue = luaTable[i]; if (!luaObjectTableValue.TryRead(out var luaObjectTable)) { - LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId); + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, i, configPluginId); continue; } if (!DataSourceERI_V1.TryParseConfiguration(i, luaObjectTable, configPluginId, dryRun, out var configObject)) { - LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid data source (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId); + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid data source (config plugin id: {ConfigPluginId}).", luaTableName, i, configPluginId); continue; } @@ -653,4 +654,4 @@ public sealed record PluginConfigurationObject var candidatePath = Path.GetFullPath(Path.Combine(fullPluginPath, configuredTokenizerPath)); return PathTools.IsInsideDirectory(fullPluginPath, candidatePath) ? candidatePath : null; } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectDescriptor.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectDescriptor.cs index 263e24cec..dbe6e2a2c 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectDescriptor.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectDescriptor.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + using AIStudio.Settings.DataModel; using Lua; @@ -22,6 +24,11 @@ public abstract class PluginConfigurationObjectDescriptor /// public abstract PluginConfigurationObjectType Type { get; } + /// + /// The Lua table in which a configuration plugin defines these objects, e.g., LLM_PROVIDERS. + /// + public abstract string LuaTableName { get; } + /// /// Reads the configuration objects of this type which a configuration plugin defines, and stores /// them in the settings unless the plugin is only checked. @@ -34,6 +41,19 @@ public abstract class PluginConfigurationObjectDescriptor /// True when the plugin defines a table for this type. public abstract bool TryParse(LuaTable mainTable, Guid configPluginId, List configObjects, bool dryRun, string pluginPath); + /// + /// Reads the configuration object of a configuration snippet, which belongs to no configuration + /// plugin. It is read as a dry run, so neither the object nor its secret is stored. + /// + /// + /// Without the directory of a plugin, a path relative to it cannot be resolved, so the parsers + /// leave out what such a path points to, e.g., a custom icon. + /// + /// The table which the snippet assigns. + /// The configuration object read from the table. + /// True when the table defines a valid configuration object. + public abstract bool TryParseSnippet(LuaTable table, [NotNullWhen(true)] out IConfigurationObject? configObject); + /// /// Removes the configuration objects of this type which belong to a configuration plugin that is /// gone or no longer defines them, along with their secrets and tokenizers. diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectDescriptors.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectDescriptors.cs index 02de9755c..d7d824215 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectDescriptors.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectDescriptors.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + using AIStudio.Settings; using AIStudio.Settings.DataModel; @@ -43,6 +45,26 @@ public static class PluginConfigurationObjectDescriptors DOCUMENT_ANALYSIS_POLICY, ]; + /// + /// The descriptor of a type of configuration object. + /// + /// The type, which must not be NONE or UNKNOWN. + /// The descriptor of the type. + public static PluginConfigurationObjectDescriptor Get(PluginConfigurationObjectType type) => + ALL.FirstOrDefault(descriptor => descriptor.Type == type) ?? throw new ArgumentOutOfRangeException(nameof(type), type, "This type of configuration object has no descriptor."); + + /// + /// Finds the descriptor of the configuration objects which a Lua table holds. + /// + /// The name of the Lua table, e.g., LLM_PROVIDERS. + /// The descriptor, when one uses this table. + /// True when a descriptor uses this table. + public static bool TryGet(string luaTableName, [NotNullWhen(true)] out PluginConfigurationObjectDescriptor? descriptor) + { + descriptor = ALL.FirstOrDefault(candidate => candidate.LuaTableName == luaTableName); + return descriptor is not null; + } + private static bool ParseLLMProvider(int index, LuaTable table, Guid configPluginId, string pluginPath, bool dryRun, out ConfigurationBaseObject configObject) => Settings.Provider.TryParseProviderTable(index, table, configPluginId, pluginPath, dryRun, out configObject) && configObject != Settings.Provider.NONE; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectTypeExtensions.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectTypeExtensions.cs new file mode 100644 index 000000000..7a94e1eb3 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectTypeExtensions.cs @@ -0,0 +1,22 @@ +namespace AIStudio.Tools.PluginSystem; + +public static class PluginConfigurationObjectTypeExtensions +{ + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfigurationObjectTypeExtensions).Namespace, nameof(PluginConfigurationObjectTypeExtensions)); + + /// + /// The name of a type of configuration object, as users read it, e.g., in a message. + /// + public static string GetName(this PluginConfigurationObjectType type) => type switch + { + PluginConfigurationObjectType.LLM_PROVIDER => TB("LLM Provider"), + PluginConfigurationObjectType.EMBEDDING_PROVIDER => TB("Embedding Provider"), + PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => TB("Transcription Provider"), + PluginConfigurationObjectType.PROFILE => TB("Profile"), + PluginConfigurationObjectType.CHAT_TEMPLATE => TB("Chat Template"), + PluginConfigurationObjectType.DATA_SOURCE => TB("Data Source"), + PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => TB("Document Analysis Policy"), + + _ => TB("Unknown configuration"), + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md index e5dda572e..bfe35b819 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md @@ -13,6 +13,10 @@ - Added backups of all your data. Under Data Backup & Restore, on the information page and in the settings, you save your chats, settings, plugins, and the index of your data sources into a single file, for example, to take everything to a new computer. Restoring such a backup replaces your data when AI Studio restarts, or you add only its chats to the ones you have. API keys and passwords never go into a backup, and after a restore, AI Studio tells you where to enter them again. Thanks, Jens Erler (`j-erler`), for this contribution. - Added organization-wide management for backups. IT departments can prevent users from backing up their data or from restoring a backup. A backup leaves out what the configurations of your organization bring along, such as their providers and plugins, so nothing appears twice after a restore. - Added an overview of the data folders of AI Studio to the information page, for when you back up your computer with a tool of your own. It shows where the folders are and which of their contents belong into a backup. AI Studio now stores the locations of its own files, such as transcripts, relative to its data folder, so such a copy also works on a computer with another user name. +- Added a way to import providers, profiles, chat templates, external data sources (ERI servers), and document analysis policies which somebody exported from AI Studio, for example, a colleague who wants to share a chat template with you. The new Import button next to the Add button lets you paste the exported configuration. AI Studio only reads the pasted values and never runs the pasted text, and what you import becomes your own, so you can change it like anything you added yourself. Thanks, Peer Hogeterp (`peerschuett`), for this contribution. +- Added hints when you import a configuration which somebody exported from AI Studio, for example, when it preselects a provider or a tool which does not exist on your computer, or when a chat template would attach files from your computer to every chat. You decide whether to continue. +- Added a way to relink or remove the file attachments which are missing on your computer when you import a chat template which somebody exported from AI Studio. +- Added organization-wide management for importing configurations which somebody exported from AI Studio. IT departments can allow or prevent such imports, either altogether or for each kind, such as providers or chat templates. A locked Import button stays visible. - Improved the Read Web Page tool. When you have not allowed the AI to choose web addresses freely, the tool now refuses every address that does not appear in your chat or in what the tools returned, instead of only asking the AI not to read it. - Improved the installation on macOS: AI Studio is now signed and checked by Apple, so it opens like any other app. You no longer need the Terminal. - Improved the installation on Windows: AI Studio is now signed, so Windows shows who published it. For a while, Windows may still ask you to confirm the installation until it knows the app better. @@ -61,6 +65,7 @@ - Fixed the security notice about removed instructions (prompt injection) appearing only minutes later while AI Studio updated a data source in the background. The notice also mixed your own attachments with files of the data source. Now each notice appears as soon as AI Studio has read the content, for example, the attachments of your message, and it names only that content. - Fixed the security notice about removed instructions (prompt injection) appearing several times for one answer when AI Studio checks which passages of your data sources fit your question: once for every passage it checked, and once more for all of them together. You now get one notice per answer, and a file counts once, however many of its passages were affected. - Fixed the example configuration for IT departments, which spelled llama.cpp in a way AI Studio does not accept. AI Studio ignored a self-hosted provider set up with that spelling. The example now also names only the self-hosted servers which can create embeddings or transcribe audio. +- Fixed exporting an LLM provider or an embedding provider with a tokenizer on Windows. The path of the tokenizer broke the exported configuration, so a configuration plugin containing it could not be loaded. - Fixed building AI Studio from source on Windows, where the checks that run before every build failed because two of them expected the line breaks and file paths of macOS and Linux. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution. - Fixed a problem on Windows where AI Studio could fail to read the configuration of your organization when the name of an environment variable on your computer contained umlauts or other special characters. - Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far. diff --git a/app/Tests/Settings/DataAppImportPermissionTests.cs b/app/Tests/Settings/DataAppImportPermissionTests.cs new file mode 100644 index 000000000..df3e4390d --- /dev/null +++ b/app/Tests/Settings/DataAppImportPermissionTests.cs @@ -0,0 +1,130 @@ +using AIStudio.Settings.DataModel; +using AIStudio.Tools.PluginSystem; + +namespace AIStudio.Tests.Settings; + +/// +/// Checks which switches decide whether the user may import a configuration somebody else exported. +/// +/// +/// An organization that switches off adding providers must not see them come back through an +/// import, and the master switch must close every type at once. Both are easy to lose when a new +/// type joins, which is why every type is checked against every switch. +/// +[TestFixture] +public sealed class DataAppImportPermissionTests +{ + private static readonly PluginConfigurationObjectType[] IMPORTABLE_TYPES = + [ + PluginConfigurationObjectType.LLM_PROVIDER, + PluginConfigurationObjectType.EMBEDDING_PROVIDER, + PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, + PluginConfigurationObjectType.PROFILE, + PluginConfigurationObjectType.CHAT_TEMPLATE, + PluginConfigurationObjectType.DATA_SOURCE, + PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, + ]; + + [TestCaseSource(nameof(IMPORTABLE_TYPES))] + public void EveryTypeMayBeImportedByDefault(PluginConfigurationObjectType type) => Assert.That(new DataApp().CanImportConfigurationSnippet(type), Is.True); + + [TestCaseSource(nameof(IMPORTABLE_TYPES))] + public void TheMasterSwitchLocksEveryType(PluginConfigurationObjectType type) => Assert.That(new DataApp { AllowUserToImportConfigurationSnippets = false }.CanImportConfigurationSnippet(type), Is.False); + + [TestCaseSource(nameof(IMPORTABLE_TYPES))] + public void TheSwitchOfATypeLocksThatTypeOnly(PluginConfigurationObjectType type) + { + var app = new DataApp(); + SwitchOff(app, type); + + Assert.Multiple(() => + { + foreach (var other in IMPORTABLE_TYPES) + Assert.That(app.CanImportConfigurationSnippet(other), Is.EqualTo(other != type), $"Switched off: {type}, checked: {other}"); + }); + } + + [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)] + [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)] + [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)] + public void AProviderMayOnlyBeImportedWhereItMayBeAdded(PluginConfigurationObjectType type) + { + var withoutAnyProvider = new DataApp { AllowUserToAddProvider = false }; + var withoutThisProvider = new DataApp + { + AllowUserToAddLLMProvider = type is not PluginConfigurationObjectType.LLM_PROVIDER, + AllowUserToAddEmbeddingProvider = type is not PluginConfigurationObjectType.EMBEDDING_PROVIDER, + AllowUserToAddTranscriptionProvider = type is not PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, + }; + + Assert.Multiple(() => + { + Assert.That(withoutAnyProvider.CanImportConfigurationSnippet(type), Is.False, "No provider may be added at all."); + Assert.That(withoutThisProvider.CanImportConfigurationSnippet(type), Is.False, "This type of provider may not be added."); + }); + } + + [Test] + public void AddingProvidersHasNoSayOverTheOtherTypes() + { + var app = new DataApp + { + AllowUserToAddProvider = false, + AllowUserToAddLLMProvider = false, + AllowUserToAddEmbeddingProvider = false, + AllowUserToAddTranscriptionProvider = false, + }; + + Assert.Multiple(() => + { + Assert.That(app.CanImportConfigurationSnippet(PluginConfigurationObjectType.PROFILE), Is.True); + Assert.That(app.CanImportConfigurationSnippet(PluginConfigurationObjectType.CHAT_TEMPLATE), Is.True); + Assert.That(app.CanImportConfigurationSnippet(PluginConfigurationObjectType.DATA_SOURCE), Is.True); + Assert.That(app.CanImportConfigurationSnippet(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), Is.True); + }); + } + + [TestCase(PluginConfigurationObjectType.NONE)] + [TestCase(PluginConfigurationObjectType.UNKNOWN)] + public void NoneAndUnknownAreNeverImported(PluginConfigurationObjectType type) => Assert.That(new DataApp().CanImportConfigurationSnippet(type), Is.False); + + [Test] + public void EveryTypeOfConfigurationObjectIsCovered() => Assert.That(IMPORTABLE_TYPES, Is.EquivalentTo(PluginConfigurationObjectDescriptors.ALL.Select(descriptor => descriptor.Type)), "A new type of configuration object needs a decision whether it may be imported, and its switch here."); + + private static void SwitchOff(DataApp app, PluginConfigurationObjectType type) + { + switch (type) + { + case PluginConfigurationObjectType.LLM_PROVIDER: + app.AllowUserToImportLLMProvider = false; + break; + + case PluginConfigurationObjectType.EMBEDDING_PROVIDER: + app.AllowUserToImportEmbeddingProvider = false; + break; + + case PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER: + app.AllowUserToImportTranscriptionProvider = false; + break; + + case PluginConfigurationObjectType.PROFILE: + app.AllowUserToImportProfile = false; + break; + + case PluginConfigurationObjectType.CHAT_TEMPLATE: + app.AllowUserToImportChatTemplate = false; + break; + + case PluginConfigurationObjectType.DATA_SOURCE: + app.AllowUserToImportERIDataSource = false; + break; + + case PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY: + app.AllowUserToImportDocumentAnalysisPolicy = false; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(type), type, "This test knows no switch for this type."); + } + } +} \ No newline at end of file diff --git a/app/Tests/Tools/PluginSystem/ConfigurationSnippetImportTests.cs b/app/Tests/Tools/PluginSystem/ConfigurationSnippetImportTests.cs new file mode 100644 index 000000000..6634ac43a --- /dev/null +++ b/app/Tests/Tools/PluginSystem/ConfigurationSnippetImportTests.cs @@ -0,0 +1,609 @@ +using AIStudio.Chat; +using AIStudio.Provider; +using AIStudio.Settings; +using AIStudio.Settings.DataModel; +using AIStudio.Tests.Tools.ToolCalling; +using AIStudio.Tools; +using AIStudio.Tools.PluginSystem; + +using Microsoft.Extensions.Logging.Abstractions; + +using AuthMethod = AIStudio.Tools.ERIClient.DataModel.AuthMethod; +using Host = AIStudio.Provider.SelfHosted.Host; + +namespace AIStudio.Tests.Tools.PluginSystem; + +/// +/// Checks that an imported configuration snippet becomes a configuration object of the user's own, +/// which keeps what the export wrote and leaves out what does not exist on this computer. +/// +/// +/// The exports encrypt their secrets with an encryption secret of the test. PluginFactory holds it +/// for the whole process, as the app does, so this fixture must not run in parallel with others. +/// Nothing here asserts on the wording of a hint or an issue, see ConfigurationSnippetParserTests. +/// +[TestFixture] +[NonParallelizable] +public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase +{ + private const string API_KEY = "sk-imported-key"; + + private const string TOKEN = "eri-imported-token"; + + private static readonly PluginConfigurationObjectType[] IMPORTABLE_TYPES = + [ + PluginConfigurationObjectType.LLM_PROVIDER, + PluginConfigurationObjectType.EMBEDDING_PROVIDER, + PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, + PluginConfigurationObjectType.PROFILE, + PluginConfigurationObjectType.CHAT_TEMPLATE, + PluginConfigurationObjectType.DATA_SOURCE, + PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, + ]; + + private string temporaryDirectory = string.Empty; + + [SetUp] + public void PrepareEncryptionAndFiles() + { + PluginFactory.InitializeEnterpriseEncryption(EnterpriseEncryption.GenerateSecret()); + this.temporaryDirectory = Directory.CreateTempSubdirectory("snippet-import-").FullName; + } + + [TearDown] + public void RemoveEncryptionAndFiles() + { + RemoveEncryptionSecret(); + Directory.Delete(this.temporaryDirectory, recursive: true); + } + + [TestCaseSource(nameof(IMPORTABLE_TYPES))] + public void EveryExportBecomesAnObjectOfTheUsersOwn(PluginConfigurationObjectType type) + { + var (original, snippet) = Export(type); + var imported = Import(snippet, type).ConfigurationObject; + + Assert.Multiple(() => + { + Assert.That(Guid.TryParse(imported.Id, out _), Is.True, "Every configuration object needs a GUID as its ID."); + Assert.That(imported.Id, Is.Not.EqualTo(original.Id).And.Not.EqualTo(ExportedId(snippet, type)), "Importing the same snippet twice must not create two objects with one ID."); + Assert.That(imported.Num, Is.Zero, "The number is given when the object is stored."); + Assert.That(imported.IsEnterpriseConfiguration, Is.False); + Assert.That(imported.EnterpriseConfigurationPluginId, Is.EqualTo(Guid.Empty)); + Assert.That(imported.Name, Is.EqualTo(original.Name)); + }); + } + + [Test] + public void EveryTypeOfConfigurationObjectCanBeImported() => Assert.That(IMPORTABLE_TYPES, Is.EquivalentTo(PluginConfigurationObjectDescriptors.ALL.Select(descriptor => descriptor.Type)), "A new type of configuration object needs a decision on how an import makes it local, and its export here."); + + [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)] + [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)] + [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)] + [TestCase(PluginConfigurationObjectType.PROFILE)] + [TestCase(PluginConfigurationObjectType.DATA_SOURCE)] + public void AnImportKeepsEveryValueButTheIdentity(PluginConfigurationObjectType type) + { + var (original, snippet) = Export(type); + var imported = Import(snippet, type).ConfigurationObject; + + Assert.That(WithIdentityOf(imported, original), Is.EqualTo(original)); + } + + [Test] + public void AChatTemplateKeepsItsPromptsConversationAndPreselections() + { + var (original, snippet) = Export(PluginConfigurationObjectType.CHAT_TEMPLATE); + var template = (ChatTemplate)original; + var imported = (ChatTemplate)Import(snippet, PluginConfigurationObjectType.CHAT_TEMPLATE).ConfigurationObject; + + Assert.Multiple(() => + { + Assert.That(imported.SystemPrompt, Is.EqualTo(template.SystemPrompt)); + Assert.That(imported.PredefinedUserPrompt, Is.EqualTo(template.PredefinedUserPrompt)); + Assert.That(imported.AllowProfileUsage, Is.EqualTo(template.AllowProfileUsage)); + Assert.That(imported.ExampleConversation.Select(Message), Is.EqualTo(template.ExampleConversation.Select(Message))); + Assert.That(imported.ToolIds, Is.EquivalentTo(template.ToolIds!)); + Assert.That(imported.DataSourceOptions!.AutomaticDataSourceSelection, Is.EqualTo(template.DataSourceOptions!.AutomaticDataSourceSelection)); + Assert.That(imported.DataSourceOptions.AutomaticValidation, Is.EqualTo(template.DataSourceOptions.AutomaticValidation)); + Assert.That(imported.DataSourceOptions.PreselectedDataSourceIds, Is.EqualTo(template.DataSourceOptions.PreselectedDataSourceIds)); + }); + } + + [Test] + public void APolicyKeepsItsRulesButNotItsProtection() + { + var (original, snippet) = Export(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY); + var policy = (DataDocumentAnalysisPolicy)original; + var imported = (DataDocumentAnalysisPolicy)Import(snippet, PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY).ConfigurationObject; + + Assert.Multiple(() => + { + Assert.That(imported.IsProtected, Is.False, "A configuration plugin protects its policies; the user's own policy is the user's to edit."); + Assert.That(imported.PolicyDescription, Is.EqualTo(policy.PolicyDescription)); + Assert.That(imported.AnalysisRules, Is.EqualTo(policy.AnalysisRules)); + Assert.That(imported.OutputRules, Is.EqualTo(policy.OutputRules)); + Assert.That(imported.MinimumProviderConfidence, Is.EqualTo(policy.MinimumProviderConfidence)); + Assert.That(imported.AllowedToolIds, Is.EquivalentTo(policy.AllowedToolIds)); + Assert.That(imported.PreselectedProvider, Is.EqualTo(policy.PreselectedProvider)); + Assert.That(imported.PreselectedProfile, Is.EqualTo(policy.PreselectedProfile)); + Assert.That(imported.HidePolicyDefinition, Is.EqualTo(policy.HidePolicyDefinition)); + }); + } + + [Test] + public void ASnippetWhichIsNoValidConfigurationIsRefused() + { + const string PROFILE_WITHOUT_ACTIONS = """CONFIG["PROFILES"][#CONFIG["PROFILES"]+1] = { ["Id"] = "6b8e1a2c-3d4f-4a5b-9c6d-7e8f9a0b1c2d", ["Name"] = "Writer", ["NeedToKnow"] = "I write." }"""; + + Assert.Multiple(() => + { + Assert.That(ConfigurationSnippetImport.TryImport(PROFILE_WITHOUT_ACTIONS, PluginConfigurationObjectType.PROFILE, out var result, out var issue), Is.False); + Assert.That(result, Is.Null); + Assert.That(issue, Is.Not.Empty); + }); + } + + [Test] + public void ASnippetWhichCannotBeReadIsRefused() + { + Assert.Multiple(() => + { + Assert.That(ConfigurationSnippetImport.TryImport("CONFIG[\"PROFILES\"] = os.getenv(\"HOME\")", PluginConfigurationObjectType.PROFILE, out _, out var issue), Is.False); + Assert.That(issue, Is.Not.Empty); + }); + } + + [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)] + [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)] + [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)] + public void TheAPIKeyOfAProviderIsDecrypted(PluginConfigurationObjectType type) + { + var result = Import(ExportProvider(type, Encrypt(API_KEY)).Snippet, type); + + Assert.Multiple(() => + { + Assert.That(result.Secret, Is.EqualTo(API_KEY)); + Assert.That(result.Hints, Is.Empty); + }); + } + + [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)] + [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)] + [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)] + public void AnAPIKeyOfAnotherOrganizationLeavesAHint(PluginConfigurationObjectType type) + { + var otherOrganization = new EnterpriseEncryption(NullLogger.Instance, EnterpriseEncryption.GenerateSecret()); + Assert.That(otherOrganization.TryEncrypt(API_KEY, out var foreignApiKey), Is.True); + + AssertAPIKeyLeftOut(Import(ExportProvider(type, foreignApiKey).Snippet, type)); + } + + [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)] + [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)] + [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)] + public void AnAPIKeyWithoutAnEncryptionSecretLeavesAHint(PluginConfigurationObjectType type) + { + var snippet = ExportProvider(type, Encrypt(API_KEY)).Snippet; + RemoveEncryptionSecret(); + + AssertAPIKeyLeftOut(Import(snippet, type)); + } + + [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)] + [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)] + [TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)] + public void AnUnencryptedAPIKeyIsLeftOut(PluginConfigurationObjectType type) => AssertAPIKeyLeftOut(Import(ExportProvider(type, API_KEY).Snippet, type)); + + [Test] + public void TheTokenOfADataSourceIsDecrypted() => Assert.That(Import(ExportDataSource(AuthMethod.TOKEN).Snippet, PluginConfigurationObjectType.DATA_SOURCE).Secret, Is.EqualTo(TOKEN)); + + [Test] + public void ADataSourceWithATokenNeedsTheEncryptionSecret() + { + var snippet = ExportDataSource(AuthMethod.TOKEN).Snippet; + RemoveEncryptionSecret(); + + AssertRefused(snippet, PluginConfigurationObjectType.DATA_SOURCE); + } + + [Test] + public void ADataSourceWithATokenOfAnotherOrganizationIsRefused() + { + var snippet = ExportDataSource(AuthMethod.TOKEN).Snippet; + PluginFactory.InitializeEnterpriseEncryption(EnterpriseEncryption.GenerateSecret()); + + AssertRefused(snippet, PluginConfigurationObjectType.DATA_SOURCE); + } + + [Test] + public void ADataSourceWithoutSignInNeedsNoEncryptionSecret() + { + var snippet = ExportDataSource(AuthMethod.NONE).Snippet; + RemoveEncryptionSecret(); + + Assert.That(Import(snippet, PluginConfigurationObjectType.DATA_SOURCE).Secret, Is.Empty); + } + + [Test] + public void AKerberosDataSourceIsRefused() => AssertRefused(ExportDataSource(AuthMethod.KERBEROS).Snippet, PluginConfigurationObjectType.DATA_SOURCE); + + [TestCase("jane.doe", "jane.doe")] + [TestCase("", "")] + public void ASharedUsernameIsKeptButNotItsPlaceholder(string username, string expectedUsername) + { + var (_, snippet) = ExportDataSource(AuthMethod.USERNAME_PASSWORD, username, DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD); + var result = Import(snippet, PluginConfigurationObjectType.DATA_SOURCE); + var dataSource = (DataSourceERI_V1)result.ConfigurationObject; + + Assert.Multiple(() => + { + Assert.That(dataSource.Username, Is.EqualTo(expectedUsername)); + Assert.That(dataSource.UsernamePasswordMode, Is.EqualTo(DataSourceERIUsernamePasswordMode.USER_MANAGED), "Only an organization manages the sign-in for its users."); + Assert.That(result.Secret, Is.EqualTo(TOKEN)); + }); + } + + [Test] + public void AttachmentsWhichAreNotOnThisComputerAreToBeRelinked() + { + var existing = this.CreateFile("report.pdf"); + var missing = Path.Combine(this.temporaryDirectory, "missing.pdf"); + const string RELATIVE = "attachments/notes.txt"; + const string WINDOWS_SHARE = @"\\fileserver\share\minutes.docx"; + const string UNIX_SHARE = "//fileserver/share/minutes.docx"; + + var result = Import(ExportChatTemplate([existing, missing, RELATIVE, WINDOWS_SHARE, UNIX_SHARE]), PluginConfigurationObjectType.CHAT_TEMPLATE); + var template = (ChatTemplate)result.ConfigurationObject; + + Assert.Multiple(() => + { + Assert.That(template.FileAttachments.Select(attachment => attachment.FilePath), Is.EqualTo(new[] { existing })); + Assert.That(result.AttachmentsToRelink, Is.EqualTo(new[] { missing, RELATIVE, WINDOWS_SHARE, UNIX_SHARE })); + }); + } + + [Test] + public void AttachmentsOnThisComputerAreNamedInAHint() + { + var existing = this.CreateFile("id_rsa"); + var result = Import(ExportChatTemplate([existing]), PluginConfigurationObjectType.CHAT_TEMPLATE); + + Assert.That(result.Hints, Has.Exactly(1).Contains(existing), "A snippet can name any file of this computer, so the user has to see which ones every chat would carry."); + } + + [Test] + public void AChatTemplateWithoutAttachmentsLeavesNoHint() + { + var result = Import(ExportChatTemplate([]), PluginConfigurationObjectType.CHAT_TEMPLATE); + + Assert.Multiple(() => + { + Assert.That(result.Hints, Is.Empty); + Assert.That(result.AttachmentsToRelink, Is.Empty); + }); + } + + [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)] + [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)] + public void ATokenizerOnThisComputerIsKept(PluginConfigurationObjectType type) + { + var tokenizer = this.CreateFile("tokenizer.json"); + var result = Import(ExportProvider(type, null, tokenizer).Snippet, type); + + Assert.Multiple(() => + { + Assert.That(TokenizerPathOf(result.ConfigurationObject), Is.EqualTo(tokenizer)); + Assert.That(result.Hints, Is.Empty); + }); + } + + [TestCase(PluginConfigurationObjectType.LLM_PROVIDER)] + [TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)] + public void ATokenizerWhichIsNotOnThisComputerIsLeftOut(PluginConfigurationObjectType type) + { + var tokenizer = Path.Combine(this.temporaryDirectory, "missing-tokenizer.json"); + var result = Import(ExportProvider(type, null, tokenizer).Snippet, type); + + Assert.Multiple(() => + { + Assert.That(TokenizerPathOf(result.ConfigurationObject), Is.Empty, "Saving the provider would fail on a tokenizer which cannot be copied."); + Assert.That(result.Hints, Has.Exactly(1).Contains(tokenizer)); + }); + } + + [Test] + public async Task MissingReferencesOfAPolicyAreNamed() + { + var missingProfileId = Guid.NewGuid().ToString(); + var snippet = ExportPolicy(preselectedProvider: "missing-provider", preselectedProfile: missingProfileId, allowedToolIds: [TOOL_ID, "missing_tool"]); + var result = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(snippet, PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), this.SettingsManager, this.CreateRegistry(new TestTool(Definition()))); + + Assert.Multiple(() => + { + Assert.That(result.Hints, Has.Count.EqualTo(3), "One sentence for each kind of reference."); + Assert.That(result.Hints, Has.Exactly(1).Contains("missing-provider")); + Assert.That(result.Hints, Has.Exactly(1).Contains(missingProfileId)); + Assert.That(result.Hints, Has.Exactly(1).Contains("missing_tool")); + Assert.That(result.Hints, Has.None.Contains(TOOL_ID), "The tool exists."); + }); + } + + [Test] + public async Task MissingReferencesOfAChatTemplateAreNamed() + { + var missingDataSourceId = Guid.NewGuid().ToString(); + var snippet = ExportChatTemplate([], toolIds: [TOOL_ID, "missing_tool"], dataSourceIds: [missingDataSourceId]); + var result = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(snippet, PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, this.CreateRegistry(new TestTool(Definition()))); + + Assert.Multiple(() => + { + Assert.That(result.Hints, Has.Count.EqualTo(2), "One sentence for each kind of reference."); + Assert.That(result.Hints, Has.Exactly(1).Contains(missingDataSourceId)); + Assert.That(result.Hints, Has.Exactly(1).Contains("missing_tool")); + }); + } + + [Test] + public async Task ReferencesWhichExistLeaveNoHint() + { + var provider = ExportProvider(PluginConfigurationObjectType.LLM_PROVIDER, null).Original; + var profile = Export(PluginConfigurationObjectType.PROFILE).Original; + var dataSource = ExportDataSource(AuthMethod.NONE).Original; + this.SettingsManager.ConfigurationData.Providers.Add((AIStudio.Settings.Provider)provider); + this.SettingsManager.ConfigurationData.Profiles.Add((Profile)profile); + this.SettingsManager.ConfigurationData.DataSources.Add((DataSourceERI_V1)dataSource); + var registry = this.CreateRegistry(new TestTool(Definition())); + + var policy = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportPolicy(provider.Id, profile.Id, [TOOL_ID]), PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), this.SettingsManager, registry); + var template = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportChatTemplate([], [TOOL_ID], [dataSource.Id]), PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, registry); + + Assert.Multiple(() => + { + Assert.That(policy.Hints, Is.Empty); + Assert.That(template.Hints, Is.Empty); + }); + } + + [Test] + public async Task AToolOfACollectionIsSelectedAsItsCollection() + { + const string COLLECTION_ID = "test_collection"; + var registry = this.CreateRegistry([new TestCollection(COLLECTION_ID, ConfidenceLevel.NONE, TOOL_ID)], new TestTool(Definition())); + + var policy = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportPolicy(allowedToolIds: [TOOL_ID]), PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), this.SettingsManager, registry); + var template = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportChatTemplate([], [TOOL_ID]), PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, registry); + + Assert.Multiple(() => + { + Assert.That(((DataDocumentAnalysisPolicy)policy.ConfigurationObject).AllowedToolIds, Is.EquivalentTo(new[] { COLLECTION_ID })); + Assert.That(((ChatTemplate)template.ConfigurationObject).ToolIds, Is.EquivalentTo(new[] { COLLECTION_ID })); + Assert.That(policy.Hints, Is.Empty); + Assert.That(template.Hints, Is.Empty); + }); + } + + [Test] + public async Task AChatTemplateWhichSaysNothingAboutToolsKeepsSayingNothing() + { + var result = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportChatTemplate([], toolIds: null), PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, this.CreateRegistry(new TestTool(Definition()))); + + Assert.That(((ChatTemplate)result.ConfigurationObject).ToolIds, Is.Null, "No tools at all would be a statement the template never made."); + } + + private static ConfigurationSnippetImportResult Import(string snippet, PluginConfigurationObjectType type) + { + Assert.That(ConfigurationSnippetImport.TryImport(snippet, type, out var result, out var issue), Is.True, issue); + return result!; + } + + private static void AssertRefused(string snippet, PluginConfigurationObjectType type) + { + Assert.Multiple(() => + { + Assert.That(ConfigurationSnippetImport.TryImport(snippet, type, out var result, out var issue), Is.False); + Assert.That(result, Is.Null); + Assert.That(issue, Is.Not.Empty); + }); + } + + private static void AssertAPIKeyLeftOut(ConfigurationSnippetImportResult result) + { + Assert.Multiple(() => + { + Assert.That(result.Secret, Is.Empty); + Assert.That(result.Hints, Has.Count.EqualTo(1), "The provider is imported, but the user has to enter a key."); + }); + } + + /// + /// Leaves this computer without the encryption secret of an organization, as most computers are. + /// + private static void RemoveEncryptionSecret() => PluginFactory.InitializeEnterpriseEncryption(encryptionSecret: null); + + private static string Encrypt(string secret) + { + Assert.That(PluginFactory.EnterpriseEncryption!.TryEncrypt(secret, out var encryptedSecret), Is.True); + return encryptedSecret; + } + + private static string ExportedId(string snippet, PluginConfigurationObjectType type) + { + Assert.That(ConfigurationSnippetParser.TryParse(snippet, type, out var table, out var issue), Is.True, issue); + return table["Id"].Read(); + } + + private static object WithIdentityOf(IConfigurationObject imported, IConfigurationObject original) => imported switch + { + ConfigurationBaseObject configObject => configObject with { Id = original.Id, Num = original.Num }, + DataSourceERI_V1 dataSource => dataSource with { Id = original.Id, Num = original.Num }, + _ => throw new ArgumentOutOfRangeException(nameof(imported), imported, "This test cannot compare this type."), + }; + + private static string TokenizerPathOf(IConfigurationObject configObject) => configObject switch + { + AIStudio.Settings.Provider provider => provider.TokenizerPath, + EmbeddingProvider provider => provider.TokenizerPath, + _ => throw new ArgumentOutOfRangeException(nameof(configObject), configObject, "This type has no tokenizer."), + }; + + private static (ChatRole Role, string Text) Message(ContentBlock block) => (block.Role, (block.Content as ContentText)?.Text ?? string.Empty); + + private string CreateFile(string fileName) + { + var path = Path.Combine(this.temporaryDirectory, fileName); + File.WriteAllText(path, "{}"); + return path; + } + + /// + /// What the app exports for one item of each type, along with the item. + /// + /// + /// The items are made here rather than in a test case source: NUnit reads such a source before + /// TestHost runs, and the provider types need its logger factory. Texts span lines with line + /// feeds only, as Lua turns every other line break of a long string into one. + /// + private static (IConfigurationObject Original, string Snippet) Export(PluginConfigurationObjectType type) + { + switch (type) + { + case PluginConfigurationObjectType.LLM_PROVIDER or PluginConfigurationObjectType.EMBEDDING_PROVIDER or PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER: + return ExportProvider(type, null); + + case PluginConfigurationObjectType.PROFILE: + var profile = new Profile(1, Guid.NewGuid().ToString(), "Technical writer", "I write manuals.\nMy readers are engineers.", "Keep the answers short."); + return (profile, profile.ExportAsConfigurationSection()); + + case PluginConfigurationObjectType.CHAT_TEMPLATE: + var template = ChatTemplateWith([], ["web_search"], [Guid.NewGuid().ToString()]); + Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue); + return (template, luaCode); + + case PluginConfigurationObjectType.DATA_SOURCE: + return ExportDataSource(AuthMethod.TOKEN); + + case PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY: + var policy = PolicyWith(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), ["web_search", "read_web_page"]); + return (policy, policy.ExportAsConfigurationSection()); + + default: + throw new ArgumentOutOfRangeException(nameof(type), type, "This test knows no export for this type."); + } + } + + private static (IConfigurationObject Original, string Snippet) ExportProvider(PluginConfigurationObjectType type, string? encryptedApiKey, string tokenizerPath = "") + { + switch (type) + { + case PluginConfigurationObjectType.LLM_PROVIDER: + var provider = new AIStudio.Settings.Provider( + 1, + Guid.NewGuid().ToString(), + "Ollama on the workstation", + LLMProviders.SELF_HOSTED, + new Model("llama-3.3-70b", "Llama 3.3"), + IsSelfHosted: true, + Hostname: "http://localhost:11434", + Host: Host.OLLAMA, + AdditionalJsonApiParameters: "\"temperature\": 0.5", + TokenizerPath: tokenizerPath, + CapabilityOverrides: new ProviderCapabilityOverrides { FunctionCalling = true, ContextWindowTokens = 32_768 }); + return (provider, provider.ExportAsConfigurationSection(encryptedApiKey)); + + case PluginConfigurationObjectType.EMBEDDING_PROVIDER: + var embeddingProvider = new EmbeddingProvider( + 1, + Guid.NewGuid().ToString(), + "Embeddings on the workstation", + LLMProviders.SELF_HOSTED, + new Model("nomic-embed-text", "Nomic"), + IsSelfHosted: true, + Hostname: "http://localhost:11434", + Host: Host.OLLAMA, + TokenizerPath: tokenizerPath, + EmbeddingBatchSize: 16, + TokenLimit: 512); + return (embeddingProvider, embeddingProvider.ExportAsConfigurationSection(encryptedApiKey)); + + case PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER: + var transcriptionProvider = new TranscriptionProvider( + 1, + Guid.NewGuid().ToString(), + "Whisper on the workstation", + LLMProviders.SELF_HOSTED, + new Model("whisper-large-v3", "Whisper"), + IsSelfHosted: true, + Hostname: "http://localhost:8080", + Host: Host.WHISPER_CPP); + return (transcriptionProvider, transcriptionProvider.ExportAsConfigurationSection(encryptedApiKey)); + + default: + throw new ArgumentOutOfRangeException(nameof(type), type, "This is no type of provider."); + } + } + + private static (IConfigurationObject Original, string Snippet) ExportDataSource(AuthMethod authMethod, string username = "", DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED) + { + var dataSource = new DataSourceERI_V1 + { + Num = 1, + Id = Guid.NewGuid().ToString(), + Name = "Contracts", + Type = DataSourceType.ERI_V1, + Hostname = "https://eri.example.org", + Port = 8443, + AuthMethod = authMethod, + Username = username, + SecurityPolicy = DataSourceSecurity.SELF_HOSTED, + SelectedRetrievalId = "semantic-search", + MaxMatches = 25, + }; + + var encryptedSecret = authMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD ? Encrypt(TOKEN) : null; + return (dataSource, dataSource.ExportAsConfigurationSection(encryptedSecret, usernamePasswordMode)); + } + + private static string ExportChatTemplate(IReadOnlyList attachmentPaths, IReadOnlyList? toolIds = null, IReadOnlyList? dataSourceIds = null) + { + var template = ChatTemplateWith(attachmentPaths, toolIds, dataSourceIds); + Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue); + return luaCode; + } + + private static ChatTemplate ChatTemplateWith(IReadOnlyList attachmentPaths, IReadOnlyList? toolIds, IReadOnlyList? dataSourceIds) => new() + { + Num = 1, + Id = Guid.NewGuid().ToString(), + Name = "Contract review", + SystemPrompt = "You review contracts.\nYou quote every clause you mention.", + PredefinedUserPrompt = "Please review: ", + AllowProfileUsage = true, + FileAttachments = attachmentPaths.Select(path => new FileAttachment(FileAttachmentType.DOCUMENT, Path.GetFileName(path), path, 0)).ToList(), + ExampleConversation = + [ + new ContentBlock { ContentType = ContentType.TEXT, Role = ChatRole.USER, Content = new ContentText { Text = "Is clause 4 fair?" } }, + new ContentBlock { ContentType = ContentType.TEXT, Role = ChatRole.AI, Content = new ContentText { Text = "Clause 4 says:\n> The tenant pays." } }, + ], + ToolIds = toolIds?.ToHashSet(StringComparer.Ordinal), + DataSourceOptions = dataSourceIds is null ? null : new() { DisableDataSources = false, AutomaticValidation = true, PreselectedDataSourceIds = [..dataSourceIds] }, + }; + + private static string ExportPolicy(string preselectedProvider = "", string preselectedProfile = "", IReadOnlyList? allowedToolIds = null) => PolicyWith(preselectedProvider, preselectedProfile, allowedToolIds ?? []).ExportAsConfigurationSection(); + + private static DataDocumentAnalysisPolicy PolicyWith(string preselectedProvider, string preselectedProfile, IReadOnlyList allowedToolIds) => new() + { + Num = 1, + Id = Guid.NewGuid().ToString(), + PolicyName = "Contract check", + PolicyDescription = "Checks contracts for the clauses our legal department cares about.", + AnalysisRules = "Find every clause on liability.\nFind every clause on termination.", + OutputRules = "- Answer in a table.\n- Quote the clause.", + MinimumProviderConfidence = ConfidenceLevel.MEDIUM, + AllowedToolIds = allowedToolIds.ToHashSet(StringComparer.Ordinal), + PreselectedProvider = preselectedProvider, + PreselectedProfile = preselectedProfile, + HidePolicyDefinition = true, + IsProtected = true, + }; +} \ No newline at end of file diff --git a/app/Tests/Tools/PluginSystem/ConfigurationSnippetParserTests.cs b/app/Tests/Tools/PluginSystem/ConfigurationSnippetParserTests.cs new file mode 100644 index 000000000..a9528f20f --- /dev/null +++ b/app/Tests/Tools/PluginSystem/ConfigurationSnippetParserTests.cs @@ -0,0 +1,358 @@ +using System.Globalization; + +using AIStudio.Chat; +using AIStudio.Provider; +using AIStudio.Settings; +using AIStudio.Settings.DataModel; +using AIStudio.Tools.PluginSystem; + +using Lua; +using Lua.Standard; + +using AuthMethod = AIStudio.Tools.ERIClient.DataModel.AuthMethod; +using Host = AIStudio.Provider.SelfHosted.Host; + +namespace AIStudio.Tests.Tools.PluginSystem; + +/// +/// Checks that the snippet parser reads what the app exports, reads it the way Lua does, and +/// refuses whatever is not plain data. +/// +/// +/// The parser stands between a text somebody pasted and the settings of the app, so accepting a +/// snippet is not enough: every snippet it accepts is also run through a real Lua state, and both +/// tables are compared value by value. A parser which read a single string differently than Lua +/// would hand the import something the configuration plugin of the same snippet never contains.

+/// Nothing here asserts on the wording of an issue: I18N is process-wide state without a reset, so +/// an Init somewhere else would decide whether these pass. +///
+[TestFixture] +public sealed class ConfigurationSnippetParserTests +{ + /// + /// Quotes, backslashes, and apostrophes, which a quoted Lua string has to escape. + /// + private const string QUOTED_TEXT = "He said \"hi\" to C:\\Users\\me, and 'bye'."; + + /// + /// Every kind of line break, an indentation, and the closing brackets of long strings. + /// + private const string MULTILINE_TEXT = "First line\r\nSecond line\rThird line\n\tIndented, with ]] and ]==] and ]===] inside."; + + private const string WINDOWS_PATH = @"C:\Users\me\AppData\Roaming\MindWork AI Studio\tokenizer.json"; + + /// + /// Every type of configuration object which configuration plugins provide, and thus can be exported. + /// + private static readonly PluginConfigurationObjectType[] EXPORTABLE_TYPES = + [ + PluginConfigurationObjectType.LLM_PROVIDER, + PluginConfigurationObjectType.EMBEDDING_PROVIDER, + PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, + PluginConfigurationObjectType.PROFILE, + PluginConfigurationObjectType.CHAT_TEMPLATE, + PluginConfigurationObjectType.DATA_SOURCE, + PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, + ]; + + [TestCaseSource(nameof(EXPORTABLE_TYPES))] + public async Task EveryExportIsReadAsLuaReadsIt(PluginConfigurationObjectType type) + { + var snippet = Export(type); + + Assert.That(ConfigurationSnippetParser.TryParse(snippet, type, out var parsed, out var issue), Is.True, issue); + AssertSameTable(await ReadWithLuaAsync(type, snippet), parsed, type.ToString()); + } + + [Test] + public void EveryTypeOfConfigurationObjectHasAnExport() => Assert.That(EXPORTABLE_TYPES, Is.EquivalentTo(PluginConfigurationObjectDescriptors.ALL.Select(descriptor => descriptor.Type)), "A new type of configuration object needs its export in the round trip above."); + + private static IEnumerable PairsOfDifferentTypes() => + from snippetType in EXPORTABLE_TYPES + from expectedType in EXPORTABLE_TYPES + where snippetType != expectedType + select new TestCaseData(snippetType, expectedType); + + [TestCaseSource(nameof(PairsOfDifferentTypes))] + public void ASnippetOfAnotherTypeIsRefused(PluginConfigurationObjectType snippetType, PluginConfigurationObjectType expectedType) + { + var section = PluginConfigurationObjectDescriptors.Get(snippetType).LuaTableName; + AssertRefused($$"""CONFIG["{{section}}"][#CONFIG["{{section}}"]+1] = { ["a"] = 1 }""", expectedType); + } + + [Test] + // ReSharper disable RedundantArgumentDefaultValue + public void ASnippetOfAnUnknownSectionIsRefused() => AssertRefused("""CONFIG["SETTINGS"][#CONFIG["SETTINGS"]+1] = { ["a"] = 1 }""", PluginConfigurationObjectType.PROFILE); + // ReSharper restore RedundantArgumentDefaultValue + + [TestCase("""["a"] = "q\"b\\c\n\r\t\a\b\f\v'" """, TestName = "Escapes in a double-quoted string")] + [TestCase("""["a"] = 'it\'s "fine"' """, TestName = "Escapes in a single-quoted string")] + [TestCase("""["a"] = [==[ends with ]] and ]=] but not yet]==]""", TestName = "A long string with a level")] + [TestCase("[\"a\"] = [[one\r\ntwo\rthree\n\rfour\n\nfive]]", TestName = "Line breaks of every kind in a long string")] + [TestCase("[\"a\"] = [[\n indented]]", TestName = "An indented long string starting on its own line")] + [TestCase("[\"a\"] = [[\r\n\r\nafter an empty line]]", TestName = "A long string starting with two line breaks")] + [TestCase("""["a"] = "Grüße – 日本", ["Ünïcode"] = true""", TestName = "Text beyond ASCII")] + [TestCase("""["a"] = 5, ["b"] = -5, ["c"] = 0.25, ["d"] = -.5, ["e"] = 5., ["f"] = 65535""", TestName = "Numbers")] + [TestCase("""["a"] = true; ["b"] = false, ["c"] = { "x", 'y', [[z]], { ["d"] = 1 }, },""", TestName = "Lists, nested tables, and both separators")] + [TestCase("""["a"] = {}, ["b"] = { {} }""", TestName = "Empty tables")] + [TestCase("-- before\n[\"a\"] = -- after the equals sign\n 1, --[ not a block comment\n-- [[ neither\n[\"b\"] = 2 -- last\n", TestName = "Line comments everywhere")] + public async Task AHandwrittenSnippetIsReadAsLuaReadsIt(string fields) => await AssertReadAsLuaReadsItAsync(Snippet(fields)); + + [Test] + public async Task CommentsBeforeTheAssignmentAreAllowed() => await AssertReadAsLuaReadsItAsync("-- Exported from the laptop of a colleague.\r\n-- Check the IDs below.\r\n\r\n" + Snippet("""["a"] = 1""")); + + [Test] + public async Task ThirtyNestedTablesAreStillRead() => await AssertReadAsLuaReadsItAsync(Snippet($"""["a"] = {string.Concat(Enumerable.Repeat("{ ", 30))}1{string.Concat(Enumerable.Repeat(" }", 30))}""")); + + [TestCase("""["a"] = print("x")""", TestName = "A function call")] + [TestCase("""["a"] = function() return 1 end""", TestName = "A function")] + [TestCase("""["a"] = "x" .. "y" """, TestName = "A concatenation")] + [TestCase("""["a"] = 1 + 1""", TestName = "Arithmetic")] + [TestCase("""["a"] = CONFIG""", TestName = "A variable")] + [TestCase("""["a"] = os.getenv("HOME")""", TestName = "A library call")] + [TestCase("""["a"] = nil""", TestName = "nil as a field")] + [TestCase("""["a"] = { "x", nil, "y" }""", TestName = "nil in a list")] + [TestCase("[\"a\"] = 1, --[[ [\"b\"] = 2, ]] [\"c\"] = 3\n", TestName = "A block comment followed by data on the same line")] + [TestCase("--[==[\n[\"a\"] = 1,\n--]==]\n[\"b\"] = 2\n", TestName = "A block comment over several lines")] + [TestCase("""["a"] = 1, ["a"] = 2""", TestName = "A field twice")] + [TestCase("""a = 1""", TestName = "A name as the key")] + [TestCase("""[1] = "x" """, TestName = "A number as the key")] + [TestCase("""["a"] = "\x41" """, TestName = "An escape Lua knows, but exports never write")] + [TestCase("""["a"] = "unterminated""", TestName = "An unterminated string")] + [TestCase("""["a"] = [==[unterminated]=]""", TestName = "An unterminated long string")] + [TestCase("""["a"] = 0x10""", TestName = "A hexadecimal number")] + public void CodeAndConstructsBeyondPlainDataAreRefused(string fields) => AssertRefused(Snippet(fields)); + + [Test] + public void ASecondStatementIsRefused() => AssertRefused(Snippet("""["a"] = 1""") + "\nos.exit()"); + + [Test] + public void ASecondAssignmentIsRefused() => AssertRefused(Snippet("""["a"] = 1""") + "\n" + Snippet("""["b"] = 2""")); + + [Test] + public void AnAssignmentWhichCountsAnotherSectionIsRefused() => AssertRefused("""CONFIG["PROFILES"][#CONFIG["CHAT_TEMPLATES"]+1] = { ["a"] = 1 }"""); + + [Test] + public void AnAssignmentToAFixedIndexIsRefused() => AssertRefused("""CONFIG["PROFILES"][1] = { ["a"] = 1 }"""); + + [Test] + public void ATooLargeSnippetIsRefused() => AssertRefused(Snippet($"""["a"] = "{new string('x', 1_000_000)}" """)); + + [Test] + public void TooManyNestedTablesAreRefused() => AssertRefused(Snippet($"""["a"] = {string.Concat(Enumerable.Repeat("{ ", 40))}1{string.Concat(Enumerable.Repeat(" }", 40))}""")); + + [TestCase("")] + [TestCase(" \r\n ")] + public void AnEmptySnippetIsRefused(string snippet) => AssertRefused(snippet); + + /// + /// Reads a profile snippet with the parser and with Lua, and compares both. + /// + private static async Task AssertReadAsLuaReadsItAsync(string snippet) + { + Assert.That(ConfigurationSnippetParser.TryParse(snippet, PluginConfigurationObjectType.PROFILE, out var parsed, out var issue), Is.True, issue); + AssertSameTable(await ReadWithLuaAsync(PluginConfigurationObjectType.PROFILE, snippet), parsed, "PROFILES"); + } + + private static void AssertRefused(string snippet, PluginConfigurationObjectType type = PluginConfigurationObjectType.PROFILE) + { + var accepted = ConfigurationSnippetParser.TryParse(snippet, type, out _, out var issue); + Assert.Multiple(() => + { + Assert.That(accepted, Is.False); + Assert.That(issue, Is.Not.Empty, "A refused snippet needs a reason the user can read."); + }); + } + + /// + /// A profile snippet with these fields. + /// + private static string Snippet(string fields) => $$"""CONFIG["PROFILES"][#CONFIG["PROFILES"]+1] = { {{fields}} }"""; + + /// + /// What the app exports for one item of each type, with values which the export has to escape. + /// + /// + /// The items are made here rather than in a test case source: NUnit reads such a source before + /// TestHost runs, and the provider types need its logger factory. + /// + private static string Export(PluginConfigurationObjectType type) => type switch + { + PluginConfigurationObjectType.LLM_PROVIDER => new AIStudio.Settings.Provider( + 1, + Guid.NewGuid().ToString(), + QUOTED_TEXT, + LLMProviders.SELF_HOSTED, + new Model("llama-3.3-70b", QUOTED_TEXT), + IsSelfHosted: true, + Hostname: "http://localhost:11434", + Host: Host.OLLAMA, + AdditionalJsonApiParameters: "\"temperature\": 0.5, \"top_p\": 0.9", + TokenizerPath: WINDOWS_PATH, + CapabilityOverrides: new ProviderCapabilityOverrides { FunctionCalling = true, ContextWindowTokens = 32_768, MaxImagesPerMessage = 0 }) + .ExportAsConfigurationSection("ENC:v1:c2VjcmV0"), + + PluginConfigurationObjectType.EMBEDDING_PROVIDER => new EmbeddingProvider( + 1, + Guid.NewGuid().ToString(), + QUOTED_TEXT, + LLMProviders.SELF_HOSTED, + new Model("nomic-embed-text", null), + IsSelfHosted: true, + Host: Host.OLLAMA, + TokenizerPath: WINDOWS_PATH, + EmbeddingBatchSize: 16, + TokenLimit: 512) + .ExportAsConfigurationSection("ENC:v1:c2VjcmV0"), + + PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => new TranscriptionProvider( + 1, + Guid.NewGuid().ToString(), + QUOTED_TEXT, + LLMProviders.SELF_HOSTED, + new Model("whisper-large-v3", "Whisper"), + IsSelfHosted: true, + Host: Host.WHISPER_CPP) + .ExportAsConfigurationSection(), + + PluginConfigurationObjectType.PROFILE => new Profile(1, Guid.NewGuid().ToString(), QUOTED_TEXT, MULTILINE_TEXT, "Short actions.").ExportAsConfigurationSection(), + + PluginConfigurationObjectType.CHAT_TEMPLATE => ExportChatTemplate(), + + PluginConfigurationObjectType.DATA_SOURCE => new DataSourceERI_V1 + { + Num = 1, + Id = Guid.NewGuid().ToString(), + Name = QUOTED_TEXT, + Type = DataSourceType.ERI_V1, + Hostname = "https://eri.example.org", + Port = 8443, + AuthMethod = AuthMethod.USERNAME_PASSWORD, + Username = "jane.doe", + SecurityPolicy = DataSourceSecurity.SELF_HOSTED, + SelectedRetrievalId = "semantic-search", + MaxMatches = 25, + }.ExportAsConfigurationSection("ENC:v1:c2VjcmV0", DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD), + + PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => new DataDocumentAnalysisPolicy + { + Id = Guid.NewGuid().ToString(), + PolicyName = QUOTED_TEXT, + PolicyDescription = "Checks contracts for the clauses our legal department cares about.", + AnalysisRules = MULTILINE_TEXT, + OutputRules = "- Answer in a table.\r\n- Quote the clause.", + MinimumProviderConfidence = ConfidenceLevel.MEDIUM, + AllowedToolIds = ["web_search", "read_web_page"], + PreselectedProvider = Guid.NewGuid().ToString(), + HidePolicyDefinition = true, + }.ExportAsConfigurationSection(), + + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "This test knows no export for this type."), + }; + + private static string ExportChatTemplate() + { + var template = new ChatTemplate + { + Num = 1, + Id = Guid.NewGuid().ToString(), + Name = QUOTED_TEXT, + SystemPrompt = MULTILINE_TEXT, + PredefinedUserPrompt = "Please summarize: ", + AllowProfileUsage = true, + FileAttachments = [new FileAttachment(FileAttachmentType.DOCUMENT, "report.pdf", WINDOWS_PATH, 1024)], + ExampleConversation = + [ + new ContentBlock { ContentType = ContentType.TEXT, Role = ChatRole.USER, Content = new ContentText { Text = QUOTED_TEXT } }, + new ContentBlock { ContentType = ContentType.TEXT, Role = ChatRole.AI, Content = new ContentText { Text = MULTILINE_TEXT } }, + ], + ToolIds = ["web_search"], + + // Preselected data sources make the export start with a comment: + DataSourceOptions = new() { DisableDataSources = false, PreselectedDataSourceIds = [Guid.NewGuid().ToString()] }, + }; + + Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue); + return luaCode; + } + + /// + /// Reads a snippet the way a configuration plugin containing it would be read. + /// + private static async Task ReadWithLuaAsync(PluginConfigurationObjectType type, string snippet) + { + var section = PluginConfigurationObjectDescriptors.Get(type).LuaTableName; + var state = LuaState.Create(); + state.OpenBasicLibrary(); + state.OpenTableLibrary(); + + await state.DoStringAsync($$""" + CONFIG = {} + CONFIG["{{section}}"] = {} + {{snippet}} + """); + + if (!state.Environment["CONFIG"].TryRead(out var configTable) || + !configTable[section].TryRead(out var sectionTable) || + !sectionTable[1].TryRead(out var itemTable)) + throw new InvalidOperationException("The snippet of this test did not add a table to its section."); + + return itemTable; + } + + private static void AssertSameTable(LuaTable expected, LuaTable actual, string path) + { + var expectedFields = Fields(expected); + var actualFields = Fields(actual); + Assert.That(actualFields.Keys, Is.EquivalentTo(expectedFields.Keys), path); + foreach (var (key, expectedValue) in expectedFields) + AssertSameValue(expectedValue, actualFields[key], $"{path}[{key}]"); + } + + private static void AssertSameValue(LuaValue expected, LuaValue actual, string path) + { + Assert.That(actual.Type, Is.EqualTo(expected.Type), path); + switch (expected.Type) + { + case LuaValueType.String: + Assert.That(actual.Read(), Is.EqualTo(expected.Read()), path); + break; + + case LuaValueType.Number: + Assert.That(actual.Read(), Is.EqualTo(expected.Read()), path); + break; + + case LuaValueType.Boolean: + Assert.That(actual.Read(), Is.EqualTo(expected.Read()), path); + break; + + case LuaValueType.Table: + AssertSameTable(expected.Read(), actual.Read(), path); + break; + + default: + Assert.Fail($"{path} holds a {expected.Type}, which no snippet of this test contains."); + break; + } + } + + private static Dictionary Fields(LuaTable table) + { + var fields = new Dictionary(StringComparer.Ordinal); + var key = LuaValue.Nil; + while (table.TryGetNext(key, out var pair)) + { + key = pair.Key; + var name = pair.Key.Type switch + { + LuaValueType.String => $"\"{pair.Key.Read()}\"", + LuaValueType.Number => pair.Key.Read().ToString(CultureInfo.InvariantCulture), + _ => throw new InvalidOperationException($"A key of the type {pair.Key.Type} cannot appear in a snippet."), + }; + + fields.Add(name, pair.Value); + } + + return fields; + } +} \ No newline at end of file diff --git a/app/Tests/Tools/PluginSystem/PluginConfigurationObjectDescriptorsTests.cs b/app/Tests/Tools/PluginSystem/PluginConfigurationObjectDescriptorsTests.cs index ef9dcbb4b..0a69659b9 100644 --- a/app/Tests/Tools/PluginSystem/PluginConfigurationObjectDescriptorsTests.cs +++ b/app/Tests/Tools/PluginSystem/PluginConfigurationObjectDescriptorsTests.cs @@ -25,4 +25,37 @@ public sealed class PluginConfigurationObjectDescriptorsTests Assert.That(describedTypes, Is.Unique, "A type described twice would be read and cleaned up twice."); }); } + + [Test] + public void EveryDescriptorReadsATableOfItsOwn() + { + var tableNames = PluginConfigurationObjectDescriptors.ALL.Select(descriptor => descriptor.LuaTableName).ToList(); + + Assert.Multiple(() => + { + Assert.That(tableNames, Has.None.Empty); + Assert.That(tableNames, Is.Unique, "Two types reading the same table would each take the objects of the other."); + }); + } + + [Test] + public void EveryDescriptorIsFoundByItsTypeAndByItsTable() + { + Assert.Multiple(() => + { + foreach (var descriptor in PluginConfigurationObjectDescriptors.ALL) + { + Assert.That(PluginConfigurationObjectDescriptors.Get(descriptor.Type), Is.SameAs(descriptor), descriptor.Type.ToString()); + Assert.That(PluginConfigurationObjectDescriptors.TryGet(descriptor.LuaTableName, out var found), Is.True, descriptor.LuaTableName); + Assert.That(found, Is.SameAs(descriptor), descriptor.LuaTableName); + } + }); + } + + [TestCase(PluginConfigurationObjectType.NONE)] + [TestCase(PluginConfigurationObjectType.UNKNOWN)] + public void NoneAndUnknownHaveNoDescriptor(PluginConfigurationObjectType type) => Assert.That(() => PluginConfigurationObjectDescriptors.Get(type), Throws.TypeOf()); + + [Test] + public void AnUnknownTableHasNoDescriptor() => Assert.That(PluginConfigurationObjectDescriptors.TryGet("SETTINGS", out _), Is.False); } \ No newline at end of file diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md index 564539dc6..8fca51aa0 100644 --- a/documentation/Enterprise IT.md +++ b/documentation/Enterprise IT.md @@ -621,6 +621,36 @@ Some exports ask a question first: a provider with an API key offers to include Handing a whole plugin to a colleague is a different thing: that is the **Share** function on the plugins page, which writes a `.mwplugin` archive and is governed by its own organization setting rather than by the administration settings. +### Importing an exported item + +Not everything worth sharing calls for a configuration plugin. Say somebody built a good chat template and wants to hand it to three colleagues: they export it as above, and each colleague imports it as a template of their own. Every item in the table above except tools has an **Import** button next to its **Add** button, e.g., **Import Chat Template** or **Import Provider**. It opens a dialog to paste the exported fragment into. + +- **Data only.** AI Studio reads the fragment with the same rules as a configuration plugin, but it never runs it. A fragment which contains anything other than values, such as a function call, a variable, or an expression, is refused. +- **An item of the user's own.** The imported item gets an ID of its own and belongs to no configuration plugin, even when the fragment came from one. Importing the same fragment twice gives two items. +- **Reviewed before it is saved.** Providers, profiles, chat templates, and data sources open in their usual form, filled in, so the user checks everything before saving. A document analysis policy is added right away and selected, and the user edits it in the assistant like any other. +- **Hints first.** Before that, the dialog lists what the user should know: a preselected provider, profile, data source, or tool which does not exist on this computer, a tokenizer which is not on this computer and is therefore left out, an API key which cannot be decrypted, and every local file which a chat template would attach to each of its chats. Attachments which are not on this computer are relinked or removed in the form. +- **Secrets.** An API key, token, or password which the export included in encrypted form is decrypted only where the encryption secret of your organization is configured, see [Encrypted API Keys](#encrypted-api-keys). Without it, a provider is imported without its key, and an ERI data source with a token or password cannot be imported at all. Data sources which sign in with Kerberos cannot be imported either. +- **No connection on its own.** Importing contacts no server. The user loads the models of a provider, or tests the connection of a data source, in the form. + +Exporting needs **Show administration settings**; importing does not. + +#### Allowing or locking imports + +Each of these settings is `true` by default: + +| Setting | Allows importing | +|---|---| +| `DataApp.AllowUserToImportConfigurationSnippets` | anything at all | +| `DataApp.AllowUserToImportProfile` | profiles | +| `DataApp.AllowUserToImportLLMProvider` | LLM providers | +| `DataApp.AllowUserToImportEmbeddingProvider` | embedding providers | +| `DataApp.AllowUserToImportTranscriptionProvider` | transcription providers | +| `DataApp.AllowUserToImportChatTemplate` | chat templates | +| `DataApp.AllowUserToImportERIDataSource` | ERI data sources | +| `DataApp.AllowUserToImportDocumentAnalysisPolicy` | document analysis policies | + +An import needs both the first setting and the one of its type. A provider also needs what adding one by hand needs: `DataApp.AllowUserToAddProvider` and the setting of its kind, such as `DataApp.AllowUserToAddLLMProvider`. A locked **Import** button stays visible with a lock. The permission is checked once more when the user saves, so locking imports while somebody fills in a form still holds. Importing plugin archives has a setting of its own, `DataApp.AllowUserToImportPlugins`. + ## 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. @@ -1104,4 +1134,4 @@ applies to it. In some countries, such as Germany, evaluating it may also requir your works council, because it could be used to monitor the behavior or performance of employees. Clarify this before you switch it on, evaluate the numbers per feature rather than per person, and -tell your colleagues that their requests name the features they use. +tell your colleagues that their requests name the features they use. \ No newline at end of file