mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-05-19 18:12:15 +00:00
Merge branch 'main' into pr/667
# Conflicts: # app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md
This commit is contained in:
commit
9186e4b26d
@ -105,6 +105,7 @@ MindWork AI Studio is a free desktop app for macOS, Windows, and Linux. It provi
|
||||
|
||||
**Key advantages:**
|
||||
- **Free of charge**: The app is free to use, both for personal and commercial purposes.
|
||||
- **Democratization of AI**: We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models.
|
||||
- **Independence**: You are not tied to any single provider. Instead, you can choose the providers that best suit your needs. Right now, we support:
|
||||
- [OpenAI](https://openai.com/) (GPT5, GPT4.1, o1, o3, o4, etc.)
|
||||
- [Perplexity](https://www.perplexity.ai/)
|
||||
|
||||
@ -1804,21 +1804,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Limitations
|
||||
-- Personal Needs and Limitations of Web Services
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1839655973"] = "Personal Needs and Limitations of Web Services"
|
||||
|
||||
-- Democratization of AI
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Democratization of AI"
|
||||
|
||||
-- While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3552777197"] = "While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations."
|
||||
|
||||
-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3569462457"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time."
|
||||
|
||||
-- Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3622193740"] = "Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices."
|
||||
-- We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3672974243"] = "We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge."
|
||||
|
||||
-- Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T372007989"] = "Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge."
|
||||
|
||||
-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T483341611"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community."
|
||||
|
||||
-- Cross-Platform and Modern Development
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T843057510"] = "Cross-Platform and Modern Development"
|
||||
|
||||
-- Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T904941692"] = "Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design."
|
||||
|
||||
-- Copies the content to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Copies the content to the clipboard"
|
||||
|
||||
@ -2434,9 +2440,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "You'll be able t
|
||||
-- It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question."
|
||||
|
||||
-- Democratization of AI
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Democratization of AI"
|
||||
|
||||
-- Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer."
|
||||
|
||||
-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2201645589"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge."
|
||||
|
||||
-- You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats."
|
||||
|
||||
@ -4981,6 +4993,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "You only pay for what yo
|
||||
-- Assistants
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1614176092"] = "Assistants"
|
||||
|
||||
-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1628689293"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models."
|
||||
|
||||
-- Unrestricted usage
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1686815996"] = "Unrestricted usage"
|
||||
|
||||
@ -4990,6 +5005,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1702902297"] = "Introduction"
|
||||
-- Vision
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1892426825"] = "Vision"
|
||||
|
||||
-- Democratization of AI
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1986314327"] = "Democratization of AI"
|
||||
|
||||
-- Let's get started
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2331588413"] = "Let's get started"
|
||||
|
||||
@ -5044,12 +5062,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1019424746"] = "Startup log file
|
||||
-- Browse AI Studio's source code on GitHub — we welcome your contributions.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Browse AI Studio's source code on GitHub — we welcome your contributions."
|
||||
|
||||
-- ID mismatch: the plugin ID differs from the enterprise configuration ID.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1137744461"] = "ID mismatch: the plugin ID differs from the enterprise configuration ID."
|
||||
|
||||
-- This is a private AI Studio installation. It runs without an enterprise configuration.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1209549230"] = "This is a private AI Studio installation. It runs without an enterprise configuration."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available."
|
||||
|
||||
-- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat."
|
||||
|
||||
@ -5059,9 +5077,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Database version
|
||||
-- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library."
|
||||
|
||||
-- Waiting for the configuration plugin...
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Waiting for the configuration plugin..."
|
||||
|
||||
-- Encryption secret: is not configured
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured"
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1596483935"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active."
|
||||
|
||||
-- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant."
|
||||
|
||||
@ -5125,9 +5149,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2272122662"] = "Configuration se
|
||||
-- We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2280402765"] = "AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management."
|
||||
|
||||
-- Configuration plugin ID:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration plugin ID:"
|
||||
|
||||
@ -5191,6 +5212,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Explanation"
|
||||
-- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available."
|
||||
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog"
|
||||
|
||||
@ -5224,6 +5248,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3433065373"] = "Information abou
|
||||
-- Used Rust compiler
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3440211747"] = "Used Rust compiler"
|
||||
|
||||
-- AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management."
|
||||
|
||||
-- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!"
|
||||
|
||||
@ -5233,9 +5260,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation"
|
||||
-- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3741877842"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active."
|
||||
|
||||
-- this version does not met the requirements
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version does not met the requirements"
|
||||
|
||||
|
||||
10
app/MindWork AI Studio/Components/ConfigInfoRow.razor
Normal file
10
app/MindWork AI Studio/Components/ConfigInfoRow.razor
Normal file
@ -0,0 +1,10 @@
|
||||
<div style="display: flex; align-items: center; gap: 8px; @this.Item.Style">
|
||||
<MudIcon Icon="@this.Item.Icon"/>
|
||||
<span>
|
||||
@this.Item.Text
|
||||
</span>
|
||||
@if (!string.IsNullOrWhiteSpace(this.Item.CopyValue))
|
||||
{
|
||||
<MudCopyClipboardButton TooltipMessage="@this.Item.CopyTooltip" StringContent="@this.Item.CopyValue"/>
|
||||
}
|
||||
</div>
|
||||
9
app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs
Normal file
9
app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class ConfigInfoRow : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public ConfigInfoRowItem Item { get; set; } = new(Icons.Material.Filled.ArrowRightAlt, string.Empty, string.Empty, string.Empty, string.Empty);
|
||||
}
|
||||
9
app/MindWork AI Studio/Components/ConfigInfoRowItem.cs
Normal file
9
app/MindWork AI Studio/Components/ConfigInfoRowItem.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public sealed record ConfigInfoRowItem(
|
||||
string Icon,
|
||||
string Text,
|
||||
string CopyValue,
|
||||
string CopyTooltip,
|
||||
string Style = ""
|
||||
);
|
||||
21
app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor
Normal file
21
app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor
Normal file
@ -0,0 +1,21 @@
|
||||
<MudPaper Outlined="true" Class="@this.Class">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<MudIcon Icon="@this.HeaderIcon" Size="Size.Small"/>
|
||||
<MudText Typo="Typo.subtitle2">
|
||||
@this.HeaderText
|
||||
</MudText>
|
||||
</div>
|
||||
|
||||
@foreach (var item in this.Items)
|
||||
{
|
||||
<ConfigInfoRow Item="@item"/>
|
||||
}
|
||||
|
||||
@if (this.ShowWarning)
|
||||
{
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Size="Size.Small" Color="Color.Warning"/>
|
||||
<MudText Typo="Typo.subtitle2">@this.WarningText</MudText>
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class ConfigPluginInfoCard : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public string HeaderIcon { get; set; } = Icons.Material.Filled.Extension;
|
||||
|
||||
[Parameter]
|
||||
public string HeaderText { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public IEnumerable<ConfigInfoRowItem> Items { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public bool ShowWarning { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string WarningText { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string Class { get; set; } = "pa-3 mt-2 mb-2";
|
||||
}
|
||||
15
app/MindWork AI Studio/Components/EncryptionSecretInfo.razor
Normal file
15
app/MindWork AI Studio/Components/EncryptionSecretInfo.razor
Normal file
@ -0,0 +1,15 @@
|
||||
<MudText Typo="Typo.body1" Class="@this.Class">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
@if (this.IsConfigured)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small"/>
|
||||
<span>@this.ConfiguredText</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Error" Size="Size.Small"/>
|
||||
<span>@this.NotConfiguredText</span>
|
||||
}
|
||||
</div>
|
||||
</MudText>
|
||||
@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class EncryptionSecretInfo : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public bool IsConfigured { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string ConfiguredText { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string NotConfiguredText { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string Class { get; set; } = "mt-2 mb-2";
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
@inherits MSGComponentBase
|
||||
<MudText Typo="Typo.body1" Class="mb-3" Style="text-align: justify; hyphens: auto;">
|
||||
@T("Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time.")
|
||||
@T("Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community.")
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@ -28,5 +28,13 @@
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mb-3" Style="text-align: justify; hyphens: auto;">
|
||||
@T("Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices.")
|
||||
</MudText>
|
||||
@T("Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design.")
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@T("Democratization of AI")
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mb-3" Style="text-align: justify; hyphens: auto;">
|
||||
@T("We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge.")
|
||||
</MudText>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
@foreach (var profile in this.SettingsManager.ConfigurationData.Profiles.GetAllProfiles())
|
||||
{
|
||||
<MudSelectItem Value="profile">
|
||||
@profile.Name
|
||||
@profile.GetSafeName()
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
@ -53,9 +53,12 @@
|
||||
<MudTooltip Text="@T("Edit")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditEmbeddingProvider(context))"/>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Export configuration")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Dataset" OnClick="@(() => this.ExportEmbeddingProvider(context))"/>
|
||||
</MudTooltip>
|
||||
@if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
|
||||
{
|
||||
<MudTooltip Text="@T("Export configuration")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Dataset" OnClick="@(() => this.ExportEmbeddingProvider(context))"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudTooltip Text="@T("Delete")">
|
||||
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteEmbeddingProvider(context))"/>
|
||||
</MudTooltip>
|
||||
|
||||
@ -117,6 +117,9 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
|
||||
|
||||
private async Task ExportEmbeddingProvider(EmbeddingProvider provider)
|
||||
{
|
||||
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
|
||||
return;
|
||||
|
||||
if (provider == EmbeddingProvider.NONE)
|
||||
return;
|
||||
|
||||
|
||||
@ -45,9 +45,12 @@
|
||||
<MudTooltip Text="@T("Edit")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditLLMProvider(context))"/>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Export configuration")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Dataset" OnClick="@(() => this.ExportLLMProvider(context))"/>
|
||||
</MudTooltip>
|
||||
@if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
|
||||
{
|
||||
<MudTooltip Text="@T("Export configuration")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Dataset" OnClick="@(() => this.ExportLLMProvider(context))"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudTooltip Text="@T("Delete")">
|
||||
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteLLMProvider(context))"/>
|
||||
</MudTooltip>
|
||||
|
||||
@ -136,6 +136,9 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
|
||||
|
||||
private async Task ExportLLMProvider(AIStudio.Settings.Provider provider)
|
||||
{
|
||||
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
|
||||
return;
|
||||
|
||||
if (provider == AIStudio.Settings.Provider.NONE)
|
||||
return;
|
||||
|
||||
|
||||
@ -50,9 +50,12 @@
|
||||
<MudTooltip Text="@T("Edit")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditTranscriptionProvider(context))"/>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Export configuration")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Dataset" OnClick="@(() => this.ExportTranscriptionProvider(context))"/>
|
||||
</MudTooltip>
|
||||
@if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
|
||||
{
|
||||
<MudTooltip Text="@T("Export configuration")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Dataset" OnClick="@(() => this.ExportTranscriptionProvider(context))"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudTooltip Text="@T("Delete")">
|
||||
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteTranscriptionProvider(context))"/>
|
||||
</MudTooltip>
|
||||
|
||||
@ -117,6 +117,9 @@ public partial class SettingsPanelTranscription : SettingsPanelProviderBase
|
||||
|
||||
private async Task ExportTranscriptionProvider(TranscriptionProvider provider)
|
||||
{
|
||||
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
|
||||
return;
|
||||
|
||||
if (provider == TranscriptionProvider.NONE)
|
||||
return;
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ public partial class Vision : MSGComponentBase
|
||||
this.itemsVision =
|
||||
[
|
||||
new(T("Meet your needs"), T("Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer.")),
|
||||
new(T("Democratization of AI"), T("We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge.")),
|
||||
new(T("Integrating your data"), T("You'll be able to integrate your data into AI Studio, like your PDF or Office files, or your Markdown notes.")),
|
||||
new(T("Integration of enterprise data"), T("It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question.")),
|
||||
new(T("Useful assistants"), T("We'll develop more assistants for everyday tasks.")),
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
@inherits MSGComponentBase
|
||||
<MudDialog>
|
||||
<MudDialog DefaultFocus="DefaultFocus.FirstChild">
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body1">
|
||||
@this.Message
|
||||
</MudText>
|
||||
<MudForm @ref="this.form" Class="mt-4">
|
||||
<MudTextField T="string" @bind-Text="@this.UserInput" Variant="Variant.Outlined" AutoGrow="@false" Lines="1" Label="@this.GetInputHeaderText" AutoFocus="@true" UserAttributes="@USER_INPUT_ATTRIBUTES" Validation="@this.ValidateUserInput" />
|
||||
<MudTextField T="string"
|
||||
@bind-Text="@this.UserInput"
|
||||
Variant="Variant.Outlined"
|
||||
AutoGrow="@false"
|
||||
Lines="1"
|
||||
Label="@this.GetInputHeaderText"
|
||||
AutoFocus="@true"
|
||||
Immediate="@true"
|
||||
OnKeyDown="@this.HandleUserInputKeyDown"
|
||||
UserAttributes="@USER_INPUT_ATTRIBUTES"
|
||||
Validation="@this.ValidateUserInput" />
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@ -16,4 +26,4 @@
|
||||
@this.ConfirmText
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
</MudDialog>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using AIStudio.Components;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
|
||||
namespace AIStudio.Dialogs;
|
||||
|
||||
@ -57,6 +58,19 @@ public partial class SingleInputDialog : MSGComponentBase
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
|
||||
private async Task HandleUserInputKeyDown(KeyboardEventArgs keyEvent)
|
||||
{
|
||||
var key = keyEvent.Key.ToLowerInvariant();
|
||||
var code = keyEvent.Code.ToLowerInvariant();
|
||||
if (key is not "enter" && code is not "enter" and not "numpadenter")
|
||||
return;
|
||||
|
||||
if (keyEvent is { AltKey: true } or { CtrlKey: true } or { MetaKey: true })
|
||||
return;
|
||||
|
||||
await this.Confirm();
|
||||
}
|
||||
|
||||
private async Task Confirm()
|
||||
{
|
||||
await this.form.Validate();
|
||||
@ -65,4 +79,4 @@ public partial class SingleInputDialog : MSGComponentBase
|
||||
|
||||
this.MudDialog.Close(DialogResult.Ok(this.UserInput));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,9 +211,32 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
//
|
||||
// Check if there is an enterprise configuration plugin to download:
|
||||
//
|
||||
var enterpriseEnvironment = this.MessageBus.CheckDeferredMessages<EnterpriseEnvironment>(Event.STARTUP_ENTERPRISE_ENVIRONMENT).FirstOrDefault();
|
||||
if (enterpriseEnvironment != default)
|
||||
await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseEnvironment.ConfigurationId, enterpriseEnvironment.ConfigurationServerUrl);
|
||||
var enterpriseEnvironments = this.MessageBus
|
||||
.CheckDeferredMessages<EnterpriseEnvironment>(Event.STARTUP_ENTERPRISE_ENVIRONMENT)
|
||||
.Where(env => env != default)
|
||||
.ToList();
|
||||
|
||||
var failedDeferredConfigIds = new HashSet<Guid>();
|
||||
foreach (var env in enterpriseEnvironments)
|
||||
{
|
||||
var wasDownloadSuccessful = await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl);
|
||||
if (!wasDownloadSuccessful)
|
||||
{
|
||||
failedDeferredConfigIds.Add(env.ConfigurationId);
|
||||
this.Logger.LogWarning("Failed to download deferred enterprise configuration '{ConfigId}' during startup. Keeping managed plugins unchanged.", env.ConfigurationId);
|
||||
}
|
||||
}
|
||||
|
||||
if (EnterpriseEnvironmentService.HasValidEnterpriseSnapshot)
|
||||
{
|
||||
var activeConfigIds = EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS
|
||||
.Select(env => env.ConfigurationId)
|
||||
.ToHashSet();
|
||||
|
||||
PluginFactory.RemoveUnreferencedManagedConfigurationPlugins(activeConfigIds);
|
||||
if (failedDeferredConfigIds.Count > 0)
|
||||
this.Logger.LogWarning("Deferred startup updates failed for {FailedCount} enterprise configuration(s). Those configurations were kept unchanged.", failedDeferredConfigIds.Count);
|
||||
}
|
||||
|
||||
// Initialize the enterprise encryption service for decrypting API keys:
|
||||
await PluginFactory.InitializeEnterpriseEncryption(this.RustService);
|
||||
|
||||
@ -31,6 +31,7 @@ public partial class Home : MSGComponentBase
|
||||
{
|
||||
this.itemsAdvantages = [
|
||||
new(this.T("Free of charge"), this.T("The app is free to use, both for personal and commercial purposes.")),
|
||||
new(this.T("Democratization of AI"), this.T("We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models.")),
|
||||
new(this.T("Independence"), this.T("You are not tied to any single provider. Instead, you might choose the provider that best suits your needs. Right now, we support OpenAI (GPT5, o1, etc.), Perplexity, Mistral, Anthropic (Claude), Google Gemini, xAI (Grok), DeepSeek, Alibaba Cloud (Qwen), OpenRouter, Hugging Face, and self-hosted models using vLLM, llama.cpp, ollama, LM Studio, Groq, or Fireworks. For scientists and employees of research institutions, we also support Helmholtz and GWDG AI services. These are available through federated logins like eduGAIN to all 18 Helmholtz Centers, the Max Planck Society, most German, and many international universities.")),
|
||||
new(this.T("Assistants"), this.T("You just want to quickly translate a text? AI Studio has so-called assistants for such and other tasks. No prompting is necessary when working with these assistants.")),
|
||||
new(this.T("Unrestricted usage"), this.T("Unlike services like ChatGPT, which impose limits after intensive use, MindWork AI Studio offers unlimited usage through the providers API.")),
|
||||
|
||||
@ -49,125 +49,129 @@
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Memory" Text="@TauriVersion"/>
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Translate" Text="@this.OSLanguage"/>
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Business">
|
||||
@switch (EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.IsActive)
|
||||
@switch (HasAnyActiveEnvironment)
|
||||
{
|
||||
case false when this.configPlug is null:
|
||||
case false when this.configPlugins.Count == 0:
|
||||
<MudText Typo="Typo.body1">
|
||||
@T("This is a private AI Studio installation. It runs without an enterprise configuration.")
|
||||
</MudText>
|
||||
break;
|
||||
|
||||
|
||||
case false:
|
||||
<MudText Typo="Typo.body1">
|
||||
@T("AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management.")
|
||||
@T("AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management.")
|
||||
</MudText>
|
||||
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Configuration plugin ID:") @this.configPlug!.Id</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the configuration plugin ID to the clipboard")" StringContent=@this.configPlug!.Id.ToString()/>
|
||||
</div>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
@if (PluginFactory.EnterpriseEncryption?.IsAvailable is true)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is configured")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Error" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is not configured")</span>
|
||||
}
|
||||
</div>
|
||||
</MudText>
|
||||
@foreach (var plug in this.configPlugins)
|
||||
{
|
||||
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.Extension"
|
||||
HeaderText="@plug.Name"
|
||||
Items="@([
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration plugin ID:")} {plug.Id}",
|
||||
plug.Id.ToString(),
|
||||
T("Copies the configuration plugin ID to the clipboard"))
|
||||
])"/>
|
||||
}
|
||||
|
||||
<EncryptionSecretInfo IsConfigured="@(PluginFactory.EnterpriseEncryption?.IsAvailable is true)"
|
||||
ConfiguredText="@T("Encryption secret: is configured")"
|
||||
NotConfiguredText="@T("Encryption secret: is not configured")"/>
|
||||
</MudCollapse>
|
||||
break;
|
||||
|
||||
case true when this.configPlug is null:
|
||||
case true when this.configPlugins.Count == 0:
|
||||
<MudText Typo="Typo.body1">
|
||||
@T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available.")
|
||||
@T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.")
|
||||
</MudText>
|
||||
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Enterprise configuration ID:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the config ID to the clipboard")" StringContent=@EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId.ToString()/>
|
||||
</div>
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Configuration server:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the server URL to the clipboard")" StringContent=@EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl/>
|
||||
</div>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
@if (PluginFactory.EnterpriseEncryption?.IsAvailable is true)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is configured")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Error" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is not configured")</span>
|
||||
}
|
||||
</div>
|
||||
</MudText>
|
||||
@foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive))
|
||||
{
|
||||
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.HourglassBottom"
|
||||
HeaderText="@T("Waiting for the configuration plugin...")"
|
||||
Items="@([
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Enterprise configuration ID:")} {env.ConfigurationId}",
|
||||
env.ConfigurationId.ToString(),
|
||||
T("Copies the config ID to the clipboard")),
|
||||
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration server:")} {env.ConfigurationServerUrl}",
|
||||
env.ConfigurationServerUrl,
|
||||
T("Copies the server URL to the clipboard"),
|
||||
"margin-top: 4px;")
|
||||
])"/>
|
||||
}
|
||||
|
||||
<EncryptionSecretInfo IsConfigured="@(PluginFactory.EnterpriseEncryption?.IsAvailable is true)"
|
||||
ConfiguredText="@T("Encryption secret: is configured")"
|
||||
NotConfiguredText="@T("Encryption secret: is not configured")"/>
|
||||
</MudCollapse>
|
||||
break;
|
||||
|
||||
case true:
|
||||
<MudText Typo="Typo.body1">
|
||||
@T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active.")
|
||||
</MudText>
|
||||
@if (this.HasAnyLoadedEnterpriseConfigurationPlugin)
|
||||
{
|
||||
<MudText Typo="Typo.body1">
|
||||
@T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.")
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body1">
|
||||
@T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.")
|
||||
</MudText>
|
||||
}
|
||||
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Enterprise configuration ID:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the config ID to the clipboard")" StringContent=@EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId.ToString()/>
|
||||
</div>
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Configuration server:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the server URL to the clipboard")" StringContent=@EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl/>
|
||||
</div>
|
||||
</MudText>
|
||||
@foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive))
|
||||
{
|
||||
var matchingPlugin = this.FindManagedConfigurationPlugin(env.ConfigurationId);
|
||||
if (matchingPlugin is null)
|
||||
{
|
||||
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.HourglassBottom"
|
||||
HeaderText="@T("Waiting for the configuration plugin...")"
|
||||
Items="@([
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Enterprise configuration ID:")} {env.ConfigurationId}",
|
||||
env.ConfigurationId.ToString(),
|
||||
T("Copies the config ID to the clipboard")),
|
||||
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration server:")} {env.ConfigurationServerUrl}",
|
||||
env.ConfigurationServerUrl,
|
||||
T("Copies the server URL to the clipboard"),
|
||||
"margin-top: 4px;")
|
||||
])"/>
|
||||
continue;
|
||||
}
|
||||
|
||||
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.Extension"
|
||||
HeaderText="@matchingPlugin.Name"
|
||||
Items="@([
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Enterprise configuration ID:")} {env.ConfigurationId}",
|
||||
env.ConfigurationId.ToString(),
|
||||
T("Copies the config ID to the clipboard")),
|
||||
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration server:")} {env.ConfigurationServerUrl}",
|
||||
env.ConfigurationServerUrl,
|
||||
T("Copies the server URL to the clipboard"),
|
||||
"margin-top: 4px;"),
|
||||
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration plugin ID:")} {matchingPlugin.Id}",
|
||||
matchingPlugin.Id.ToString(),
|
||||
T("Copies the configuration plugin ID to the clipboard"),
|
||||
"margin-top: 4px;")
|
||||
])"
|
||||
ShowWarning="@this.IsManagedConfigurationIdMismatch(matchingPlugin, env.ConfigurationId)"
|
||||
WarningText="@T("ID mismatch: the plugin ID differs from the enterprise configuration ID.")"/>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Configuration plugin ID:") @this.configPlug!.Id</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the configuration plugin ID to the clipboard")" StringContent=@this.configPlug!.Id.ToString()/>
|
||||
</div>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
@if (PluginFactory.EnterpriseEncryption?.IsAvailable is true)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is configured")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Error" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is not configured")</span>
|
||||
}
|
||||
</div>
|
||||
</MudText>
|
||||
<EncryptionSecretInfo IsConfigured="@(PluginFactory.EnterpriseEncryption?.IsAvailable is true)"
|
||||
ConfiguredText="@T("Encryption secret: is configured")"
|
||||
NotConfiguredText="@T("Encryption secret: is not configured")"/>
|
||||
</MudCollapse>
|
||||
break;
|
||||
}
|
||||
@ -184,10 +188,10 @@
|
||||
</MudListItem>
|
||||
</MudList>
|
||||
<MudStack Row="true">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Update" OnClick="() => this.CheckForUpdate()">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Update" OnClick="@(() => this.CheckForUpdate())">
|
||||
@T("Check for updates")
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Default" StartIcon="@Icons.Material.Filled.Download" OnClick="async () => await this.ShowPandocDialog()">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Default" StartIcon="@Icons.Material.Filled.Download" OnClick="@(async () => await this.ShowPandocDialog())">
|
||||
@this.PandocButtonText
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
@ -195,7 +199,7 @@
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Custom.Brands.GitHub" HeaderText="@T("Community & Code")">
|
||||
<MudList T="string" Class="mb-1">
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Home" Target="_blank" Href="http://mindworkai.org/">
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Home" Target="_blank" Href="https://mindworkai.org/">
|
||||
@T("Discover MindWork AI's mission and vision on our official homepage.")
|
||||
</MudListItem>
|
||||
<MudListItem T="string" Icon="@Icons.Custom.Brands.GitHub" Target="_blank" Href="https://github.com/MindWorkAI/AI-Studio">
|
||||
@ -236,14 +240,14 @@
|
||||
@T("Startup log file")
|
||||
</MudText>
|
||||
<MudList T="string" Class="mb-3">
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Folder" Text="@this.logPaths.LogStartupPath" OnClick="() => this.CopyStartupLogPath()"/>
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Folder" Text="@this.logPaths.LogStartupPath" OnClick="@(() => this.CopyStartupLogPath())"/>
|
||||
</MudList>
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@T("Usage log file")
|
||||
</MudText>
|
||||
<MudList T="string" Class="mb-3">
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Folder" Text="@this.logPaths.LogAppPath" OnClick="() => this.CopyAppLogPath()"/>
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Folder" Text="@this.logPaths.LogAppPath" OnClick="@(() => this.CopyAppLogPath())"/>
|
||||
</MudList>
|
||||
</ExpansionPanel>
|
||||
|
||||
|
||||
@ -69,12 +69,21 @@ public partial class Information : MSGComponentBase
|
||||
|
||||
private bool showDatabaseDetails;
|
||||
|
||||
private IPluginMetadata? configPlug = PluginFactory.AvailablePlugins.FirstOrDefault(x => x.Type is PluginType.CONFIGURATION);
|
||||
private List<IAvailablePlugin> configPlugins = PluginFactory.AvailablePlugins
|
||||
.Where(x => x.Type is PluginType.CONFIGURATION)
|
||||
.OfType<IAvailablePlugin>()
|
||||
.ToList();
|
||||
|
||||
private sealed record DatabaseDisplayInfo(string Label, string Value);
|
||||
|
||||
private readonly List<DatabaseDisplayInfo> databaseDisplayInfo = new();
|
||||
|
||||
private static bool HasAnyActiveEnvironment => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Any(e => e.IsActive);
|
||||
|
||||
private bool HasAnyLoadedEnterpriseConfigurationPlugin => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS
|
||||
.Where(e => e.IsActive)
|
||||
.Any(env => this.FindManagedConfigurationPlugin(env.ConfigurationId) is not null);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the enterprise configuration has details that can be shown/hidden.
|
||||
/// Returns true if there are details available, false otherwise.
|
||||
@ -83,16 +92,16 @@ public partial class Information : MSGComponentBase
|
||||
{
|
||||
get
|
||||
{
|
||||
return EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.IsActive switch
|
||||
return HasAnyActiveEnvironment switch
|
||||
{
|
||||
// Case 1: No enterprise config and no plugin - no details available
|
||||
false when this.configPlug is null => false,
|
||||
false when this.configPlugins.Count == 0 => false,
|
||||
|
||||
// Case 2: Enterprise config with plugin but no central management - has details
|
||||
false => true,
|
||||
|
||||
// Case 3: Enterprise config active but no plugin - has details
|
||||
true when this.configPlug is null => true,
|
||||
true when this.configPlugins.Count == 0 => true,
|
||||
|
||||
// Case 4: Enterprise config active with plugin - has details
|
||||
true => true
|
||||
@ -128,7 +137,10 @@ public partial class Information : MSGComponentBase
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.PLUGINS_RELOADED:
|
||||
this.configPlug = PluginFactory.AvailablePlugins.FirstOrDefault(x => x.Type is PluginType.CONFIGURATION);
|
||||
this.configPlugins = PluginFactory.AvailablePlugins
|
||||
.Where(x => x.Type is PluginType.CONFIGURATION)
|
||||
.OfType<IAvailablePlugin>()
|
||||
.ToList();
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
break;
|
||||
}
|
||||
@ -194,6 +206,18 @@ public partial class Information : MSGComponentBase
|
||||
this.showDatabaseDetails = !this.showDatabaseDetails;
|
||||
}
|
||||
|
||||
private IAvailablePlugin? FindManagedConfigurationPlugin(Guid configurationId)
|
||||
{
|
||||
return this.configPlugins.FirstOrDefault(plugin => plugin.ManagedConfigurationId == configurationId)
|
||||
// Backward compatibility for already downloaded plugins without ManagedConfigurationId.
|
||||
?? this.configPlugins.FirstOrDefault(plugin => plugin.ManagedConfigurationId is null && plugin.Id == configurationId);
|
||||
}
|
||||
|
||||
private bool IsManagedConfigurationIdMismatch(IAvailablePlugin plugin, Guid configurationId)
|
||||
{
|
||||
return plugin.ManagedConfigurationId == configurationId && plugin.Id != configurationId;
|
||||
}
|
||||
|
||||
private async Task CopyStartupLogPath()
|
||||
{
|
||||
await this.RustService.CopyText2Clipboard(this.Snackbar, this.logPaths.LogStartupPath);
|
||||
|
||||
@ -24,6 +24,9 @@ VERSION = "1.0.0"
|
||||
-- The type of the plugin:
|
||||
TYPE = "CONFIGURATION"
|
||||
|
||||
-- True when this plugin is deployed by an enterprise configuration server:
|
||||
DEPLOYED_USING_CONFIG_SERVER = false
|
||||
|
||||
-- The authors of the plugin:
|
||||
AUTHORS = {"<Company Name>"}
|
||||
|
||||
|
||||
@ -1798,7 +1798,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANAGEPANDOCDEPENDENCY::T527187983"] = "
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANAGEPANDOCDEPENDENCY::T986578435"] = "Pandoc installieren"
|
||||
|
||||
-- Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Da mein Arbeitgeber sowohl Windows als auch Linux am Arbeitsplatz nutzt, wollte ich eine plattformübergreifende Lösung, die nahtlos auf allen wichtigen Betriebssystemen, einschließlich macOS, funktioniert. Außerdem wollte ich zeigen, dass es möglich ist, moderne, effiziente und plattformübergreifende Anwendungen zu erstellen, ohne auf Software-Ballast, wie z.B. das Electron-Framework, zurückzugreifen. Die Kombination aus .NET und Rust mit Tauri hat sich dabei als hervorragender Technologiestapel für den Bau solch robuster Anwendungen erwiesen."
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Da mein Arbeitgeber sowohl Windows als auch Linux am Arbeitsplatz nutzt, wollte ich eine plattformübergreifende Lösung, die nahtlos auf allen wichtigen Betriebssystemen, einschließlich macOS, funktioniert. Außerdem wollte ich zeigen, dass es möglich ist, moderne, effiziente und plattformübergreifende Anwendungen zu erstellen, ohne auf Software-Ballast, wie z.B. das Electron-Framework, zurückzugreifen. Die Kombination aus .NET und Rust mit Tauri hat sich dabei als hervorragender Technologie-Stack für den Bau solch robuster Anwendungen erwiesen."
|
||||
|
||||
-- Limitations of Existing Solutions
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Einschränkungen bestehender Lösungen"
|
||||
@ -1806,21 +1806,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Einschränku
|
||||
-- Personal Needs and Limitations of Web Services
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1839655973"] = "Persönliche Bedürfnisse und Einschränkungen von Webdiensten"
|
||||
|
||||
-- Democratization of AI
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Demokratisierung von KI"
|
||||
|
||||
-- While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3552777197"] = "Während ich nach passenden Lösungen suchte, stieß ich auf eine Desktop-Anwendung namens Anything LLM. Leider konnte sie meine spezifischen Anforderungen nicht erfüllen und entsprach auch nicht dem Benutzeroberflächendesign, das ich mir vorgestellt hatte. Für macOS gab es zwar mehrere Apps, die meiner Vorstellung ähnelten, aber sie waren allesamt kostenpflichtige Lösungen mit unklarer Herkunft. Die Identität der Entwickler und die Ursprünge dieser Apps waren nicht ersichtlich, was erhebliche Sicherheitsbedenken hervorrief. Berichte von Nutzern über gestohlene API-Schlüssel und unerwünschte Abbuchungen verstärkten meine Bedenken zusätzlich."
|
||||
|
||||
-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3569462457"] = "Hallo, mein Name ist Thorsten Sommer und ich bin der ursprüngliche Entwickler von MindWork AI Studio. Die Motivation zur Entwicklung dieser App entstand aus mehreren wichtigen Bedürfnissen und Beobachtungen, die ich im Laufe der Zeit gemacht habe."
|
||||
|
||||
-- Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3622193740"] = "Mit MindWork AI Studio möchte ich ein sicheres, flexibles und benutzerfreundliches Werkzeug bereitstellen, das für ein breites Publikum geeignet ist, ohne Kompromisse bei Funktionalität oder Design einzugehen. Diese App ist das Ergebnis meines Wunsches, persönliche Anforderungen zu erfüllen, bestehende Lücken auf dem Markt zu schließen und innovative Entwicklungsmethoden zu präsentieren."
|
||||
-- We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3672974243"] = "Wir möchten auch zur Demokratisierung von KI beitragen. MindWork AI Studio läuft selbst auf kostengünstiger Hardware, einschließlich Computern für rund 100 € wie dem Raspberry Pi. Dadurch sind die App und ihr voller Funktionsumfang auch für Menschen und Familien mit begrenztem Budget zugänglich. Für Ihre ersten Schritte können Sie mit lokalen LLMs beginnen oder günstige Cloud-Modelle nutzen. MindWork AI Studio selbst ist kostenlos erhältlich."
|
||||
|
||||
-- Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T372007989"] = "Sich auf Webdienste wie ChatGPT zu verlassen, war für mich keine nachhaltige Lösung. Ich brauchte eine KI, die auch direkt auf Dateien auf meinem Gerät zugreifen kann – eine Funktion, die Webdienste aus Sicherheits- und Datenschutzgründen grundsätzlich nicht bieten. Zwar hätte ich mir eine eigene Lösung in Python programmieren können, aber das wäre für den Alltag zu umständlich gewesen. Noch wichtiger war mir, eine Lösung zu entwickeln, die jeder nutzen kann, ganz ohne Programmierkenntnisse."
|
||||
|
||||
-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T483341611"] = "Hallo, mein Name ist Thorsten Sommer und ich bin der initiale Entwickler von MindWork AI Studio. Ich habe dieses Projekt auf Grundlage von Bedürfnissen und Beobachtungen gestartet, die ich im Laufe der Zeit gemacht habe. Heute haben wir ein Kernteam von Entwicklern und Unterstützung aus der Open-Source-Community."
|
||||
|
||||
-- Cross-Platform and Modern Development
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T843057510"] = "Plattformübergreifende und moderne Entwicklung"
|
||||
|
||||
-- Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T904941692"] = "Heute verfolgt unser Team das Ziel, ein sicheres, flexibles und benutzerfreundliches Tool bereitzustellen, das eine breite Zielgruppe anspricht, ohne dabei Kompromisse bei Funktionalität oder dem Design einzugehen."
|
||||
|
||||
-- Copies the content to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Kopiert den Inhalt in die Zwischenablage"
|
||||
|
||||
@ -2436,9 +2442,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "Sie können ihre
|
||||
-- It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "Bald wird es möglich sein, Daten aus dem Firmennetzwerk über eine festgelegte Schnittstelle (External Retrieval Interface, kurz ERI) zu integrieren. Dafür wird voraussichtlich Entwicklungsaufwand seitens der jeweiligen Organisation nötig sein."
|
||||
|
||||
-- Democratization of AI
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Demokratisierung von KI"
|
||||
|
||||
-- Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Was auch immer ihr Beruf oder ihre Aufgabe ist, MindWork AI Studio möchte ihre Bedürfnisse erfüllen: Egal, ob Sie Projektmanager, Wissenschaftler, Künstler, Autor, Softwareentwickler oder Spieleentwickler sind."
|
||||
|
||||
-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2201645589"] = "Wir möchten zur Demokratisierung von KI beitragen. MindWork AI Studio läuft sogar auf kostengünstiger Hardware, einschließlich Computern für etwa 100 € wie dem Raspberry Pi. Dadurch werden die App und ihr voller Funktionsumfang auch für Menschen und Familien mit begrenztem Budget zugänglich. Sie können mit lokalen LLMs starten oder günstige Cloud-Modelle nutzen. MindWork AI Studio selbst ist kostenlos erhältlich."
|
||||
|
||||
-- You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "Sie können ihre E-Mail-Postfächer mit AI Studio verbinden. Die KI liest ihre E-Mails und benachrichtigt Sie über wichtige Ereignisse. Außerdem haben Sie in ihren Chats Zugriff auf das Wissen aus ihren E-Mails."
|
||||
|
||||
@ -4983,6 +4995,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "Sie zahlen nur für das,
|
||||
-- Assistants
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1614176092"] = "Assistenten"
|
||||
|
||||
-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1628689293"] = "Wir möchten zur Demokratisierung von KI beitragen. MindWork AI Studio läuft sogar auf kostengünstiger Hardware, einschließlich Computern für etwa 100 € wie dem Raspberry Pi. Dadurch werden die App und ihr vollständiger Funktionsumfang auch für Menschen und Familien mit begrenztem Budget zugänglich. Sie können mit lokalen LLMs starten oder günstige Cloud-Modelle nutzen."
|
||||
|
||||
-- Unrestricted usage
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1686815996"] = "Unbeschränkte Nutzung"
|
||||
|
||||
@ -4992,6 +5007,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1702902297"] = "Einführung"
|
||||
-- Vision
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1892426825"] = "Vision"
|
||||
|
||||
-- Democratization of AI
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1986314327"] = "Demokratisierung von KI"
|
||||
|
||||
-- Let's get started
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2331588413"] = "Los geht's"
|
||||
|
||||
@ -5046,12 +5064,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1019424746"] = "Startprotokollda
|
||||
-- Browse AI Studio's source code on GitHub — we welcome your contributions.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Sehen Sie sich den Quellcode von AI Studio auf GitHub an – wir freuen uns über ihre Beiträge."
|
||||
|
||||
-- ID mismatch: the plugin ID differs from the enterprise configuration ID.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1137744461"] = "ID-Konflikt: Die Plugin-ID stimmt nicht mit der ID der Unternehmenskonfiguration überein."
|
||||
|
||||
-- This is a private AI Studio installation. It runs without an enterprise configuration.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1209549230"] = "Dies ist eine private AI Studio-Installation. Sie läuft ohne Unternehmenskonfiguration."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio läuft mit einer Unternehmenskonfiguration und einem Konfigurationsserver. Das Konfigurations-Plugin ist noch nicht verfügbar."
|
||||
|
||||
-- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "Diese Bibliothek wird verwendet, um PDF-Dateien zu lesen. Das ist zum Beispiel notwendig, um PDFs als Datenquelle für einen Chat zu nutzen."
|
||||
|
||||
@ -5061,9 +5079,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Datenbankversion
|
||||
-- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "Diese Bibliothek wird verwendet, um die MudBlazor-Bibliothek zu erweitern. Sie stellt zusätzliche Komponenten bereit, die nicht Teil der MudBlazor-Bibliothek sind."
|
||||
|
||||
-- Waiting for the configuration plugin...
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Warten auf das Konfigurations-Plugin …"
|
||||
|
||||
-- Encryption secret: is not configured
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Geheimnis für die Verschlüsselung: ist nicht konfiguriert"
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1596483935"] = "AI Studio wird mit Unternehmenskonfigurationen und Konfigurationsservern betrieben. Die Konfigurations-Plugins sind aktiv."
|
||||
|
||||
-- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant ist eine Vektordatenbank und Suchmaschine für Vektoren. Wir nutzen Qdrant, um lokales RAG (Retrieval-Augmented Generation) innerhalb von AI Studio zu realisieren. Vielen Dank für den Einsatz und die großartige Arbeit, die in Qdrant gesteckt wurde und weiterhin gesteckt wird."
|
||||
|
||||
@ -5127,9 +5151,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2272122662"] = "Konfigurationsse
|
||||
-- We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "Wir müssen Zufallszahlen erzeugen, z. B. um die Kommunikation zwischen der Benutzeroberfläche und der Laufzeitumgebung abzusichern. Die rand-Bibliothek eignet sich dafür hervorragend."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2280402765"] = "AI Studio läuft mit einer Unternehmenskonfiguration über ein Konfigurations-Plugin, ohne zentrale Konfigurationsverwaltung."
|
||||
|
||||
-- Configuration plugin ID:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Konfigurations-Plugin-ID:"
|
||||
|
||||
@ -5193,6 +5214,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Erklärung"
|
||||
-- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "Das .NET-Backend kann nicht als Desktop-App gestartet werden. Deshalb verwende ich ein zweites Backend in Rust, das ich „Runtime“ nenne. Mit Rust als Runtime kann Tauri genutzt werden, um eine typische Desktop-App zu realisieren. Dank Rust kann diese App für Windows-, macOS- und Linux-Desktops angeboten werden. Rust ist eine großartige Sprache für die Entwicklung sicherer und leistungsstarker Software."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio wird mit Unternehmenskonfigurationen und Konfigurationsservern betrieben. Die Konfigurations-Plugins sind noch nicht verfügbar."
|
||||
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Änderungsprotokoll"
|
||||
|
||||
@ -5226,6 +5250,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3433065373"] = "Informationen ü
|
||||
-- Used Rust compiler
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3440211747"] = "Verwendeter Rust-Compiler"
|
||||
|
||||
-- AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio wird mit Unternehmenskonfigurationen unter Verwendung von Konfigurations-Plugins betrieben. Eine zentrale Konfigurationsverwaltung wird nicht eingesetzt."
|
||||
|
||||
-- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri wird verwendet, um die Blazor-Benutzeroberfläche bereitzustellen. Es ist ein großartiges Projekt, das die Erstellung von Desktop-Anwendungen mit Webtechnologien ermöglicht. Ich liebe Tauri!"
|
||||
|
||||
@ -5235,9 +5262,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation"
|
||||
-- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "Diese Bibliothek wird verwendet, um Excel- und OpenDocument-Tabellendateien zu lesen. Dies ist zum Beispiel notwendig, wenn Tabellen als Datenquelle für einen Chat verwendet werden sollen."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3741877842"] = "AI Studio läuft mit einer Unternehmenskonfiguration und einem Konfigurationsserver. Das Konfigurations-Plugin ist aktiv."
|
||||
|
||||
-- this version does not met the requirements
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "diese Version erfüllt die Anforderungen nicht"
|
||||
|
||||
|
||||
@ -1806,21 +1806,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Limitations
|
||||
-- Personal Needs and Limitations of Web Services
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1839655973"] = "Personal Needs and Limitations of Web Services"
|
||||
|
||||
-- Democratization of AI
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Democratization of AI"
|
||||
|
||||
-- While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3552777197"] = "While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations."
|
||||
|
||||
-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3569462457"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time."
|
||||
|
||||
-- Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3622193740"] = "Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices."
|
||||
-- We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3672974243"] = "We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge."
|
||||
|
||||
-- Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T372007989"] = "Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge."
|
||||
|
||||
-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T483341611"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community."
|
||||
|
||||
-- Cross-Platform and Modern Development
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T843057510"] = "Cross-Platform and Modern Development"
|
||||
|
||||
-- Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T904941692"] = "Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design."
|
||||
|
||||
-- Copies the content to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Copies the content to the clipboard"
|
||||
|
||||
@ -2436,9 +2442,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "You'll be able t
|
||||
-- It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question."
|
||||
|
||||
-- Democratization of AI
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Democratization of AI"
|
||||
|
||||
-- Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer."
|
||||
|
||||
-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2201645589"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge."
|
||||
|
||||
-- You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats."
|
||||
|
||||
@ -4983,6 +4995,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "You only pay for what yo
|
||||
-- Assistants
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1614176092"] = "Assistants"
|
||||
|
||||
-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1628689293"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models."
|
||||
|
||||
-- Unrestricted usage
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1686815996"] = "Unrestricted usage"
|
||||
|
||||
@ -4992,6 +5007,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1702902297"] = "Introduction"
|
||||
-- Vision
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1892426825"] = "Vision"
|
||||
|
||||
-- Democratization of AI
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1986314327"] = "Democratization of AI"
|
||||
|
||||
-- Let's get started
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2331588413"] = "Let's get started"
|
||||
|
||||
@ -5046,12 +5064,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1019424746"] = "Startup log file
|
||||
-- Browse AI Studio's source code on GitHub — we welcome your contributions.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Browse AI Studio's source code on GitHub — we welcome your contributions."
|
||||
|
||||
-- ID mismatch: the plugin ID differs from the enterprise configuration ID.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1137744461"] = "ID mismatch: the plugin ID differs from the enterprise configuration ID."
|
||||
|
||||
-- This is a private AI Studio installation. It runs without an enterprise configuration.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1209549230"] = "This is a private AI Studio installation. It runs without an enterprise configuration."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available."
|
||||
|
||||
-- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat."
|
||||
|
||||
@ -5061,9 +5079,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Database version
|
||||
-- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library."
|
||||
|
||||
-- Waiting for the configuration plugin...
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Waiting for the configuration plugin..."
|
||||
|
||||
-- Encryption secret: is not configured
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured"
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1596483935"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active."
|
||||
|
||||
-- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant."
|
||||
|
||||
@ -5127,9 +5151,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2272122662"] = "Configuration se
|
||||
-- We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2280402765"] = "AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management."
|
||||
|
||||
-- Configuration plugin ID:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration plugin ID:"
|
||||
|
||||
@ -5193,6 +5214,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Explanation"
|
||||
-- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available."
|
||||
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog"
|
||||
|
||||
@ -5226,6 +5250,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3433065373"] = "Information abou
|
||||
-- Used Rust compiler
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3440211747"] = "Used Rust compiler"
|
||||
|
||||
-- AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management."
|
||||
|
||||
-- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!"
|
||||
|
||||
@ -5235,9 +5262,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation"
|
||||
-- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat."
|
||||
|
||||
-- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3741877842"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active."
|
||||
|
||||
-- this version does not met the requirements
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version does not met the requirements"
|
||||
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
namespace AIStudio.Provider.Google;
|
||||
|
||||
public readonly record struct Model(string Name, string DisplayName);
|
||||
@ -1,7 +0,0 @@
|
||||
namespace AIStudio.Provider.Google;
|
||||
|
||||
/// <summary>
|
||||
/// A data model for the response from the model endpoint.
|
||||
/// </summary>
|
||||
/// <param name="Models"></param>
|
||||
public readonly record struct ModelsResponse(IList<Model> Models);
|
||||
@ -22,7 +22,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener
|
||||
public override string InstanceName { get; set; } = "Google Gemini";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async IAsyncEnumerable<ContentStreamChunk> StreamChatCompletion(Provider.Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default)
|
||||
public override async IAsyncEnumerable<ContentStreamChunk> StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
// Get the API key:
|
||||
var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER);
|
||||
@ -76,57 +76,50 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
/// <inheritdoc />
|
||||
public override async IAsyncEnumerable<ImageURL> StreamImageCompletion(Provider.Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default)
|
||||
public override async IAsyncEnumerable<ImageURL> StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<string> TranscribeAudioAsync(Provider.Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
|
||||
public override Task<string> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<IEnumerable<Provider.Model>> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
public override async Task<IEnumerable<Model>> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
var modelResponse = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
|
||||
if(modelResponse == default)
|
||||
return [];
|
||||
|
||||
return modelResponse.Models.Where(model =>
|
||||
model.Name.StartsWith("models/gemini-", StringComparison.OrdinalIgnoreCase) && !model.Name.Contains("embed"))
|
||||
.Select(n => new Provider.Model(n.Name.Replace("models/", string.Empty), n.DisplayName));
|
||||
var models = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
|
||||
return models.Where(model =>
|
||||
model.Id.StartsWith("gemini-", StringComparison.OrdinalIgnoreCase) &&
|
||||
!this.IsEmbeddingModel(model.Id))
|
||||
.Select(this.WithDisplayNameFallback);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<IEnumerable<Provider.Model>> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
public override Task<IEnumerable<Model>> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(Enumerable.Empty<Provider.Model>());
|
||||
return Task.FromResult(Enumerable.Empty<Model>());
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<Provider.Model>> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
public override async Task<IEnumerable<Model>> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
var modelResponse = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional);
|
||||
if(modelResponse == default)
|
||||
return [];
|
||||
|
||||
return modelResponse.Models.Where(model =>
|
||||
model.Name.StartsWith("models/text-embedding-", StringComparison.OrdinalIgnoreCase) ||
|
||||
model.Name.StartsWith("models/gemini-embed", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(n => new Provider.Model(n.Name.Replace("models/", string.Empty), n.DisplayName));
|
||||
var models = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional);
|
||||
return models.Where(model => this.IsEmbeddingModel(model.Id))
|
||||
.Select(this.WithDisplayNameFallback);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<IEnumerable<Provider.Model>> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
public override Task<IEnumerable<Model>> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(Enumerable.Empty<Provider.Model>());
|
||||
return Task.FromResult(Enumerable.Empty<Model>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task<ModelsResponse> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null)
|
||||
private async Task<IReadOnlyList<Model>> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null)
|
||||
{
|
||||
var secretKey = apiKeyProvisional switch
|
||||
{
|
||||
@ -138,16 +131,57 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener
|
||||
}
|
||||
};
|
||||
|
||||
if (secretKey is null)
|
||||
return default;
|
||||
if (string.IsNullOrWhiteSpace(secretKey))
|
||||
return [];
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"models?key={secretKey}");
|
||||
using var response = await this.httpClient.SendAsync(request, token);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "models");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey);
|
||||
|
||||
using var response = await this.httpClient.SendAsync(request, token);
|
||||
if(!response.IsSuccessStatusCode)
|
||||
return default;
|
||||
{
|
||||
LOGGER.LogError("Failed to load models with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, await response.Content.ReadAsStringAsync(token));
|
||||
return [];
|
||||
}
|
||||
|
||||
var modelResponse = await response.Content.ReadFromJsonAsync<ModelsResponse>(token);
|
||||
return modelResponse;
|
||||
try
|
||||
{
|
||||
var modelResponse = await response.Content.ReadFromJsonAsync<ModelsResponse>(token);
|
||||
if (modelResponse == default || modelResponse.Data.Count is 0)
|
||||
{
|
||||
LOGGER.LogError("Google model list response did not contain a valid data array.");
|
||||
return [];
|
||||
}
|
||||
|
||||
return modelResponse.Data
|
||||
.Where(model => !string.IsNullOrWhiteSpace(model.Id))
|
||||
.Select(model => new Model(this.NormalizeModelId(model.Id), model.DisplayName))
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOGGER.LogError("Failed to parse Google model list response: '{Message}'.", e.Message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsEmbeddingModel(string modelId)
|
||||
{
|
||||
return modelId.Contains("embedding", StringComparison.OrdinalIgnoreCase) ||
|
||||
modelId.Contains("embed", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private Model WithDisplayNameFallback(Model model)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(model.DisplayName)
|
||||
? new Model(model.Id, model.Id)
|
||||
: model;
|
||||
}
|
||||
|
||||
private string NormalizeModelId(string modelId)
|
||||
{
|
||||
return modelId.StartsWith("models/", StringComparison.OrdinalIgnoreCase)
|
||||
? modelId["models/".Length..]
|
||||
: modelId;
|
||||
}
|
||||
}
|
||||
@ -7,15 +7,15 @@ namespace AIStudio.Provider.OpenAI;
|
||||
/// <param name="Delta">The delta content of the response.</param>
|
||||
public record ResponsesDeltaStreamLine(
|
||||
string Type,
|
||||
string Delta) : IResponseStreamLine
|
||||
string? Delta) : IResponseStreamLine
|
||||
{
|
||||
#region Implementation of IResponseStreamLine
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ContainsContent() => !string.IsNullOrWhiteSpace(this.Delta);
|
||||
public bool ContainsContent() => this.Delta is not null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ContentStreamChunk GetContent() => new(this.Delta, this.GetSources());
|
||||
public ContentStreamChunk GetContent() => new(this.Delta ?? string.Empty, this.GetSources());
|
||||
|
||||
//
|
||||
// Please note that there are multiple options where LLM providers might stream sources:
|
||||
|
||||
@ -201,7 +201,7 @@ public static class ConfigurationSelectDataFactory
|
||||
public static IEnumerable<ConfigurationSelectData<string>> GetProfilesData(IEnumerable<Profile> profiles)
|
||||
{
|
||||
foreach (var profile in profiles.GetAllProfiles())
|
||||
yield return new(profile.Name, profile.Id);
|
||||
yield return new(profile.GetSafeName(), profile.Id);
|
||||
}
|
||||
|
||||
public static IEnumerable<ConfigurationSelectData<string>> GetTranscriptionProvidersData(IEnumerable<TranscriptionProvider> transcriptionProviders)
|
||||
|
||||
@ -56,43 +56,43 @@ public sealed record EmbeddingProvider(
|
||||
provider = NONE;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid ID. The ID must be a valid GUID.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead<string>(out var name))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid name.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead<string>(out var usedLLMProviderText) || !Enum.TryParse<LLMProviders>(usedLLMProviderText, true, out var usedLLMProvider))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid LLM provider enum value.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead<string>(out var hostText) || !Enum.TryParse<Host>(hostText, true, out var host))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid host enum value.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead<string>(out var hostname))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid hostname.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead<LuaTable>(out var modelTable))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model table.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReadModelTable(idx, modelTable, out var model))
|
||||
if (!TryReadModelTable(idx, modelTable, configPluginId, out var model))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model configuration.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ public sealed record EmbeddingProvider(
|
||||
if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
|
||||
{
|
||||
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
|
||||
else
|
||||
{
|
||||
var encryption = PluginFactory.EnterpriseEncryption;
|
||||
@ -128,31 +128,31 @@ public sealed record EmbeddingProvider(
|
||||
name,
|
||||
decryptedApiKey,
|
||||
SecretStoreType.EMBEDDING_PROVIDER));
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for embedding provider {idx}. It will be stored in the OS keyring.");
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for embedding provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for embedding provider {idx}. The encryption secret may be incorrect.");
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for embedding provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} contains an encrypted API key, but no encryption secret is configured.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, out Model model)
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model)
|
||||
{
|
||||
model = default;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model ID.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead<string>(out var displayName))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model display name.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -94,31 +94,31 @@ public sealed record Provider(
|
||||
provider = NONE;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid ID. The ID must be a valid GUID.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("InstanceName", out var instanceNameValue) || !instanceNameValue.TryRead<string>(out var instanceName))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid instance name.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid instance name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead<string>(out var usedLLMProviderText) || !Enum.TryParse<LLMProviders>(usedLLMProviderText, true, out var usedLLMProvider))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid LLM provider enum value.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead<string>(out var hostText) || !Enum.TryParse<Host>(hostText, true, out var host))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid host enum value.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead<string>(out var hostname))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid hostname.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -127,27 +127,27 @@ public sealed record Provider(
|
||||
{
|
||||
if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid Hugging Face inference provider enum value.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid Hugging Face inference provider enum value. (Plugin ID: {configPluginId})");
|
||||
hfInferenceProvider = HFInferenceProvider.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead<LuaTable>(out var modelTable))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model table.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReadModelTable(idx, modelTable, out var model))
|
||||
if (!TryReadModelTable(idx, modelTable, configPluginId, out var model))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model configuration.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("AdditionalJsonApiParameters", out var additionalJsonApiParametersValue) || !additionalJsonApiParametersValue.TryRead<string>(out var additionalJsonApiParameters))
|
||||
{
|
||||
// In this case, no reason exists to reject this provider, though.
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain valid additional JSON API parameters.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain valid additional JSON API parameters. (Plugin ID: {configPluginId})");
|
||||
additionalJsonApiParameters = string.Empty;
|
||||
}
|
||||
|
||||
@ -171,7 +171,7 @@ public sealed record Provider(
|
||||
if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
|
||||
{
|
||||
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
|
||||
LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
|
||||
else
|
||||
{
|
||||
var encryption = PluginFactory.EnterpriseEncryption;
|
||||
@ -185,31 +185,31 @@ public sealed record Provider(
|
||||
instanceName,
|
||||
decryptedApiKey,
|
||||
SecretStoreType.LLM_PROVIDER));
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for provider {idx}. It will be stored in the OS keyring.");
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for provider {idx}. The encryption secret may be incorrect.");
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"The configured provider {idx} contains an encrypted API key, but no encryption secret is configured.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, out Model model)
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model)
|
||||
{
|
||||
model = default;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model ID.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead<string>(out var displayName))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model display name.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -172,17 +172,31 @@ public sealed class SettingsManager
|
||||
{
|
||||
case LangBehavior.AUTO:
|
||||
var languageCode = await this.rustService.ReadUserLanguage();
|
||||
var languagePlugin = PluginFactory.RunningPlugins.FirstOrDefault(x => x is ILanguagePlugin langPlug && langPlug.IETFTag == languageCode);
|
||||
if (languagePlugin is null)
|
||||
var languagePlugins = PluginFactory.RunningPlugins.OfType<ILanguagePlugin>().ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(languageCode))
|
||||
{
|
||||
this.logger.LogWarning($"The language plugin for the language '{languageCode}' is not available.");
|
||||
return PluginFactory.BaseLanguage;
|
||||
var exactMatch = languagePlugins.FirstOrDefault(x => string.Equals(x.IETFTag, languageCode, StringComparison.OrdinalIgnoreCase));
|
||||
if (exactMatch is not null)
|
||||
return exactMatch;
|
||||
|
||||
var primaryLanguage = GetPrimaryLanguage(languageCode);
|
||||
if (!string.IsNullOrWhiteSpace(primaryLanguage))
|
||||
{
|
||||
var primaryLanguageMatch = languagePlugins
|
||||
.Where(x => string.Equals(GetPrimaryLanguage(x.IETFTag), primaryLanguage, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(x => x.IETFTag, StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (primaryLanguageMatch is not null)
|
||||
{
|
||||
this.logger.LogWarning($"No exact language plugin found for '{languageCode}'. Use language fallback '{primaryLanguageMatch.IETFTag}'.");
|
||||
return primaryLanguageMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (languagePlugin is ILanguagePlugin langPlugin)
|
||||
return langPlugin;
|
||||
|
||||
this.logger.LogError("The language plugin is not a language plugin.");
|
||||
this.logger.LogWarning($"The language plugin for the language '{languageCode}' (normalized='{languageCode}') is not available.");
|
||||
return PluginFactory.BaseLanguage;
|
||||
|
||||
case LangBehavior.MANUAL:
|
||||
@ -204,6 +218,18 @@ public sealed class SettingsManager
|
||||
this.logger.LogError("The language behavior is unknown.");
|
||||
return PluginFactory.BaseLanguage;
|
||||
}
|
||||
|
||||
private static string GetPrimaryLanguage(string localeTag)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(localeTag))
|
||||
return string.Empty;
|
||||
|
||||
var separatorIndex = localeTag.IndexOf('-');
|
||||
if (separatorIndex < 0)
|
||||
return localeTag;
|
||||
|
||||
return localeTag[..separatorIndex];
|
||||
}
|
||||
|
||||
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
|
||||
public Provider GetPreselectedProvider(Tools.Components component, string? currentProviderId = null, bool usePreselectionBeforeCurrentProvider = false)
|
||||
@ -365,4 +391,4 @@ public sealed class SettingsManager
|
||||
// Return the full name of the property, including the class name:
|
||||
return $"{typeof(TIn).Name}.{memberExpr.Member.Name}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,43 +56,43 @@ public sealed record TranscriptionProvider(
|
||||
provider = NONE;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid ID. The ID must be a valid GUID.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead<string>(out var name))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid name.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead<string>(out var usedLLMProviderText) || !Enum.TryParse<LLMProviders>(usedLLMProviderText, true, out var usedLLMProvider))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid LLM provider enum value.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead<string>(out var hostText) || !Enum.TryParse<Host>(hostText, true, out var host))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid host enum value.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead<string>(out var hostname))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid hostname.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead<LuaTable>(out var modelTable))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model table.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReadModelTable(idx, modelTable, out var model))
|
||||
if (!TryReadModelTable(idx, modelTable, configPluginId, out var model))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model configuration.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ public sealed record TranscriptionProvider(
|
||||
if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
|
||||
{
|
||||
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
|
||||
else
|
||||
{
|
||||
var encryption = PluginFactory.EnterpriseEncryption;
|
||||
@ -128,31 +128,31 @@ public sealed record TranscriptionProvider(
|
||||
name,
|
||||
decryptedApiKey,
|
||||
SecretStoreType.TRANSCRIPTION_PROVIDER));
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for transcription provider {idx}. It will be stored in the OS keyring.");
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for transcription provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for transcription provider {idx}. The encryption secret may be incorrect.");
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for transcription provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} contains an encrypted API key, but no encryption secret is configured.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, out Model model)
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model)
|
||||
{
|
||||
model = default;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model ID.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead<string>(out var displayName))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model display name.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -3,4 +3,8 @@ namespace AIStudio.Tools.PluginSystem;
|
||||
public interface IAvailablePlugin : IPluginMetadata
|
||||
{
|
||||
public string LocalPath { get; }
|
||||
|
||||
public bool IsManagedByConfigServer { get; }
|
||||
|
||||
public Guid? ManagedConfigurationId { get; }
|
||||
}
|
||||
@ -17,6 +17,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
/// The list of configuration objects. Configuration objects are, e.g., providers or chat templates.
|
||||
/// </summary>
|
||||
public IEnumerable<PluginConfigurationObject> ConfigObjects => this.configObjects;
|
||||
|
||||
/// <summary>
|
||||
/// True/false when explicitly configured in the plugin, otherwise null.
|
||||
/// </summary>
|
||||
public bool? DeployedUsingConfigServer { get; } = ReadDeployedUsingConfigServer(state);
|
||||
|
||||
public async Task InitializeAsync(bool dryRun)
|
||||
{
|
||||
@ -69,6 +74,14 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
/// </summary>
|
||||
private sealed record TemporarySecretId(string SecretId, string SecretName) : ISecretId;
|
||||
|
||||
private static bool? ReadDeployedUsingConfigServer(LuaState state)
|
||||
{
|
||||
if (state.Environment["DEPLOYED_USING_CONFIG_SERVER"].TryRead<bool>(out var deployedUsingConfigServer))
|
||||
return deployedUsingConfigServer;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to initialize the UI text content of the plugin.
|
||||
/// </summary>
|
||||
|
||||
@ -79,13 +79,13 @@ public sealed record PluginConfigurationObject
|
||||
|
||||
if (luaTableName is null)
|
||||
{
|
||||
LOG.LogError($"The configuration object type '{configObjectType}' is not supported yet.");
|
||||
LOG.LogError("The configuration object type '{ConfigObjectType}' is not supported yet (config plugin id: {ConfigPluginId}).", configObjectType, configPluginId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!mainTable.TryGetValue(luaTableName, out var luaValue) || !luaValue.TryRead<LuaTable>(out var luaTable))
|
||||
{
|
||||
LOG.LogWarning($"The {luaTableName} table does not exist or is not a valid table.");
|
||||
LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, configPluginId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -97,7 +97,7 @@ public sealed record PluginConfigurationObject
|
||||
var luaObjectTableValue = luaTable[i];
|
||||
if (!luaObjectTableValue.TryRead<LuaTable>(out var luaObjectTable))
|
||||
{
|
||||
LOG.LogWarning($"The {luaObjectTable} table at index {i} is not a valid table.");
|
||||
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, i, configPluginId);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -151,12 +151,12 @@ public sealed record PluginConfigurationObject
|
||||
random ??= new ThreadSafeRandom();
|
||||
configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) };
|
||||
storedObjects.Add((TClass)configObject);
|
||||
LOG.LogWarning($"The next number for the configuration object '{configObject.Name}' (id={configObject.Id}) could not be incremented. Using a random number instead.");
|
||||
LOG.LogWarning("The next number for the configuration object '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
LOG.LogWarning($"The {luaObjectTable} table at index {i} does not contain a valid chat template configuration.");
|
||||
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid configuration object (type={ConfigObjectType}, config plugin id: {ConfigPluginId}).", luaTableName, i, configObjectType, configPluginId);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@ -5,10 +5,10 @@ namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static partial class PluginFactory
|
||||
{
|
||||
public static async Task<EntityTagHeaderValue?> DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default)
|
||||
public static async Task<(bool Success, EntityTagHeaderValue? ETag, string? Issue)> DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if(configPlugId == Guid.Empty || string.IsNullOrWhiteSpace(configServerUrl))
|
||||
return null;
|
||||
return (false, null, "Configuration ID or server URL is missing.");
|
||||
|
||||
try
|
||||
{
|
||||
@ -18,18 +18,24 @@ public static partial class PluginFactory
|
||||
using var http = new HttpClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
return response.Headers.ETag;
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
LOG.LogError($"Failed to determine the ETag for configuration plugin '{configPlugId}'. HTTP Status: {response.StatusCode}");
|
||||
return (false, null, $"HTTP status: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return (true, response.Headers.ETag, null);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, "An error occurred while determining the ETag for the configuration plugin.");
|
||||
return null;
|
||||
return (false, null, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<bool> TryDownloadingConfigPluginAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if(!IS_INITIALIZED)
|
||||
if(!IsInitialized)
|
||||
{
|
||||
LOG.LogWarning("Plugin factory is not yet initialized. Cannot download configuration plugin.");
|
||||
return false;
|
||||
@ -40,36 +46,72 @@ public static partial class PluginFactory
|
||||
|
||||
LOG.LogInformation($"Try to download configuration plugin with ID='{configPlugId}' from server='{configServerUrl}' (GET {downloadUrl})");
|
||||
var tempDownloadFile = Path.GetTempFileName();
|
||||
var stagedDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.staging-{Guid.NewGuid():N}");
|
||||
string? backupDirectory = null;
|
||||
var wasSuccessful = false;
|
||||
try
|
||||
{
|
||||
await LockHotReloadAsync();
|
||||
using var httpClient = new HttpClient();
|
||||
var response = await httpClient.GetAsync(downloadUrl, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await using(var tempFileStream = File.Create(tempDownloadFile))
|
||||
{
|
||||
await response.Content.CopyToAsync(tempFileStream, cancellationToken);
|
||||
}
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if(Directory.Exists(configDirectory))
|
||||
Directory.Delete(configDirectory, true);
|
||||
|
||||
Directory.CreateDirectory(configDirectory);
|
||||
ZipFile.ExtractToDirectory(tempDownloadFile, configDirectory);
|
||||
|
||||
LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'.");
|
||||
}
|
||||
else
|
||||
LOG.LogError($"Failed to download the enterprise configuration plugin. HTTP Status: {response.StatusCode}");
|
||||
return false;
|
||||
}
|
||||
|
||||
await using(var tempFileStream = File.Create(tempDownloadFile))
|
||||
{
|
||||
await response.Content.CopyToAsync(tempFileStream, cancellationToken);
|
||||
}
|
||||
|
||||
ZipFile.ExtractToDirectory(tempDownloadFile, stagedDirectory);
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if (Directory.Exists(configDirectory))
|
||||
{
|
||||
backupDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.backup-{Guid.NewGuid():N}");
|
||||
Directory.Move(configDirectory, backupDirectory);
|
||||
}
|
||||
|
||||
Directory.Move(stagedDirectory, configDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory))
|
||||
Directory.Delete(backupDirectory, true);
|
||||
|
||||
LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'.");
|
||||
wasSuccessful = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, "An error occurred while downloading or extracting the enterprise configuration plugin.");
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !Directory.Exists(configDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(backupDirectory, configDirectory);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
LOG.LogError(restoreException, "Failed to restore the previous configuration plugin after a failed update.");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(stagedDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(stagedDirectory, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, "Failed to delete the staged configuration plugin directory.");
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(tempDownloadFile))
|
||||
{
|
||||
try
|
||||
@ -85,6 +127,6 @@ public static partial class PluginFactory
|
||||
UnlockHotReload();
|
||||
}
|
||||
|
||||
return true;
|
||||
return wasSuccessful;
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ public static partial class PluginFactory
|
||||
|
||||
public static void SetUpHotReloading()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||
return;
|
||||
|
||||
@ -10,7 +10,7 @@ public static partial class PluginFactory
|
||||
{
|
||||
public static async Task EnsureInternalPlugins()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||
return;
|
||||
|
||||
@ -30,7 +30,7 @@ public static partial class PluginFactory
|
||||
/// </remarks>
|
||||
public static async Task LoadAll(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||
return;
|
||||
@ -103,7 +103,41 @@ public static partial class PluginFactory
|
||||
}
|
||||
|
||||
LOG.LogInformation($"Successfully loaded plugin: '{pluginMainFile}' (Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}', Authors='{string.Join(", ", plugin.Authors)}')");
|
||||
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath));
|
||||
|
||||
var isConfigurationPluginInConfigDirectory =
|
||||
plugin.Type is PluginType.CONFIGURATION &&
|
||||
pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var isManagedByConfigServer = false;
|
||||
Guid? managedConfigurationId = null;
|
||||
if (plugin is PluginConfiguration configPlugin)
|
||||
{
|
||||
if (configPlugin.DeployedUsingConfigServer.HasValue)
|
||||
isManagedByConfigServer = configPlugin.DeployedUsingConfigServer.Value;
|
||||
|
||||
else if (isConfigurationPluginInConfigDirectory)
|
||||
{
|
||||
isManagedByConfigServer = true;
|
||||
LOG.LogWarning($"The configuration plugin '{plugin.Id}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
}
|
||||
}
|
||||
|
||||
// For configuration plugins, validate that the plugin ID matches the enterprise config ID
|
||||
// (the directory name under which the plugin was downloaded):
|
||||
if (isConfigurationPluginInConfigDirectory && isManagedByConfigServer)
|
||||
{
|
||||
var directoryName = Path.GetFileName(pluginPath);
|
||||
if (Guid.TryParse(directoryName, out var enterpriseConfigId))
|
||||
{
|
||||
managedConfigurationId = enterpriseConfigId;
|
||||
if (enterpriseConfigId != plugin.Id)
|
||||
LOG.LogWarning($"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID.");
|
||||
}
|
||||
else
|
||||
LOG.LogWarning($"Could not determine the managed configuration ID for configuration plugin '{plugin.Id}'. The plugin directory '{pluginPath}' does not end with a valid GUID.");
|
||||
}
|
||||
|
||||
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer, managedConfigurationId));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@ -1,54 +1,129 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static partial class PluginFactory
|
||||
{
|
||||
public static void RemovePluginAsync(Guid pluginId)
|
||||
private const string REASON_NO_LONGER_REFERENCED = "no longer referenced by active enterprise environments";
|
||||
|
||||
public static void RemoveUnreferencedManagedConfigurationPlugins(ISet<Guid> activeConfigurationIds)
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
return;
|
||||
|
||||
LOG.LogWarning($"Try to remove plugin with ID: {pluginId}");
|
||||
var pluginIdsToRemove = new HashSet<Guid>();
|
||||
|
||||
// Case 1: Plugins are already loaded and metadata is available.
|
||||
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin =>
|
||||
plugin.Type is PluginType.CONFIGURATION &&
|
||||
plugin.IsManagedByConfigServer &&
|
||||
!activeConfigurationIds.Contains(plugin.Id)))
|
||||
pluginIdsToRemove.Add(plugin.Id);
|
||||
|
||||
// Case 2: Startup cleanup before the initial plugin load.
|
||||
// In this case, we inspect the .config directories directly.
|
||||
if (Directory.Exists(CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
foreach (var pluginDirectory in Directory.EnumerateDirectories(CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
var directoryName = Path.GetFileName(pluginDirectory);
|
||||
if (!Guid.TryParse(directoryName, out var pluginId))
|
||||
continue;
|
||||
|
||||
if (activeConfigurationIds.Contains(pluginId))
|
||||
continue;
|
||||
|
||||
var deployFlag = ReadDeployFlagFromPluginFile(pluginDirectory);
|
||||
var isManagedByConfigServer = deployFlag ?? true;
|
||||
if (!deployFlag.HasValue)
|
||||
LOG.LogWarning($"Configuration plugin '{pluginId}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
|
||||
if (isManagedByConfigServer)
|
||||
pluginIdsToRemove.Add(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pluginId in pluginIdsToRemove)
|
||||
RemovePluginAsync(pluginId, REASON_NO_LONGER_REFERENCED);
|
||||
}
|
||||
|
||||
private static void RemovePluginAsync(Guid pluginId, string reason)
|
||||
{
|
||||
if (!IsInitialized)
|
||||
return;
|
||||
|
||||
LOG.LogWarning("Removing plugin with ID '{PluginId}'. Reason: {Reason}.", pluginId, reason);
|
||||
|
||||
//
|
||||
// Remove the plugin from the available plugins list:
|
||||
//
|
||||
var availablePluginToRemove = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||
if (availablePluginToRemove == null)
|
||||
{
|
||||
LOG.LogWarning($"No plugin found with ID: {pluginId}");
|
||||
return;
|
||||
}
|
||||
|
||||
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
|
||||
if (availablePluginToRemove != null)
|
||||
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
|
||||
else
|
||||
LOG.LogWarning("No available plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
|
||||
|
||||
//
|
||||
// Remove the plugin from the running plugins list:
|
||||
//
|
||||
var runningPluginToRemove = RUNNING_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||
if (runningPluginToRemove == null)
|
||||
LOG.LogWarning($"No running plugin found with ID: {pluginId}");
|
||||
LOG.LogWarning("No running plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
|
||||
else
|
||||
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
||||
|
||||
//
|
||||
// Delete the plugin directory:
|
||||
//
|
||||
var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, availablePluginToRemove.Id.ToString());
|
||||
if (Directory.Exists(pluginDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(pluginDirectory, true);
|
||||
LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'.");
|
||||
}
|
||||
}
|
||||
else
|
||||
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
|
||||
DeleteConfigurationPluginDirectory(pluginId);
|
||||
|
||||
LOG.LogInformation($"Plugin with ID: {pluginId} removed successfully.");
|
||||
LOG.LogInformation("Plugin with ID '{PluginId}' removed successfully. Reason: {Reason}.", pluginId, reason);
|
||||
}
|
||||
|
||||
private static bool? ReadDeployFlagFromPluginFile(string pluginDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginFile = Path.Join(pluginDirectory, "plugin.lua");
|
||||
if (!File.Exists(pluginFile))
|
||||
return null;
|
||||
|
||||
var pluginCode = File.ReadAllText(pluginFile);
|
||||
var match = DeployedByConfigServerRegex().Match(pluginCode);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
return bool.TryParse(match.Groups[1].Value, out var deployFlag)
|
||||
? deployFlag
|
||||
: null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LOG.LogWarning(ex, $"Failed to parse deployment flag from plugin directory '{pluginDirectory}'.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteConfigurationPluginDirectory(Guid pluginId)
|
||||
{
|
||||
var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, pluginId.ToString());
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
{
|
||||
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(pluginDirectory, true);
|
||||
LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'.");
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\s*DEPLOYED_USING_CONFIG_SERVER\s*=\s*(true|false)\s*(?:--.*)?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
|
||||
private static partial Regex DeployedByConfigServerRegex();
|
||||
}
|
||||
@ -34,7 +34,7 @@ public static partial class PluginFactory
|
||||
|
||||
if (startedBasePlugin is PluginLanguage languagePlugin)
|
||||
{
|
||||
BASE_LANGUAGE_PLUGIN = languagePlugin;
|
||||
BaseLanguage = languagePlugin;
|
||||
RUNNING_PLUGINS.Add(languagePlugin);
|
||||
LOG.LogInformation($"Successfully started the base language plugin: Id='{languagePlugin.Id}', Type='{languagePlugin.Type}', Name='{languagePlugin.Name}', Version='{languagePlugin.Version}'");
|
||||
}
|
||||
@ -44,7 +44,7 @@ public static partial class PluginFactory
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, $"An error occurred while starting the base language plugin: Id='{baseLanguagePluginId}'.");
|
||||
BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE;
|
||||
BaseLanguage = NoPluginLanguage.INSTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,8 +106,8 @@ public static partial class PluginFactory
|
||||
//
|
||||
// When this is a language plugin, we need to set the base language plugin.
|
||||
//
|
||||
if (plugin is PluginLanguage languagePlugin && BASE_LANGUAGE_PLUGIN != NoPluginLanguage.INSTANCE)
|
||||
languagePlugin.SetBaseLanguage(BASE_LANGUAGE_PLUGIN);
|
||||
if (plugin is PluginLanguage languagePlugin && BaseLanguage != NoPluginLanguage.INSTANCE)
|
||||
languagePlugin.SetBaseLanguage(BaseLanguage);
|
||||
|
||||
if(plugin is PluginConfiguration configPlugin)
|
||||
await configPlugin.InitializeAsync(false);
|
||||
|
||||
@ -6,17 +6,17 @@ public static partial class PluginFactory
|
||||
{
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginFactory));
|
||||
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
|
||||
private static bool IS_INITIALIZED;
|
||||
|
||||
private static string DATA_DIR = string.Empty;
|
||||
private static string PLUGINS_ROOT = string.Empty;
|
||||
private static string INTERNAL_PLUGINS_ROOT = string.Empty;
|
||||
private static string CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
||||
private static string HOT_RELOAD_LOCK_FILE = string.Empty;
|
||||
private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
|
||||
private static ILanguagePlugin BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE;
|
||||
|
||||
public static ILanguagePlugin BaseLanguage => BASE_LANGUAGE_PLUGIN;
|
||||
public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE;
|
||||
|
||||
public static bool IsInitialized { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the enterprise encryption instance for decrypting API keys in configuration plugins.
|
||||
@ -47,7 +47,7 @@ public static partial class PluginFactory
|
||||
/// </summary>
|
||||
public static bool Setup()
|
||||
{
|
||||
if(IS_INITIALIZED)
|
||||
if(IsInitialized)
|
||||
return false;
|
||||
|
||||
LOG.LogInformation("Initializing plugin factory...");
|
||||
@ -61,14 +61,14 @@ public static partial class PluginFactory
|
||||
Directory.CreateDirectory(PLUGINS_ROOT);
|
||||
|
||||
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
|
||||
IS_INITIALIZED = true;
|
||||
IsInitialized = true;
|
||||
LOG.LogInformation("Plugin factory initialized successfully.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task LockHotReloadAsync()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized.");
|
||||
return;
|
||||
@ -92,7 +92,7 @@ public static partial class PluginFactory
|
||||
|
||||
private static void UnlockHotReload()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized.");
|
||||
return;
|
||||
@ -113,7 +113,7 @@ public static partial class PluginFactory
|
||||
|
||||
public static void Dispose()
|
||||
{
|
||||
if(!IS_INITIALIZED)
|
||||
if(!IsInitialized)
|
||||
return;
|
||||
|
||||
HOT_RELOAD_WATCHER.Dispose();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvailablePlugin
|
||||
public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isManagedByConfigServer = false, Guid? managedConfigurationId = null) : IAvailablePlugin
|
||||
{
|
||||
#region Implementation of IPluginMetadata
|
||||
|
||||
@ -51,6 +51,10 @@ public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvail
|
||||
#region Implementation of IAvailablePlugin
|
||||
|
||||
public string LocalPath { get; } = localPath;
|
||||
|
||||
public bool IsManagedByConfigServer { get; } = isManagedByConfigServer;
|
||||
|
||||
public Guid? ManagedConfigurationId { get; } = managedConfigurationId;
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
3
app/MindWork AI Studio/Tools/Rust/EnterpriseConfig.cs
Normal file
3
app/MindWork AI Studio/Tools/Rust/EnterpriseConfig.cs
Normal file
@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public sealed record EnterpriseConfig(string Id, string ServerUrl);
|
||||
@ -4,7 +4,9 @@ namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentService> logger, RustService rustService) : BackgroundService
|
||||
{
|
||||
public static EnterpriseEnvironment CURRENT_ENVIRONMENT;
|
||||
public static List<EnterpriseEnvironment> CURRENT_ENVIRONMENTS = [];
|
||||
|
||||
public static bool HasValidEnterpriseSnapshot { get; private set; }
|
||||
|
||||
#if DEBUG
|
||||
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6);
|
||||
@ -33,84 +35,138 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Start updating of the enterprise environment.");
|
||||
|
||||
Guid enterpriseRemoveConfigId;
|
||||
try
|
||||
{
|
||||
enterpriseRemoveConfigId = await rustService.EnterpriseEnvRemoveConfigId();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Failed to fetch the enterprise remove configuration ID from the Rust service.");
|
||||
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvRemoveConfigId failed");
|
||||
return;
|
||||
}
|
||||
|
||||
var isPlugin2RemoveInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == enterpriseRemoveConfigId);
|
||||
if (enterpriseRemoveConfigId != Guid.Empty && isPlugin2RemoveInUse)
|
||||
{
|
||||
logger.LogWarning("The enterprise environment configuration ID '{EnterpriseRemoveConfigId}' must be removed.", enterpriseRemoveConfigId);
|
||||
PluginFactory.RemovePluginAsync(enterpriseRemoveConfigId);
|
||||
}
|
||||
HasValidEnterpriseSnapshot = false;
|
||||
|
||||
string? enterpriseConfigServerUrl;
|
||||
//
|
||||
// Step 1: Fetch all active configurations.
|
||||
//
|
||||
List<EnterpriseEnvironment> fetchedConfigs;
|
||||
try
|
||||
{
|
||||
enterpriseConfigServerUrl = await rustService.EnterpriseEnvConfigServerUrl();
|
||||
fetchedConfigs = await rustService.EnterpriseEnvConfigs();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Failed to fetch the enterprise configuration server URL from the Rust service.");
|
||||
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigServerUrl failed");
|
||||
logger.LogError(e, "Failed to fetch the enterprise configurations from the Rust service.");
|
||||
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigs failed");
|
||||
return;
|
||||
}
|
||||
|
||||
Guid enterpriseConfigId;
|
||||
try
|
||||
{
|
||||
enterpriseConfigId = await rustService.EnterpriseEnvConfigId();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Failed to fetch the enterprise configuration ID from the Rust service.");
|
||||
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigId failed");
|
||||
return;
|
||||
}
|
||||
//
|
||||
// Step 2: Determine ETags and build the list of reachable configurations.
|
||||
// IMPORTANT: when one config server fails, we continue with the others.
|
||||
//
|
||||
var reachableEnvironments = new List<EnterpriseEnvironment>();
|
||||
var failedConfigIds = new HashSet<Guid>();
|
||||
var currentEnvironmentsById = CURRENT_ENVIRONMENTS
|
||||
.GroupBy(env => env.ConfigurationId)
|
||||
.ToDictionary(group => group.Key, group => group.Last());
|
||||
|
||||
var etag = await PluginFactory.DetermineConfigPluginETagAsync(enterpriseConfigId, enterpriseConfigServerUrl);
|
||||
var nextEnterpriseEnvironment = new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId, etag);
|
||||
if (CURRENT_ENVIRONMENT != nextEnterpriseEnvironment)
|
||||
{
|
||||
logger.LogInformation("The enterprise environment has changed. Updating the current environment.");
|
||||
CURRENT_ENVIRONMENT = nextEnterpriseEnvironment;
|
||||
var activeFetchedEnvironmentsById = fetchedConfigs
|
||||
.Where(config => config.IsActive)
|
||||
.GroupBy(config => config.ConfigurationId)
|
||||
.ToDictionary(group => group.Key, group => group.Last());
|
||||
|
||||
switch (enterpriseConfigServerUrl)
|
||||
foreach (var config in fetchedConfigs)
|
||||
{
|
||||
if (!config.IsActive)
|
||||
{
|
||||
case null when enterpriseConfigId == Guid.Empty:
|
||||
case not null when string.IsNullOrWhiteSpace(enterpriseConfigServerUrl) && enterpriseConfigId == Guid.Empty:
|
||||
logger.LogInformation("AI Studio runs without an enterprise configuration.");
|
||||
break;
|
||||
logger.LogWarning("Skipping inactive enterprise configuration with ID '{ConfigId}'. There is either no valid server URL or config ID set.", config.ConfigurationId);
|
||||
continue;
|
||||
}
|
||||
|
||||
case null:
|
||||
logger.LogWarning("AI Studio runs with an enterprise configuration id ('{EnterpriseConfigId}'), but the configuration server URL is not set.", enterpriseConfigId);
|
||||
break;
|
||||
var etagResponse = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl);
|
||||
if (!etagResponse.Success)
|
||||
{
|
||||
failedConfigIds.Add(config.ConfigurationId);
|
||||
logger.LogWarning("Failed to read enterprise config metadata for '{ConfigId}' from '{ServerUrl}': {Issue}. Keeping the current plugin state for this configuration.", config.ConfigurationId, config.ConfigurationServerUrl, etagResponse.Issue ?? "Unknown issue");
|
||||
continue;
|
||||
}
|
||||
|
||||
case not null when !string.IsNullOrWhiteSpace(enterpriseConfigServerUrl) && enterpriseConfigId == Guid.Empty:
|
||||
logger.LogWarning("AI Studio runs with an enterprise configuration server URL ('{EnterpriseConfigServerUrl}'), but the configuration ID is not set.", enterpriseConfigServerUrl);
|
||||
break;
|
||||
reachableEnvironments.Add(config with { ETag = etagResponse.ETag });
|
||||
}
|
||||
|
||||
default:
|
||||
logger.LogInformation("AI Studio runs with an enterprise configuration id ('{EnterpriseConfigId}') and configuration server URL ('{EnterpriseConfigServerUrl}').", enterpriseConfigId, enterpriseConfigServerUrl);
|
||||
|
||||
if(isFirstRun)
|
||||
MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId, etag));
|
||||
//
|
||||
// Step 3: Compare with current environments and process changes.
|
||||
// Download per configuration. A single failure must not block others.
|
||||
//
|
||||
var shouldDeferStartupDownloads = isFirstRun && !PluginFactory.IsInitialized;
|
||||
var effectiveEnvironmentsById = new Dictionary<Guid, EnterpriseEnvironment>();
|
||||
|
||||
// Process new or changed configs:
|
||||
foreach (var nextEnv in reachableEnvironments)
|
||||
{
|
||||
var hasCurrentEnvironment = currentEnvironmentsById.TryGetValue(nextEnv.ConfigurationId, out var currentEnv);
|
||||
if (hasCurrentEnvironment && currentEnv == nextEnv) // Hint: This relies on the record equality to check if anything relevant has changed (e.g. server URL or ETag).
|
||||
{
|
||||
logger.LogInformation("Enterprise configuration '{ConfigId}' has not changed. No update required.", nextEnv.ConfigurationId);
|
||||
effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(!hasCurrentEnvironment)
|
||||
logger.LogInformation("Detected new enterprise configuration with ID '{ConfigId}' and server URL '{ServerUrl}'.", nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl);
|
||||
else
|
||||
logger.LogInformation("Detected change in enterprise configuration with ID '{ConfigId}'. Server URL or ETag has changed.", nextEnv.ConfigurationId);
|
||||
|
||||
if (shouldDeferStartupDownloads)
|
||||
{
|
||||
MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, nextEnv);
|
||||
effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv;
|
||||
}
|
||||
else
|
||||
{
|
||||
var wasDownloadSuccessful = await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl);
|
||||
if (!wasDownloadSuccessful)
|
||||
{
|
||||
failedConfigIds.Add(nextEnv.ConfigurationId);
|
||||
if (hasCurrentEnvironment)
|
||||
{
|
||||
logger.LogWarning("Failed to update enterprise configuration '{ConfigId}'. Keeping the previously active version.", nextEnv.ConfigurationId);
|
||||
effectiveEnvironmentsById[nextEnv.ConfigurationId] = currentEnv;
|
||||
}
|
||||
else
|
||||
await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseConfigId, enterpriseConfigServerUrl);
|
||||
break;
|
||||
logger.LogWarning("Failed to download the new enterprise configuration '{ConfigId}'. Skipping activation for now.", nextEnv.ConfigurationId);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv;
|
||||
}
|
||||
}
|
||||
else
|
||||
logger.LogInformation("The enterprise environment has not changed. No update required.");
|
||||
|
||||
// Retain configurations for all failed IDs. On cold start there might be no
|
||||
// previous in-memory snapshot yet, so we also keep the current fetched entry
|
||||
// to protect it from cleanup while the server is unreachable.
|
||||
foreach (var failedConfigId in failedConfigIds)
|
||||
{
|
||||
if (effectiveEnvironmentsById.ContainsKey(failedConfigId))
|
||||
continue;
|
||||
|
||||
if (!currentEnvironmentsById.TryGetValue(failedConfigId, out var retainedEnvironment))
|
||||
{
|
||||
if (!activeFetchedEnvironmentsById.TryGetValue(failedConfigId, out retainedEnvironment))
|
||||
continue;
|
||||
|
||||
logger.LogWarning("Could not refresh enterprise configuration '{ConfigId}'. Protecting it from cleanup until connectivity is restored.", failedConfigId);
|
||||
}
|
||||
else
|
||||
logger.LogWarning("Could not refresh enterprise configuration '{ConfigId}'. Keeping the previously active version.", failedConfigId);
|
||||
|
||||
effectiveEnvironmentsById[failedConfigId] = retainedEnvironment;
|
||||
}
|
||||
|
||||
var effectiveEnvironments = effectiveEnvironmentsById.Values.ToList();
|
||||
|
||||
// Cleanup is only allowed after a successful sync cycle:
|
||||
if (PluginFactory.IsInitialized && !shouldDeferStartupDownloads)
|
||||
PluginFactory.RemoveUnreferencedManagedConfigurationPlugins(effectiveEnvironmentsById.Keys.ToHashSet());
|
||||
|
||||
if (effectiveEnvironments.Count == 0)
|
||||
logger.LogInformation("AI Studio runs without any enterprise configurations.");
|
||||
|
||||
CURRENT_ENVIRONMENTS = effectiveEnvironments;
|
||||
HasValidEnterpriseSnapshot = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@ -1,71 +1,9 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class RustService
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to read the enterprise environment for the current user's configuration ID.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Returns the empty Guid when the environment is not set or the request fails.
|
||||
/// Otherwise, the configuration ID.
|
||||
/// </returns>
|
||||
public async Task<Guid> EnterpriseEnvConfigId()
|
||||
{
|
||||
var result = await this.http.GetAsync("/system/enterprise/config/id");
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogError($"Failed to query the enterprise configuration ID: '{result.StatusCode}'");
|
||||
return Guid.Empty;
|
||||
}
|
||||
|
||||
Guid.TryParse(await result.Content.ReadAsStringAsync(), out var configurationId);
|
||||
return configurationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the enterprise environment for a configuration ID, which must be removed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Removing a configuration ID is necessary when the user moved to another department or
|
||||
/// left the company, or when the configuration ID is no longer valid.
|
||||
/// </remarks>
|
||||
/// <returns>
|
||||
/// Returns the empty Guid when the environment is not set or the request fails.
|
||||
/// Otherwise, the configuration ID.
|
||||
/// </returns>
|
||||
public async Task<Guid> EnterpriseEnvRemoveConfigId()
|
||||
{
|
||||
var result = await this.http.DeleteAsync("/system/enterprise/config/id");
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogError($"Failed to query the enterprise configuration ID for removal: '{result.StatusCode}'");
|
||||
return Guid.Empty;
|
||||
}
|
||||
|
||||
Guid.TryParse(await result.Content.ReadAsStringAsync(), out var configurationId);
|
||||
return configurationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the enterprise environment for the current user's configuration server URL.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Returns null when the environment is not set or the request fails.
|
||||
/// Otherwise, the configuration server URL.
|
||||
/// </returns>
|
||||
public async Task<string> EnterpriseEnvConfigServerUrl()
|
||||
{
|
||||
var result = await this.http.GetAsync("/system/enterprise/config/server");
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogError($"Failed to query the enterprise configuration server URL: '{result.StatusCode}'");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var serverUrl = await result.Content.ReadAsStringAsync();
|
||||
return string.IsNullOrWhiteSpace(serverUrl) ? string.Empty : serverUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the enterprise environment for the configuration encryption secret.
|
||||
/// </summary>
|
||||
@ -85,4 +23,35 @@ public sealed partial class RustService
|
||||
var encryptionSecret = await result.Content.ReadAsStringAsync();
|
||||
return string.IsNullOrWhiteSpace(encryptionSecret) ? string.Empty : encryptionSecret;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads all enterprise configurations (multi-config support).
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Returns a list of enterprise environments parsed from the Rust runtime.
|
||||
/// The ETag is not yet determined; callers must resolve it separately.
|
||||
/// </returns>
|
||||
public async Task<List<EnterpriseEnvironment>> EnterpriseEnvConfigs()
|
||||
{
|
||||
var result = await this.http.GetAsync("/system/enterprise/configs");
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Failed to query the enterprise configurations: '{result.StatusCode}'");
|
||||
}
|
||||
|
||||
var configs = await result.Content.ReadFromJsonAsync<List<EnterpriseConfig>>(this.jsonRustSerializerOptions);
|
||||
if (configs is null)
|
||||
throw new InvalidOperationException("Failed to parse the enterprise configurations from Rust.");
|
||||
|
||||
var environments = new List<EnterpriseEnvironment>();
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (Guid.TryParse(config.Id, out var id))
|
||||
environments.Add(new EnterpriseEnvironment(config.ServerUrl, id, null));
|
||||
else
|
||||
this.logger!.LogWarning($"Skipping enterprise config with invalid ID: '{config.Id}'.");
|
||||
}
|
||||
|
||||
return environments;
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,9 +37,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[9.0.12, )",
|
||||
"resolved": "9.0.12",
|
||||
"contentHash": "StA3kyImQHqDo8A8ZHaSxgASbEuT5UIqgeCvK5SzUPj//xE1QSys421J9pEs4cYuIVwq7CJvWSKxtyH7aPr1LA=="
|
||||
"requested": "[9.0.13, )",
|
||||
"resolved": "9.0.13",
|
||||
"contentHash": "f7t15I9ZXV7fNk3FIzPAlkJNG1A1tkSeDpRh+TFWEToGGqA+uj6uqU15I8YOkkYICNY2tqOVm2CMe6ScPFPwEg=="
|
||||
},
|
||||
"MudBlazor": {
|
||||
"type": "Direct",
|
||||
|
||||
@ -3,9 +3,19 @@
|
||||
- Added an app setting to enable administration options for IT staff to configure and maintain organization-wide settings.
|
||||
- Added an option to export all provider types (LLMs, embeddings, transcriptions) so you can use them in a configuration plugin. You'll be asked if you want to export the related API key too. API keys will be encrypted in the export. This feature only shows up when administration options are enabled.
|
||||
- Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled.
|
||||
- Added support for using multiple enterprise configurations simultaneously. Enabled organizations to apply configurations based on employee affiliations, such as departments and working groups. See the enterprise configuration documentation for details.
|
||||
- Added the `DEPLOYED_USING_CONFIG_SERVER` field for configuration plugins so enterprise-managed plugins can be identified explicitly. Administrators should update their configuration plugins accordingly. See the enterprise configuration documentation for details.
|
||||
- Improved the enterprise configuration synchronization to be fail-safe on unstable or unavailable internet connections (for example, during business travel). If metadata checks or downloads fail, AI Studio keeps the current configuration plugins unchanged.
|
||||
- Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users.
|
||||
- Improved the workspaces experience by using a different color for the delete button to avoid confusion.
|
||||
- Improved single-input dialogs (e.g., renaming chats) so pressing `Enter` confirmed immediately and the input field focused automatically when the dialog opened.
|
||||
- Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition.
|
||||
- Improved the configuration plugins by making EnabledPreviewFeatures additive rather than exclusive. Users can now enable additional preview features without being restricted to those selected by the configuration plugin.
|
||||
- Improved the system language detection for locale values such as `C` and variants like `de_DE.UTF-8`, enabling AI Studio to apply the matching UI language more reliably.
|
||||
- Fixed an issue where leftover enterprise configuration plugins could remain active after organizational assignment changes during longer absences (for example, vacation), which could lead to configuration conflicts.
|
||||
- Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves.
|
||||
- Fixed an issue where in some places "No profile" was displayed instead of the localized text.
|
||||
- Fixed a bug in the Responses API of our OpenAI provider implementation where streamed whitespace chunks were discarded. We thank Oliver Kunc `OliverKunc` for his first contribution in resolving this issue. We appreciate your help, Oliver.
|
||||
- Fixed the Google Gemini model API. Switched to the default OpenAI-compatible API to retrieve the model list after Google changed the previous API, which stopped working.
|
||||
- Upgraded to .NET 9.0.13 & Rust 1.93.1.
|
||||
- Upgraded dependencies.
|
||||
@ -13,27 +13,133 @@ Do you want to manage MindWork AI Studio in a corporate environment or within an
|
||||
AI Studio checks about every 16 minutes to see if the configuration ID, the server for the configuration, or the configuration itself has changed. If it finds any changes, it loads the updated configuration from the server and applies it right away.
|
||||
|
||||
## 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:
|
||||
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).
|
||||
|
||||
- **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.
|
||||
|
||||
The following keys and values (registry) and variables are checked and read:
|
||||
### 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:
|
||||
|
||||
- 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).
|
||||
|
||||
- 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.
|
||||
|
||||
**Example:** To configure two enterprise configurations (one for the organization and one for a department):
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
### Windows GPO / PowerShell example for `configs`
|
||||
|
||||
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"
|
||||
```
|
||||
|
||||
### Single configuration (legacy)
|
||||
|
||||
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:
|
||||
|
||||
- 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.
|
||||
|
||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `delete_config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID`: This is a configuration ID that should be removed. This is helpful if an employee moves to a different department or leaves the organization.
|
||||
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
|
||||
### How configurations are downloaded
|
||||
|
||||
Let's assume as example that `https://intranet.my-company.com:30100/ai-studio/configuration` is the server address and `9072b77d-ca81-40da-be6a-861da525ef7b` is the configuration ID. AI Studio will derive the following address from this information: `https://intranet.my-company.com:30100/ai-studio/configuration/9072b77d-ca81-40da-be6a-861da525ef7b.zip`. Important: The configuration ID will always be written in lowercase, even if it is configured in uppercase. If `9072B77D-CA81-40DA-BE6A-861DA525EF7B` is configured, the same address will be derived. Your web server must be configured accordingly.
|
||||
|
||||
Finally, AI Studio will send a GET request and download the ZIP file. The ZIP file only contains the files necessary for the configuration. It's normal to include a file for an icon along with the actual configuration plugin.
|
||||
|
||||
Approximately every 16 minutes, AI Studio checks the metadata of the ZIP file by reading the [ETag](https://en.wikipedia.org/wiki/HTTP_ETag). When the ETag was not changed, no download will be performed. Make sure that your web server supports this.
|
||||
Approximately every 16 minutes, AI Studio checks the metadata of the ZIP file by reading the [ETag](https://en.wikipedia.org/wiki/HTTP_ETag). When the ETag was not changed, no download will be performed. Make sure that your web server supports this. When using multiple configurations, each configuration is checked independently.
|
||||
|
||||
## Configure the configuration web server
|
||||
|
||||
@ -75,6 +181,26 @@ 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.
|
||||
|
||||
For example, if your enterprise configuration ID is `9072b77d-ca81-40da-be6a-861da525ef7b`, then your plugin must declare:
|
||||
|
||||
```lua
|
||||
ID = "9072b77d-ca81-40da-be6a-861da525ef7b"
|
||||
```
|
||||
|
||||
## Important: Mark enterprise-managed plugins explicitly
|
||||
|
||||
Configuration plugins deployed by your configuration server should define:
|
||||
|
||||
```lua
|
||||
DEPLOYED_USING_CONFIG_SERVER = true
|
||||
```
|
||||
|
||||
Local, manually managed configuration plugins should set this to `false`. If the field is missing, AI Studio falls back to the plugin path (`.config`) to determine whether the plugin is managed and logs a warning.
|
||||
|
||||
## Example AI Studio configuration
|
||||
The latest example of an AI Studio configuration via configuration plugin can always be found in the repository in the `app/MindWork AI Studio/Plugins/configuration` folder. Here are the links to the files:
|
||||
|
||||
@ -141,4 +267,4 @@ CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = {
|
||||
}
|
||||
```
|
||||
|
||||
The API key will be automatically decrypted when the configuration is loaded and stored securely in the operating system's credential store (Windows Credential Manager / macOS Keychain).
|
||||
The API key will be automatically decrypted when the configuration is loaded and stored securely in the operating system's credential store (Windows Credential Manager / macOS Keychain).
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
26.2.1
|
||||
2026-02-01 19:16:01 UTC
|
||||
233
|
||||
9.0.113 (commit 64f9f590b3)
|
||||
9.0.12 (commit 2f12400757)
|
||||
1.93.0 (commit 254b59607)
|
||||
9.0.114 (commit 4c5aac3d56)
|
||||
9.0.13 (commit 9ecbfd4f3f)
|
||||
1.93.1 (commit 01f6ddf75)
|
||||
8.15.0
|
||||
1.8.1
|
||||
8f9cd40d060, release
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
use std::env;
|
||||
use std::sync::OnceLock;
|
||||
use log::{debug, warn};
|
||||
use rocket::{delete, get};
|
||||
use log::{debug, info, warn};
|
||||
use rocket::get;
|
||||
use rocket::serde::json::Json;
|
||||
use serde::Serialize;
|
||||
use sys_locale::get_locale;
|
||||
use crate::api_token::APIToken;
|
||||
|
||||
const DEFAULT_LANGUAGE: &str = "en-US";
|
||||
|
||||
/// The data directory where the application stores its data.
|
||||
pub static DATA_DIRECTORY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
@ -39,12 +43,115 @@ pub fn is_prod() -> bool {
|
||||
!is_dev()
|
||||
}
|
||||
|
||||
fn normalize_locale_tag(locale: &str) -> Option<String> {
|
||||
let trimmed = locale.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let without_encoding = trimmed
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(trimmed)
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or(trimmed)
|
||||
.trim();
|
||||
|
||||
if without_encoding.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let normalized_delimiters = without_encoding.replace('_', "-");
|
||||
let mut segments = normalized_delimiters
|
||||
.split('-')
|
||||
.filter(|segment| !segment.is_empty());
|
||||
|
||||
let language = segments.next()?;
|
||||
if language.eq_ignore_ascii_case("c") || language.eq_ignore_ascii_case("posix") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let language = language.to_ascii_lowercase();
|
||||
if language.len() < 2 || !language.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(region) = segments.next() {
|
||||
if region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Some(format!("{}-{}", language, region.to_ascii_uppercase()));
|
||||
}
|
||||
}
|
||||
|
||||
Some(language)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_locale_from_environment() -> Option<String> {
|
||||
if let Ok(language) = env::var("LANGUAGE") {
|
||||
for candidate in language.split(':') {
|
||||
if let Some(locale) = normalize_locale_tag(candidate) {
|
||||
info!("Detected user language from Linux environment variable 'LANGUAGE': '{}'.", locale);
|
||||
return Some(locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
|
||||
if let Ok(value) = env::var(key) {
|
||||
if let Some(locale) = normalize_locale_tag(&value) {
|
||||
info!("Detected user language from Linux environment variable '{}': '{}'.", key, locale);
|
||||
return Some(locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn read_locale_from_environment() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[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 {
|
||||
get_locale().unwrap_or_else(|| {
|
||||
warn!("Could not determine the system language. Use default 'en-US'.");
|
||||
String::from("en-US")
|
||||
})
|
||||
if let Some(locale) = get_locale() {
|
||||
if let Some(normalized_locale) = normalize_locale_tag(&locale) {
|
||||
info!("Detected user language from sys-locale: '{}'.", normalized_locale);
|
||||
return normalized_locale;
|
||||
}
|
||||
|
||||
warn!("sys-locale returned an unusable locale value: '{}'.", locale);
|
||||
}
|
||||
|
||||
if let Some(locale) = read_locale_from_environment() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
warn!("Could not determine the system language. Use default '{}'.", DEFAULT_LANGUAGE);
|
||||
String::from(DEFAULT_LANGUAGE)
|
||||
}
|
||||
|
||||
#[get("/system/enterprise/config/id")]
|
||||
@ -71,30 +178,6 @@ pub fn read_enterprise_env_config_id(_token: APIToken) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
#[delete("/system/enterprise/config/id")]
|
||||
pub fn delete_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:
|
||||
// - delete_config_id
|
||||
//
|
||||
// The environment variable is:
|
||||
// MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID
|
||||
//
|
||||
debug!("Trying to read the enterprise environment for some config ID, which should be deleted.");
|
||||
get_enterprise_configuration(
|
||||
"delete_config_id",
|
||||
"MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID",
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/system/enterprise/config/server")]
|
||||
pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String {
|
||||
//
|
||||
@ -143,23 +226,87 @@ pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String
|
||||
)
|
||||
}
|
||||
|
||||
/// Represents a single enterprise configuration entry with an ID and server URL.
|
||||
#[derive(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.
|
||||
#[get("/system/enterprise/configs")]
|
||||
pub fn read_enterprise_configs(_token: APIToken) -> Json<Vec<EnterpriseConfig>> {
|
||||
info!("Trying to read the enterprise environment for all configurations.");
|
||||
|
||||
let mut configs: Vec<EnterpriseConfig> = Vec::new();
|
||||
let mut seen_ids: std::collections::HashSet<String> = 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)
|
||||
}
|
||||
|
||||
fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "windows")] {
|
||||
debug!(r"Detected a Windows machine, trying to read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT' or environment variables.");
|
||||
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(_) => {
|
||||
debug!(r"Could not read the registry key HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT. Falling back to environment variables.");
|
||||
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) => {
|
||||
debug!("Falling back to the environment variable '{}' was successful.", env_name);
|
||||
info!("Falling back to the environment variable '{}' was successful.", env_name);
|
||||
val
|
||||
},
|
||||
Err(_) => {
|
||||
debug!("Falling back to the environment variable '{}' was not successful.", env_name);
|
||||
info!("Falling back to the environment variable '{}' was not successful. It seems that there is no enterprise environment available.", env_name);
|
||||
"".to_string()
|
||||
},
|
||||
}
|
||||
@ -169,14 +316,14 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String {
|
||||
match key.get_string(_reg_value) {
|
||||
Ok(val) => val,
|
||||
Err(_) => {
|
||||
debug!(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 environment variables.", _reg_value);
|
||||
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) => {
|
||||
debug!("Falling back to the environment variable '{}' was successful.", env_name);
|
||||
info!("Falling back to the environment variable '{}' was successful.", env_name);
|
||||
val
|
||||
},
|
||||
Err(_) => {
|
||||
debug!("Falling back to the environment variable '{}' was not successful.", env_name);
|
||||
info!("Falling back to the environment variable '{}' was not successful. It seems that there is no enterprise environment available.", env_name);
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
@ -184,14 +331,14 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String {
|
||||
}
|
||||
} else {
|
||||
// In the case of macOS or Linux, we just read the environment variable:
|
||||
debug!(r"Detected a Unix machine, trying to read the environment variable '{}'.", env_name);
|
||||
info!(r"Detected a Unix machine, trying to read the environment variable '{}'.", env_name);
|
||||
match env::var(env_name) {
|
||||
Ok(val) => val,
|
||||
Err(_) => {
|
||||
debug!("The environment variable '{}' was not found.", env_name);
|
||||
info!("The environment variable '{}' was not found. It seems that there is no enterprise environment available.", env_name);
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,9 +83,9 @@ pub fn start_runtime_api() {
|
||||
crate::environment::get_config_directory,
|
||||
crate::environment::read_user_language,
|
||||
crate::environment::read_enterprise_env_config_id,
|
||||
crate::environment::delete_enterprise_env_config_id,
|
||||
crate::environment::read_enterprise_env_config_server_url,
|
||||
crate::environment::read_enterprise_env_config_encryption_secret,
|
||||
crate::environment::read_enterprise_configs,
|
||||
crate::file_data::extract_data,
|
||||
crate::log::get_log_paths,
|
||||
crate::log::log_event,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user