diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 715f64af..81e7960a 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -1456,9 +1456,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T1793579367 -- Text content UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T1820253043"] = "Text content" --- Slide Assistant -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T1883918574"] = "Slide Assistant" - -- Please provide a text or at least one valid document or image. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T2013746884"] = "Please provide a text or at least one valid document or image." @@ -1489,6 +1486,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T2823798965 -- This assistant helps you create clear, structured slides from long texts or documents. Enter a presentation title and provide the content either as text or with one or more documents. Important aspects allow you to add instructions to the LLM regarding output or formatting. Set the number of slides either directly or based on your desired presentation duration. You can also specify the number of bullet points. If the default value of 0 is not changed, the LLM will independently determine how many slides or bullet points to generate. The output can be flexibly generated in various languages and tailored to a specific audience. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T2910177051"] = "This assistant helps you create clear, structured slides from long texts or documents. Enter a presentation title and provide the content either as text or with one or more documents. Important aspects allow you to add instructions to the LLM regarding output or formatting. Set the number of slides either directly or based on your desired presentation duration. You can also specify the number of bullet points. If the default value of 0 is not changed, the LLM will independently determine how many slides or bullet points to generate. The output can be flexibly generated in various languages and tailored to a specific audience." +-- Slide Planner Assistant +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T2924755246"] = "Slide Planner Assistant" + -- The result of your previous slide builder session. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T3000286990"] = "The result of your previous slide builder session." @@ -4708,8 +4708,8 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T20146 -- Which audience organizational level should be preselected? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T216511105"] = "Which audience organizational level should be preselected?" --- Preselect Slide Assistant options? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T227645894"] = "Preselect Slide Assistant options?" +-- Preselect Slide Planner Assistant options? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T227645894"] = "Preselect Slide Planner Assistant options?" -- Preselect a profile UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T2322771068"] = "Preselect a profile" @@ -4726,8 +4726,8 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T25714 -- Preselect the audience age group UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T2645589441"] = "Preselect the audience age group" --- Assistant: Slide Assistant Options -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T3215549988"] = "Assistant: Slide Assistant Options" +-- Assistant: Slide Planner Assistant Options +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T3215549988"] = "Assistant: Slide Planner Assistant Options" -- Which audience expertise should be preselected? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T3228597992"] = "Which audience expertise should be preselected?" @@ -4738,14 +4738,14 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T34481 -- Preselect important aspects UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T3705987833"] = "Preselect important aspects" --- No Slide Assistant options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T4214398691"] = "No Slide Assistant options are preselected" +-- No Slide Planner Assistant options are preselected +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T4214398691"] = "No Slide Planner Assistant options are preselected" -- Preselect the audience profile UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T861397972"] = "Preselect the audience profile" --- Slide Assistant options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T93124146"] = "Slide Assistant options are preselected" +-- Slide Planner Assistant options are preselected +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T93124146"] = "Slide Planner Assistant options are preselected" -- Which audience age group should be preselected? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T956845877"] = "Which audience age group should be preselected?" @@ -5191,9 +5191,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1617786407"] = "Coding" -- Analyze a text or an email for tasks you need to complete. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1728590051"] = "Analyze a text or an email for tasks you need to complete." --- Slide Assistant -UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1883918574"] = "Slide Assistant" - -- Text Summarizer UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1907192403"] = "Text Summarizer" @@ -5227,6 +5224,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T2830810750"] = "AI Studio Develop -- Generate a job posting for a given job description. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T2831103254"] = "Generate a job posting for a given job description." +-- Slide Planner Assistant +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T2924755246"] = "Slide Planner Assistant" + -- My Tasks UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3011450657"] = "My Tasks" @@ -6172,9 +6172,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T1546040625"] = "My Task -- Grammar & Spelling Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T166453786"] = "Grammar & Spelling Assistant" --- Slide Assistant -UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T1883918574"] = "Slide Assistant" - -- Legal Check Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T1886447798"] = "Legal Check Assistant" @@ -6190,6 +6187,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T2684676843"] = "Text Su -- Synonym Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T2921123194"] = "Synonym Assistant" +-- Slide Planner Assistant +UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T2924755246"] = "Slide Planner Assistant" + -- Document Analysis Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T348883878"] = "Document Analysis Assistant" diff --git a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs index e01320d5..1faf2bde 100644 --- a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs @@ -8,7 +8,7 @@ public partial class SlideAssistant : AssistantBaseCore Tools.Components.SLIDE_BUILDER_ASSISTANT; - protected override string Title => T("Slide Assistant"); + protected override string Title => T("Slide Planner Assistant"); protected override string Description => T("This assistant helps you create clear, structured slides from long texts or documents. Enter a presentation title and provide the content either as text or with one or more documents. Important aspects allow you to add instructions to the LLM regarding output or formatting. Set the number of slides either directly or based on your desired presentation duration. You can also specify the number of bullet points. If the default value of 0 is not changed, the LLM will independently determine how many slides or bullet points to generate. The output can be flexibly generated in various languages and tailored to a specific audience."); diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogSlideBuilder.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogSlideBuilder.razor index 1c8dad1f..18d51280 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogSlideBuilder.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogSlideBuilder.razor @@ -5,12 +5,12 @@ - @T("Assistant: Slide Assistant Options") + @T("Assistant: Slide Planner Assistant Options") - + @if (this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTargetLanguage is CommonLanguages.OTHER) diff --git a/app/MindWork AI Studio/Pages/Assistants.razor b/app/MindWork AI Studio/Pages/Assistants.razor index 88fdd68a..d37fce12 100644 --- a/app/MindWork AI Studio/Pages/Assistants.razor +++ b/app/MindWork AI Studio/Pages/Assistants.razor @@ -52,7 +52,7 @@ - + } 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 5cf76278..c7af15bb 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 @@ -1458,9 +1458,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T1793579367 -- Text content UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T1820253043"] = "Textinhalt" --- Slide Assistant -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T1883918574"] = "Folienassistent" - -- Please provide a text or at least one valid document or image. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T2013746884"] = "Bitte geben Sie einen Text oder mindestens ein gültiges Dokument oder Bild an." @@ -1491,8 +1488,11 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T2823798965 -- This assistant helps you create clear, structured slides from long texts or documents. Enter a presentation title and provide the content either as text or with one or more documents. Important aspects allow you to add instructions to the LLM regarding output or formatting. Set the number of slides either directly or based on your desired presentation duration. You can also specify the number of bullet points. If the default value of 0 is not changed, the LLM will independently determine how many slides or bullet points to generate. The output can be flexibly generated in various languages and tailored to a specific audience. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T2910177051"] = "Dieser Assistent hilft Ihnen, aus langen Texten oder Dokumenten klare, strukturierte Folien zu erstellen. Geben Sie einen Titel für die Präsentation ein und stellen Sie den Inhalt entweder als Text oder über ein oder mehrere Dokumente bereit. Unter „Wichtige Aspekte“ können Sie dem LLM Anweisungen zur Ausgabe oder Formatierung geben. Legen Sie die Anzahl der Folien entweder direkt oder anhand der gewünschten Präsentationsdauer fest. Sie können auch die Anzahl der Aufzählungspunkte angeben. Wenn der Standardwert 0 nicht geändert wird, bestimmt das LLM selbstständig, wie viele Folien oder Aufzählungspunkte erstellt werden. Die Ausgabe kann flexibel in verschiedenen Sprachen erzeugt und auf eine bestimmte Zielgruppe zugeschnitten werden." +-- Slide Planner Assistant +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T2924755246"] = "Folienplaner-Assistent" + -- The result of your previous slide builder session. -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T3000286990"] = "Das Ergebnis Ihrer vorherigen Folienassistent-Sitzung." +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T3000286990"] = "Das Ergebnis Ihrer vorherigen Sitzung im Folienplaner-Assistenten." -- Please enter a title for the presentation. This will help the LLM to select more relevant content. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T3013824309"] = "Bitte geben Sie einen Titel für die Präsentation ein. Dies hilft dem LLM, relevantere Inhalte auszuwählen." @@ -1531,7 +1531,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T3848935911 UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T3893271035"] = "Präsentationstitel" -- {0} - Slide Builder Session -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T3922788056"] = "{0} – Folienassistent-Sitzung" +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T3922788056"] = "{0} – Folienplaner-Assistenten-Sitzung" -- Please specify the extent of the planned presentation. This can be the number of slides, the number of bullet points per slide, or the time specification for the presentation. This will help the LLM to create a presentation that fits your needs. Leave the default values if you don't have specific requirements regarding the extent of the presentation. You might only want to specify one of these parameters, for example the time specification, and leave the others at their default values. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T4131419342"] = "Bitte geben Sie den Umfang der geplanten Präsentation an. Das kann die Anzahl der Folien, die Anzahl der Aufzählungspunkte pro Folie oder die Zeitvorgabe für die Präsentation sein. Das hilft dem LLM, eine Präsentation zu erstellen, die Ihren Anforderungen entspricht. Lassen Sie die Standardwerte unverändert, wenn Sie keine besonderen Anforderungen an den Umfang der Präsentation haben. Möglicherweise möchten Sie nur einen dieser Parameter angeben, zum Beispiel die Zeitvorgabe, und die anderen auf den Standardwerten belassen." @@ -4696,7 +4696,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGREWRITE::T553954963" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T1017131030"] = "Expertise der Zielgruppe vorauswählen" -- When enabled, you can preselect slide builder options. This is might be useful when you prefer a specific language or LLM model. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T1393378753"] = "Wenn diese Option aktiviert ist, können Sie Optionen für den Folienassistent vorab auswählen. Dies kann nützlich sein, wenn Sie eine bestimmte Sprache oder ein bestimmtes LLM-Modell bevorzugen." +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T1393378753"] = "Wenn diese Option aktiviert ist, können Sie Optionen für den Folienplaner-Assistenten vorab auswählen. Dies kann nützlich sein, wenn Sie eine bestimmte Sprache oder ein bestimmtes LLM-Modell bevorzugen." -- Preselect aspects for the LLM to focus on when generating slides, such as bullet points or specific topics to emphasize. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T1528169602"] = "Wählen Sie Aspekte vorab aus, auf die sich das LLM bei der Erstellung von Folien konzentrieren soll, z. B. Aufzählungspunkte oder bestimmte Themen, die hervorgehoben werden sollen." @@ -4710,8 +4710,8 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T20146 -- Which audience organizational level should be preselected? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T216511105"] = "Welche organisatorische Ebene der Zielgruppe soll vorausgewählt werden?" --- Preselect Slide Assistant options? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T227645894"] = "Optionen des Folienassistenten vorauswählen?" +-- Preselect Slide Planner Assistant options? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T227645894"] = "Optionen des Folienplaner-Assistenten vorauswählen?" -- Preselect a profile UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T2322771068"] = "Profil vorauswählen" @@ -4728,7 +4728,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T25714 -- Preselect the audience age group UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T2645589441"] = "Altersgruppe der Zielgruppe vorauswählen" --- Assistant: Slide Assistant Options +-- Assistant: Slide Planner Assistant Options UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T3215549988"] = "Assistent: Optionen für die Erstellung von Folien" -- Which audience expertise should be preselected? @@ -4740,14 +4740,14 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T34481 -- Preselect important aspects UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T3705987833"] = "Wichtige Aspekte vorauswählen" --- No Slide Assistant options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T4214398691"] = "Keine Optionen für den Folienassistenten sind vorausgewählt." +-- No Slide Planner Assistant options are preselected +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T4214398691"] = "Keine Optionen für den Folienplaner-Assistenten sind vorausgewählt." -- Preselect the audience profile UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T861397972"] = "Zielgruppenprofil vorauswählen" --- Slide Assistant options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T93124146"] = "Optionen des Folienassistenten sind vorausgewählt" +-- Slide Planner Assistant options are preselected +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T93124146"] = "Optionen des Folienplaner-Assistenten sind vorausgewählt" -- Which audience age group should be preselected? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T956845877"] = "Welche Altersgruppe der Zielgruppe sollte vorausgewählt sein?" @@ -5193,9 +5193,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1617786407"] = "Programmieren" -- Analyze a text or an email for tasks you need to complete. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1728590051"] = "Analysieren Sie einen Text oder eine E-Mail nach Aufgaben, die Sie erledigen müssen." --- Slide Assistant -UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1883918574"] = "Folienassistent" - -- Text Summarizer UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1907192403"] = "Texte zusammenfassen" @@ -5229,6 +5226,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T2830810750"] = "AI Studio Entwick -- Generate a job posting for a given job description. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T2831103254"] = "Erstellen Sie eine Stellenanzeige anhand einer vorgegebenen Stellenbeschreibung." +-- Slide Planner Assistant +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T2924755246"] = "Folienplaner-Assistent" + -- My Tasks UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3011450657"] = "Meine Aufgaben" @@ -6174,9 +6174,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T1546040625"] = "Meine A -- Grammar & Spelling Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T166453786"] = "Grammatik- & Rechtschreib-Assistent" --- Slide Assistant -UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T1883918574"] = "Folienassistent" - -- Legal Check Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T1886447798"] = "Rechtlichen Prüfungs-Assistent" @@ -6192,6 +6189,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T2684676843"] = "Texte z -- Synonym Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T2921123194"] = "Synonym-Assistent" +-- Slide Planner Assistant +UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T2924755246"] = "Folienplaner-Assistent" + -- Document Analysis Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T348883878"] = "Dokumentenanalyse-Assistent" 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 edd72cc6..1314ff3f 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 @@ -1458,8 +1458,8 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T1793579367 -- Text content UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T1820253043"] = "Text content" --- Slide Assistant -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T1883918574"] = "Slide Assistant" +-- Slide Planner Assistant +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T1883918574"] = "Slide Planner Assistant" -- Please provide a text or at least one valid document or image. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::SLIDEBUILDER::SLIDEASSISTANT::T2013746884"] = "Please provide a text or at least one valid document or image." @@ -4710,8 +4710,8 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T20146 -- Which audience organizational level should be preselected? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T216511105"] = "Which audience organizational level should be preselected?" --- Preselect Slide Assistant options? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T227645894"] = "Preselect Slide Assistant options?" +-- Preselect Slide Planner Assistant options? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T227645894"] = "Preselect Slide Planner Assistant options?" -- Preselect a profile UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T2322771068"] = "Preselect a profile" @@ -4728,8 +4728,8 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T25714 -- Preselect the audience age group UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T2645589441"] = "Preselect the audience age group" --- Assistant: Slide Assistant Options -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T3215549988"] = "Assistant: Slide Assistant Options" +-- Assistant: Slide Planner Assistant Options +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T3215549988"] = "Assistant: Slide Planner Assistant Options" -- Which audience expertise should be preselected? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T3228597992"] = "Which audience expertise should be preselected?" @@ -4740,14 +4740,14 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T34481 -- Preselect important aspects UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T3705987833"] = "Preselect important aspects" --- No Slide Assistant options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T4214398691"] = "No Slide Assistant options are preselected" +-- No Slide Planner Assistant options are preselected +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T4214398691"] = "No Slide Planner Assistant options are preselected" -- Preselect the audience profile UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T861397972"] = "Preselect the audience profile" --- Slide Assistant options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T93124146"] = "Slide Assistant options are preselected" +-- Slide Planner Assistant options are preselected +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T93124146"] = "Slide Planner Assistant options are preselected" -- Which audience age group should be preselected? UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGSLIDEBUILDER::T956845877"] = "Which audience age group should be preselected?" @@ -5193,8 +5193,8 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1617786407"] = "Coding" -- Analyze a text or an email for tasks you need to complete. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1728590051"] = "Analyze a text or an email for tasks you need to complete." --- Slide Assistant -UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1883918574"] = "Slide Assistant" +-- Slide Planner Assistant +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1883918574"] = "Slide Planner Assistant" -- Text Summarizer UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T1907192403"] = "Text Summarizer" @@ -6174,8 +6174,8 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T1546040625"] = "My Task -- Grammar & Spelling Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T166453786"] = "Grammar & Spelling Assistant" --- Slide Assistant -UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T1883918574"] = "Slide Assistant" +-- Slide Planner Assistant +UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T1883918574"] = "Slide Planner Assistant" -- Legal Check Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T1886447798"] = "Legal Check Assistant" diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs index a65c1534..b7dc39ef 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs @@ -154,6 +154,28 @@ public static partial class ProviderExtensions Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API, ]; + if(modelName is "gpt-5.3" || modelName.StartsWith("gpt-5.3-")) + return + [ + Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, + Capability.TEXT_OUTPUT, + + Capability.FUNCTION_CALLING, Capability.OPTIONAL_REASONING, + Capability.WEB_SEARCH, + Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API, + ]; + + if(modelName is "gpt-5.4" || modelName.StartsWith("gpt-5.4-")) + return + [ + Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, + Capability.TEXT_OUTPUT, + + Capability.FUNCTION_CALLING, Capability.OPTIONAL_REASONING, + Capability.WEB_SEARCH, + Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API, + ]; + return [ Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, @@ -161,6 +183,7 @@ public static partial class ProviderExtensions Capability.FUNCTION_CALLING, Capability.RESPONSES_API, + Capability.WEB_SEARCH, ]; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs index 70f06380..0dab2298 100644 --- a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs +++ b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs @@ -45,7 +45,7 @@ public static class ComponentsExtensions Components.ERI_ASSISTANT => TB("ERI Server"), Components.I18N_ASSISTANT => TB("Localization Assistant"), Components.DOCUMENT_ANALYSIS_ASSISTANT => TB("Document Analysis Assistant"), - Components.SLIDE_BUILDER_ASSISTANT => TB("Slide Assistant"), + Components.SLIDE_BUILDER_ASSISTANT => TB("Slide Planner Assistant"), Components.CHAT => TB("New Chat"), diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs index 5f7f0df0..4b4f6a08 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs @@ -25,13 +25,22 @@ public static partial class PluginFactory /// /// Initializes the enterprise encryption service by reading the encryption secret - /// from the Windows Registry or environment variables. + /// from the effective enterprise source. /// /// The Rust service to use for reading the encryption secret. public static async Task InitializeEnterpriseEncryption(Services.RustService rustService) { - LOG.LogInformation("Initializing enterprise encryption service..."); var encryptionSecret = await rustService.EnterpriseEnvConfigEncryptionSecret(); + InitializeEnterpriseEncryption(encryptionSecret); + } + + /// + /// Initializes the enterprise encryption service using a prefetched secret value. + /// + /// The base64-encoded enterprise encryption secret. + public static void InitializeEnterpriseEncryption(string? encryptionSecret) + { + LOG.LogInformation("Initializing enterprise encryption service..."); var enterpriseEncryptionLogger = Program.LOGGER_FACTORY.CreateLogger(); EnterpriseEncryption = new EnterpriseEncryption(enterpriseEncryptionLogger, encryptionSecret); diff --git a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs index 4d38eb15..656d7358 100644 --- a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs +++ b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs @@ -1,4 +1,8 @@ using AIStudio.Tools.PluginSystem; +using AIStudio.Settings; + +using System.Security.Cryptography; +using System.Text; namespace AIStudio.Tools.Services; @@ -7,8 +11,14 @@ public sealed class EnterpriseEnvironmentService(ILogger CURRENT_ENVIRONMENTS = []; public static bool HasValidEnterpriseSnapshot { get; private set; } + + private static EnterpriseSecretSnapshot CURRENT_SECRET_SNAPSHOT; private readonly record struct EnterpriseEnvironmentSnapshot(Guid ConfigurationId, string ConfigurationServerUrl, string? ETag); + + private readonly record struct EnterpriseSecretSnapshot(bool HasSecret, string Fingerprint); + + private readonly record struct EnterpriseSecretTarget(string SecretId, string SecretName, SecretStoreType StoreType) : ISecretId; #if DEBUG private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6); @@ -39,6 +49,7 @@ public sealed class EnterpriseEnvironmentService(ILogger(null, Event.ENTERPRISE_ENVIRONMENTS_CHANGED); } catch (Exception e) @@ -193,8 +229,81 @@ public sealed class EnterpriseEnvironmentService(ILogger BuildSecretSnapshot(string secret) + { + if (string.IsNullOrWhiteSpace(secret)) + return new EnterpriseSecretSnapshot(false, string.Empty); + + return new EnterpriseSecretSnapshot(true, await ComputeSecretFingerprint(secret)); + } + + private static async Task ComputeSecretFingerprint(string secret) + { + using var secretStream = new MemoryStream(Encoding.UTF8.GetBytes(secret)); + var hash = await SHA256.HashDataAsync(secretStream); + return Convert.ToHexString(hash); + } + private static string NormalizeServerUrl(string serverUrl) { return serverUrl.Trim().TrimEnd('/'); } + + private async Task RemoveEnterpriseManagedApiKeysAsync() + { + var secretTargets = GetEnterpriseManagedSecretTargets(); + if (secretTargets.Count == 0) + { + logger.LogInformation("No enterprise-managed API keys are currently known in the settings. No keyring cleanup is required."); + return; + } + + logger.LogInformation("Removing {SecretCount} enterprise-managed API key(s) from the OS keyring after an enterprise encryption secret change.", secretTargets.Count); + foreach (var target in secretTargets) + { + try + { + var deleteResult = await rustService.DeleteAPIKey(target, target.StoreType); + if (deleteResult.Success) + { + if (deleteResult.WasEntryFound) + logger.LogInformation("Successfully deleted enterprise-managed API key '{SecretName}' from the OS keyring.", target.SecretName); + else + logger.LogInformation("Enterprise-managed API key '{SecretName}' was already absent from the OS keyring.", target.SecretName); + } + else + logger.LogWarning("Failed to delete enterprise-managed API key '{SecretName}' from the OS keyring: {Issue}", target.SecretName, deleteResult.Issue); + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to delete enterprise-managed API key '{SecretName}' from the OS keyring.", target.SecretName); + } + } + } + + private static List GetEnterpriseManagedSecretTargets() + { + var configurationData = Program.SERVICE_PROVIDER.GetRequiredService().ConfigurationData; + var secretTargets = new HashSet(); + + AddEnterpriseManagedSecretTargets(configurationData.Providers, SecretStoreType.LLM_PROVIDER, secretTargets); + AddEnterpriseManagedSecretTargets(configurationData.EmbeddingProviders, SecretStoreType.EMBEDDING_PROVIDER, secretTargets); + AddEnterpriseManagedSecretTargets(configurationData.TranscriptionProviders, SecretStoreType.TRANSCRIPTION_PROVIDER, secretTargets); + + return secretTargets.ToList(); + } + + private static void AddEnterpriseManagedSecretTargets( + IEnumerable secrets, + SecretStoreType storeType, + ISet secretTargets) where TSecret : ISecretId, IConfigurationObject + { + foreach (var secret in secrets) + { + if (!secret.IsEnterpriseConfiguration || secret.EnterpriseConfigurationPluginId == Guid.Empty) + continue; + + secretTargets.Add(new EnterpriseSecretTarget(secret.SecretId, secret.SecretName, storeType)); + } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md index 5e4e7791..02e30fa4 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md @@ -1,13 +1,15 @@ # v26.3.1, build 235 (2026-03-xx xx:xx UTC) - Added support for the new Qwen 3.5 model family. -- Added a slide assistant, which helps you turn longer texts or documents into clear, structured presentation slides. Many thanks to Sabrina `Sabrina-devops` for her wonderful work on this assistant. +- Added a slide planner assistant, which helps you turn longer texts or documents into clear, structured presentation slides. Many thanks to Sabrina `Sabrina-devops` for her wonderful work on this assistant. - Added a reminder in chats and assistants that LLMs can make mistakes, helping you double-check important information more easily. - Added the ability to format your user prompt in the chat using icons instead of typing Markdown directly. - Added the ability to load a system prompt from a file when creating or editing chat templates. - Added a start-page setting, so AI Studio can now open directly on your preferred page when the app starts. Configuration plugins can also provide and optionally lock this default for organizations. - Added pre-call validation to check if the selected model exists for the provider before making the request. - Added math rendering in chats for LaTeX display formulas, including block formats such as `$$ ... $$` and `\[ ... \]`. +- Added the latest OpenAI models. - Released the document analysis assistant after an intense testing phase. +- Improved enterprise deployment for organizations: administrators can now provide up to 10 centrally managed enterprise configuration slots, use policy files on Linux and macOS, and continue using older configuration formats as a fallback during migration. - Improved the profile selection for assistants and the chat. You can now explicitly choose between the app default profile, no profile, or a specific profile. - Improved the performance by caching the OS language detection and requesting the user language only once per app start. - Improved the chat performance by reducing unnecessary UI updates, making chats smoother and more responsive, especially in longer conversations. @@ -20,6 +22,7 @@ - Improved file attachments in chats: configuration and project files such as `Dockerfile`, `Caddyfile`, `Makefile`, or `Jenkinsfile` are now included more reliably when you send them to the AI. - Improved the validation of additional API parameters in the advanced provider settings to help catch formatting mistakes earlier. - Improved the app startup resilience by allowing AI Studio to continue without Qdrant if it fails to initialize. +- Improved the translation assistant by updating the system and user prompts. - Fixed an issue where assistants hidden via configuration plugins still appear in "Send to ..." menus. Thanks, Gunnar, for reporting this issue. - Fixed an issue with voice recording where AI Studio could log errors and keep the feature available even though required parts failed to initialize. Voice recording is now disabled automatically for the current session in that case. - Fixed an issue where the app could turn white or appear invisible in certain chats after HTML-like content was shown. Thanks, Inga, for reporting this issue and providing some context on how to reproduce it. diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md index 279214d2..221a24db 100644 --- a/documentation/Enterprise IT.md +++ b/documentation/Enterprise IT.md @@ -15,123 +15,118 @@ AI Studio checks about every 16 minutes to see if the configuration ID, the serv ## Configure the devices So that MindWork AI Studio knows where to load which configuration, this information must be provided as metadata on employees' devices. Currently, the following options are available: -- **Registry** (only available for Microsoft Windows): On Windows devices, AI Studio first tries to read the information from the registry. The registry information can be managed and distributed centrally as a so-called Group Policy Object (GPO). +- **Windows Registry / GPO**: On Windows, AI Studio first tries to read the enterprise configuration metadata from the registry. This is the preferred option for centrally managed Windows devices. -- **Environment variables**: On all operating systems (on Windows as a fallback after the registry), AI Studio tries to read the configuration metadata from environment variables. +- **Policy files**: AI Studio can read simple YAML policy files from a system-wide directory. On Linux and macOS, this is the preferred option. On Windows, it is used as a fallback after the registry. + +- **Environment variables**: Environment variables are still supported on all operating systems, but they are now only used as the last fallback. + +### Source order and fallback behavior + +AI Studio does **not** merge the registry, policy files, and environment variables. Instead, it checks them in order: + +- **Windows:** Registry -> Policy files -> Environment variables +- **Linux:** Policy files -> Environment variables +- **macOS:** Policy files -> Environment variables + +For enterprise configurations, AI Studio uses the **first source that contains at least one valid enterprise configuration**. + +For the encryption secret, AI Studio uses the **first source that contains a non-empty encryption secret**, even if that source does not contain any enterprise configuration IDs or server URLs. This allows secret-only setups during migration or on machines that only need encrypted API key support. ### Multiple configurations (recommended) -AI Studio supports loading multiple enterprise configurations simultaneously. This enables hierarchical configuration schemes, e.g., organization-wide settings combined with department-specific settings. The following keys and variables are used: +AI Studio supports loading multiple enterprise configurations simultaneously. This enables hierarchical configuration schemes, such as organization-wide settings combined with institute- or department-specific settings. -- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `configs` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS`: A combined format containing one or more configuration entries. Each entry consists of a configuration ID and a server URL separated by `@`. Multiple entries are separated by `;`. The format is: `id1@url1;id2@url2;id3@url3`. The configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). +The preferred format is a fixed set of indexed pairs: -- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. All configurations share the same encryption secret. +- Registry values `config_id0` to `config_id9` together with `config_server_url0` to `config_server_url9` +- Environment variables `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID0` to `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID9` together with `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL0` to `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL9` +- Policy files `config0.yaml` to `config9.yaml` -**Example:** To configure two enterprise configurations (one for the organization and one for a department): +Each configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). Up to ten configurations are supported per device. -``` -MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS=9072b77d-ca81-40da-be6a-861da525ef7b@https://intranet.my-company.com:30100/ai-studio/configuration;a1b2c3d4-e5f6-7890-abcd-ef1234567890@https://intranet.my-company.com:30100/ai-studio/department-config +If multiple configurations define the same setting, the first definition wins. For indexed pairs and policy files, the order is slot `0`, then `1`, and so on up to `9`. + +### Windows registry example + +The Windows registry path is: + +`HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT` + +Example values: + +- `config_id0` = `9072b77d-ca81-40da-be6a-861da525ef7b` +- `config_server_url0` = `https://intranet.example.org/ai-studio/configuration` +- `config_id1` = `a1b2c3d4-e5f6-7890-abcd-ef1234567890` +- `config_server_url1` = `https://intranet.example.org/ai-studio/department-config` +- `config_encryption_secret` = `BASE64...` + +This approach works well with GPOs because each slot can be managed independently without rewriting a shared combined string. + +### Policy files + +#### Windows policy directory + +`%ProgramData%\MindWorkAI\AI-Studio\` + +#### Linux policy directories + +AI Studio checks each directory listed in `$XDG_CONFIG_DIRS` and looks for a `mindwork-ai-studio` subdirectory in each one. If `$XDG_CONFIG_DIRS` is empty or not set, AI Studio falls back to: + +`/etc/xdg/mindwork-ai-studio/` + +The directories from `$XDG_CONFIG_DIRS` are processed in order. + +#### macOS policy directory + +`/Library/Application Support/MindWork/AI Studio/` + +#### Policy file names and content + +Configuration files: + +- `config0.yaml` +- `config1.yaml` +- ... +- `config9.yaml` + +Each configuration file contains one configuration ID and one server URL: + +```yaml +id: "9072b77d-ca81-40da-be6a-861da525ef7b" +server_url: "https://intranet.example.org/ai-studio/configuration" ``` -**Priority:** When multiple configurations define the same setting (e.g., a provider with the same ID), the first definition wins. The order of entries in the variable determines priority. Place the organization-wide configuration first, followed by department-specific configurations if the organization should have higher priority. +Optional encryption secret file: -### Windows GPO / PowerShell example for `configs` +- `config_encryption_secret.yaml` -If you distribute multiple GPOs, each GPO should read and write the same registry value (`configs`) and only update its own `id@url` entry. Other entries must stay untouched. - -The following PowerShell example provides helper functions for appending and removing entries safely: - -```powershell -$RegistryPath = "HKCU:\Software\github\MindWork AI Studio\Enterprise IT" -$ConfigsValueName = "configs" - -function Get-ConfigEntries { - param([string]$RawValue) - - if ([string]::IsNullOrWhiteSpace($RawValue)) { return @() } - - $entries = @() - foreach ($part in $RawValue.Split(';')) { - $trimmed = $part.Trim() - if ([string]::IsNullOrWhiteSpace($trimmed)) { continue } - - $pair = $trimmed.Split('@', 2) - if ($pair.Count -ne 2) { continue } - - $id = $pair[0].Trim().ToLowerInvariant() - $url = $pair[1].Trim() - if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($url)) { continue } - - $entries += [PSCustomObject]@{ - Id = $id - Url = $url - } - } - - return $entries -} - -function ConvertTo-ConfigValue { - param([array]$Entries) - - return ($Entries | ForEach-Object { "$($_.Id)@$($_.Url)" }) -join ';' -} - -function Add-EnterpriseConfigEntry { - param( - [Parameter(Mandatory=$true)][Guid]$ConfigId, - [Parameter(Mandatory=$true)][string]$ServerUrl - ) - - if (-not (Test-Path $RegistryPath)) { - New-Item -Path $RegistryPath -Force | Out-Null - } - - $raw = (Get-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -ErrorAction SilentlyContinue).$ConfigsValueName - $entries = Get-ConfigEntries -RawValue $raw - $normalizedId = $ConfigId.ToString().ToLowerInvariant() - $normalizedUrl = $ServerUrl.Trim() - - # Replace only this one ID, keep all other entries unchanged. - $entries = @($entries | Where-Object { $_.Id -ne $normalizedId }) - $entries += [PSCustomObject]@{ - Id = $normalizedId - Url = $normalizedUrl - } - - Set-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -Type String -Value (ConvertTo-ConfigValue -Entries $entries) -} - -function Remove-EnterpriseConfigEntry { - param( - [Parameter(Mandatory=$true)][Guid]$ConfigId - ) - - if (-not (Test-Path $RegistryPath)) { return } - - $raw = (Get-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -ErrorAction SilentlyContinue).$ConfigsValueName - $entries = Get-ConfigEntries -RawValue $raw - $normalizedId = $ConfigId.ToString().ToLowerInvariant() - - # Remove only this one ID, keep all other entries unchanged. - $updated = @($entries | Where-Object { $_.Id -ne $normalizedId }) - Set-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -Type String -Value (ConvertTo-ConfigValue -Entries $updated) -} - -# Example usage: -# Add-EnterpriseConfigEntry -ConfigId "9072b77d-ca81-40da-be6a-861da525ef7b" -ServerUrl "https://intranet.example.org:30100/ai-studio/configuration" -# Remove-EnterpriseConfigEntry -ConfigId "9072b77d-ca81-40da-be6a-861da525ef7b" +```yaml +config_encryption_secret: "BASE64..." ``` -### Single configuration (legacy) +### Environment variable example -The following single-configuration keys and variables are still supported for backwards compatibility. AI Studio always reads both the multi-config and legacy variables and merges all found configurations into one list. If a configuration ID appears in both, the entry from the multi-config format takes priority (first occurrence wins). This means you can migrate to the new format incrementally without losing existing configurations: +If you need the fallback environment-variable format, configure the values like this: -- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID`: This must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). It uniquely identifies the configuration. You can use an ID per department, institute, or even per person. +```bash +MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID0=9072b77d-ca81-40da-be6a-861da525ef7b +MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL0=https://intranet.example.org/ai-studio/configuration +MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID1=a1b2c3d4-e5f6-7890-abcd-ef1234567890 +MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL1=https://intranet.example.org/ai-studio/department-config +MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET=BASE64... +``` -- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_server_url` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL`: An HTTP or HTTPS address using an IP address or DNS name. This is the web server from which AI Studio attempts to load the specified configuration as a ZIP file. +### Legacy formats (still supported) -- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. +The following older formats are still supported for backwards compatibility: + +- Registry value `configs` or environment variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS`: Combined format `id1@url1;id2@url2;...` +- Registry value `config_id` or environment variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID` +- Registry value `config_server_url` or environment variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL` +- Registry value `config_encryption_secret` or environment variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET` + +Within a single source, AI Studio reads the new indexed pairs first, then the combined legacy format, and finally the legacy single-configuration format. This makes it possible to migrate gradually without breaking older setups. ### How configurations are downloaded @@ -183,7 +178,7 @@ intranet.my-company.com:30100 { ## Important: Plugin ID must match the enterprise configuration ID -The `ID` field inside your configuration plugin (the Lua file) **must** be identical to the enterprise configuration ID used in the registry or environment variable. AI Studio uses this ID to match downloaded configurations to their plugins. If the IDs do not match, AI Studio will log a warning and the configuration may not be displayed correctly on the Information page. +The `ID` field inside your configuration plugin (the Lua file) **must** be identical to the enterprise configuration ID configured on the client device, whether it comes from the registry, a policy file, or an environment variable. AI Studio uses this ID to match downloaded configurations to their plugins. If the IDs do not match, AI Studio will log a warning and the configuration may not be displayed correctly on the Information page. For example, if your enterprise configuration ID is `9072b77d-ca81-40da-be6a-861da525ef7b`, then your plugin must declare: @@ -233,9 +228,10 @@ You can include encrypted API keys in your configuration plugins for cloud provi In AI Studio, enable the "Show administration settings" toggle in the app settings. Then click the "Generate encryption secret and copy to clipboard" button in the "Enterprise Administration" section. This generates a cryptographically secure 256-bit key and copies it to your clipboard as a base64 string. 2. **Deploy the encryption secret:** - Distribute the secret to all client machines via Group Policy (Windows Registry) or environment variables: - - Registry: `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret` - - Environment: `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET` + Distribute the secret to all client machines using any supported enterprise source. The secret can be deployed on its own, even when no enterprise configuration IDs or server URLs are defined on that machine: + - Windows Registry / GPO: `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret` + - Policy file: `config_encryption_secret.yaml` + - Environment fallback: `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET` You must also deploy the same secret on the machine where you will export the encrypted API keys (step 3). diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index a1477269..593ac2d7 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -1,14 +1,24 @@ -use std::env; -use std::sync::OnceLock; +use crate::api_token::APIToken; use log::{debug, info, warn}; use rocket::get; use rocket::serde::json::Json; use serde::Serialize; +use std::collections::{HashMap, HashSet}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use sys_locale::get_locale; -use crate::api_token::APIToken; const DEFAULT_LANGUAGE: &str = "en-US"; +const ENTERPRISE_CONFIG_SLOT_COUNT: usize = 10; + +#[cfg(target_os = "windows")] +const ENTERPRISE_REGISTRY_KEY_PATH: &str = r"Software\github\MindWork AI Studio\Enterprise IT"; + +const ENTERPRISE_POLICY_SECRET_FILE_NAME: &str = "config_encryption_secret.yaml"; + /// The data directory where the application stores its data. pub static DATA_DIRECTORY: OnceLock = OnceLock::new(); @@ -140,27 +150,6 @@ fn detect_user_language() -> (String, LanguageDetectionSource) { ) } -#[cfg(test)] -mod tests { - use super::normalize_locale_tag; - - #[test] - fn normalize_locale_tag_supports_common_linux_formats() { - assert_eq!(normalize_locale_tag("de_DE.UTF-8"), Some(String::from("de-DE"))); - assert_eq!(normalize_locale_tag("de_DE@euro"), Some(String::from("de-DE"))); - assert_eq!(normalize_locale_tag("de"), Some(String::from("de"))); - assert_eq!(normalize_locale_tag("en-US"), Some(String::from("en-US"))); - } - - #[test] - fn normalize_locale_tag_rejects_non_language_locales() { - assert_eq!(normalize_locale_tag("C"), None); - assert_eq!(normalize_locale_tag("C.UTF-8"), None); - assert_eq!(normalize_locale_tag("POSIX"), None); - assert_eq!(normalize_locale_tag(""), None); - } -} - #[get("/system/language")] pub fn read_user_language(_token: APIToken) -> String { USER_LANGUAGE @@ -191,191 +180,828 @@ pub fn read_user_language(_token: APIToken) -> String { .clone() } -#[get("/system/enterprise/config/id")] -pub fn read_enterprise_env_config_id(_token: APIToken) -> String { - // - // When we are on a Windows machine, we try to read the enterprise config from - // the Windows registry. In case we can't find the registry key, or we are on a - // macOS or Linux machine, we try to read the enterprise config from the - // environment variables. - // - // The registry key is: - // HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT - // - // In this registry key, we expect the following values: - // - config_id - // - // The environment variable is: - // MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID - // - debug!("Trying to read the enterprise environment for some config ID."); - get_enterprise_configuration( - "config_id", - "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID", - ) -} - -#[get("/system/enterprise/config/server")] -pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { - // - // When we are on a Windows machine, we try to read the enterprise config from - // the Windows registry. In case we can't find the registry key, or we are on a - // macOS or Linux machine, we try to read the enterprise config from the - // environment variables. - // - // The registry key is: - // HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT - // - // In this registry key, we expect the following values: - // - config_server_url - // - // The environment variable is: - // MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL - // - debug!("Trying to read the enterprise environment for the config server URL."); - get_enterprise_configuration( - "config_server_url", - "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL", - ) -} - -#[get("/system/enterprise/config/encryption_secret")] -pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String { - // - // When we are on a Windows machine, we try to read the enterprise config from - // the Windows registry. In case we can't find the registry key, or we are on a - // macOS or Linux machine, we try to read the enterprise config from the - // environment variables. - // - // The registry key is: - // HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT - // - // In this registry key, we expect the following values: - // - config_encryption_secret - // - // The environment variable is: - // MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET - // - debug!("Trying to read the enterprise environment for the config encryption secret."); - get_enterprise_configuration( - "config_encryption_secret", - "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET", - ) -} - /// Represents a single enterprise configuration entry with an ID and server URL. -#[derive(Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct EnterpriseConfig { pub id: String, pub server_url: String, } -/// Returns all enterprise configurations. Collects configurations from both the -/// new multi-config format (`id1@url1;id2@url2`) and the legacy single-config -/// environment variables, merging them into one list. Duplicates (by ID) are -/// skipped — the first occurrence wins. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct EnterpriseSourceData { + source_name: String, + configs: Vec, + encryption_secret: String, +} + +#[get("/system/enterprise/config/id")] +pub fn read_enterprise_env_config_id(_token: APIToken) -> String { + debug!("Trying to read the effective enterprise configuration ID."); + resolve_effective_enterprise_config_source() + .configs + .into_iter() + .next() + .map(|config| config.id) + .unwrap_or_default() +} + +#[get("/system/enterprise/config/server")] +pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { + debug!("Trying to read the effective enterprise configuration server URL."); + resolve_effective_enterprise_config_source() + .configs + .into_iter() + .next() + .map(|config| config.server_url) + .unwrap_or_default() +} + +#[get("/system/enterprise/config/encryption_secret")] +pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String { + debug!("Trying to read the effective enterprise configuration encryption secret."); + resolve_effective_enterprise_secret_source().encryption_secret +} + +/// Returns all enterprise configurations from the effective source. #[get("/system/enterprise/configs")] pub fn read_enterprise_configs(_token: APIToken) -> Json> { - info!("Trying to read the enterprise environment for all configurations."); - - let mut configs: Vec = Vec::new(); - let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); - - // Read the new combined format: - let combined = get_enterprise_configuration( - "configs", - "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS", - ); - - if !combined.is_empty() { - // Parse the new format: id1@url1;id2@url2;... - for entry in combined.split(';') { - let entry = entry.trim(); - if entry.is_empty() { - continue; - } - - // Split at the first '@' (GUIDs never contain '@'): - if let Some((id, url)) = entry.split_once('@') { - let id = id.trim().to_lowercase(); - let url = url.trim().to_string(); - if !id.is_empty() && !url.is_empty() && seen_ids.insert(id.clone()) { - configs.push(EnterpriseConfig { id, server_url: url }); - } - } - } - } - - // Also read the legacy single-config variables: - let config_id = get_enterprise_configuration( - "config_id", - "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID", - ); - - let config_server_url = get_enterprise_configuration( - "config_server_url", - "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL", - ); - - if !config_id.is_empty() && !config_server_url.is_empty() { - let id = config_id.trim().to_lowercase(); - if seen_ids.insert(id.clone()) { - configs.push(EnterpriseConfig { id, server_url: config_server_url }); - } - } - - Json(configs) + info!("Trying to read the effective enterprise configurations."); + Json(resolve_effective_enterprise_config_source().configs) } -fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { +fn resolve_effective_enterprise_config_source() -> EnterpriseSourceData { + select_effective_enterprise_config_source(gather_enterprise_sources()) +} + +fn resolve_effective_enterprise_secret_source() -> EnterpriseSourceData { + select_effective_enterprise_secret_source(gather_enterprise_sources()) +} + +fn select_effective_enterprise_config_source( + sources: Vec, +) -> EnterpriseSourceData { + for source in sources { + if !source.configs.is_empty() { + info!("Using enterprise configuration source '{}'.", source.source_name); + return source; + } + + info!("Enterprise configuration source '{}' did not provide any valid configurations.", source.source_name); + } + + info!("No enterprise configuration source provided any valid configurations."); + EnterpriseSourceData::default() +} + +fn select_effective_enterprise_secret_source( + sources: Vec, +) -> EnterpriseSourceData { + for source in sources { + if !source.encryption_secret.is_empty() { + info!("Using enterprise encryption-secret source '{}'.", source.source_name); + return source; + } + + info!("Enterprise encryption-secret source '{}' did not provide a usable secret.", source.source_name); + } + + info!("No enterprise source provided an enterprise encryption secret."); + EnterpriseSourceData::default() +} + +fn gather_enterprise_sources() -> Vec { cfg_if::cfg_if! { if #[cfg(target_os = "windows")] { - info!(r"Detected a Windows machine, trying to read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\{}' or the environment variable '{}'.", _reg_value, env_name); - use windows_registry::*; - let key_path = r"Software\github\MindWork AI Studio\Enterprise IT"; - let key = match CURRENT_USER.open(key_path) { - Ok(key) => key, - Err(_) => { - info!(r"Could not read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\{}'. Falling back to the environment variable '{}'.", _reg_value, env_name); - return match env::var(env_name) { - Ok(val) => { - info!("Falling back to the environment variable '{}' was successful.", env_name); - val - }, - Err(_) => { - info!("Falling back to the environment variable '{}' was not successful. It seems that there is no enterprise environment available.", env_name); - "".to_string() - }, - } - }, - }; - - match key.get_string(_reg_value) { - Ok(val) => val, - Err(_) => { - info!(r"We could read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT', but the value '{}' could not be read. Falling back to the environment variable '{}'.", _reg_value, env_name); - match env::var(env_name) { - Ok(val) => { - info!("Falling back to the environment variable '{}' was successful.", env_name); - val - }, - Err(_) => { - info!("Falling back to the environment variable '{}' was not successful. It seems that there is no enterprise environment available.", env_name); - "".to_string() - } - } - }, - } + vec![ + load_registry_enterprise_source(), + load_policy_file_enterprise_source(), + load_environment_enterprise_source(), + ] + } else if #[cfg(any(target_os = "linux", target_os = "macos"))] { + vec![ + load_policy_file_enterprise_source(), + load_environment_enterprise_source(), + ] } else { - // In the case of macOS or Linux, we just read the environment variable: - info!(r"Detected a Unix machine, trying to read the environment variable '{}'.", env_name); - match env::var(env_name) { - Ok(val) => val, - Err(_) => { - info!("The environment variable '{}' was not found. It seems that there is no enterprise environment available.", env_name); - "".to_string() - } - } + vec![load_environment_enterprise_source()] } } } + +#[cfg(target_os = "windows")] +fn load_registry_enterprise_source() -> EnterpriseSourceData { + use windows_registry::*; + + info!(r"Trying to read enterprise configuration metadata from 'HKEY_CURRENT_USER\{}'.", ENTERPRISE_REGISTRY_KEY_PATH); + + let mut values = HashMap::new(); + let key = match CURRENT_USER.open(ENTERPRISE_REGISTRY_KEY_PATH) { + Ok(key) => key, + Err(_) => { + info!(r"Could not read 'HKEY_CURRENT_USER\{}'.", ENTERPRISE_REGISTRY_KEY_PATH); + return EnterpriseSourceData { + source_name: String::from("Windows registry"), + ..EnterpriseSourceData::default() + }; + } + }; + + for index in 0..ENTERPRISE_CONFIG_SLOT_COUNT { + insert_registry_value(&mut values, &key, &format!("config_id{index}")); + insert_registry_value(&mut values, &key, &format!("config_server_url{index}")); + } + + for key_name in [ + "configs", + "config_id", + "config_server_url", + "config_encryption_secret", + ] { + insert_registry_value(&mut values, &key, key_name); + } + + parse_enterprise_source_values("Windows registry", &values) +} + +#[cfg(target_os = "windows")] +fn insert_registry_value( + values: &mut HashMap, + key: &windows_registry::Key, + key_name: &str, +) { + if let Ok(value) = key.get_string(key_name) { + values.insert(String::from(key_name), value); + } +} + +fn load_policy_file_enterprise_source() -> EnterpriseSourceData { + let directories = enterprise_policy_directories(); + info!("Trying to read enterprise configuration metadata from policy files in {} director{}.", directories.len(), if directories.len() == 1 { "y" } else { "ies" }); + + let values = load_policy_values_from_directories(&directories); + parse_enterprise_source_values("policy files", &values) +} + +fn load_environment_enterprise_source() -> EnterpriseSourceData { + info!("Trying to read enterprise configuration metadata from environment variables."); + let mut values = HashMap::new(); + for index in 0..ENTERPRISE_CONFIG_SLOT_COUNT { + insert_env_value(&mut values, &format!("MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID{index}"), &format!("config_id{index}")); + insert_env_value(&mut values, &format!("MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL{index}"), &format!("config_server_url{index}")); + } + + insert_env_value(&mut values, "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS", "configs"); + insert_env_value(&mut values, "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID", "config_id"); + insert_env_value(&mut values, "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL", "config_server_url"); + insert_env_value(&mut values, "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET", "config_encryption_secret"); + + parse_enterprise_source_values("environment variables", &values) +} + +fn insert_env_value(values: &mut HashMap, env_name: &str, key_name: &str) { + if let Ok(value) = env::var(env_name) { + values.insert(String::from(key_name), value); + } +} + +#[cfg(target_os = "windows")] +fn enterprise_policy_directories() -> Vec { + let base = env::var_os("ProgramData") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + vec![base.join("MindWorkAI").join("AI-Studio")] +} + +#[cfg(target_os = "linux")] +fn enterprise_policy_directories() -> Vec { + let xdg_config_dirs = env::var("XDG_CONFIG_DIRS").ok(); + linux_policy_directories_from_xdg(xdg_config_dirs.as_deref()) +} + +#[cfg(target_os = "macos")] +fn enterprise_policy_directories() -> Vec { + vec![PathBuf::from( + "/Library/Application Support/MindWork/AI Studio", + )] +} + +#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] +fn enterprise_policy_directories() -> Vec { + Vec::new() +} + +#[cfg(any(target_os = "linux", test))] +fn linux_policy_directories_from_xdg(xdg_config_dirs: Option<&str>) -> Vec { + let mut directories = Vec::new(); + if let Some(raw_directories) = xdg_config_dirs { + for path in raw_directories.split(':') { + if let Some(path) = normalize_enterprise_value(path) { + directories.push(PathBuf::from(path).join("mindwork-ai-studio")); + } + } + } + + if directories.is_empty() { + directories.push(PathBuf::from("/etc/xdg/mindwork-ai-studio")); + } + + directories +} + +fn load_policy_values_from_directories(directories: &[PathBuf]) -> HashMap { + let mut values = HashMap::new(); + for directory in directories { + info!("Checking enterprise policy directory '{}'.", directory.display()); + for index in 0..ENTERPRISE_CONFIG_SLOT_COUNT { + let path = directory.join(format!("config{index}.yaml")); + if let Some(config_values) = read_policy_yaml_mapping(&path) { + if let Some(id) = config_values.get("id") { + insert_first_non_empty_value(&mut values, &format!("config_id{index}"), id); + } + + if let Some(server_url) = config_values.get("server_url") { + insert_first_non_empty_value(&mut values, &format!("config_server_url{index}"), server_url); + } + } + } + + let secret_path = directory.join(ENTERPRISE_POLICY_SECRET_FILE_NAME); + if let Some(secret_values) = read_policy_yaml_mapping(&secret_path) { + if let Some(secret) = secret_values.get("config_encryption_secret") { + insert_first_non_empty_value(&mut values, "config_encryption_secret", secret); + } + } + } + + values +} + +fn read_policy_yaml_mapping(path: &Path) -> Option> { + if !path.exists() { + return None; + } + + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(error) => { + warn!("Could not read enterprise policy file '{}': {}", path.display(), error); + return None; + } + }; + + match parse_policy_yaml_mapping(path, &content) { + Some(values) => Some(values), + None => { + warn!("Could not parse enterprise policy file '{}'.", path.display()); + None + } + } +} + +fn parse_policy_yaml_mapping(path: &Path, content: &str) -> Option> { + let mut values = HashMap::new(); + for (line_number, line) in content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let (key, raw_value) = match trimmed.split_once(':') { + Some(parts) => parts, + None => { + warn!("Invalid enterprise policy file '{}': line {} does not contain ':'.", path.display(), line_number + 1); + return None; + } + }; + + let key = key.trim(); + if key.is_empty() { + warn!("Invalid enterprise policy file '{}': line {} contains an empty key.", path.display(), line_number + 1); + return None; + } + + let value = match parse_policy_yaml_value(raw_value) { + Some(value) => value, + None => { + warn!("Invalid enterprise policy file '{}': line {} contains an unsupported YAML value.", path.display(), line_number + 1); + return None; + } + }; + + values.insert(String::from(key), value); + } + + Some(values) +} + +fn parse_policy_yaml_value(raw_value: &str) -> Option { + let trimmed = raw_value.trim(); + if trimmed.is_empty() { + return Some(String::new()); + } + + if trimmed.starts_with('"') || trimmed.ends_with('"') { + if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { + return Some(trimmed[1..trimmed.len() - 1].to_string()); + } + + return None; + } + + if trimmed.starts_with('\'') || trimmed.ends_with('\'') { + if trimmed.len() >= 2 && trimmed.starts_with('\'') && trimmed.ends_with('\'') { + return Some(trimmed[1..trimmed.len() - 1].to_string()); + } + + return None; + } + + Some(String::from(trimmed)) +} + +fn insert_first_non_empty_value(values: &mut HashMap, key: &str, raw_value: &str) { + if let Some(value) = normalize_enterprise_value(raw_value) { + values.entry(String::from(key)).or_insert(value); + } +} + +fn parse_enterprise_source_values( + source_name: &str, + values: &HashMap, +) -> EnterpriseSourceData { + let mut configs = Vec::new(); + let mut seen_ids = HashSet::new(); + + for index in 0..ENTERPRISE_CONFIG_SLOT_COUNT { + let id_key = format!("config_id{index}"); + let server_url_key = format!("config_server_url{index}"); + add_enterprise_config_pair( + source_name, + &format!("indexed slot {index}"), + values.get(&id_key).map(String::as_str), + values.get(&server_url_key).map(String::as_str), + &mut configs, + &mut seen_ids, + ); + } + + if let Some(combined) = values + .get("configs") + .and_then(|value| normalize_enterprise_value(value)) + { + add_combined_enterprise_configs(source_name, &combined, &mut configs, &mut seen_ids); + } + + add_enterprise_config_pair( + source_name, + "legacy single configuration", + values.get("config_id").map(String::as_str), + values.get("config_server_url").map(String::as_str), + &mut configs, + &mut seen_ids, + ); + + let encryption_secret = values + .get("config_encryption_secret") + .and_then(|value| normalize_enterprise_value(value)) + .unwrap_or_default(); + + EnterpriseSourceData { + source_name: String::from(source_name), + configs, + encryption_secret, + } +} + +fn add_enterprise_config_pair( + source_name: &str, + context: &str, + raw_id: Option<&str>, + raw_server_url: Option<&str>, + configs: &mut Vec, + seen_ids: &mut HashSet, +) { + let id = raw_id.and_then(normalize_enterprise_config_id); + let server_url = raw_server_url.and_then(normalize_enterprise_value); + + match (id, server_url) { + (Some(id), Some(server_url)) => { + if seen_ids.insert(id.clone()) { + configs.push(EnterpriseConfig { id, server_url }); + } else { + info!("Ignoring duplicate enterprise configuration '{}' from {} in '{}'.", id, source_name, context); + } + } + + (Some(_), None) | (None, Some(_)) => { + warn!("Ignoring incomplete enterprise configuration from {} in '{}'.", source_name, context); + } + + (None, None) => {} + } +} + +fn add_combined_enterprise_configs( + source_name: &str, + combined: &str, + configs: &mut Vec, + seen_ids: &mut HashSet, +) { + for (index, entry) in combined.split(';').enumerate() { + let trimmed = entry.trim(); + if trimmed.is_empty() { + continue; + } + + let Some((raw_id, raw_server_url)) = trimmed.split_once('@') else { + warn!("Ignoring malformed enterprise configuration entry '{}' from {} in combined legacy format.", trimmed, source_name); + continue; + }; + + add_enterprise_config_pair( + source_name, + &format!("combined legacy entry {}", index + 1), + Some(raw_id), + Some(raw_server_url), + configs, + seen_ids, + ); + } +} + +fn normalize_enterprise_value(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(String::from(trimmed)) + } +} + +fn normalize_enterprise_config_id(value: &str) -> Option { + normalize_enterprise_value(value).map(|value| value.to_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::{ + linux_policy_directories_from_xdg, load_policy_values_from_directories, + normalize_locale_tag, parse_enterprise_source_values, + select_effective_enterprise_config_source, select_effective_enterprise_secret_source, + EnterpriseConfig, EnterpriseSourceData, + }; + use std::collections::HashMap; + use std::fs; + use std::path::PathBuf; + use tempfile::tempdir; + + const TEST_ID_A: &str = "9072B77D-CA81-40DA-BE6A-861DA525EF7B"; + const TEST_ID_B: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + const TEST_ID_C: &str = "11111111-2222-3333-4444-555555555555"; + + #[test] + fn normalize_locale_tag_supports_common_linux_formats() { + assert_eq!( + normalize_locale_tag("de_DE.UTF-8"), + Some(String::from("de-DE")) + ); + assert_eq!( + normalize_locale_tag("de_DE@euro"), + Some(String::from("de-DE")) + ); + assert_eq!(normalize_locale_tag("de"), Some(String::from("de"))); + assert_eq!(normalize_locale_tag("en-US"), Some(String::from("en-US"))); + } + + #[test] + fn normalize_locale_tag_rejects_non_language_locales() { + assert_eq!(normalize_locale_tag("C"), None); + assert_eq!(normalize_locale_tag("C.UTF-8"), None); + assert_eq!(normalize_locale_tag("POSIX"), None); + assert_eq!(normalize_locale_tag(""), None); + } + + #[test] + fn parse_enterprise_source_values_prefers_indexed_then_combined_then_legacy() { + let mut values = HashMap::new(); + values.insert(String::from("config_id0"), String::from(TEST_ID_A)); + values.insert( + String::from("config_server_url0"), + String::from(" https://indexed.example.org "), + ); + values.insert( + String::from("configs"), + format!( + "{TEST_ID_A}@https://duplicate.example.org;{TEST_ID_B}@https://combined.example.org" + ), + ); + values.insert(String::from("config_id"), String::from(TEST_ID_C)); + values.insert( + String::from("config_server_url"), + String::from("https://legacy.example.org"), + ); + values.insert( + String::from("config_encryption_secret"), + String::from(" secret "), + ); + + let source = parse_enterprise_source_values("test", &values); + + assert_eq!( + source.configs, + vec![ + EnterpriseConfig { + id: String::from("9072b77d-ca81-40da-be6a-861da525ef7b"), + server_url: String::from("https://indexed.example.org"), + }, + EnterpriseConfig { + id: String::from(TEST_ID_B), + server_url: String::from("https://combined.example.org"), + }, + EnterpriseConfig { + id: String::from(TEST_ID_C), + server_url: String::from("https://legacy.example.org"), + }, + ] + ); + assert_eq!(source.encryption_secret, "secret"); + } + + #[test] + fn parse_enterprise_source_values_supports_gaps_between_indexed_slots() { + let mut values = HashMap::new(); + values.insert(String::from("config_id0"), String::from(TEST_ID_A)); + values.insert( + String::from("config_server_url0"), + String::from("https://slot0.example.org"), + ); + values.insert(String::from("config_id4"), String::from(TEST_ID_B)); + values.insert( + String::from("config_server_url4"), + String::from("https://slot4.example.org"), + ); + + let source = parse_enterprise_source_values("test", &values); + + assert_eq!( + source.configs, + vec![ + EnterpriseConfig { + id: String::from("9072b77d-ca81-40da-be6a-861da525ef7b"), + server_url: String::from("https://slot0.example.org"), + }, + EnterpriseConfig { + id: String::from(TEST_ID_B), + server_url: String::from("https://slot4.example.org"), + }, + ] + ); + } + + #[test] + fn select_effective_enterprise_config_source_uses_first_source_with_configs_only() { + let selected = select_effective_enterprise_config_source(vec![ + EnterpriseSourceData { + source_name: String::from("registry"), + configs: vec![EnterpriseConfig { + id: TEST_ID_A.to_lowercase(), + server_url: String::from("https://registry.example.org"), + }], + encryption_secret: String::new(), + }, + EnterpriseSourceData { + source_name: String::from("environment"), + configs: vec![EnterpriseConfig { + id: String::from(TEST_ID_B), + server_url: String::from("https://env.example.org"), + }], + encryption_secret: String::from("ENV-SECRET"), + }, + ]); + + assert_eq!(selected.source_name, "registry"); + assert_eq!(selected.encryption_secret, ""); + assert_eq!(selected.configs.len(), 1); + } + + #[test] + fn select_effective_enterprise_secret_source_allows_secret_only_source() { + let selected = select_effective_enterprise_secret_source(vec![ + EnterpriseSourceData { + source_name: String::from("policy files"), + configs: Vec::new(), + encryption_secret: String::from("POLICY-SECRET"), + }, + EnterpriseSourceData { + source_name: String::from("environment"), + configs: vec![EnterpriseConfig { + id: String::from(TEST_ID_B), + server_url: String::from("https://env.example.org"), + }], + encryption_secret: String::new(), + }, + ]); + + assert_eq!(selected.source_name, "policy files"); + assert_eq!(selected.encryption_secret, "POLICY-SECRET"); + assert!(selected.configs.is_empty()); + } + + #[test] + fn select_effective_enterprise_secret_source_falls_back_independently_from_configs() { + let selected = select_effective_enterprise_secret_source(vec![ + EnterpriseSourceData { + source_name: String::from("registry"), + configs: vec![EnterpriseConfig { + id: TEST_ID_A.to_lowercase(), + server_url: String::from("https://registry.example.org"), + }], + encryption_secret: String::new(), + }, + EnterpriseSourceData { + source_name: String::from("environment"), + configs: Vec::new(), + encryption_secret: String::from("ENV-SECRET"), + }, + ]); + + assert_eq!(selected.source_name, "environment"); + assert_eq!(selected.encryption_secret, "ENV-SECRET"); + assert!(selected.configs.is_empty()); + } + + #[test] + fn select_effective_enterprise_secret_source_ignores_empty_secrets() { + let selected = select_effective_enterprise_secret_source(vec![ + EnterpriseSourceData { + source_name: String::from("policy files"), + configs: Vec::new(), + encryption_secret: String::new(), + }, + EnterpriseSourceData { + source_name: String::from("environment"), + configs: Vec::new(), + encryption_secret: String::from("VALID-SECRET"), + }, + ]); + + assert_eq!(selected.source_name, "environment"); + assert_eq!(selected.encryption_secret, "VALID-SECRET"); + } + + #[test] + fn parse_enterprise_source_values_supports_secret_without_configs() { + let mut values = HashMap::new(); + values.insert( + String::from("config_encryption_secret"), + String::from(" SECRET-ONLY "), + ); + + let source = parse_enterprise_source_values("environment variables", &values); + + assert!(source.configs.is_empty()); + assert_eq!(source.encryption_secret, "SECRET-ONLY"); + } + + #[test] + fn linux_policy_directories_from_xdg_preserves_order_and_falls_back() { + assert_eq!( + linux_policy_directories_from_xdg(Some(" /opt/company:/etc/xdg ")), + vec![ + PathBuf::from("/opt/company/mindwork-ai-studio"), + PathBuf::from("/etc/xdg/mindwork-ai-studio"), + ] + ); + + assert_eq!( + linux_policy_directories_from_xdg(Some(" : ")), + vec![PathBuf::from("/etc/xdg/mindwork-ai-studio")] + ); + assert_eq!( + linux_policy_directories_from_xdg(None), + vec![PathBuf::from("/etc/xdg/mindwork-ai-studio")] + ); + } + + #[test] + fn load_policy_values_from_directories_uses_first_directory_wins() { + let directory_a = tempdir().unwrap(); + let directory_b = tempdir().unwrap(); + + fs::write( + directory_a.path().join("config0.yaml"), + "id: \"9072b77d-ca81-40da-be6a-861da525ef7b\"\nserver_url: \"https://org.example.org\"", + ) + .unwrap(); + fs::write( + directory_a.path().join("config_encryption_secret.yaml"), + "config_encryption_secret: \"SECRET-A\"", + ) + .unwrap(); + + fs::write( + directory_b.path().join("config0.yaml"), + "id: \"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\"\nserver_url: \"https://ignored.example.org\"", + ) + .unwrap(); + fs::write( + directory_b.path().join("config1.yaml"), + "id: \"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\"\nserver_url: \"https://dept.example.org\"", + ) + .unwrap(); + fs::write( + directory_b.path().join("config_encryption_secret.yaml"), + "config_encryption_secret: \"SECRET-B\"", + ) + .unwrap(); + + let values = load_policy_values_from_directories(&[ + directory_a.path().to_path_buf(), + directory_b.path().to_path_buf(), + ]); + + assert_eq!( + values.get("config_id0").map(String::as_str), + Some("9072b77d-ca81-40da-be6a-861da525ef7b") + ); + assert_eq!( + values.get("config_server_url0").map(String::as_str), + Some("https://org.example.org") + ); + assert_eq!( + values.get("config_id1").map(String::as_str), + Some("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + ); + assert_eq!( + values.get("config_encryption_secret").map(String::as_str), + Some("SECRET-A") + ); + } + + #[test] + fn load_policy_values_from_directories_supports_gaps_between_policy_slots() { + let directory = tempdir().unwrap(); + + fs::write( + directory.path().join("config0.yaml"), + "id: \"9072b77d-ca81-40da-be6a-861da525ef7b\"\nserver_url: \"https://slot0.example.org\"", + ) + .unwrap(); + fs::write( + directory.path().join("config4.yaml"), + "id: \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\"\nserver_url: \"https://slot4.example.org\"", + ) + .unwrap(); + + let values = load_policy_values_from_directories(&[directory.path().to_path_buf()]); + let source = parse_enterprise_source_values("policy files", &values); + + assert_eq!( + source.configs, + vec![ + EnterpriseConfig { + id: String::from("9072b77d-ca81-40da-be6a-861da525ef7b"), + server_url: String::from("https://slot0.example.org"), + }, + EnterpriseConfig { + id: String::from(TEST_ID_B), + server_url: String::from("https://slot4.example.org"), + }, + ] + ); + } + + #[test] + fn load_policy_values_from_directories_supports_secret_only_policy_files() { + let directory = tempdir().unwrap(); + + fs::write( + directory.path().join("config_encryption_secret.yaml"), + "config_encryption_secret: \"POLICY-SECRET\"", + ) + .unwrap(); + + let values = load_policy_values_from_directories(&[directory.path().to_path_buf()]); + let source = parse_enterprise_source_values("policy files", &values); + + assert!(source.configs.is_empty()); + assert_eq!(source.encryption_secret, "POLICY-SECRET"); + } + + #[test] + fn load_policy_values_from_directories_ignores_invalid_and_incomplete_files() { + let directory = tempdir().unwrap(); + + fs::write(directory.path().join("config0.yaml"), "id [broken").unwrap(); + fs::write( + directory.path().join("config1.yaml"), + "id: \"9072b77d-ca81-40da-be6a-861da525ef7b\"", + ) + .unwrap(); + + let values = load_policy_values_from_directories(&[directory.path().to_path_buf()]); + let source = parse_enterprise_source_values("policy files", &values); + + assert!(source.configs.is_empty()); + } +} \ No newline at end of file