Merge branch 'main' into vectordb

This commit is contained in:
PaulKoudelka 2026-01-16 21:11:53 +01:00
commit e9c8cbd54a
234 changed files with 8415 additions and 2189 deletions

3
.gitignore vendored
View File

@ -166,3 +166,6 @@ orleans.codegen.cs
# Ignore AI plugin config files:
/app/.idea/.idea.MindWork AI Studio/.idea/AugmentWebviewStateStore.xml
# Ignore GitHub Copilot migration files:
**/copilot.data.migration.*.xml

15
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/contentModel.xml
/modules.xml
/projectSettingsUpdater.xml
/.idea.mindwork-ai-studio.iml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

4
.idea/encodings.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

8
.idea/indexLayout.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -13,7 +13,7 @@ MindWork AI Studio is a cross-platform desktop application for interacting with
- **Providers:** Multi-provider architecture supporting OpenAI, Anthropic, Google, Mistral, Perplexity, self-hosted models, and others
- **Plugin System:** Lua-based plugin system for language packs, configuration, and future assistant plugins
## Building and Running
## Building
### Prerequisites
- .NET 9 SDK
@ -22,39 +22,16 @@ MindWork AI Studio is a cross-platform desktop application for interacting with
- Tauri prerequisites (platform-specific dependencies)
- **Note:** Development on Linux is discouraged due to complex Tauri dependencies that vary by distribution
### One-Time Setup
### Build
```bash
cd app/Build
dotnet run build
```
This builds the .NET app as a Tauri "sidecar" binary, which is required even for development.
### Development Workflow
Run these commands in separate terminals:
**Terminal 1 - Start Rust runtime:**
```bash
cd runtime
cargo tauri dev --no-watch
```
**Terminal 2 - Start .NET app:**
```bash
cd "app/MindWork AI Studio"
dotnet run
```
The app will start in the Tauri window. Hot reload is supported for .NET code changes.
### Building for Production
```bash
cd app/Build
dotnet run build
```
Creates a release build for the current platform and architecture. Output is in `runtime/target/release/`.
### Running Tests
Currently no automated test suite exists in the repository.
Currently, no automated test suite exists in the repository.
## Architecture Details
@ -125,6 +102,14 @@ Plugins can configure:
- Preview features visibility
- Preselected profiles
- Chat templates
- etc.
When adding configuration options, update:
- `app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs`: In method `TryProcessConfiguration` register new options.
- `app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs`: In method `LoadAll` check for leftover configuration.
- The corresponding data class in `app/MindWork AI Studio/Settings/DataModel/` to call `ManagedConfiguration.Register(...)`, when adding config options (in contrast to complex config. objects)
- `app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs` for parsing logic of complex configuration objects.
- `app/MindWork AI Studio/Plugins/configuration/plugin.lua` to document the new configuration option.
## RAG (Retrieval-Augmented Generation)

View File

@ -6,7 +6,7 @@ FSL-1.1-MIT
## Notice
Copyright 2025 Thorsten Sommer
Copyright 2026 Thorsten Sommer
## Terms and Conditions

View File

@ -79,6 +79,8 @@ Since March 2025: We have started developing the plugin system. There will be la
</h3>
</summary>
- v26.1.1: Added the option to attach files, including images, to chat templates; added support for source code file attachments in chats and document analysis; added a preview feature for recording your own voice for transcription; fixed various bugs in provider dialogs and profile selection.
- v0.10.0: Added support for newer models like Mistral 3 & GPT 5.2, OpenRouter as LLM and embedding provider, the possibility to use file attachments in chats, and support for images as input.
- v0.9.51: Added support for [Perplexity](https://www.perplexity.ai/); citations added so that LLMs can provide source references (e.g., some OpenAI models, Perplexity); added support for OpenAI's Responses API so that all text LLMs from OpenAI now work in MindWork AI Studio, including Deep Research models; web searches are now possible (some OpenAI models, Perplexity).
- v0.9.50: Added support for self-hosted LLMs using [vLLM](https://blog.vllm.ai/2023/06/20/vllm.html).
- v0.9.46: Released our plugin system, a German language plugin, early support for enterprise environments, and configuration plugins. Additionally, we added the Pandoc integration for future data processing and file generation.
@ -89,8 +91,6 @@ Since March 2025: We have started developing the plugin system. There will be la
- v0.9.31: Added Helmholtz & GWDG as LLM providers. This is a huge improvement for many researchers out there who can use these providers for free. We added DeepSeek as a provider as well.
- v0.9.29: Added agents to support the RAG process (selecting the best data sources & validating retrieved data as part of the augmentation process)
- v0.9.26+: Added RAG for external data sources using our [ERI interface](https://mindworkai.org/#eri---external-retrieval-interface) as a preview feature.
- v0.9.25: Added [xAI](https://x.ai/) as a new provider. xAI provides their Grok models for generating content.
- v0.9.23: Added support for OpenAI `o` models (`o1`, `o1-mini`, `o3`, etc.); added also an [ERI](https://github.com/MindWorkAI/ERI) server coding assistant as a preview feature behind the RAG feature flag. Your own ERI server can be used to gain access to, e.g., your enterprise data from within AI Studio.
</details>
@ -114,6 +114,7 @@ MindWork AI Studio is a free desktop app for macOS, Windows, and Linux. It provi
- [xAI](https://x.ai/) (Grok)
- [DeepSeek](https://www.deepseek.com/en)
- [Alibaba Cloud](https://www.alibabacloud.com) (Qwen)
- [OpenRouter](https://openrouter.ai/)
- [Hugging Face](https://huggingface.co/) using their [inference providers](https://huggingface.co/docs/inference-providers/index) such as Cerebras, Nebius, Sambanova, Novita, Hyperbolic, Together AI, Fireworks, Hugging Face
- Self-hosted models using [llama.cpp](https://github.com/ggerganov/llama.cpp), [ollama](https://github.com/ollama/ollama), [LM Studio](https://lmstudio.ai/), and [vLLM](https://github.com/vllm-project/vllm)
- [Groq](https://groq.com/)

View File

@ -4,7 +4,9 @@ public enum PrepareAction
{
NONE,
PATCH,
MINOR,
MAJOR,
BUILD,
MONTH,
YEAR,
SET,
}

View File

@ -13,13 +13,32 @@ namespace Build.Commands;
public sealed partial class UpdateMetadataCommands
{
[Command("release", Description = "Prepare & build the next release")]
public async Task Release(PrepareAction action)
public async Task Release(
[Option("action", ['a'], Description = "The release action: patch, minor, or major")] PrepareAction action = PrepareAction.NONE,
[Option("version", ['v'], Description = "Set a specific version directly, e.g., 26.1.2")] string? version = null)
{
if(!Environment.IsWorkingDirectoryValid())
return;
// Validate parameters: either action or version must be specified, but not both:
if (action == PrepareAction.NONE && string.IsNullOrWhiteSpace(version))
{
Console.WriteLine("- Error: You must specify either --action (-a) or --version (-v).");
return;
}
if (action != PrepareAction.NONE && !string.IsNullOrWhiteSpace(version))
{
Console.WriteLine("- Error: You cannot specify both --action and --version. Please use only one.");
return;
}
// If version is specified, use SET action:
if (!string.IsNullOrWhiteSpace(version))
action = PrepareAction.SET;
// Prepare the metadata for the next release:
await this.PerformPrepare(action, true);
await this.PerformPrepare(action, true, version);
// Build once to allow the Rust compiler to read the changed metadata
// and to update all .NET artifacts:
@ -53,11 +72,30 @@ public sealed partial class UpdateMetadataCommands
}
[Command("prepare", Description = "Prepare the metadata for the next release")]
public async Task Prepare(PrepareAction action)
public async Task Prepare(
[Option("action", ['a'], Description = "The release action: patch, minor, or major")] PrepareAction action = PrepareAction.NONE,
[Option("version", ['v'], Description = "Set a specific version directly, e.g., 26.1.2")] string? version = null)
{
if(!Environment.IsWorkingDirectoryValid())
return;
// Validate parameters: either action or version must be specified, but not both:
if (action == PrepareAction.NONE && string.IsNullOrWhiteSpace(version))
{
Console.WriteLine("- Error: You must specify either --action (-a) or --version (-v).");
return;
}
if (action != PrepareAction.NONE && !string.IsNullOrWhiteSpace(version))
{
Console.WriteLine("- Error: You cannot specify both --action and --version. Please use only one.");
return;
}
// If version is specified, use SET action:
if (!string.IsNullOrWhiteSpace(version))
action = PrepareAction.SET;
Console.WriteLine("==============================");
Console.Write("- Are you trying to prepare a new release? (y/n) ");
var userAnswer = Console.ReadLine();
@ -67,17 +105,17 @@ public sealed partial class UpdateMetadataCommands
return;
}
await this.PerformPrepare(action, false);
await this.PerformPrepare(action, false, version);
}
private async Task PerformPrepare(PrepareAction action, bool internalCall)
private async Task PerformPrepare(PrepareAction action, bool internalCall, string? version = null)
{
if(internalCall)
Console.WriteLine("==============================");
Console.WriteLine("- Prepare the metadata for the next release ...");
var appVersion = await this.UpdateAppVersion(action);
var appVersion = await this.UpdateAppVersion(action, version);
if (!string.IsNullOrWhiteSpace(appVersion.VersionText))
{
var buildNumber = await this.IncreaseBuildNumber();
@ -90,7 +128,7 @@ public sealed partial class UpdateMetadataCommands
await this.UpdateTauriVersion();
await this.UpdateProjectCommitHash();
await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "..", "..", "LICENSE.md")));
await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "Pages", "About.razor.cs")));
await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "Pages", "Information.razor.cs")));
Console.WriteLine();
}
}
@ -242,17 +280,6 @@ public sealed partial class UpdateMetadataCommands
var pathChangelogs = Path.Combine(Environment.GetAIStudioDirectory(), "wwwroot", "changelog");
var nextBuildNumber = currentBuildNumber + 1;
//
// We assume that most of the time, there will be patch releases:
//
var nextMajor = currentAppVersion.Major;
var nextMinor = currentAppVersion.Minor;
var nextPatch = currentAppVersion.Patch + 1;
var nextAppVersion = $"{nextMajor}.{nextMinor}.{nextPatch}";
var nextChangelogFilename = $"v{nextAppVersion}.md";
var nextChangelogFilePath = Path.Combine(pathChangelogs, nextChangelogFilename);
//
// Regarding the next build time: We assume that the next release will take place in one week from now.
// Thus, we check how many days this month has left. In the end, we want to predict the year and month
@ -262,6 +289,19 @@ public sealed partial class UpdateMetadataCommands
var nextBuildYear = (DateTime.Today + TimeSpan.FromDays(7)).Year;
var nextBuildTimeString = $"{nextBuildYear}-{nextBuildMonth:00}-xx xx:xx UTC";
//
// We assume that most of the time, there will be patch releases:
//
// skipping the first 2 digits for major version
var nextBuildYearShort = nextBuildYear - 2000;
var nextMajor = nextBuildYearShort;
var nextMinor = nextBuildMonth;
var nextPatch = currentAppVersion.Major != nextBuildYearShort || currentAppVersion.Minor != nextBuildMonth ? 1 : currentAppVersion.Patch + 1;
var nextAppVersion = $"{nextMajor}.{nextMinor}.{nextPatch}";
var nextChangelogFilename = $"v{nextAppVersion}.md";
var nextChangelogFilePath = Path.Combine(pathChangelogs, nextChangelogFilename);
var changelogHeader = $"""
# v{nextAppVersion}, build {nextBuildNumber} ({nextBuildTimeString})
@ -368,7 +408,7 @@ public sealed partial class UpdateMetadataCommands
await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM);
}
private async Task<AppVersion> UpdateAppVersion(PrepareAction action)
private async Task<AppVersion> UpdateAppVersion(PrepareAction action, string? version = null)
{
const int APP_VERSION_INDEX = 0;
@ -381,36 +421,56 @@ public sealed partial class UpdateMetadataCommands
var pathMetadata = Environment.GetMetadataPath();
var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8);
var currentAppVersionLine = lines[APP_VERSION_INDEX].Trim();
var currentAppVersion = AppVersionRegex().Match(currentAppVersionLine);
var currentPatch = int.Parse(currentAppVersion.Groups["patch"].Value);
var currentMinor = int.Parse(currentAppVersion.Groups["minor"].Value);
var currentMajor = int.Parse(currentAppVersion.Groups["major"].Value);
switch (action)
int newMajor, newMinor, newPatch;
if (action == PrepareAction.SET && !string.IsNullOrWhiteSpace(version))
{
case PrepareAction.PATCH:
currentPatch++;
break;
// Parse the provided version string:
var versionMatch = AppVersionRegex().Match(version);
if (!versionMatch.Success)
{
Console.WriteLine($"- Error: Invalid version format '{version}'. Expected format: major.minor.patch (e.g., 26.1.2)");
return new(string.Empty, 0, 0, 0);
}
case PrepareAction.MINOR:
currentPatch = 0;
currentMinor++;
break;
newMajor = int.Parse(versionMatch.Groups["major"].Value);
newMinor = int.Parse(versionMatch.Groups["minor"].Value);
newPatch = int.Parse(versionMatch.Groups["patch"].Value);
}
else
{
// Parse current version and increment based on action:
var currentAppVersion = AppVersionRegex().Match(currentAppVersionLine);
newPatch = int.Parse(currentAppVersion.Groups["patch"].Value);
newMinor = int.Parse(currentAppVersion.Groups["minor"].Value);
newMajor = int.Parse(currentAppVersion.Groups["major"].Value);
case PrepareAction.MAJOR:
currentPatch = 0;
currentMinor = 0;
currentMajor++;
break;
switch (action)
{
case PrepareAction.BUILD:
newPatch++;
break;
case PrepareAction.MONTH:
newPatch = 1;
newMinor++;
break;
case PrepareAction.YEAR:
newPatch = 1;
newMinor = 1;
newMajor++;
break;
}
}
var updatedAppVersion = $"{currentMajor}.{currentMinor}.{currentPatch}";
var updatedAppVersion = $"{newMajor}.{newMinor}.{newPatch}";
Console.WriteLine($"- Updating app version from '{currentAppVersionLine}' to '{updatedAppVersion}'.");
lines[APP_VERSION_INDEX] = updatedAppVersion;
await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM);
return new(updatedAppVersion, currentMajor, currentMinor, currentPatch);
return new(updatedAppVersion, newMajor, newMinor, newPatch);
}
private async Task UpdateLicenceYear(string licenceFilePath)

View File

@ -6,6 +6,7 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=GWDG/@EntryIndexedValue">GWDG</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HF/@EntryIndexedValue">HF</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IERI/@EntryIndexedValue">IERI</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IMIME/@EntryIndexedValue">IMIME</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LLM/@EntryIndexedValue">LLM</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LM/@EntryIndexedValue">LM</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=MSG/@EntryIndexedValue">MSG</s:String>
@ -18,10 +19,12 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=URL/@EntryIndexedValue">URL</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=I18N/@EntryIndexedValue">I18N</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=agentic/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=eri/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=groq/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=gwdg/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=huggingface/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ieri/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mime/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mwais/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ollama/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Qdrant/@EntryIndexedValue">True</s:Boolean>

View File

@ -159,7 +159,9 @@ public sealed class AgentDataSourceSelection (ILogger<AgentDataSourceSelection>
ContentText text => text.Text,
// Image prompts may be empty, e.g., when the image is too large:
ContentImage image => await image.AsBase64(token),
ContentImage image => await image.TryAsBase64(token) is (success: true, { } base64Image)
? base64Image
: string.Empty,
// Other content types are not supported yet:
_ => string.Empty,

View File

@ -219,7 +219,9 @@ public sealed class AgentRetrievalContextValidation (ILogger<AgentRetrievalConte
ContentText text => text.Text,
// Image prompts may be empty, e.g., when the image is too large:
ContentImage image => await image.AsBase64(token),
ContentImage image => await image.TryAsBase64(token) is (success: true, { } base64Image)
? base64Image
: string.Empty,
// Other content types are not supported yet:
_ => string.Empty,

View File

@ -217,12 +217,13 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
return chatId;
}
protected DateTimeOffset AddUserRequest(string request, bool hideContentFromUser = false)
protected DateTimeOffset AddUserRequest(string request, bool hideContentFromUser = false, params List<FileAttachment> attachments)
{
var time = DateTimeOffset.Now;
this.lastUserPrompt = new ContentText
{
Text = request,
FileAttachments = attachments,
};
this.chatThread!.Blocks.Add(new ContentBlock

View File

@ -103,7 +103,7 @@ else
@T("Documents for the analysis")
</MudText>
<AttachDocuments Name="Document Analysis Files" @bind-DocumentPaths="@this.loadedDocumentPaths" CatchAllDocuments="true" UseSmallForm="false"/>
<AttachDocuments Name="Document Analysis Files" Layer="@DropLayers.ASSISTANTS" @bind-DocumentPaths="@this.loadedDocumentPaths" CatchAllDocuments="true" UseSmallForm="false" Provider="@this.providerSettings"/>
</ExpansionPanel>
</MudExpansionPanels>

View File

@ -1,3 +1,5 @@
using System.Text;
using AIStudio.Chat;
using AIStudio.Dialogs;
using AIStudio.Dialogs.Settings;
@ -34,11 +36,13 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<SettingsDialo
DOCUMENTS: the only content you may analyze.
Maybe, there are image files attached. IMAGES may contain important information. Use them as part of your analysis.
{this.GetDocumentTaskDescription()}
# Scope and precedence
Use only information explicitly contained in DOCUMENTS and/or POLICY_*.
Use only information explicitly contained in DOCUMENTS, IMAGES, and/or POLICY_*.
You may paraphrase but must not add facts, assumptions, or outside knowledge.
Content decisions are governed by POLICY_ANALYSIS_RULES; formatting is governed by POLICY_OUTPUT_RULES.
If there is a conflict between DOCUMENTS and POLICY_*, follow POLICY_ANALYSIS_RULES for analysis and POLICY_OUTPUT_RULES for formatting. Do not invent reconciliations.
@ -46,7 +50,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<SettingsDialo
# Process
1) Read POLICY_ANALYSIS_RULES and POLICY_OUTPUT_RULES end to end.
2) Extract only the information from DOCUMENTS that POLICY_ANALYSIS_RULES permits.
2) Extract only the information from DOCUMENTS and IMAGES that POLICY_ANALYSIS_RULES permits.
3) Perform the analysis strictly according to POLICY_ANALYSIS_RULES.
4) Produce the final answer strictly according to POLICY_OUTPUT_RULES.
@ -74,16 +78,33 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<SettingsDialo
# Selfcheck before sending
Verify the answer matches POLICY_OUTPUT_RULES exactly.
Verify every statement is attributable to DOCUMENTS or POLICY_*.
Verify every statement is attributable to DOCUMENTS, IMAGES, or POLICY_*.
Remove any text not required by POLICY_OUTPUT_RULES.
{this.PromptGetActivePolicy()}
""";
private string GetDocumentTaskDescription() =>
this.loadedDocumentPaths.Count > 1
? $"Your task is to analyze {this.loadedDocumentPaths.Count} DOCUMENTS. Different DOCUMENTS are divided by a horizontal rule in markdown formatting followed by the name of the document."
: "Your task is to analyze a single document.";
private string GetDocumentTaskDescription()
{
var numDocuments = this.loadedDocumentPaths.Count(x => x is { Exists: true, IsImage: false });
var numImages = this.loadedDocumentPaths.Count(x => x is { Exists: true, IsImage: true });
return (numDocuments, numImages) switch
{
(0, 1) => "Your task is to analyze a single image file attached as a document.",
(0, > 1) => $"Your task is to analyze {numImages} image file(s) attached as documents.",
(1, 0) => "Your task is to analyze a single DOCUMENT.",
(1, 1) => "Your task is to analyze a single DOCUMENT and 1 image file attached as a document.",
(1, > 1) => $"Your task is to analyze a single DOCUMENT and {numImages} image file(s) attached as documents.",
(> 0, 0) => $"Your task is to analyze {numDocuments} DOCUMENTS. Different DOCUMENTS are divided by a horizontal rule in markdown formatting followed by the name of the document.",
(> 0, 1) => $"Your task is to analyze {numDocuments} DOCUMENTS and 1 image file attached as a document. Different DOCUMENTS are divided by a horizontal rule in Markdown formatting followed by the name of the document.",
(> 0, > 0) => $"Your task is to analyze {numDocuments} DOCUMENTS and {numImages} image file(s) attached as documents. Different DOCUMENTS are divided by a horizontal rule in Markdown formatting followed by the name of the document.",
_ => "Your task is to analyze a single DOCUMENT."
};
}
protected override IReadOnlyList<IButtonData> FooterButtons => [];
@ -185,7 +206,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<SettingsDialo
private string policyOutputRules = string.Empty;
#warning Use deferred content for document analysis
private string deferredContent = string.Empty;
private HashSet<string> loadedDocumentPaths = [];
private HashSet<FileAttachment> loadedDocumentPaths = [];
private bool IsNoPolicySelectedOrProtected => this.selectedPolicy is null || this.selectedPolicy.IsProtected;
@ -327,31 +348,68 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<SettingsDialo
if (this.loadedDocumentPaths.Count == 0)
return string.Empty;
var documentSections = new List<string>();
var count = 1;
var documents = this.loadedDocumentPaths.Where(n => n is { Exists: true, IsImage: false }).ToList();
var sb = new StringBuilder();
foreach (var documentPath in this.loadedDocumentPaths)
if (documents.Count > 0)
{
var fileContent = await this.RustService.ReadArbitraryFileData(documentPath, int.MaxValue);
sb.AppendLine("""
# DOCUMENTS:
documentSections.Add($"""
## DOCUMENT {count}:
File path: {documentPath}
Content:
```
{fileContent}
```
---
""");
count++;
""");
}
return $"""
# DOCUMENTS:
var numDocuments = 1;
foreach (var document in documents)
{
if (document.IsForbidden)
{
this.Logger.LogWarning($"Skipping forbidden file: '{document.FilePath}'.");
continue;
}
{string.Join("\n", documentSections)}
""";
var fileContent = await this.RustService.ReadArbitraryFileData(document.FilePath, int.MaxValue);
sb.AppendLine($"""
## DOCUMENT {numDocuments}:
File path: {document.FilePath}
Content:
```
{fileContent}
```
---
""");
numDocuments++;
}
var numImages = this.loadedDocumentPaths.Count(x => x is { IsImage: true, Exists: true });
if (numImages > 0)
{
if (documents.Count == 0)
{
sb.AppendLine($"""
There are {numImages} image file(s) attached as documents.
Please consider them as documents as well and use them to
answer accordingly.
""");
}
else
{
sb.AppendLine($"""
Additionally, there are {numImages} image file(s) attached.
Please consider them as documents as well and use them to
answer accordingly.
""");
}
}
return sb.ToString();
}
private async Task Analyze()
@ -364,7 +422,9 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<SettingsDialo
this.CreateChatThread();
var userRequest = this.AddUserRequest(
$"{await this.PromptLoadDocumentsContent()}", hideContentFromUser:true);
await this.PromptLoadDocumentsContent(),
hideContentFromUser: true,
this.loadedDocumentPaths.Where(n => n is { Exists: true, IsImage: true }).ToList());
await this.AddAIResponseAsync(userRequest);
}

View File

@ -56,10 +56,18 @@ public partial class AssistantI18N : AssistantBaseCore<SettingsDialogI18N>
[
new ButtonData
{
#if DEBUG
Text = T("Write Lua code to language plugin file"),
#else
Text = T("Copy Lua code to clipboard"),
#endif
Icon = Icons.Material.Filled.Extension,
Color = Color.Default,
#if DEBUG
AsyncAction = async () => await this.WriteToPluginFile(),
#else
AsyncAction = async () => await this.RustService.CopyText2Clipboard(this.Snackbar, this.finalLuaCode.ToString()),
#endif
DisabledActionParam = () => this.finalLuaCode.Length == 0,
},
];
@ -374,4 +382,65 @@ public partial class AssistantI18N : AssistantBaseCore<SettingsDialogI18N>
UI_TEXT_CONTENT["
""");
}
#if DEBUG
private async Task WriteToPluginFile()
{
if (this.selectedLanguagePlugin is null)
{
this.Snackbar.Add(T("No language plugin selected."), Severity.Error);
return;
}
if (this.finalLuaCode.Length == 0)
{
this.Snackbar.Add(T("No Lua code generated yet."), Severity.Error);
return;
}
try
{
// Determine the plugin file path based on the selected language plugin:
var pluginDirectory = Path.Join(Environment.CurrentDirectory, "Plugins", "languages");
var pluginId = this.selectedLanguagePluginId.ToString();
var ietfTag = this.selectedLanguagePlugin.IETFTag.ToLowerInvariant();
var pluginFolderName = $"{ietfTag}-{pluginId}";
var pluginFilePath = Path.Join(pluginDirectory, pluginFolderName, "plugin.lua");
if (!File.Exists(pluginFilePath))
{
this.Logger.LogError("Plugin file not found: {PluginFilePath}.", pluginFilePath);
this.Snackbar.Add(T("Plugin file not found."), Severity.Error);
return;
}
// Read the existing plugin file:
var existingContent = await File.ReadAllTextAsync(pluginFilePath);
// Find the position of "UI_TEXT_CONTENT = {}":
const string MARKER = "UI_TEXT_CONTENT = {}";
var markerIndex = existingContent.IndexOf(MARKER, StringComparison.Ordinal);
if (markerIndex == -1)
{
this.Logger.LogError("Could not find 'UI_TEXT_CONTENT = {{}}' marker in plugin file: {PluginFilePath}", pluginFilePath);
this.Snackbar.Add(T("Could not find 'UI_TEXT_CONTENT = {}' marker in plugin file."), Severity.Error);
return;
}
// Keep everything before the marker and replace everything from the marker onwards:
var metadataSection = existingContent[..markerIndex];
var newContent = metadataSection + this.finalLuaCode;
// Write the updated content back to the file:
await File.WriteAllTextAsync(pluginFilePath, newContent);
this.Snackbar.Add(T("Successfully updated plugin file."), Severity.Success);
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Error writing to plugin file.");
this.Snackbar.Add(T("Error writing to plugin file."), Severity.Error);
}
}
#endif
}

File diff suppressed because it is too large Load Diff

View File

@ -238,7 +238,7 @@ public sealed record ChatThread
{
var (contentData, contentType) = block.Content switch
{
ContentImage image => (await image.AsBase64(token), Tools.ERIClient.DataModel.ContentType.IMAGE),
ContentImage image => (await image.TryAsBase64(token) is (success: true, { } base64Image) ? base64Image : string.Empty, Tools.ERIClient.DataModel.ContentType.IMAGE),
ContentText text => (text.Text, Tools.ERIClient.DataModel.ContentType.TEXT),
_ => (string.Empty, Tools.ERIClient.DataModel.ContentType.UNKNOWN),

View File

@ -20,7 +20,8 @@
{
<MudTooltip Text="@T("Number of attachments")" Placement="Placement.Bottom">
<MudBadge Content="@this.Content.FileAttachments.Count" Color="Color.Primary" Overlap="true" BadgeClass="sources-card-header">
<MudIconButton Icon="@Icons.Material.Filled.AttachFile" />
<MudIconButton Icon="@Icons.Material.Filled.AttachFile"
OnClick="@this.OpenAttachmentsDialog"/>
</MudBadge>
</MudTooltip>
}
@ -107,9 +108,21 @@
break;
case ContentType.IMAGE:
if (this.Content is ContentImage { SourceType: ContentImageSource.URL or ContentImageSource.LOCAL_PATH } imageContent)
if (this.Content is ContentImage imageContent)
{
<MudImage Src="@imageContent.Source"/>
var imageSrc = imageContent.SourceType switch
{
ContentImageSource.BASE64 => ImageHelpers.ToDataUrl(imageContent.Source),
ContentImageSource.URL => imageContent.Source,
ContentImageSource.LOCAL_PATH => imageContent.Source,
_ => string.Empty
};
if (!string.IsNullOrWhiteSpace(imageSrc))
{
<MudImage Src="@imageSrc" />
}
}
break;

View File

@ -1,4 +1,5 @@
using AIStudio.Components;
using AIStudio.Dialogs;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
@ -188,4 +189,9 @@ public partial class ContentBlockComponent : MSGComponentBase
await this.EditLastUserBlockFunc(this.Content);
}
private async Task OpenAttachmentsDialog()
{
var result = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.Content.FileAttachments.ToHashSet());
this.Content.FileAttachments = result.ToList();
}
}

View File

@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using AIStudio.Provider;
using AIStudio.Tools.Validation;
namespace AIStudio.Chat;
@ -31,7 +32,7 @@ public sealed class ContentImage : IContent, IImageSource
public List<Source> Sources { get; set; } = [];
/// <inheritdoc />
public List<string> FileAttachments { get; set; } = [];
public List<FileAttachment> FileAttachments { get; set; } = [];
/// <inheritdoc />
public Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatChatThread, CancellationToken token = default)
@ -46,10 +47,29 @@ public sealed class ContentImage : IContent, IImageSource
InitialRemoteWait = this.InitialRemoteWait,
IsStreaming = this.IsStreaming,
SourceType = this.SourceType,
Sources = [..this.Sources],
FileAttachments = [..this.FileAttachments],
};
#endregion
/// <summary>
/// Creates a ContentImage from a local file path.
/// </summary>
/// <param name="filePath">The path to the image file.</param>
/// <returns>A new ContentImage instance if the file is valid, null otherwise.</returns>
public static async Task<ContentImage?> CreateFromFileAsync(string filePath)
{
if (!await FileExtensionValidation.IsImageExtensionValidWithNotifyAsync(filePath))
return null;
return new ContentImage
{
SourceType = ContentImageSource.LOCAL_PATH,
Source = filePath,
};
}
/// <summary>
/// The type of the image source.
/// </summary>

View File

@ -42,7 +42,7 @@ public sealed class ContentText : IContent
public List<Source> Sources { get; set; } = [];
/// <inheritdoc />
public List<string> FileAttachments { get; set; } = [];
public List<FileAttachment> FileAttachments { get; set; } = [];
/// <inheritdoc />
public async Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default)
@ -149,24 +149,24 @@ public sealed class ContentText : IContent
#endregion
public async Task<string> PrepareContentForAI()
public async Task<string> PrepareTextContentForAI()
{
var sb = new StringBuilder();
sb.AppendLine(this.Text);
if(this.FileAttachments.Count > 0)
{
// Filter out files that no longer exist
var existingFiles = this.FileAttachments.Where(File.Exists).ToList();
// Get the list of existing documents:
var existingDocuments = this.FileAttachments.Where(x => x.Type is FileAttachmentType.DOCUMENT && x.Exists).ToList();
// Log warning for missing files
var missingFiles = this.FileAttachments.Except(existingFiles).ToList();
if (missingFiles.Count > 0)
foreach (var missingFile in missingFiles)
LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingFile}'", missingFile);
// Log warning for missing files:
var missingDocuments = this.FileAttachments.Except(existingDocuments).Where(x => x.Type is FileAttachmentType.DOCUMENT).ToList();
if (missingDocuments.Count > 0)
foreach (var missingDocument in missingDocuments)
LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingDocument}'.", missingDocument.FilePath);
// Only proceed if there are existing files
if (existingFiles.Count > 0)
// Only proceed if there are existing, allowed documents:
if (existingDocuments.Count > 0)
{
// Check Pandoc availability once before processing file attachments
var pandocState = await Pandoc.CheckAvailabilityAsync(Program.RUST_SERVICE, showMessages: true, showSuccessMessage: false);
@ -179,16 +179,30 @@ public sealed class ContentText : IContent
{
sb.AppendLine();
sb.AppendLine("The following files are attached to this message:");
foreach(var file in existingFiles)
foreach(var document in existingDocuments)
{
if (document.IsForbidden)
{
LOGGER.LogWarning("File attachment '{FilePath}' has a forbidden file type and will be skipped.", document.FilePath);
continue;
}
sb.AppendLine();
sb.AppendLine("---------------------------------------");
sb.AppendLine($"File path: {file}");
sb.AppendLine($"File path: {document.FilePath}");
sb.AppendLine("File content:");
sb.AppendLine("````");
sb.AppendLine(await Program.RUST_SERVICE.ReadArbitraryFileData(file, int.MaxValue));
sb.AppendLine(await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue));
sb.AppendLine("````");
}
var numImages = this.FileAttachments.Count(x => x is { IsImage: true, Exists: true });
if (numImages > 0)
{
sb.AppendLine();
sb.AppendLine($"Additionally, there are {numImages} image file(s) attached to this message. ");
sb.AppendLine("Please consider them as part of the message content and use them to answer accordingly.");
}
}
}
}

View File

@ -0,0 +1,105 @@
using System.Text.Json.Serialization;
using AIStudio.Tools.Rust;
namespace AIStudio.Chat;
/// <summary>
/// Represents an immutable file attachment with details about its type, name, path, and size.
/// </summary>
/// <param name="Type">The type of the file attachment.</param>
/// <param name="FileName">The name of the file, including extension.</param>
/// <param name="FilePath">The full path to the file, including the filename and extension.</param>
/// <param name="FileSizeBytes">The size of the file in bytes.</param>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(FileAttachment), typeDiscriminator: "file")]
[JsonDerivedType(typeof(FileAttachmentImage), typeDiscriminator: "image")]
public record FileAttachment(FileAttachmentType Type, string FileName, string FilePath, long FileSizeBytes)
{
/// <summary>
/// Gets a value indicating whether the file type is forbidden and should not be attached.
/// </summary>
/// <remarks>
/// The state is determined once during construction and does not change.
/// </remarks>
public bool IsForbidden { get; } = Type == FileAttachmentType.FORBIDDEN;
/// <summary>
/// Gets a value indicating whether the file type is valid and allowed to be attached.
/// </summary>
/// <remarks>
/// The state is determined once during construction and does not change.
/// </remarks>
public bool IsValid { get; } = Type != FileAttachmentType.FORBIDDEN;
/// <summary>
/// Gets a value indicating whether the file type is an image.
/// </summary>
/// <remarks>
/// The state is determined once during construction and does not change.
/// </remarks>
public bool IsImage { get; } = Type == FileAttachmentType.IMAGE;
/// <summary>
/// Gets the file path for loading the file from the web browser-side (Blazor).
/// </summary>
public string FilePathAsUrl { get; } = FileHandler.CreateFileUrl(FilePath);
/// <summary>
/// Gets a value indicating whether the file still exists on the file system.
/// </summary>
/// <remarks>
/// This property checks the file system each time it is accessed.
/// </remarks>
public bool Exists => File.Exists(this.FilePath);
/// <summary>
/// Creates a FileAttachment from a file path by automatically determining the type,
/// extracting the filename, and reading the file size.
/// </summary>
/// <param name="filePath">The full path to the file.</param>
/// <returns>A FileAttachment instance with populated properties.</returns>
public static FileAttachment FromPath(string filePath)
{
var fileName = Path.GetFileName(filePath);
var fileSize = File.Exists(filePath) ? new FileInfo(filePath).Length : 0;
var type = DetermineFileType(filePath);
return type switch
{
FileAttachmentType.DOCUMENT => new FileAttachment(type, fileName, filePath, fileSize),
FileAttachmentType.IMAGE => new FileAttachmentImage(fileName, filePath, fileSize),
_ => new FileAttachment(type, fileName, filePath, fileSize),
};
}
/// <summary>
/// Determines the file attachment type based on the file extension.
/// Uses centrally defined file type filters from <see cref="FileTypeFilter"/>.
/// </summary>
/// <param name="filePath">The file path to analyze.</param>
/// <returns>The corresponding FileAttachmentType.</returns>
private static FileAttachmentType DetermineFileType(string filePath)
{
var extension = Path.GetExtension(filePath).TrimStart('.').ToLowerInvariant();
// Check if it's an image file:
if (FileTypeFilter.AllImages.FilterExtensions.Contains(extension))
return FileAttachmentType.IMAGE;
// Check if it's an audio file:
if (FileTypeFilter.AllAudio.FilterExtensions.Contains(extension))
return FileAttachmentType.AUDIO;
// Check if it's an allowed document file (PDF, Text, or Office):
if (FileTypeFilter.PDF.FilterExtensions.Contains(extension) ||
FileTypeFilter.Text.FilterExtensions.Contains(extension) ||
FileTypeFilter.AllOffice.FilterExtensions.Contains(extension) ||
FileTypeFilter.AllSourceCode.FilterExtensions.Contains(extension))
return FileAttachmentType.DOCUMENT;
// All other file types are forbidden:
return FileAttachmentType.FORBIDDEN;
}
}

View File

@ -0,0 +1,17 @@
namespace AIStudio.Chat;
public record FileAttachmentImage(string FileName, string FilePath, long FileSizeBytes) : FileAttachment(FileAttachmentType.IMAGE, FileName, FilePath, FileSizeBytes), IImageSource
{
/// <summary>
/// The type of the image source.
/// </summary>
/// <remarks>
/// Is the image source a URL, a local file path, a base64 string, etc.?
/// </remarks>
public ContentImageSource SourceType { get; init; } = ContentImageSource.LOCAL_PATH;
/// <summary>
/// The image source.
/// </summary>
public string Source { get; set; } = FilePath;
}

View File

@ -0,0 +1,27 @@
namespace AIStudio.Chat;
/// <summary>
/// Represents different types of file attachments.
/// </summary>
public enum FileAttachmentType
{
/// <summary>
/// Document file types, such as .pdf, .docx, .txt, etc.
/// </summary>
DOCUMENT,
/// <summary>
/// All image file types, such as .jpg, .png, .gif, etc.
/// </summary>
IMAGE,
/// <summary>
/// All audio file types, such as .mp3, .wav, .aac, etc.
/// </summary>
AUDIO,
/// <summary>
/// Forbidden file types that should not be attached, such as executables.
/// </summary>
FORBIDDEN,
}

View File

@ -50,10 +50,10 @@ public interface IContent
/// <summary>
/// Represents a collection of file attachments associated with the content.
/// This property contains a list of file paths that are appended
/// This property contains a list of file attachments that are appended
/// to the content to provide additional context or resources.
/// </summary>
public List<string> FileAttachments { get; set; }
public List<FileAttachment> FileAttachments { get; set; }
/// <summary>
/// Uses the provider to create the content.

View File

@ -1,28 +1,91 @@
using AIStudio.Tools.MIME;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Chat;
public static class IImageSourceExtensions
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(IImageSourceExtensions).Namespace, nameof(IImageSourceExtensions));
public static MIMEType DetermineMimeType(this IImageSource image)
{
switch (image.SourceType)
{
case ContentImageSource.BASE64:
{
// Try to detect the mime type from the base64 string:
var base64Data = image.Source;
if (base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var mimeEnd = base64Data.IndexOf(';');
if (mimeEnd > 5)
return Builder.FromTextRepresentation(base64Data[5..mimeEnd]);
}
// Fallback:
return Builder.Create().UseApplication().UseSubtype(ApplicationSubtype.OCTET_STREAM).Build();
}
case ContentImageSource.URL:
{
// Try to detect the mime type from the URL extension:
var uri = new Uri(image.Source);
var extension = Path.GetExtension(uri.AbsolutePath).ToLowerInvariant();
return DeriveMIMETypeFromExtension(extension);
}
case ContentImageSource.LOCAL_PATH:
{
var extension = Path.GetExtension(image.Source).ToLowerInvariant();
return DeriveMIMETypeFromExtension(extension);
}
default:
return Builder.Create().UseApplication().UseSubtype(ApplicationSubtype.OCTET_STREAM).Build();
}
}
private static MIMEType DeriveMIMETypeFromExtension(string extension)
{
var imageBuilder = Builder.Create().UseImage();
return extension switch
{
".png" => imageBuilder.UseSubtype(ImageSubtype.PNG).Build(),
".jpg" or ".jpeg" => imageBuilder.UseSubtype(ImageSubtype.JPEG).Build(),
".gif" => imageBuilder.UseSubtype(ImageSubtype.GIF).Build(),
".webp" => imageBuilder.UseSubtype(ImageSubtype.WEBP).Build(),
".tiff" or ".tif" => imageBuilder.UseSubtype(ImageSubtype.TIFF).Build(),
".heic" or ".heif" => imageBuilder.UseSubtype(ImageSubtype.HEIC).Build(),
_ => Builder.Create().UseApplication().UseSubtype(ApplicationSubtype.OCTET_STREAM).Build()
};
}
/// <summary>
/// Read the image content as a base64 string.
/// </summary>
/// <remarks>
/// The images are directly converted to base64 strings. The maximum
/// size of the image is around 10 MB. If the image is larger, the method
/// returns an empty string.
///
/// returns an empty string.<br/>
/// <br/>
/// As of now, this method does no sort of image processing. LLMs usually
/// do not work with arbitrary image sizes. In the future, we might have
/// to resize the images before sending them to the model.
/// to resize the images before sending them to the model.<br/>
/// <br/>
/// Note as well that this method returns just the base64 string without
/// any data URI prefix (like "data:image/png;base64,"). The caller has
/// to take care of that if needed.
/// </remarks>
/// <param name="image">The image source.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The image content as a base64 string; might be empty.</returns>
public static async Task<string> AsBase64(this IImageSource image, CancellationToken token = default)
public static async Task<(bool success, string base64Content)> TryAsBase64(this IImageSource image, CancellationToken token = default)
{
switch (image.SourceType)
{
case ContentImageSource.BASE64:
return image.Source;
return (success: true, image.Source);
case ContentImageSource.URL:
{
@ -33,13 +96,17 @@ public static class IImageSourceExtensions
// Read the length of the content:
var lengthBytes = response.Content.Headers.ContentLength;
if(lengthBytes > 10_000_000)
return string.Empty;
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.ImageNotSupported, TB("The image at the URL is too large (>10 MB). Skipping the image.")));
return (success: false, string.Empty);
}
var bytes = await response.Content.ReadAsByteArrayAsync(token);
return Convert.ToBase64String(bytes);
return (success: true, Convert.ToBase64String(bytes));
}
return string.Empty;
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.ImageNotSupported, TB("Failed to download the image from the URL. Skipping the image.")));
return (success: false, string.Empty);
}
case ContentImageSource.LOCAL_PATH:
@ -48,16 +115,20 @@ public static class IImageSourceExtensions
// Read the content length:
var length = new FileInfo(image.Source).Length;
if(length > 10_000_000)
return string.Empty;
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.ImageNotSupported, TB("The local image file is too large (>10 MB). Skipping the image.")));
return (success: false, string.Empty);
}
var bytes = await File.ReadAllBytesAsync(image.Source, token);
return Convert.ToBase64String(bytes);
return (success: true, Convert.ToBase64String(bytes));
}
return string.Empty;
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.ImageNotSupported, TB("The local image file does not exist. Skipping the image.")));
return (success: false, string.Empty);
default:
return string.Empty;
return (success: false, string.Empty);
}
}
}

View File

@ -1,4 +1,8 @@
namespace AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Provider.OpenAI;
using AIStudio.Settings;
namespace AIStudio.Chat;
public static class ListContentBlockExtensions
{
@ -6,20 +10,171 @@ public static class ListContentBlockExtensions
/// Processes a list of content blocks by transforming them into a collection of message results asynchronously.
/// </summary>
/// <param name="blocks">The list of content blocks to process.</param>
/// <param name="transformer">A function that transforms each content block into a message result asynchronously.</param>
/// <typeparam name="TResult">The type of the result produced by the transformation function.</typeparam>
/// <param name="roleTransformer">A function that transforms each content block into a message result asynchronously.</param>
/// <param name="selectedProvider">The selected LLM provider.</param>
/// <param name="selectedModel">The selected model.</param>
/// <param name="textSubContentFactory">A factory function to create text sub-content.</param>
/// <param name="imageSubContentFactory">A factory function to create image sub-content.</param>
/// <returns>An asynchronous task that resolves to a list of transformed results.</returns>
public static async Task<IList<TResult>> BuildMessages<TResult>(this List<ContentBlock> blocks, Func<ContentBlock, Task<TResult>> transformer)
public static async Task<IList<IMessageBase>> BuildMessagesAsync(
this List<ContentBlock> blocks,
LLMProviders selectedProvider,
Model selectedModel,
Func<ChatRole, string> roleTransformer,
Func<string, ISubContent> textSubContentFactory,
Func<FileAttachmentImage, Task<ISubContent>> imageSubContentFactory)
{
var messages = blocks
.Where(n => n.ContentType is ContentType.TEXT && !string.IsNullOrWhiteSpace((n.Content as ContentText)?.Text))
.Select(transformer)
.ToList();
var capabilities = selectedProvider.GetModelCapabilities(selectedModel);
var canProcessImages = capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT) ||
capabilities.Contains(Capability.SINGLE_IMAGE_INPUT);
var messageTaskList = new List<Task<IMessageBase>>(blocks.Count);
foreach (var block in blocks)
{
switch (block.Content)
{
// The prompt may or may not contain image(s), but the provider/model cannot process images.
// Thus, we treat it as a regular text message.
case ContentText text when block.ContentType is ContentType.TEXT && !string.IsNullOrWhiteSpace(text.Text) && !canProcessImages:
messageTaskList.Add(CreateTextMessageAsync(block, text));
break;
// The regular case for text content without images:
case ContentText text when block.ContentType is ContentType.TEXT && !string.IsNullOrWhiteSpace(text.Text) && !text.FileAttachments.ContainsImages():
messageTaskList.Add(CreateTextMessageAsync(block, text));
break;
// Text prompt with images as attachments, and the provider/model can process images:
case ContentText text when block.ContentType is ContentType.TEXT && !string.IsNullOrWhiteSpace(text.Text) && text.FileAttachments.ContainsImages():
messageTaskList.Add(CreateMultimodalMessageAsync(block, text, textSubContentFactory, imageSubContentFactory));
break;
}
}
// Await all messages:
await Task.WhenAll(messages);
await Task.WhenAll(messageTaskList);
// Select all results:
return messages.Select(n => n.Result).ToList();
return messageTaskList.Select(n => n.Result).ToList();
// Local function to create a text message asynchronously.
Task<IMessageBase> CreateTextMessageAsync(ContentBlock block, ContentText text)
{
return Task.Run(async () => new TextMessage
{
Role = roleTransformer(block.Role),
Content = await text.PrepareTextContentForAI(),
} as IMessageBase);
}
// Local function to create a multimodal message asynchronously.
Task<IMessageBase> CreateMultimodalMessageAsync(
ContentBlock block,
ContentText text,
Func<string, ISubContent> innerTextSubContentFactory,
Func<FileAttachmentImage, Task<ISubContent>> innerImageSubContentFactory)
{
return Task.Run(async () =>
{
var imagesTasks = text.FileAttachments
.Where(x => x is { IsImage: true, Exists: true })
.Cast<FileAttachmentImage>()
.Select(innerImageSubContentFactory)
.ToList();
Task.WaitAll(imagesTasks);
var images = imagesTasks.Select(t => t.Result).ToList();
return new MultimodalMessage
{
Role = roleTransformer(block.Role),
Content =
[
innerTextSubContentFactory(await text.PrepareTextContentForAI()),
..images,
]
} as IMessageBase;
});
}
}
/// <summary>
/// Processes a list of content blocks using direct image URL format to create message results asynchronously.
/// </summary>
/// <param name="blocks">The list of content blocks to process.</param>
/// <param name="selectedProvider">The selected LLM provider.</param>
/// <param name="selectedModel">The selected model.</param>
/// <returns>An asynchronous task that resolves to a list of transformed message results.</returns>
/// <remarks>
/// Uses direct image URL format where the image data is placed directly in the image_url field:
/// <code>
/// { "type": "image_url", "image_url": "data:image/jpeg;base64,..." }
/// </code>
/// This format is used by OpenAI, Mistral, and Ollama.
/// </remarks>
public static async Task<IList<IMessageBase>> BuildMessagesUsingDirectImageUrlAsync(
this List<ContentBlock> blocks,
LLMProviders selectedProvider,
Model selectedModel) => await blocks.BuildMessagesAsync(
selectedProvider,
selectedModel,
StandardRoleTransformer,
StandardTextSubContentFactory,
DirectImageSubContentFactory);
/// <summary>
/// Processes a list of content blocks using nested image URL format to create message results asynchronously.
/// </summary>
/// <param name="blocks">The list of content blocks to process.</param>
/// <param name="selectedProvider">The selected LLM provider.</param>
/// <param name="selectedModel">The selected model.</param>
/// <returns>An asynchronous task that resolves to a list of transformed message results.</returns>
/// <remarks>
/// Uses nested image URL format where the image data is wrapped in an object:
/// <code>
/// { "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,..." } }
/// </code>
/// This format is used by LM Studio, VLLM, llama.cpp, and other OpenAI-compatible providers.
/// </remarks>
public static async Task<IList<IMessageBase>> BuildMessagesUsingNestedImageUrlAsync(
this List<ContentBlock> blocks,
LLMProviders selectedProvider,
Model selectedModel) => await blocks.BuildMessagesAsync(
selectedProvider,
selectedModel,
StandardRoleTransformer,
StandardTextSubContentFactory,
NestedImageSubContentFactory);
private static ISubContent StandardTextSubContentFactory(string text) => new SubContentText
{
Text = text,
};
private static async Task<ISubContent> DirectImageSubContentFactory(FileAttachmentImage attachment) => new SubContentImageUrl
{
ImageUrl = await attachment.TryAsBase64() is (true, var base64Content)
? $"data:{attachment.DetermineMimeType()};base64,{base64Content}"
: string.Empty,
};
private static async Task<ISubContent> NestedImageSubContentFactory(FileAttachmentImage attachment) => new SubContentImageUrlNested
{
ImageUrl = new SubContentImageUrlData
{
Url = await attachment.TryAsBase64() is (true, var base64Content)
? $"data:{attachment.DetermineMimeType()};base64,{base64Content}"
: string.Empty,
},
};
private static string StandardRoleTransformer(ChatRole role) => role switch
{
ChatRole.USER => "user",
ChatRole.AI => "assistant",
ChatRole.AGENT => "assistant",
ChatRole.SYSTEM => "system",
_ => "user",
};
}

View File

@ -0,0 +1,6 @@
namespace AIStudio.Chat;
public static class ListFileAttachmentExtensions
{
public static bool ContainsImages(this List<FileAttachment> attachments) => attachments.Any(attachment => attachment.IsImage);
}

View File

@ -1,30 +1,33 @@
@inherits MSGComponentBase
@typeparam TSettings
<MudCard Outlined="@true" Style="@this.BlockStyle">
<MudCardHeader>
<CardHeaderContent>
<MudStack AlignItems="AlignItems.Center" Row="@true">
<MudIcon Icon="@this.Icon" Size="Size.Large" Color="Color.Primary"/>
<MudText Typo="Typo.h6">
@this.Name
@if (this.IsVisible)
{
<MudCard Outlined="@true" Style="@this.BlockStyle">
<MudCardHeader>
<CardHeaderContent>
<MudStack AlignItems="AlignItems.Center" Row="@true">
<MudIcon Icon="@this.Icon" Size="Size.Large" Color="Color.Primary"/>
<MudText Typo="Typo.h6">
@this.Name
</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack>
<MudText>
@this.Description
</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack>
<MudText>
@this.Description
</MudText>
</MudStack>
</MudCardContent>
<MudCardActions>
<MudButtonGroup Variant="Variant.Outlined">
<MudButton Size="Size.Large" Variant="Variant.Filled" StartIcon="@this.Icon" Color="Color.Default" Href="@this.Link">
@this.ButtonText
</MudButton>
<MudIconButton Variant="Variant.Text" Icon="@Icons.Material.Filled.Settings" Color="Color.Default" OnClick="@this.OpenSettingsDialog"/>
</MudButtonGroup>
</MudCardActions>
</MudCard>
</MudCardContent>
<MudCardActions>
<MudButtonGroup Variant="Variant.Outlined">
<MudButton Size="Size.Large" Variant="Variant.Filled" StartIcon="@this.Icon" Color="Color.Default" Href="@this.Link">
@this.ButtonText
</MudButton>
<MudIconButton Variant="Variant.Text" Icon="@Icons.Material.Filled.Settings" Color="Color.Default" OnClick="@this.OpenSettingsDialog"/>
</MudButtonGroup>
</MudCardActions>
</MudCard>
}

View File

@ -1,3 +1,5 @@
using AIStudio.Settings.DataModel;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
@ -21,6 +23,12 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
[Parameter]
public string Link { get; set; } = string.Empty;
[Parameter]
public Tools.Components Component { get; set; } = Tools.Components.NONE;
[Parameter]
public PreviewFeatures RequiredPreviewFeature { get; set; } = PreviewFeatures.NONE;
[Inject]
private MudTheme ColorTheme { get; init; } = null!;
@ -41,4 +49,6 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
};
private string BlockStyle => $"border-width: 2px; border-color: {this.BorderColor}; border-radius: 12px; border-style: solid; max-width: 20em;";
private bool IsVisible => this.SettingsManager.IsAssistantVisible(this.Component, assistantName: this.Name, requiredPreviewFeature: this.RequiredPreviewFeature);
}

View File

@ -3,28 +3,49 @@
@if (this.UseSmallForm)
{
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
@{
var fileInfos = this.DocumentPaths.Select(file => new FileInfo(file)).ToList();
}
@if (fileInfos.Any())
@if (this.isDraggingOver)
{
<MudBadge
Content="@this.DocumentPaths.Count"
Color="Color.Primary"
Overlap="true">
<MudIconButton
Icon="@Icons.Material.Filled.AttachFile"
Color="Color.Default"
OnClick="@AddFilesManually"/>
Overlap="true"
Class="cursor-pointer"
OnClick="@this.OpenAttachmentsDialog">
<MudLink OnClick="@this.AddFilesManually" Style="text-decoration: none;">
<MudTextField T="string"
Text="@DROP_FILES_HERE_TEXT"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.AttachFile"
Typo="Typo.body2"
Variant="Variant.Outlined"
ReadOnly="true"
/>
</MudLink>
</MudBadge>
}
else if (this.DocumentPaths.Any())
{
<MudTooltip Text="@T("Click the paperclip to attach files, or click the number to see your attached files.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudBadge
Content="@this.DocumentPaths.Count"
Color="Color.Primary"
Overlap="true"
Class="cursor-pointer"
OnClick="@this.OpenAttachmentsDialog">
<MudIconButton
Icon="@Icons.Material.Filled.AttachFile"
Color="Color.Default"
OnClick="@this.AddFilesManually"/>
</MudBadge>
</MudTooltip>
}
else
{
<MudTooltip Text="@T("Click to attach files")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudTooltip Text="@T("Click here to attach files.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton
Icon="@Icons.Material.Filled.AttachFile"
Color="Color.Default"
OnClick="@AddFilesManually"/>
OnClick="@this.AddFilesManually"/>
</MudTooltip>
}
</div>
@ -47,9 +68,9 @@ else
</MudStack>
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
<MudPaper Height="20em" Outlined="true" Class="@this.dragClass" Style="overflow-y: auto;">
@foreach (var fileInfo in this.DocumentPaths.Select(file => new FileInfo(file)))
@foreach (var fileAttachment in this.DocumentPaths)
{
<MudChip T="string" Color="Color.Dark" Text="@fileInfo.Name" tabindex="-1" Icon="@Icons.Material.Filled.Search" OnClick="@(() => this.InvestigateFile(@fileInfo))" OnClose="@(() => this.RemoveDocumentPathFromDocumentPaths(@fileInfo))"/>
<MudChip T="string" Color="Color.Dark" Text="@fileAttachment.FileName" tabindex="-1" Icon="@Icons.Material.Filled.Search" OnClick="@(() => this.InvestigateFile(fileAttachment))" OnClose="@(() => this.RemoveDocument(fileAttachment))"/>
}
</MudPaper>
</div>

View File

@ -1,6 +1,9 @@
using AIStudio.Chat;
using AIStudio.Dialogs;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using AIStudio.Tools.Validation;
using Microsoft.AspNetCore.Components;
@ -10,17 +13,31 @@ using DialogOptions = Dialogs.DialogOptions;
public partial class AttachDocuments : MSGComponentBase
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AttachDocuments).Namespace, nameof(AttachDocuments));
[Parameter]
public string Name { get; set; } = string.Empty;
/// <summary>
/// On which layer to register the drop area. Higher layers have priority over lower layers.
/// </summary>
[Parameter]
public HashSet<string> DocumentPaths { get; set; } = [];
public int Layer { get; set; }
/// <summary>
/// When true, pause catching dropped files. Default is false.
/// </summary>
[Parameter]
public bool PauseCatchingDrops { get; set; }
[Parameter]
public EventCallback<HashSet<string>> DocumentPathsChanged { get; set; }
public HashSet<FileAttachment> DocumentPaths { get; set; } = [];
[Parameter]
public Func<HashSet<string>, Task> OnChange { get; set; } = _ => Task.CompletedTask;
public EventCallback<HashSet<FileAttachment>> DocumentPathsChanged { get; set; }
[Parameter]
public Func<HashSet<FileAttachment>, Task> OnChange { get; set; } = _ => Task.CompletedTask;
/// <summary>
/// Catch all documents that are hovered over the AI Studio window and not only over the drop zone.
@ -31,6 +48,18 @@ public partial class AttachDocuments : MSGComponentBase
[Parameter]
public bool UseSmallForm { get; set; }
/// <summary>
/// When true, validate media file types before attaching. Default is true. That means that
/// the user cannot attach unsupported media file types when the provider or model does not
/// support them. Set it to false in order to disable this validation. This is useful for places
/// where the user might want to prepare a template.
/// </summary>
[Parameter]
public bool ValidateMediaFileTypes { get; set; } = true;
[Parameter]
public AIStudio.Settings.Provider? Provider { get; set; }
[Inject]
private ILogger<AttachDocuments> Logger { get; set; } = null!;
@ -40,13 +69,24 @@ public partial class AttachDocuments : MSGComponentBase
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private PandocAvailabilityService PandocAvailabilityService { get; init; } = null!;
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
private static readonly string DROP_FILES_HERE_TEXT = TB("Drop files here to attach them.");
private uint numDropAreasAboveThis;
private bool isComponentHovered;
private bool isDraggingOver;
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED ]);
this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED, Event.REGISTER_FILE_DROP_AREA, Event.UNREGISTER_FILE_DROP_AREA ]);
// Register this drop area:
await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, this.Layer);
await base.OnInitializedAsync();
}
@ -54,34 +94,101 @@ public partial class AttachDocuments : MSGComponentBase
{
switch (triggeredEvent)
{
case Event.REGISTER_FILE_DROP_AREA when sendingComponent != this:
{
if(data is int layer && layer > this.Layer)
{
this.numDropAreasAboveThis++;
this.PauseCatchingDrops = true;
}
break;
}
case Event.UNREGISTER_FILE_DROP_AREA when sendingComponent != this:
{
if(data is int layer && layer > this.Layer)
{
if(this.numDropAreasAboveThis > 0)
this.numDropAreasAboveThis--;
if(this.numDropAreasAboveThis is 0)
this.PauseCatchingDrops = false;
}
break;
}
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_HOVERED }:
if(this.PauseCatchingDrops)
return;
if(!this.isComponentHovered && !this.CatchAllDocuments)
{
this.Logger.LogDebug("Attach documents component '{Name}' is not hovered, ignoring file drop hovered event.", this.Name);
return;
}
this.isDraggingOver = true;
this.SetDragClass();
this.StateHasChanged();
break;
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_CANCELED }:
if(this.PauseCatchingDrops)
return;
this.isDraggingOver = false;
this.StateHasChanged();
break;
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.WINDOW_NOT_FOCUSED }:
if(this.PauseCatchingDrops)
return;
this.isDraggingOver = false;
this.isComponentHovered = false;
this.ClearDragClass();
this.StateHasChanged();
break;
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_DROPPED, Payload: var paths }:
if(this.PauseCatchingDrops)
return;
if(!this.isComponentHovered && !this.CatchAllDocuments)
{
this.Logger.LogDebug("Attach documents component '{Name}' is not hovered, ignoring file drop dropped event.", this.Name);
return;
}
// Ensure that Pandoc is installed and ready:
var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync(
showSuccessMessage: false,
showDialog: true);
// If Pandoc is not available (user cancelled installation), abort file drop:
if (!pandocState.IsAvailable)
{
this.Logger.LogWarning("The user cancelled the Pandoc installation or Pandoc is not available. Aborting file drop.");
this.isDraggingOver = false;
this.ClearDragClass();
this.StateHasChanged();
return;
}
foreach (var path in paths)
{
if(!await this.IsFileExtensionValid(path))
if(!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider))
continue;
this.DocumentPaths.Add(path);
this.DocumentPaths.Add(FileAttachment.FromPath(path));
}
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
this.isDraggingOver = false;
this.ClearDragClass();
this.StateHasChanged();
break;
}
@ -93,47 +200,42 @@ public partial class AttachDocuments : MSGComponentBase
private string dragClass = DEFAULT_DRAG_CLASS;
private bool isComponentHovered;
private async Task AddFilesManually()
{
var selectedFile = await this.RustService.SelectFile(T("Select a file to attach"));
if (selectedFile.UserCancelled)
// Ensure that Pandoc is installed and ready:
var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync(
showSuccessMessage: false,
showDialog: true);
// If Pandoc is not available (user cancelled installation), abort file selection:
if (!pandocState.IsAvailable)
{
this.Logger.LogWarning("The user cancelled the Pandoc installation or Pandoc is not available. Aborting file selection.");
return;
}
var selectFiles = await this.RustService.SelectFiles(T("Select files to attach"));
if (selectFiles.UserCancelled)
return;
if (!File.Exists(selectedFile.SelectedFilePath))
return;
foreach (var selectedFilePath in selectFiles.SelectedFilePaths)
{
if (!File.Exists(selectedFilePath))
continue;
if (!await this.IsFileExtensionValid(selectedFile.SelectedFilePath))
return;
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, selectedFilePath, this.ValidateMediaFileTypes, this.Provider))
continue;
this.DocumentPaths.Add(FileAttachment.FromPath(selectedFilePath));
}
this.DocumentPaths.Add(selectedFile.SelectedFilePath);
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
}
private async Task<bool> IsFileExtensionValid(string selectedFile)
private async Task OpenAttachmentsDialog()
{
var ext = Path.GetExtension(selectedFile).TrimStart('.');
if (Array.Exists(FileTypeFilter.Executables.FilterExtensions, x => x.Equals(ext, StringComparison.OrdinalIgnoreCase)))
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.AppBlocking, this.T("Executables are not allowed")));
return false;
}
if (Array.Exists(FileTypeFilter.AllImages.FilterExtensions, x => x.Equals(ext, StringComparison.OrdinalIgnoreCase)))
{
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.ImageNotSupported, this.T("Images are not supported yet")));
return false;
}
if (Array.Exists(FileTypeFilter.AllVideos.FilterExtensions, x => x.Equals(ext, StringComparison.OrdinalIgnoreCase)))
{
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.FeaturedVideo, this.T("Videos are not supported yet")));
return false;
}
return true;
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths);
}
private async Task ClearAllFiles()
@ -149,6 +251,9 @@ public partial class AttachDocuments : MSGComponentBase
private void OnMouseEnter(EventArgs _)
{
if(this.PauseCatchingDrops)
return;
this.Logger.LogDebug("Attach documents component '{Name}' is hovered.", this.Name);
this.isComponentHovered = true;
this.SetDragClass();
@ -157,15 +262,18 @@ public partial class AttachDocuments : MSGComponentBase
private void OnMouseLeave(EventArgs _)
{
if(this.PauseCatchingDrops)
return;
this.Logger.LogDebug("Attach documents component '{Name}' is no longer hovered.", this.Name);
this.isComponentHovered = false;
this.ClearDragClass();
this.StateHasChanged();
}
private async Task RemoveDocumentPathFromDocumentPaths(FileInfo file)
private async Task RemoveDocument(FileAttachment fileAttachment)
{
this.DocumentPaths.Remove(file.ToString());
this.DocumentPaths.Remove(fileAttachment);
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
@ -174,12 +282,12 @@ public partial class AttachDocuments : MSGComponentBase
/// <summary>
/// The user might want to check what we actually extract from his file and therefore give the LLM as an input.
/// </summary>
/// <param name="file">The file to check.</param>
private async Task InvestigateFile(FileInfo file)
/// <param name="fileAttachment">The file to check.</param>
private async Task InvestigateFile(FileAttachment fileAttachment)
{
var dialogParameters = new DialogParameters<DocumentCheckDialog>
{
{ x => x.FilePath, file.FullName },
{ x => x.Document, fileAttachment },
};
await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);

View File

@ -13,6 +13,8 @@ public partial class Changelog
public static readonly Log[] LOGS =
[
new (231, "v26.1.1, build 231 (2026-01-11 15:53 UTC)", "v26.1.1.md"),
new (230, "v0.10.0, build 230 (2025-12-31 14:04 UTC)", "v0.10.0.md"),
new (229, "v0.9.54, build 229 (2025-11-24 18:28 UTC)", "v0.9.54.md"),
new (228, "v0.9.53, build 228 (2025-11-14 13:14 UTC)", "v0.9.53.md"),
new (227, "v0.9.52, build 227 (2025-10-24 06:00 UTC)", "v0.9.52.md"),

View File

@ -59,46 +59,42 @@
&& this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior is WorkspaceDisplayBehavior.TOGGLE_OVERLAY)
{
<MudTooltip Text="@T("Show your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.SnippetFolder" OnClick="() => this.ToggleWorkspaceOverlay()"/>
<MudIconButton Icon="@Icons.Material.Filled.SnippetFolder" OnClick="@(() => this.ToggleWorkspaceOverlay())"/>
</MudTooltip>
}
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY)
{
<MudTooltip Text="@T("Save chat")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Save" OnClick="() => this.SaveThread()" Disabled="@(!this.CanThreadBeSaved)"/>
<MudIconButton Icon="@Icons.Material.Filled.Save" OnClick="@(() => this.SaveThread())" Disabled="@(!this.CanThreadBeSaved)"/>
</MudTooltip>
}
<MudTooltip Text="@T("Start temporary chat")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.AddComment" OnClick="() => this.StartNewChat(useSameWorkspace: false)"/>
<MudIconButton Icon="@Icons.Material.Filled.AddComment" OnClick="@(() => this.StartNewChat(useSameWorkspace: false))"/>
</MudTooltip>
@if (!string.IsNullOrWhiteSpace(this.currentWorkspaceName))
{
<MudTooltip Text="@this.TooltipAddChatToWorkspace" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.CommentBank" OnClick="() => this.StartNewChat(useSameWorkspace: true)"/>
<MudIconButton Icon="@Icons.Material.Filled.CommentBank" OnClick="@(() => this.StartNewChat(useSameWorkspace: true))"/>
</MudTooltip>
}
<ChatTemplateSelection CanChatThreadBeUsedForTemplate="@this.CanThreadBeSaved" CurrentChatThread="@this.ChatThread" CurrentChatTemplate="@this.currentChatTemplate" CurrentChatTemplateChanged="@this.ChatTemplateWasChanged"/>
@if (this.isPandocAvailable)
{
<AttachDocuments Name="File Attachments" @bind-DocumentPaths="@this.chatDocumentPaths" CatchAllDocuments="true" UseSmallForm="true"/>
}
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" @bind-DocumentPaths="@this.chatDocumentPaths" CatchAllDocuments="true" UseSmallForm="true" Provider="@this.Provider"/>
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
{
<MudTooltip Text="@T("Delete this chat & start a new one.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="() => this.StartNewChat(useSameWorkspace: true, deletePreviousChat: true)" Disabled="@(!this.CanThreadBeSaved)"/>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="@(() => this.StartNewChat(useSameWorkspace: true, deletePreviousChat: true))" Disabled="@(!this.CanThreadBeSaved)"/>
</MudTooltip>
}
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES)
{
<MudTooltip Text="@T("Move the chat to a workspace, or to another if it is already in one.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved)" OnClick="() => this.MoveChatToWorkspace()"/>
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved)" OnClick="@(() => this.MoveChatToWorkspace())"/>
</MudTooltip>
}
@ -110,7 +106,7 @@
@if (this.isStreaming && this.cancellationTokenSource is not null)
{
<MudTooltip Text="@T("Stop generation")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="() => this.CancelStreaming()"/>
<MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@(() => this.CancelStreaming())"/>
</MudTooltip>
}

View File

@ -3,7 +3,6 @@ using AIStudio.Dialogs;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
@ -38,9 +37,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private PandocAvailabilityService PandocAvailabilityService { get; init; } = null!;
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
@ -61,8 +57,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private string currentWorkspaceName = string.Empty;
private Guid currentWorkspaceId = Guid.Empty;
private CancellationTokenSource? cancellationTokenSource;
private HashSet<string> chatDocumentPaths = [];
private bool isPandocAvailable;
private HashSet<FileAttachment> chatDocumentPaths = [];
// Unfortunately, we need the input field reference to blur the focus away. Without
// this, we cannot clear the input field.
@ -85,6 +80,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT);
this.userInput = this.currentChatTemplate.PredefinedUserPrompt;
// Apply template's file attachments, if any:
foreach (var attachment in this.currentChatTemplate.FileAttachments)
this.chatDocumentPaths.Add(attachment);
//
// Check for deferred messages of the kind 'SEND_TO_CHAT',
// aka the user sends an assistant result to the chat:
@ -204,9 +203,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Select the correct provider:
await this.SelectProviderWhenLoadingChat();
// Check if Pandoc is available (no dialog or messages):
this.isPandocAvailable = await this.PandocAvailabilityService.IsAvailableAsync();
await base.OnInitializedAsync();
}
@ -337,6 +333,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if(!string.IsNullOrWhiteSpace(this.currentChatTemplate.PredefinedUserPrompt))
this.userInput = this.currentChatTemplate.PredefinedUserPrompt;
// Apply template's file attachments (replaces existing):
this.chatDocumentPaths.Clear();
foreach (var attachment in this.currentChatTemplate.FileAttachments)
this.chatDocumentPaths.Add(attachment);
if(this.ChatThread is null)
return;
@ -472,7 +473,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
lastUserPrompt = new ContentText
{
Text = this.userInput,
FileAttachments = this.chatDocumentPaths.ToList(),
FileAttachments = [..this.chatDocumentPaths.Where(x => x.IsValid)],
};
//
@ -688,6 +689,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.userInput = this.currentChatTemplate.PredefinedUserPrompt;
// Apply template's file attachments:
this.chatDocumentPaths.Clear();
foreach (var attachment in this.currentChatTemplate.FileAttachments)
this.chatDocumentPaths.Add(attachment);
// Now, we have to reset the data source options as well:
this.ApplyStandardDataSourceOptions();
@ -941,10 +947,17 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if (this.cancellationTokenSource is not null)
{
if(!this.cancellationTokenSource.IsCancellationRequested)
await this.cancellationTokenSource.CancelAsync();
try
{
if(!this.cancellationTokenSource.IsCancellationRequested)
await this.cancellationTokenSource.CancelAsync();
this.cancellationTokenSource.Dispose();
this.cancellationTokenSource.Dispose();
}
catch
{
// ignored
}
}
}

View File

@ -7,7 +7,7 @@
@if (this.CurrentChatTemplate != ChatTemplate.NO_CHAT_TEMPLATE)
{
<MudButton IconSize="Size.Large" StartIcon="@Icons.Material.Filled.RateReview" IconColor="Color.Default">
@this.CurrentChatTemplate.Name
@this.CurrentChatTemplate.GetSafeName()
</MudButton>
}
else
@ -16,14 +16,14 @@
}
</ActivatorContent>
<ChildContent>
<MudMenuItem Icon="@Icons.Material.Filled.Settings" Label="@T("Manage your templates")" OnClick="async () => await this.OpenSettingsDialog()" />
<MudMenuItem Icon="@Icons.Material.Filled.Settings" Label="@T("Manage your templates")" OnClick="@(async () => await this.OpenSettingsDialog())" />
<MudDivider/>
<MudMenuItem Icon="@Icons.Material.Filled.AddComment" Label="@T("Create template from current chat")" OnClick="async () => await this.CreateNewChatTemplateFromChat()" Disabled="@(!this.CanChatThreadBeUsedForTemplate)"/>
<MudMenuItem Icon="@Icons.Material.Filled.AddComment" Label="@T("Create template from current chat")" OnClick="@(async () => await this.CreateNewChatTemplateFromChat())" Disabled="@(!this.CanChatThreadBeUsedForTemplate)"/>
<MudDivider/>
@foreach (var chatTemplate in this.SettingsManager.ConfigurationData.ChatTemplates.GetAllChatTemplates())
{
<MudMenuItem Icon="@Icons.Material.Filled.RateReview" OnClick="async () => await this.SelectionChanged(chatTemplate)">
@chatTemplate.Name
<MudMenuItem Icon="@Icons.Material.Filled.RateReview" OnClick="@(async () => await this.SelectionChanged(chatTemplate))">
@chatTemplate.GetSafeName()
</MudMenuItem>
}
</ChildContent>

View File

@ -65,7 +65,7 @@ public partial class DataSourceSelection : MSGComponentBase
this.aiBasedSourceSelection = this.DataSourceOptions.AutomaticDataSourceSelection;
this.aiBasedValidation = this.DataSourceOptions.AutomaticValidation;
this.areDataSourcesEnabled = !this.DataSourceOptions.DisableDataSources;
this.waitingForDataSources = this.areDataSourcesEnabled;
this.waitingForDataSources = this.areDataSourcesEnabled && this.SelectionMode is not DataSourceSelectionMode.CONFIGURATION_MODE;
//
// Preselect the data sources. Right now, we cannot filter
@ -182,6 +182,9 @@ public partial class DataSourceSelection : MSGComponentBase
if(this.DataSourceOptions.DisableDataSources)
return;
if(this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE)
return;
this.waitingForDataSources = true;
this.StateHasChanged();

View File

@ -6,7 +6,7 @@
@if (this.CurrentProfile != Profile.NO_PROFILE)
{
<MudButton IconSize="Size.Large" StartIcon="@Icons.Material.Filled.Person4" IconColor="Color.Default">
@this.CurrentProfile.Name
@this.CurrentProfile.GetSafeName()
</MudButton>
}
else
@ -15,12 +15,12 @@
}
</ActivatorContent>
<ChildContent>
<MudMenuItem Icon="@Icons.Material.Filled.Settings" Label="@T("Manage your profiles")" OnClick="async () => await this.OpenSettingsDialog()" />
<MudMenuItem Icon="@Icons.Material.Filled.Settings" Label="@T("Manage your profiles")" OnClick="@(async () => await this.OpenSettingsDialog())" />
<MudDivider/>
@foreach (var profile in this.SettingsManager.ConfigurationData.Profiles.GetAllProfiles())
{
<MudMenuItem Icon="@this.ProfileIcon(profile)" OnClick="() => this.SelectionChanged(profile)">
@profile.Name
<MudMenuItem Icon="@this.ProfileIcon(profile)" OnClick="@(() => this.SelectionChanged(profile))">
@profile.GetSafeName()
</MudMenuItem>
}
</ChildContent>

View File

@ -1,5 +1,5 @@
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using AIStudio.Tools.Validation;
using Microsoft.AspNetCore.Components;
@ -30,6 +30,18 @@ public partial class ReadFileContent : MSGComponentBase
private async Task SelectFile()
{
// Ensure that Pandoc is installed and ready:
var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync(
showSuccessMessage: false,
showDialog: true);
// Check if Pandoc is available after the check / installation:
if (!pandocState.IsAvailable)
{
this.Logger.LogWarning("The user cancelled the Pandoc installation or Pandoc is not available. Aborting file selection.");
return;
}
var selectedFile = await this.RustService.SelectFile(T("Select file to read its content"));
if (selectedFile.UserCancelled)
{
@ -43,33 +55,12 @@ public partial class ReadFileContent : MSGComponentBase
return;
}
var ext = Path.GetExtension(selectedFile.SelectedFilePath).TrimStart('.');
if (Array.Exists(FileTypeFilter.Executables.FilterExtensions, x => x.Equals(ext, StringComparison.OrdinalIgnoreCase)))
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.DIRECTLY_LOADING_CONTENT, selectedFile.SelectedFilePath))
{
this.Logger.LogWarning("User attempted to load executable file: {FilePath} with extension: {Extension}", selectedFile.SelectedFilePath, ext);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.AppBlocking, T("Executables are not allowed")));
this.Logger.LogWarning("User attempted to load unsupported file: {FilePath}", selectedFile.SelectedFilePath);
return;
}
if (Array.Exists(FileTypeFilter.AllImages.FilterExtensions, x => x.Equals(ext, StringComparison.OrdinalIgnoreCase)))
{
this.Logger.LogWarning("User attempted to load image file: {FilePath} with extension: {Extension}", selectedFile.SelectedFilePath, ext);
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.ImageNotSupported, T("Images are not supported yet")));
return;
}
if (Array.Exists(FileTypeFilter.AllVideos.FilterExtensions, x => x.Equals(ext, StringComparison.OrdinalIgnoreCase)))
{
this.Logger.LogWarning("User attempted to load video file: {FilePath} with extension: {Extension}", selectedFile.SelectedFilePath, ext);
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.FeaturedVideo, this.T("Videos are not supported yet")));
return;
}
// Ensure that Pandoc is installed and ready:
await this.PandocAvailabilityService.EnsureAvailabilityAsync(
showSuccessMessage: false,
showDialog: true);
try
{
var fileContent = await UserFile.LoadFileData(selectedFile.SelectedFilePath, this.RustService, this.DialogService);

View File

@ -15,6 +15,6 @@
UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
<MudTooltip Text="@this.ToggleVisibilityTooltip">
<MudIconButton Icon="@this.InputTypeIcon" OnClick="() => this.ToggleVisibility()"/>
<MudIconButton Icon="@this.InputTypeIcon" OnClick="@(() => this.ToggleVisibility())"/>
</MudTooltip>
</MudStack>

View File

@ -29,4 +29,9 @@
<ConfigurationProviderSelection Component="Components.APP_SETTINGS" Data="@this.AvailableLLMProvidersFunc()" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreselectedProvider)" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.PreselectedProvider = selectedValue)" HelpText="@(() => T("Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence."))"/>
<ConfigurationSelect OptionDescription="@T("Preselect one of your profiles?")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreselectedProfile)" Data="@ConfigurationSelectDataFactory.GetProfilesData(this.SettingsManager.ConfigurationData.Profiles)" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.PreselectedProfile = selectedValue)" OptionHelp="@T("Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.PreselectedProfile, out var meta) && meta.IsLocked"/>
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
{
<ConfigurationSelect OptionDescription="@T("Select a transcription provider")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider)" Data="@this.GetFilteredTranscriptionProviders()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider = selectedValue)" OptionHelp="@T("Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.UseTranscriptionProvider, out var meta) && meta.IsLocked"/>
}
</ExpansionPanel>

View File

@ -1,9 +1,23 @@
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
namespace AIStudio.Components.Settings;
public partial class SettingsPanelApp : SettingsPanelBase
{
private IEnumerable<ConfigurationSelectData<string>> GetFilteredTranscriptionProviders()
{
yield return new(T("Disable dictation and transcription"), string.Empty);
var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(Tools.Components.APP_SETTINGS);
foreach (var provider in this.SettingsManager.ConfigurationData.TranscriptionProviders)
{
if (provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel)
yield return new(provider.Name, provider.Id);
}
}
private void UpdatePreviewFeatures(PreviewVisibility previewVisibility)
{
this.SettingsManager.ConfigurationData.App.PreviewVisibility = previewVisibility;

View File

@ -4,10 +4,10 @@
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.IntegrationInstructions" HeaderText="@T("Configure Embeddings")">
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.IntegrationInstructions" HeaderText="@T("Configure Embedding Providers")">
<PreviewPrototype ApplyInnerScrollingFix="true"/>
<MudText Typo="Typo.h4" Class="mb-3">
@T("Configured Embeddings")
@T("Configured Embedding Providers")
</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Embeddings are a way to represent words, sentences, entire documents, or even images and videos as digital fingerprints. Just like each person has a unique fingerprint, embedding models create unique digital patterns that capture the meaning and characteristics of the content they analyze. When two things are similar in meaning or content, their digital fingerprints will look very similar. For example, the fingerprints for 'happy' and 'joyful' would be more alike than those for 'happy' and 'sad'.")
@ -35,19 +35,28 @@
<MudTd>@context.Num</MudTd>
<MudTd>@context.Name</MudTd>
<MudTd>@context.UsedLLMProvider.ToName()</MudTd>
<MudTd>@this.GetEmbeddingProviderModelName(context)</MudTd>
<MudTd>@GetEmbeddingProviderModelName(context)</MudTd>
<MudTd>
<MudStack Row="true" Class="mb-2 mt-2" Wrap="Wrap.Wrap">
<MudTooltip Text="@T("Open Dashboard")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.OpenInBrowser" Href="@context.UsedLLMProvider.GetDashboardURL()" Target="_blank" Disabled="@(!context.UsedLLMProvider.HasDashboard())"/>
</MudTooltip>
<MudTooltip Text="@T("Edit")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="() => this.EditEmbeddingProvider(context)"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")">
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="() => this.DeleteEmbeddingProvider(context)"/>
</MudTooltip>
<MudStack Row="true" Class="mb-2 mt-2" Spacing="1" Wrap="Wrap.Wrap">
@if (context.IsEnterpriseConfiguration)
{
<MudTooltip Text="@T("This embedding provider is managed by your organization.")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Business" Disabled="true"/>
</MudTooltip>
}
else
{
<MudTooltip Text="@T("Open Dashboard")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.OpenInBrowser" Href="@context.UsedLLMProvider.GetDashboardURL()" Target="_blank" Disabled="@(!context.UsedLLMProvider.HasDashboard())"/>
</MudTooltip>
<MudTooltip Text="@T("Edit")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditEmbeddingProvider(context))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")">
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteEmbeddingProvider(context))"/>
</MudTooltip>
}
</MudStack>
</MudTd>
</RowTemplate>

View File

@ -15,7 +15,7 @@ public partial class SettingsPanelEmbeddings : SettingsPanelBase
[Parameter]
public EventCallback<List<ConfigurationSelectData<string>>> AvailableEmbeddingProvidersChanged { get; set; }
private string GetEmbeddingProviderModelName(EmbeddingProvider provider)
private static string GetEmbeddingProviderModelName(EmbeddingProvider provider)
{
const int MAX_LENGTH = 36;
var modelName = provider.Model.ToString();
@ -100,7 +100,7 @@ public partial class SettingsPanelEmbeddings : SettingsPanelBase
if (dialogResult is null || dialogResult.Canceled)
return;
var deleteSecretResponse = await this.RustService.DeleteAPIKey(provider);
var deleteSecretResponse = await this.RustService.DeleteAPIKey(provider, SecretStoreType.EMBEDDING_PROVIDER);
if(deleteSecretResponse.Success)
{
this.SettingsManager.ConfigurationData.EmbeddingProviders.Remove(provider);

View File

@ -3,9 +3,9 @@
@using AIStudio.Provider.SelfHosted
@inherits SettingsPanelBase
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Layers" HeaderText="@T("Configure Providers")">
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Layers" HeaderText="@T("Configure LLM Providers")">
<MudText Typo="Typo.h4" Class="mb-3">
@T("Configured Providers")
@T("Configured LLM Providers")
</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("What we call a provider is the combination of an LLM provider such as OpenAI and a model like GPT-4o. You can configure as many providers as you want. This way, you can use the appropriate model for each task. As an LLM provider, you can also choose local providers. However, to use this app, you must configure at least one provider.")
@ -32,11 +32,11 @@
<MudTd>
@if (context.UsedLLMProvider is not LLMProviders.SELF_HOSTED)
{
@this.GetLLMProviderModelName(context)
@GetLLMProviderModelName(context)
}
else if (context.UsedLLMProvider is LLMProviders.SELF_HOSTED && context.Host is not Host.LLAMACPP)
else if (context.UsedLLMProvider is LLMProviders.SELF_HOSTED && context.Host is not Host.LLAMA_CPP)
{
@this.GetLLMProviderModelName(context)
@GetLLMProviderModelName(context)
}
else
{
@ -57,10 +57,10 @@
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.OpenInBrowser" Href="@context.UsedLLMProvider.GetDashboardURL()" Target="_blank" Disabled="@(!context.UsedLLMProvider.HasDashboard())"/>
</MudTooltip>
<MudTooltip Text="@T("Edit")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="() => this.EditLLMProvider(context)"/>
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditLLMProvider(context))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")">
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="() => this.DeleteLLMProvider(context)"/>
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteLLMProvider(context))"/>
</MudTooltip>
}
</MudStack>

View File

@ -107,7 +107,7 @@ public partial class SettingsPanelProviders : SettingsPanelBase
if (dialogResult is null || dialogResult.Canceled)
return;
var deleteSecretResponse = await this.RustService.DeleteAPIKey(provider);
var deleteSecretResponse = await this.RustService.DeleteAPIKey(provider, SecretStoreType.LLM_PROVIDER);
if(deleteSecretResponse.Success)
{
this.SettingsManager.ConfigurationData.Providers.Remove(provider);
@ -134,7 +134,7 @@ public partial class SettingsPanelProviders : SettingsPanelBase
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private string GetLLMProviderModelName(AIStudio.Settings.Provider provider)
private static string GetLLMProviderModelName(AIStudio.Settings.Provider provider)
{
const int MAX_LENGTH = 36;
var modelName = provider.Model.ToString();

View File

@ -0,0 +1,73 @@
@using AIStudio.Provider
@using AIStudio.Settings.DataModel
@inherits SettingsPanelBase
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
{
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.VoiceChat" HeaderText="@T("Configure Transcription Providers")">
<PreviewPrototype ApplyInnerScrollingFix="true"/>
<MudText Typo="Typo.h4" Class="mb-3">
@T("Configured Transcription Providers")
</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("With the support of transcription models, MindWork AI Studio can convert human speech into text. This is useful, for example, when you need to dictate text. You can choose from dedicated transcription models, but not multimodal LLMs (large language models) that can handle both speech and text. The configuration of multimodal models is done in the 'Configure providers' section.")
</MudJustifiedText>
<MudTable Items="@this.SettingsManager.ConfigurationData.TranscriptionProviders" Hover="@true" Class="border-dashed border rounded-lg">
<ColGroup>
<col style="width: 3em;"/>
<col style="width: 12em;"/>
<col style="width: 12em;"/>
<col/>
<col style="width: 16em;"/>
</ColGroup>
<HeaderContent>
<MudTh>#</MudTh>
<MudTh>@T("Name")</MudTh>
<MudTh>@T("Provider")</MudTh>
<MudTh>@T("Model")</MudTh>
<MudTh>@T("Actions")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Num</MudTd>
<MudTd>@context.Name</MudTd>
<MudTd>@context.UsedLLMProvider.ToName()</MudTd>
<MudTd>@GetTranscriptionProviderModelName(context)</MudTd>
<MudTd>
<MudStack Row="true" Class="mb-2 mt-2" Spacing="1" Wrap="Wrap.Wrap">
@if (context.IsEnterpriseConfiguration)
{
<MudTooltip Text="@T("This transcription provider is managed by your organization.")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Business" Disabled="true"/>
</MudTooltip>
}
else
{
<MudTooltip Text="@T("Open Dashboard")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.OpenInBrowser" Href="@context.UsedLLMProvider.GetDashboardURL()" Target="_blank" Disabled="@(!context.UsedLLMProvider.HasDashboard())"/>
</MudTooltip>
<MudTooltip Text="@T("Edit")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditTranscriptionProvider(context))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")">
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteTranscriptionProvider(context))"/>
</MudTooltip>
}
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
@if (this.SettingsManager.ConfigurationData.TranscriptionProviders.Count == 0)
{
<MudText Typo="Typo.h6" Class="mt-3">
@T("No transcription provider configured yet.")
</MudText>
}
<MudButton Variant="Variant.Filled" Color="@Color.Primary" StartIcon="@Icons.Material.Filled.AddRoad" Class="mt-3 mb-6" OnClick="@this.AddTranscriptionProvider">
@T("Add transcription provider")
</MudButton>
</ExpansionPanel>
}

View File

@ -0,0 +1,122 @@
using AIStudio.Dialogs;
using AIStudio.Settings;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components.Settings;
public partial class SettingsPanelTranscription : SettingsPanelBase
{
[Parameter]
public List<ConfigurationSelectData<string>> AvailableTranscriptionProviders { get; set; } = new();
[Parameter]
public EventCallback<List<ConfigurationSelectData<string>>> AvailableTranscriptionProvidersChanged { get; set; }
private static string GetTranscriptionProviderModelName(TranscriptionProvider provider)
{
const int MAX_LENGTH = 36;
var modelName = provider.Model.ToString();
return modelName.Length > MAX_LENGTH ? "[...] " + modelName[^Math.Min(MAX_LENGTH, modelName.Length)..] : modelName;
}
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
await this.UpdateTranscriptionProviders();
await base.OnInitializedAsync();
}
#endregion
private async Task AddTranscriptionProvider()
{
var dialogParameters = new DialogParameters<TranscriptionProviderDialog>
{
{ x => x.IsEditing, false },
};
var dialogReference = await this.DialogService.ShowAsync<TranscriptionProviderDialog>(T("Add Transcription Provider"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
var addedTranscription = (TranscriptionProvider)dialogResult.Data!;
addedTranscription = addedTranscription with { Num = this.SettingsManager.ConfigurationData.NextTranscriptionNum++ };
this.SettingsManager.ConfigurationData.TranscriptionProviders.Add(addedTranscription);
await this.UpdateTranscriptionProviders();
await this.SettingsManager.StoreSettings();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task EditTranscriptionProvider(TranscriptionProvider transcriptionProvider)
{
var dialogParameters = new DialogParameters<TranscriptionProviderDialog>
{
{ x => x.DataNum, transcriptionProvider.Num },
{ x => x.DataId, transcriptionProvider.Id },
{ x => x.DataName, transcriptionProvider.Name },
{ x => x.DataLLMProvider, transcriptionProvider.UsedLLMProvider },
{ x => x.DataModel, transcriptionProvider.Model },
{ x => x.DataHostname, transcriptionProvider.Hostname },
{ x => x.IsSelfHosted, transcriptionProvider.IsSelfHosted },
{ x => x.IsEditing, true },
{ x => x.DataHost, transcriptionProvider.Host },
};
var dialogReference = await this.DialogService.ShowAsync<TranscriptionProviderDialog>(T("Edit Transcription Provider"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
var editedTranscriptionProvider = (TranscriptionProvider)dialogResult.Data!;
// Set the provider number if it's not set. This is important for providers
// added before we started saving the provider number.
if(editedTranscriptionProvider.Num == 0)
editedTranscriptionProvider = editedTranscriptionProvider with { Num = this.SettingsManager.ConfigurationData.NextTranscriptionNum++ };
this.SettingsManager.ConfigurationData.TranscriptionProviders[this.SettingsManager.ConfigurationData.TranscriptionProviders.IndexOf(transcriptionProvider)] = editedTranscriptionProvider;
await this.UpdateTranscriptionProviders();
await this.SettingsManager.StoreSettings();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task DeleteTranscriptionProvider(TranscriptionProvider provider)
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, string.Format(T("Are you sure you want to delete the transcription provider '{0}'?"), provider.Name) },
};
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Delete Transcription Provider"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
var deleteSecretResponse = await this.RustService.DeleteAPIKey(provider, SecretStoreType.TRANSCRIPTION_PROVIDER);
if(deleteSecretResponse.Success)
{
this.SettingsManager.ConfigurationData.TranscriptionProviders.Remove(provider);
await this.SettingsManager.StoreSettings();
}
await this.UpdateTranscriptionProviders();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task UpdateTranscriptionProviders()
{
this.AvailableTranscriptionProviders.Clear();
foreach (var provider in this.SettingsManager.ConfigurationData.TranscriptionProviders)
this.AvailableTranscriptionProviders.Add(new (provider.Name, provider.Id));
await this.AvailableTranscriptionProvidersChanged.InvokeAsync(this.AvailableTranscriptionProviders);
}
}

View File

@ -0,0 +1,23 @@
@using AIStudio.Settings.DataModel
@namespace AIStudio.Components
@inherits MSGComponentBase
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager) && !string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider))
{
<MudTooltip Text="@this.Tooltip">
@if (this.isTranscribing)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Primary"/>
}
else
{
<MudToggleIconButton Toggled="@this.isRecording"
ToggledChanged="@this.OnRecordingToggled"
Icon="@Icons.Material.Filled.Mic"
ToggledIcon="@Icons.Material.Filled.Stop"
Color="Color.Primary"
ToggledColor="Color.Error"/>
}
</MudTooltip>
}

View File

@ -0,0 +1,321 @@
using AIStudio.Provider;
using AIStudio.Tools.MIME;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class VoiceRecorder : MSGComponentBase
{
[Inject]
private ILogger<VoiceRecorder> Logger { get; init; } = null!;
[Inject]
private IJSRuntime JsRuntime { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private ISnackbar Snackbar { get; init; } = null!;
private uint numReceivedChunks;
private bool isRecording;
private bool isTranscribing;
private FileStream? currentRecordingStream;
private string? currentRecordingPath;
private string? currentRecordingMimeType;
private string? finalRecordingPath;
private DotNetObjectReference<VoiceRecorder>? dotNetReference;
private string Tooltip => this.isTranscribing
? T("Transcription in progress...")
: this.isRecording
? T("Stop recording and start transcription")
: T("Start recording your voice for a transcription");
private async Task OnRecordingToggled(bool toggled)
{
if (toggled)
{
var mimeTypes = GetPreferredMimeTypes(
Builder.Create().UseAudio().UseSubtype(AudioSubtype.OGG).Build(),
Builder.Create().UseAudio().UseSubtype(AudioSubtype.AAC).Build(),
Builder.Create().UseAudio().UseSubtype(AudioSubtype.MP3).Build(),
Builder.Create().UseAudio().UseSubtype(AudioSubtype.AIFF).Build(),
Builder.Create().UseAudio().UseSubtype(AudioSubtype.WAV).Build(),
Builder.Create().UseAudio().UseSubtype(AudioSubtype.FLAC).Build()
);
this.Logger.LogInformation("Starting audio recording with preferred MIME types: '{PreferredMimeTypes}'.", string.Join<MIMEType>(", ", mimeTypes));
// Create a DotNetObjectReference to pass to JavaScript:
this.dotNetReference = DotNetObjectReference.Create(this);
// Initialize the file stream for writing chunks:
await this.InitializeRecordingStream();
var mimeTypeStrings = mimeTypes.ToStringArray();
var actualMimeType = await this.JsRuntime.InvokeAsync<string>("audioRecorder.start", this.dotNetReference, mimeTypeStrings);
// Store the MIME type for later use:
this.currentRecordingMimeType = actualMimeType;
this.Logger.LogInformation("Audio recording started with MIME type: '{ActualMimeType}'.", actualMimeType);
this.isRecording = true;
}
else
{
var result = await this.JsRuntime.InvokeAsync<AudioRecordingResult>("audioRecorder.stop");
if (result.ChangedMimeType)
this.Logger.LogWarning("The recorded audio MIME type was changed to '{ResultMimeType}'.", result.MimeType);
// Close and finalize the recording stream:
await this.FinalizeRecordingStream();
this.isRecording = false;
this.StateHasChanged();
// Start transcription if we have a recording and a configured provider:
if (this.finalRecordingPath is not null)
await this.TranscribeRecordingAsync();
}
}
private static MIMEType[] GetPreferredMimeTypes(params MIMEType[] mimeTypes)
{
// Default list if no parameters provided:
if (mimeTypes.Length is 0)
{
var audioBuilder = Builder.Create().UseAudio();
return
[
audioBuilder.UseSubtype(AudioSubtype.WEBM).Build(),
audioBuilder.UseSubtype(AudioSubtype.OGG).Build(),
audioBuilder.UseSubtype(AudioSubtype.MP4).Build(),
audioBuilder.UseSubtype(AudioSubtype.MPEG).Build(),
];
}
return mimeTypes;
}
private async Task InitializeRecordingStream()
{
this.numReceivedChunks = 0;
var dataDirectory = await this.RustService.GetDataDirectory();
var recordingDirectory = Path.Combine(dataDirectory, "audioRecordings");
if (!Directory.Exists(recordingDirectory))
Directory.CreateDirectory(recordingDirectory);
var fileName = $"recording_{DateTime.UtcNow:yyyyMMdd_HHmmss}.audio";
this.currentRecordingPath = Path.Combine(recordingDirectory, fileName);
this.currentRecordingStream = new FileStream(this.currentRecordingPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true);
this.Logger.LogInformation("Initialized audio recording stream: '{RecordingPath}'.", this.currentRecordingPath);
}
[JSInvokable]
public async Task OnAudioChunkReceived(byte[] chunkBytes)
{
if (this.currentRecordingStream is null)
{
this.Logger.LogWarning("Received audio chunk but no recording stream is active.");
return;
}
try
{
this.numReceivedChunks++;
await this.currentRecordingStream.WriteAsync(chunkBytes);
await this.currentRecordingStream.FlushAsync();
this.Logger.LogDebug("Wrote {ByteCount} bytes to recording stream.", chunkBytes.Length);
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Error writing audio chunk to stream.");
}
}
private async Task FinalizeRecordingStream()
{
this.finalRecordingPath = null;
if (this.currentRecordingStream is not null)
{
await this.currentRecordingStream.FlushAsync();
await this.currentRecordingStream.DisposeAsync();
this.currentRecordingStream = null;
// Rename the file with the correct extension based on MIME type:
if (this.currentRecordingPath is not null && this.currentRecordingMimeType is not null)
{
var extension = GetFileExtension(this.currentRecordingMimeType);
var newPath = Path.ChangeExtension(this.currentRecordingPath, extension);
if (File.Exists(this.currentRecordingPath))
{
File.Move(this.currentRecordingPath, newPath, overwrite: true);
this.finalRecordingPath = newPath;
this.Logger.LogInformation("Finalized audio recording over {NumChunks} streamed audio chunks to the file '{RecordingPath}'.", this.numReceivedChunks, newPath);
}
}
}
this.currentRecordingPath = null;
this.currentRecordingMimeType = null;
// Dispose the .NET reference:
this.dotNetReference?.Dispose();
this.dotNetReference = null;
}
private static string GetFileExtension(string mimeType)
{
var baseMimeType = mimeType.Split(';')[0].Trim().ToLowerInvariant();
return baseMimeType switch
{
"audio/webm" => ".webm",
"audio/ogg" => ".ogg",
"audio/mp4" => ".m4a",
"audio/mpeg" => ".mp3",
"audio/wav" => ".wav",
"audio/x-wav" => ".wav",
_ => ".audio" // Fallback
};
}
private async Task TranscribeRecordingAsync()
{
if (this.finalRecordingPath is null)
return;
this.isTranscribing = true;
this.StateHasChanged();
try
{
// Get the configured transcription provider ID:
var transcriptionProviderId = this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider;
if (string.IsNullOrWhiteSpace(transcriptionProviderId))
{
this.Logger.LogWarning("No transcription provider is configured.");
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("No transcription provider is configured.")));
return;
}
// Find the transcription provider in the list of configured providers:
var transcriptionProviderSettings = this.SettingsManager.ConfigurationData.TranscriptionProviders
.FirstOrDefault(x => x.Id == transcriptionProviderId);
if (transcriptionProviderSettings is null)
{
this.Logger.LogWarning("The configured transcription provider with ID '{ProviderId}' was not found.", transcriptionProviderId);
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("The configured transcription provider was not found.")));
return;
}
// Check the confidence level:
var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(Tools.Components.NONE);
var providerConfidence = transcriptionProviderSettings.UsedLLMProvider.GetConfidence(this.SettingsManager);
if (providerConfidence.Level < minimumLevel)
{
this.Logger.LogWarning(
"The configured transcription provider '{ProviderName}' has a confidence level of '{ProviderLevel}', which is below the minimum required level of '{MinimumLevel}'.",
transcriptionProviderSettings.Name,
providerConfidence.Level,
minimumLevel);
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("The configured transcription provider does not meet the minimum confidence level.")));
return;
}
// Create the provider instance:
var provider = transcriptionProviderSettings.CreateProvider();
if (provider.Provider is LLMProviders.NONE)
{
this.Logger.LogError("Failed to create the transcription provider instance.");
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("Failed to create the transcription provider.")));
return;
}
// Call the transcription API:
this.Logger.LogInformation("Starting transcription with provider '{ProviderName}' and model '{ModelName}'.", transcriptionProviderSettings.Name, transcriptionProviderSettings.Model.DisplayName);
var transcribedText = await provider.TranscribeAudioAsync(transcriptionProviderSettings.Model, this.finalRecordingPath, this.SettingsManager);
if (string.IsNullOrWhiteSpace(transcribedText))
{
this.Logger.LogWarning("The transcription result is empty.");
await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, this.T("The transcription result is empty.")));
return;
}
// Remove trailing and leading whitespace:
transcribedText = transcribedText.Trim();
// Replace line breaks with spaces:
transcribedText = transcribedText.Replace("\r", " ").Replace("\n", " ");
// Replace two spaces with a single space:
transcribedText = transcribedText.Replace(" ", " ");
this.Logger.LogInformation("Transcription completed successfully. Result length: {Length} characters.", transcribedText.Length);
// Play the transcription done sound effect:
await this.JsRuntime.InvokeVoidAsync("playSound", "/sounds/transcription_done.ogg");
// Copy the transcribed text to the clipboard:
await this.RustService.CopyText2Clipboard(this.Snackbar, transcribedText);
// Delete the recording file:
try
{
if (File.Exists(this.finalRecordingPath))
{
File.Delete(this.finalRecordingPath);
this.Logger.LogInformation("Deleted the recording file '{RecordingPath}'.", this.finalRecordingPath);
}
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Failed to delete the recording file '{RecordingPath}'.", this.finalRecordingPath);
}
}
catch (Exception ex)
{
this.Logger.LogError(ex, "An error occurred during transcription.");
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("An error occurred during transcription.")));
}
finally
{
this.finalRecordingPath = null;
this.isTranscribing = false;
this.StateHasChanged();
}
}
private sealed class AudioRecordingResult
{
public string MimeType { get; init; } = string.Empty;
public bool ChangedMimeType { get; init; }
}
#region Overrides of MSGComponentBase
protected override void DisposeResources()
{
// Clean up recording resources if still active:
if (this.currentRecordingStream is not null)
{
this.currentRecordingStream.Dispose();
this.currentRecordingStream = null;
}
this.dotNetReference?.Dispose();
this.dotNetReference = null;
base.DisposeResources();
}
#endregion
}

View File

@ -12,7 +12,7 @@
case TreeItemData treeItem:
@if (treeItem.Type is TreeItemType.CHAT)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children" OnClick="() => this.LoadChat(treeItem.Path, true)">
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children" OnClick="@(() => this.LoadChat(treeItem.Path, true))">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@ -28,15 +28,15 @@
<div style="justify-self: end;">
<MudTooltip Text="@T("Move to workspace")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Size="Size.Medium" Color="Color.Inherit" OnClick="() => this.MoveChat(treeItem.Path)"/>
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.MoveChat(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="() => this.RenameChat(treeItem.Path)"/>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameChat(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="() => this.DeleteChat(treeItem.Path)"/>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.DeleteChat(treeItem.Path))"/>
</MudTooltip>
</div>
</div>
@ -53,11 +53,11 @@
</MudText>
<div style="justify-self: end;">
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="() => this.RenameWorkspace(treeItem.Path)"/>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameWorkspace(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="() => this.DeleteWorkspace(treeItem.Path)"/>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.DeleteWorkspace(treeItem.Path))"/>
</MudTooltip>
</div>
</div>
@ -82,7 +82,7 @@
<li>
<div class="mud-treeview-item-content" style="background-color: unset;">
<div class="mud-treeview-item-arrow"></div>
<MudButton StartIcon="@treeButton.Icon" Variant="Variant.Filled" OnClick="treeButton.Action">
<MudButton StartIcon="@treeButton.Icon" Variant="Variant.Filled" OnClick="@treeButton.Action">
@treeButton.Text
</MudButton>
</div>

View File

@ -78,6 +78,21 @@
HelperText="@T("Tell the AI your predefined user input.")"
/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("File Attachments")
</MudText>
<MudJustifiedText Class="mb-3" Typo="Typo.body1">
@T("You can attach files that will be automatically included when using this chat template. These files will be added to the first message sent in any chat using this template.")
</MudJustifiedText>
<AttachDocuments
Name="ChatTemplateFileAttachments"
Layer="@DropLayers.DIALOGS"
@bind-DocumentPaths="@this.fileAttachments"
UseSmallForm="false"
CatchAllDocuments="true"
ValidateMediaFileTypes="false"
/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Profile Usage")
</MudText>

View File

@ -50,6 +50,9 @@ public partial class ChatTemplateDialog : MSGComponentBase
[Parameter]
public IReadOnlyCollection<ContentBlock> ExampleConversation { get; init; } = [];
[Parameter]
public IReadOnlyCollection<FileAttachment> FileAttachments { get; init; } = [];
[Parameter]
public bool AllowProfileUsage { get; set; } = true;
@ -71,6 +74,7 @@ public partial class ChatTemplateDialog : MSGComponentBase
private bool dataIsValid;
private List<ContentBlock> dataExampleConversation = [];
private HashSet<FileAttachment> fileAttachments = [];
private string[] dataIssues = [];
private string dataEditingPreviousName = string.Empty;
private bool isInlineEditOnGoing;
@ -95,6 +99,7 @@ public partial class ChatTemplateDialog : MSGComponentBase
{
this.dataEditingPreviousName = this.DataName.ToLowerInvariant();
this.dataExampleConversation = this.ExampleConversation.Select(n => n.DeepClone()).ToList();
this.fileAttachments = [..this.FileAttachments];
}
if (this.CreateFromExistingChatThread && this.ExistingChatThread is not null)
@ -128,6 +133,7 @@ public partial class ChatTemplateDialog : MSGComponentBase
SystemPrompt = this.DataSystemPrompt,
PredefinedUserPrompt = this.PredefinedUserPrompt,
ExampleConversation = this.dataExampleConversation,
FileAttachments = [..this.fileAttachments],
AllowProfileUsage = this.AllowProfileUsage,
EnterpriseConfigurationPluginId = Guid.Empty,

View File

@ -96,7 +96,7 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
#endregion
private bool SelectedCloudEmbedding => !this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == this.dataEmbeddingId).IsSelfHosted;
private bool SelectedCloudEmbedding => !this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == this.dataEmbeddingId)?.IsSelfHosted ?? false;
private DataSourceLocalDirectory CreateDataSource() => new()
{

View File

@ -27,7 +27,7 @@ public partial class DataSourceLocalDirectoryInfoDialog : MSGComponentBase, IAsy
protected override async Task OnInitializedAsync()
{
this.embeddingProvider = this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == this.DataSource.EmbeddingId);
this.embeddingProvider = this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == this.DataSource.EmbeddingId) ?? EmbeddingProvider.NONE;
this.directoryInfo = new DirectoryInfo(this.DataSource.Path);
if (this.directoryInfo.Exists)
@ -46,7 +46,7 @@ public partial class DataSourceLocalDirectoryInfoDialog : MSGComponentBase, IAsy
private readonly CancellationTokenSource cts = new();
private EmbeddingProvider embeddingProvider;
private EmbeddingProvider embeddingProvider = EmbeddingProvider.NONE;
private DirectoryInfo directoryInfo = null!;
private long directorySizeBytes;
private long directorySizeNumFiles;

View File

@ -96,7 +96,7 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
#endregion
private bool SelectedCloudEmbedding => !this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == this.dataEmbeddingId).IsSelfHosted;
private bool SelectedCloudEmbedding => !this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == this.dataEmbeddingId)?.IsSelfHosted ?? false;
private DataSourceLocalFile CreateDataSource() => new()
{

View File

@ -18,14 +18,14 @@ public partial class DataSourceLocalFileInfoDialog : MSGComponentBase
protected override async Task OnInitializedAsync()
{
this.embeddingProvider = this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == this.DataSource.EmbeddingId);
this.embeddingProvider = this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == this.DataSource.EmbeddingId) ?? EmbeddingProvider.NONE;
this.fileInfo = new FileInfo(this.DataSource.FilePath);
await base.OnInitializedAsync();
}
#endregion
private EmbeddingProvider embeddingProvider;
private EmbeddingProvider embeddingProvider = EmbeddingProvider.NONE;
private FileInfo fileInfo = null!;
private bool IsCloudEmbedding => !this.embeddingProvider.IsSelfHosted;

View File

@ -6,7 +6,7 @@
@T("See how we load your file. Review the content before we process it further.")
</MudJustifiedText>
@if (string.IsNullOrWhiteSpace(this.FilePath))
@if (this.Document is null)
{
<ReadFileContent Text="@T("Load file")" @bind-FileContent="@this.FileContent"/>
}
@ -14,7 +14,7 @@
{
<MudTextField
T="string"
@bind-Text="@this.FilePath"
Text="@this.Document.FilePath"
AdornmentIcon="@Icons.Material.Filled.FileOpen"
Adornment="Adornment.Start"
Immediate="@true"
@ -27,43 +27,59 @@
/>
}
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
<MudTabPanel Text="@T("Markdown View")" Icon="@Icons.Material.Filled.TextSnippet">
<MudField
Variant="Variant.Outlined"
AdornmentIcon="@Icons.Material.Filled.Article"
Adornment="Adornment.Start"
Label="@T("Loaded Content")"
FullWidth="true"
Class="ma-2 pe-4"
HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")"
>
<div style="max-height: 40vh; overflow-y: auto;">
<MudMarkdown Value="@this.FileContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling"/>
</div>
</MudField>
</MudTabPanel>
<MudTabPanel Text="@T("Simple View")" Icon="@Icons.Material.Filled.Terminal">
<MudTextField
T="string"
@bind-Text="@this.FileContent"
AdornmentIcon="@Icons.Material.Filled.Article"
Adornment="Adornment.Start"
Immediate="@true"
Label="@T("Loaded Content")"
Variant="Variant.Outlined"
Lines="6"
AutoGrow="@true"
MaxLines="25"
ReadOnly="true"
Class="ma-2"
HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")"/>
</MudTabPanel>
</MudTabs>
@if (!this.Document?.Exists ?? false)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="my-2">
@T("The specified file could not be found. The file have been moved, deleted, renamed, or is otherwise inaccessible.")
</MudAlert>
}
else
{
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
@if (this.Document?.IsImage ?? false)
{
<MudTabPanel Text="@T("Image View")" Icon="@Icons.Material.Filled.Image">
<MudImage ObjectFit="ObjectFit.ScaleDown" Style="width: 100%;" Src="@this.Document.FilePathAsUrl"/>
</MudTabPanel>
}
else
{
<MudTabPanel Text="@T("Markdown View")" Icon="@Icons.Material.Filled.TextSnippet">
<MudField
Variant="Variant.Outlined"
AdornmentIcon="@Icons.Material.Filled.Article"
Adornment="Adornment.Start"
Label="@T("Loaded Content")"
FullWidth="true"
Class="ma-2 pe-4"
HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")">
<div style="max-height: 40vh; overflow-y: auto;">
<MudMarkdown Value="@this.FileContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling"/>
</div>
</MudField>
</MudTabPanel>
<MudTabPanel Text="@T("Simple View")" Icon="@Icons.Material.Filled.Terminal">
<MudTextField
T="string"
@bind-Text="@this.FileContent"
AdornmentIcon="@Icons.Material.Filled.Article"
Adornment="Adornment.Start"
Immediate="@true"
Label="@T("Loaded Content")"
Variant="Variant.Outlined"
Lines="6"
AutoGrow="@true"
MaxLines="25"
ReadOnly="true"
Class="ma-2"
HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")"/>
</MudTabPanel>
}
</MudTabs>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Close" Variant="Variant.Filled">
<MudButton OnClick="@this.Close" Variant="Variant.Filled" Color="Color.Primary">
@T("Close")
</MudButton>
</DialogActions>

View File

@ -1,4 +1,5 @@
using AIStudio.Components;
using AIStudio.Chat;
using AIStudio.Components;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
@ -13,7 +14,7 @@ public partial class DocumentCheckDialog : MSGComponentBase
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public string FilePath { get; set; } = string.Empty;
public FileAttachment? Document { get; set; }
private void Close() => this.MudDialog.Cancel();
@ -27,27 +28,30 @@ public partial class DocumentCheckDialog : MSGComponentBase
private IDialogService DialogService { get; init; } = null!;
[Inject]
private ILogger<ReadFileContent> Logger { get; init; } = null!;
private ILogger<DocumentCheckDialog> Logger { get; init; } = null!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !string.IsNullOrWhiteSpace(this.FilePath))
if (firstRender && this.Document is not null)
{
try
{
var fileContent = await UserFile.LoadFileData(this.FilePath, this.RustService, this.DialogService);
this.FileContent = fileContent;
this.StateHasChanged();
if (!this.Document.IsImage)
{
var fileContent = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService);
this.FileContent = fileContent;
}
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.FilePath);
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document);
this.FileContent = string.Empty;
this.StateHasChanged();
}
this.StateHasChanged();
}
else if (firstRender)
this.Logger.LogWarning("Document check dialog opened without a valid file path");
this.Logger.LogWarning("Document check dialog opened without a valid file path.");
}
private CodeBlockTheme CodeColorPalette => this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default;

View File

@ -10,7 +10,7 @@
<MudSelect @bind-Value="@this.DataLLMProvider" Label="@T("Provider")" Class="mb-3" OpenIcon="@Icons.Material.Filled.AccountBalance" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.providerValidation.ValidatingProvider">
@foreach (LLMProviders provider in Enum.GetValues(typeof(LLMProviders)))
{
if (provider.ProvideEmbeddings() || provider is LLMProviders.NONE)
if (provider.ProvideEmbeddingAPI() || provider is LLMProviders.NONE)
{
<MudSelectItem Value="@provider">
@provider.ToName()
@ -25,7 +25,7 @@
@if (this.DataLLMProvider.IsAPIKeyNeeded(this.DataHost))
{
<SecretInputField @bind-Secret="@this.dataAPIKey" Label="@this.APIKeyText" Validation="@this.providerValidation.ValidatingAPIKey"/>
<SecretInputField Secret="@this.dataAPIKey" SecretChanged="@this.OnAPIKeyChanged" Label="@this.APIKeyText" Validation="@this.providerValidation.ValidatingAPIKey"/>
}
@if (this.DataLLMProvider.IsHostnameNeeded())
@ -47,7 +47,7 @@
<MudSelect @bind-Value="@this.DataHost" Label="@T("Host")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.providerValidation.ValidatingHost">
@foreach (Host host in Enum.GetValues(typeof(Host)))
{
if (host.AreEmbeddingsSupported())
if (host.IsEmbeddingSupported())
{
<MudSelectItem Value="@host">
@host.Name()
@ -71,12 +71,12 @@
AdornmentColor="Color.Info"
Validation="@this.ValidateManuallyModel"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
HelperText="@T("Currently, we cannot query the embedding models of self-hosted systems. Therefore, enter the model name manually.")"
HelperText="@T("Currently, we cannot query the embedding models for the selected provider and/or host. Therefore, please enter the model name manually.")"
/>
}
else
{
<MudButton Disabled="@(!this.DataLLMProvider.CanLoadModels(this.DataHost, this.dataAPIKey))" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="this.ReloadModels">
<MudButton Disabled="@(!this.DataLLMProvider.CanLoadModels(this.DataHost, this.dataAPIKey))" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@this.ReloadModels">
@T("Load")
</MudButton>
@if(this.availableModels.Count is 0)

View File

@ -129,6 +129,8 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
IsSelfHosted = this.DataLLMProvider is LLMProviders.SELF_HOSTED,
Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname,
Host = this.DataHost,
IsEnterpriseConfiguration = false,
EnterpriseConfigurationPluginId = Guid.Empty,
};
}
@ -136,6 +138,9 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
protected override async Task OnInitializedAsync()
{
// Call the base initialization first so that the I18N is ready:
await base.OnInitializedAsync();
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
@ -162,7 +167,7 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
}
// Load the API key:
var requestedSecret = await this.RustService.GetAPIKey(this, isTrying: this.DataLLMProvider is LLMProviders.SELF_HOSTED);
var requestedSecret = await this.RustService.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER, isTrying: this.DataLLMProvider is LLMProviders.SELF_HOSTED);
if (requestedSecret.Success)
this.dataAPIKey = await requestedSecret.Secret.Decrypt(this.encryption);
else
@ -177,8 +182,6 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
await this.ReloadModels();
}
await base.OnInitializedAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@ -195,7 +198,7 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
#region Implementation of ISecretId
public string SecretId => this.DataId;
public string SecretId => this.DataLLMProvider.ToName();
public string SecretName => this.DataName;
@ -216,7 +219,7 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
if (!string.IsNullOrWhiteSpace(this.dataAPIKey))
{
// Store the API key in the OS secure storage:
var storeResponse = await this.RustService.SetAPIKey(this, this.dataAPIKey);
var storeResponse = await this.RustService.SetAPIKey(this, this.dataAPIKey, SecretStoreType.EMBEDDING_PROVIDER);
if (!storeResponse.Success)
{
this.dataAPIKeyStorageIssue = string.Format(T("Failed to store the API key in the operating system. The message was: {0}. Please try again."), storeResponse.Issue);
@ -238,6 +241,16 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
private void Cancel() => this.MudDialog.Cancel();
private async Task OnAPIKeyChanged(string apiKey)
{
this.dataAPIKey = apiKey;
if (!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue))
{
this.dataAPIKeyStorageIssue = string.Empty;
await this.form.Validate();
}
}
private async Task ReloadModels()
{
var currentEmbeddingProviderSettings = this.CreateEmbeddingProviderSettings();

View File

@ -22,7 +22,7 @@
@if (this.DataLLMProvider.IsAPIKeyNeeded(this.DataHost))
{
<SecretInputField @bind-Secret="@this.dataAPIKey" Label="@this.APIKeyText" Validation="@this.providerValidation.ValidatingAPIKey"/>
<SecretInputField Secret="@this.dataAPIKey" SecretChanged="@this.OnAPIKeyChanged" Label="@this.APIKeyText" Validation="@this.providerValidation.ValidatingAPIKey"/>
}
@if (this.DataLLMProvider.IsHostnameNeeded())
@ -44,9 +44,12 @@
<MudSelect @bind-Value="@this.DataHost" Label="@T("Host")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.providerValidation.ValidatingHost">
@foreach (Host host in Enum.GetValues(typeof(Host)))
{
<MudSelectItem Value="@host">
@host.Name()
</MudSelectItem>
@if (host.IsChatSupported())
{
<MudSelectItem Value="@host">
@host.Name()
</MudSelectItem>
}
}
</MudSelect>
}
@ -84,11 +87,12 @@
AdornmentColor="Color.Info"
Validation="@this.ValidateManuallyModel"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
HelperText="@T("Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually.")"
/>
}
else
{
<MudButton Disabled="@(!this.DataLLMProvider.CanLoadModels(this.DataHost, this.dataAPIKey))" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="this.ReloadModels">
<MudButton Disabled="@(!this.DataLLMProvider.CanLoadModels(this.DataHost, this.dataAPIKey))" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@this.ReloadModels">
@T("Load models")
</MudButton>
@if(this.availableModels.Count is 0)

View File

@ -147,6 +147,9 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
protected override async Task OnInitializedAsync()
{
// Call the base initialization first so that the I18N is ready:
await base.OnInitializedAsync();
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
@ -177,7 +180,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
}
// Load the API key:
var requestedSecret = await this.RustService.GetAPIKey(this, isTrying: this.DataLLMProvider is LLMProviders.SELF_HOSTED);
var requestedSecret = await this.RustService.GetAPIKey(this, SecretStoreType.LLM_PROVIDER, isTrying: this.DataLLMProvider is LLMProviders.SELF_HOSTED);
if (requestedSecret.Success)
this.dataAPIKey = await requestedSecret.Secret.Decrypt(this.encryption);
else
@ -192,8 +195,6 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
await this.ReloadModels();
}
await base.OnInitializedAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@ -232,7 +233,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
if (!string.IsNullOrWhiteSpace(this.dataAPIKey))
{
// Store the API key in the OS secure storage:
var storeResponse = await this.RustService.SetAPIKey(this, this.dataAPIKey);
var storeResponse = await this.RustService.SetAPIKey(this, this.dataAPIKey, SecretStoreType.LLM_PROVIDER);
if (!storeResponse.Success)
{
this.dataAPIKeyStorageIssue = string.Format(T("Failed to store the API key in the operating system. The message was: {0}. Please try again."), storeResponse.Issue);
@ -254,6 +255,16 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
private void Cancel() => this.MudDialog.Cancel();
private async Task OnAPIKeyChanged(string apiKey)
{
this.dataAPIKey = apiKey;
if (!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue))
{
this.dataAPIKeyStorageIssue = string.Empty;
await this.form.Validate();
}
}
private async Task ReloadModels()
{
var currentProviderSettings = this.CreateProviderSettings();

View File

@ -0,0 +1,100 @@
@inherits MSGComponentBase
<MudDialog>
<DialogContent>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon.")
</MudJustifiedText>
<MudDivider Class="mt-3 mb-3"/>
<div style="max-height: 50vh; overflow-y: auto; overflow-x: hidden; padding-right: 8px;">
@if (!this.DocumentPaths.Any())
{
<MudJustifiedText Typo="Typo.body1" Class="mt-3">
@T("There aren't any file attachments available right now.")
</MudJustifiedText>
}
@{
var currentFolder = string.Empty;
foreach (var fileAttachment in this.DocumentPaths)
{
var folderPath = Path.GetDirectoryName(fileAttachment.FilePath);
if (folderPath != currentFolder)
{
currentFolder = folderPath;
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mt-6 mb-3">
<MudIcon Icon="@Icons.Material.Filled.Folder" Class="mr-2" />
<MudText Typo="Typo.h6">
@folderPath:
</MudText>
</MudStack>
}
@if (fileAttachment.Exists)
{
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="ms-3 mb-2">
<div style="min-width: 0; flex: 1; overflow: hidden;">
<MudTooltip Text="@T("Your attached file.")" Placement="Placement.Bottom">
<span class="d-inline-flex align-items-center" style="overflow: hidden; width: 100%;">
<MudIcon Icon="@Icons.Material.Filled.AttachFile" Class="mr-2" Style="flex-shrink: 0;"/>
<MudText Style="white-space: nowrap;">
@fileAttachment.FileName
</MudText>
</span>
</MudTooltip>
</div>
<MudToolBar WrapContent="true" Gutters="false" Class="ml-2" Style="flex-shrink: 0; min-height: 1em;">
<MudTooltip Text="@T("Preview what we send to the AI.")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Search"
Color="Color.Primary"
OnClick="@(() => this.InvestigateFile(fileAttachment))"/>
</MudTooltip>
<MudTooltip Text="@T("Remove this attachment.")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="@(() => this.DeleteAttachment(fileAttachment))"/>
</MudTooltip>
</MudToolBar>
</MudStack>
}
else
{
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="ms-3">
<div style="min-width: 0; flex: 1; overflow: hidden;">
<MudTooltip Text="@T("The file was deleted, renamed, or moved.")" Placement="Placement.Bottom">
<span class="d-inline-flex align-items-center" style="overflow: hidden; width: 100%;">
<MudIcon Icon="@Icons.Material.Filled.Report" Color="Color.Error" Class="mr-2" Style="flex-shrink: 0;"/>
<MudText Style="white-space: nowrap;">
<s>@fileAttachment.FileName</s>
</MudText>
</span>
</MudTooltip>
</div>
<MudToolBar WrapContent="true" Gutters="false" Class="ml-2" Style="flex-shrink: 0; min-height: 1em;">
<MudIconButton Icon="@Icons.Material.Filled.Search"
Color="Color.Primary"
Disabled="true"/>
<MudTooltip Text="@T("Remove this attachment.")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="@(() => this.DeleteAttachment(fileAttachment))"/>
</MudTooltip>
</MudToolBar>
</MudStack>
}
}
}
</div>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Close" Variant="Variant.Filled" Color="Color.Primary">
@T("Close")
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,63 @@
using AIStudio.Chat;
using AIStudio.Components;
using AIStudio.Tools.PluginSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
public partial class ReviewAttachmentsDialog : MSGComponentBase
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ReviewAttachmentsDialog).Namespace, nameof(ReviewAttachmentsDialog));
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public HashSet<FileAttachment> DocumentPaths { get; set; } = new();
[Inject]
private IDialogService DialogService { get; set; } = null!;
private void Close() => this.MudDialog.Close(DialogResult.Ok(this.DocumentPaths));
public static async Task<HashSet<FileAttachment>> OpenDialogAsync(IDialogService dialogService, params HashSet<FileAttachment> documentPaths)
{
var dialogParameters = new DialogParameters<ReviewAttachmentsDialog>
{
{ x => x.DocumentPaths, documentPaths }
};
var dialogReference = await dialogService.ShowAsync<ReviewAttachmentsDialog>(TB("Your attached files"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return documentPaths;
if (dialogResult.Data is null)
return documentPaths;
return dialogResult.Data as HashSet<FileAttachment> ?? documentPaths;
}
private void DeleteAttachment(FileAttachment fileAttachment)
{
if (this.DocumentPaths.Remove(fileAttachment))
{
this.StateHasChanged();
}
}
/// <summary>
/// The user might want to check what we actually extract from his file and therefore give the LLM as an input.
/// </summary>
/// <param name="fileAttachment">The file to check.</param>
private async Task InvestigateFile(FileAttachment fileAttachment)
{
var dialogParameters = new DialogParameters<DocumentCheckDialog>
{
{ x => x.Document, fileAttachment },
};
await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);
}
}

View File

@ -41,10 +41,10 @@
{
<MudStack Row="true" Class="mb-2 mt-2" Wrap="Wrap.Wrap">
<MudTooltip Text="@T("Edit")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="() => this.EditChatTemplate(context)"/>
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditChatTemplate(context))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")">
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="() => this.DeleteChatTemplate(context)"/>
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteChatTemplate(context))"/>
</MudTooltip>
</MudStack>
}

View File

@ -65,6 +65,7 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase
{ x => x.PredefinedUserPrompt, chatTemplate.PredefinedUserPrompt },
{ x => x.IsEditing, true },
{ x => x.ExampleConversation, chatTemplate.ExampleConversation },
{ x => x.FileAttachments, chatTemplate.FileAttachments },
{ x => x.AllowProfileUsage, chatTemplate.AllowProfileUsage },
};

View File

@ -0,0 +1,140 @@
@using AIStudio.Provider
@using AIStudio.Provider.SelfHosted
@inherits MSGComponentBase
<MudDialog>
<DialogContent>
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
<MudStack Row="@true" AlignItems="AlignItems.Center">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudSelect @bind-Value="@this.DataLLMProvider" Label="@T("Provider")" Class="mb-3" OpenIcon="@Icons.Material.Filled.AccountBalance" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.providerValidation.ValidatingProvider">
@foreach (LLMProviders provider in Enum.GetValues(typeof(LLMProviders)))
{
if (provider.ProvideTranscriptionAPI() || provider is LLMProviders.NONE)
{
<MudSelectItem Value="@provider">
@provider.ToName()
</MudSelectItem>
}
}
</MudSelect>
<MudButton Disabled="@(!this.DataLLMProvider.ShowRegisterButton())" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.OpenInBrowser" Href="@this.DataLLMProvider.GetCreationURL()" Target="_blank">
@T("Create account")
</MudButton>
</MudStack>
@if (this.DataLLMProvider.IsAPIKeyNeeded(this.DataHost))
{
<SecretInputField Secret="@this.dataAPIKey" SecretChanged="@this.OnAPIKeyChanged" Label="@this.APIKeyText" Validation="@this.providerValidation.ValidatingAPIKey"/>
}
@if (this.DataLLMProvider.IsHostnameNeeded())
{
<MudTextField
T="string"
@bind-Text="@this.DataHostname"
Label="@T("Hostname")"
Class="mb-3"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Dns"
AdornmentColor="Color.Info"
Validation="@this.providerValidation.ValidatingHostname"
UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
}
@if (this.DataLLMProvider.IsHostNeeded())
{
<MudSelect @bind-Value="@this.DataHost" Label="@T("Host")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.providerValidation.ValidatingHost">
@foreach (Host host in Enum.GetValues(typeof(Host)))
{
if (host.IsTranscriptionSupported())
{
<MudSelectItem Value="@host">
@host.Name()
</MudSelectItem>
}
}
</MudSelect>
}
<MudField FullWidth="true" Label="@T("Model selection")" Variant="Variant.Outlined" Class="mb-3">
<MudStack Row="@true" AlignItems="AlignItems.Center" StretchItems="StretchItems.End">
@if (this.DataLLMProvider.IsTranscriptionModelProvidedManually(this.DataHost))
{
<MudTextField
T="string"
@bind-Text="@this.dataManuallyModel"
Label="@T("Model")"
Class="mb-3"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Dns"
AdornmentColor="Color.Info"
Validation="@this.ValidateManuallyModel"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
HelperText="@T("Currently, we cannot query the transcription models for the selected provider and/or host. Therefore, please enter the model name manually.")"
/>
}
else
{
<MudButton Disabled="@(!this.DataLLMProvider.CanLoadModels(this.DataHost, this.dataAPIKey))" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@this.ReloadModels">
@T("Load")
</MudButton>
@if(this.availableModels.Count is 0)
{
<MudText Typo="Typo.body1">
@T("No models loaded or available.")
</MudText>
}
else
{
<MudSelect Disabled="@this.IsNoneProvider" @bind-Value="@this.DataModel" Label="@T("Model")"
OpenIcon="@Icons.Material.Filled.FaceRetouchingNatural"
AdornmentColor="Color.Info" Adornment="Adornment.Start"
Validation="@this.providerValidation.ValidatingModel">
@foreach (var model in this.availableModels)
{
<MudSelectItem Value="@model">
@model
</MudSelectItem>
}
</MudSelect>
}
}
</MudStack>
</MudField>
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.DataName"
Label="@T("Instance Name")"
Class="mb-3"
MaxLength="40"
Counter="40"
Immediate="@true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lightbulb"
AdornmentColor="Color.Info"
Validation="@this.providerValidation.ValidatingInstanceName"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
/>
</MudForm>
<Issues IssuesData="@this.dataIssues"/>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
@T("Cancel")
</MudButton>
<MudButton OnClick="@this.Store" Variant="Variant.Filled" Color="Color.Primary">
@if(this.IsEditing)
{
@T("Update")
}
else
{
@T("Add")
}
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,285 @@
using AIStudio.Components;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools.Services;
using AIStudio.Tools.Validation;
using Microsoft.AspNetCore.Components;
using Host = AIStudio.Provider.SelfHosted.Host;
namespace AIStudio.Dialogs;
public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
/// <summary>
/// The transcription provider's number in the list.
/// </summary>
[Parameter]
public uint DataNum { get; set; }
/// <summary>
/// The transcription provider's ID.
/// </summary>
[Parameter]
public string DataId { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// The user chosen name.
/// </summary>
[Parameter]
public string DataName { get; set; } = string.Empty;
/// <summary>
/// The chosen hostname for self-hosted providers.
/// </summary>
[Parameter]
public string DataHostname { get; set; } = string.Empty;
/// <summary>
/// The host to use, e.g., llama.cpp.
/// </summary>
[Parameter]
public Host DataHost { get; set; } = Host.NONE;
/// <summary>
/// Is this provider self-hosted?
/// </summary>
[Parameter]
public bool IsSelfHosted { get; set; }
/// <summary>
/// The provider to use.
/// </summary>
[Parameter]
public LLMProviders DataLLMProvider { get; set; } = LLMProviders.NONE;
/// <summary>
/// The transcription model to use.
/// </summary>
[Parameter]
public Model DataModel { get; set; }
/// <summary>
/// Should the dialog be in editing mode?
/// </summary>
[Parameter]
public bool IsEditing { get; init; }
[Inject]
private RustService RustService { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
/// <summary>
/// The list of used instance names. We need this to check for uniqueness.
/// </summary>
private List<string> UsedInstanceNames { get; set; } = [];
private bool dataIsValid;
private string[] dataIssues = [];
private string dataAPIKey = string.Empty;
private string dataManuallyModel = string.Empty;
private string dataAPIKeyStorageIssue = string.Empty;
private string dataEditingPreviousInstanceName = string.Empty;
// We get the form reference from Blazor code to validate it manually:
private MudForm form = null!;
private readonly List<Model> availableModels = new();
private readonly Encryption encryption = Program.ENCRYPTION;
private readonly ProviderValidation providerValidation;
public TranscriptionProviderDialog()
{
this.providerValidation = new()
{
GetProvider = () => this.DataLLMProvider,
GetAPIKeyStorageIssue = () => this.dataAPIKeyStorageIssue,
GetPreviousInstanceName = () => this.dataEditingPreviousInstanceName,
GetUsedInstanceNames = () => this.UsedInstanceNames,
GetHost = () => this.DataHost,
};
}
private TranscriptionProvider CreateTranscriptionProviderSettings()
{
var cleanedHostname = this.DataHostname.Trim();
Model model = default;
if(this.DataLLMProvider is LLMProviders.SELF_HOSTED)
{
switch (this.DataHost)
{
case Host.OLLAMA:
model = new Model(this.dataManuallyModel, null);
break;
case Host.VLLM:
case Host.LM_STUDIO:
case Host.WHISPER_CPP:
model = this.DataModel;
break;
}
}
else
model = this.DataModel;
return new()
{
Num = this.DataNum,
Id = this.DataId,
Name = this.DataName,
UsedLLMProvider = this.DataLLMProvider,
Model = model,
IsSelfHosted = this.DataLLMProvider is LLMProviders.SELF_HOSTED,
Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname,
Host = this.DataHost,
IsEnterpriseConfiguration = false,
EnterpriseConfigurationPluginId = Guid.Empty,
};
}
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
// Call the base initialization first so that the I18N is ready:
await base.OnInitializedAsync();
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
// Load the used instance names:
this.UsedInstanceNames = this.SettingsManager.ConfigurationData.TranscriptionProviders.Select(x => x.Name.ToLowerInvariant()).ToList();
// When editing, we need to load the data:
if(this.IsEditing)
{
this.dataEditingPreviousInstanceName = this.DataName.ToLowerInvariant();
// When using self-hosted models, we must copy the model name:
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED)
this.dataManuallyModel = this.DataModel.Id;
//
// We cannot load the API key for self-hosted providers:
//
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED && this.DataHost is not Host.OLLAMA)
{
await this.ReloadModels();
await base.OnInitializedAsync();
return;
}
// Load the API key:
var requestedSecret = await this.RustService.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER, isTrying: this.DataLLMProvider is LLMProviders.SELF_HOSTED);
if (requestedSecret.Success)
this.dataAPIKey = await requestedSecret.Secret.Decrypt(this.encryption);
else
{
this.dataAPIKey = string.Empty;
if (this.DataLLMProvider is not LLMProviders.SELF_HOSTED)
{
this.dataAPIKeyStorageIssue = string.Format(T("Failed to load the API key from the operating system. The message was: {0}. You might ignore this message and provide the API key again."), requestedSecret.Issue);
await this.form.Validate();
}
}
await this.ReloadModels();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// Reset the validation when not editing and on the first render.
// We don't want to show validation errors when the user opens the dialog.
if(!this.IsEditing && firstRender)
this.form.ResetValidation();
await base.OnAfterRenderAsync(firstRender);
}
#endregion
#region Implementation of ISecretId
public string SecretId => this.DataLLMProvider.ToName();
public string SecretName => this.DataName;
#endregion
private async Task Store()
{
await this.form.Validate();
this.dataAPIKeyStorageIssue = string.Empty;
// When the data is not valid, we don't store it:
if (!this.dataIsValid)
return;
// Use the data model to store the provider.
// We just return this data to the parent component:
var addedProviderSettings = this.CreateTranscriptionProviderSettings();
if (!string.IsNullOrWhiteSpace(this.dataAPIKey))
{
// Store the API key in the OS secure storage:
var storeResponse = await this.RustService.SetAPIKey(this, this.dataAPIKey, SecretStoreType.TRANSCRIPTION_PROVIDER);
if (!storeResponse.Success)
{
this.dataAPIKeyStorageIssue = string.Format(T("Failed to store the API key in the operating system. The message was: {0}. Please try again."), storeResponse.Issue);
await this.form.Validate();
return;
}
}
this.MudDialog.Close(DialogResult.Ok(addedProviderSettings));
}
private string? ValidateManuallyModel(string manuallyModel)
{
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED && string.IsNullOrWhiteSpace(manuallyModel))
return T("Please enter a transcription model name.");
return null;
}
private void Cancel() => this.MudDialog.Cancel();
private async Task OnAPIKeyChanged(string apiKey)
{
this.dataAPIKey = apiKey;
if (!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue))
{
this.dataAPIKeyStorageIssue = string.Empty;
await this.form.Validate();
}
}
private async Task ReloadModels()
{
var currentTranscriptionProviderSettings = this.CreateTranscriptionProviderSettings();
var provider = currentTranscriptionProviderSettings.CreateProvider();
if(provider is NoProvider)
return;
var models = await provider.GetTranscriptionModels(this.dataAPIKey);
// Order descending by ID means that the newest models probably come first:
var orderedModels = models.OrderByDescending(n => n.Id);
this.availableModels.Clear();
this.availableModels.AddRange(orderedModels);
}
private string APIKeyText => this.DataLLMProvider switch
{
LLMProviders.SELF_HOSTED => T("(Optional) API Key"),
_ => T("API Key"),
};
private bool IsNoneProvider => this.DataLLMProvider is LLMProviders.NONE;
}

View File

@ -0,0 +1,80 @@
using Microsoft.AspNetCore.StaticFiles;
namespace AIStudio;
internal static class FileHandler
{
private const string ENDPOINT = "/local/file";
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(FileHandler));
internal static string CreateFileUrl(string filePath)
{
var encodedPath = Uri.EscapeDataString(filePath);
return $"{ENDPOINT}?path={encodedPath}";
}
internal static async Task HandlerAsync(HttpContext context, Func<Task> nextHandler)
{
var requestPath = context.Request.Path.Value;
if (string.IsNullOrWhiteSpace(requestPath) || !requestPath.Equals(ENDPOINT, StringComparison.Ordinal))
{
await nextHandler();
return;
}
// Extract the file path from the query parameter:
// Format: /local/file?path={url-encoded-path}
if (!context.Request.Query.TryGetValue("path", out var pathValues) || pathValues.Count == 0)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
LOGGER.LogWarning("No file path provided in the request. Using ?path={{url-encoded-path}} format.");
return;
}
// The query parameter is automatically URL-decoded by ASP.NET Core:
var filePath = pathValues[0];
if (string.IsNullOrWhiteSpace(filePath))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
LOGGER.LogWarning("Empty file path provided in the request.");
return;
}
// Security check: Prevent path traversal attacks:
var fullPath = Path.GetFullPath(filePath);
if (fullPath != filePath && !filePath.StartsWith('/'))
{
// On Windows, absolute paths may differ, so we do an additional check
// to ensure no path traversal sequences are present:
if (filePath.Contains(".."))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
LOGGER.LogWarning("Path traversal attempt detected: {FilePath}", filePath);
return;
}
}
// Check if the file exists:
if (!File.Exists(filePath))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
LOGGER.LogWarning("Requested file not found: '{FilePath}'", filePath);
return;
}
// Determine the content type:
var contentTypeProvider = new FileExtensionContentTypeProvider();
if (!contentTypeProvider.TryGetContentType(filePath, out var contentType))
contentType = "application/octet-stream";
// Set response headers:
context.Response.ContentType = contentType;
context.Response.Headers.ContentDisposition = $"inline; filename=\"{Path.GetFileName(filePath)}\"";
// Stream the file to the response:
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 64 * 1024, useAsync: true);
context.Response.ContentLength = fileStream.Length;
await fileStream.CopyToAsync(context.Response.Body);
}
}

View File

@ -1,4 +1,6 @@
@using AIStudio.Settings.DataModel
@using AIStudio.Components
@using Microsoft.AspNetCore.Components.Routing
@using MudBlazor
@ -20,12 +22,20 @@
</MudNavLink>
}
</MudNavMenu>
<MudSpacer/>
<MudStack AlignItems="AlignItems.Center">
<MudToolBar WrapContent="true">
<VoiceRecorder />
</MudToolBar>
</MudStack>
</MudDrawer>
</MudDrawerContainer>
}
else
{
<MudPaper Width="4em" Class="mud-height-full absolute">
<MudPaper Width="4em" Class="mud-height-full absolute" Style="display: flex; flex-direction: column;">
<MudNavMenu>
@foreach (var navBarItem in this.navItems)
{
@ -41,6 +51,14 @@
}
}
</MudNavMenu>
<MudSpacer/>
<MudStack AlignItems="AlignItems.Center">
<MudToolBar WrapContent="true">
<VoiceRecorder />
</MudToolBar>
</MudStack>
</MudPaper>
}
}

View File

@ -264,7 +264,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
yield return new(T("Plugins"), Icons.Material.TwoTone.Extension, palette.DarkLighten, palette.GrayLight, Routes.PLUGINS, false);
yield return new(T("Supporters"), Icons.Material.Filled.Favorite, palette.Error.Value, "#801a00", Routes.SUPPORTERS, false);
yield return new(T("About"), Icons.Material.Filled.Info, palette.DarkLighten, palette.GrayLight, Routes.ABOUT, false);
yield return new(T("Information"), Icons.Material.Filled.Info, palette.DarkLighten, palette.GrayLight, Routes.ABOUT, false);
yield return new(T("Settings"), Icons.Material.Filled.Settings, palette.DarkLighten, palette.GrayLight, Routes.SETTINGS, false);
}

View File

@ -47,10 +47,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CodeBeam.MudBlazor.Extensions" Version="8.2.5" />
<PackageReference Include="CodeBeam.MudBlazor.Extensions" Version="8.3.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="9.0.11" />
<PackageReference Include="MudBlazor" Version="8.12.0" />
<PackageReference Include="MudBlazor" Version="8.15.0" />
<PackageReference Include="MudBlazor.Markdown" Version="8.11.0" />
<PackageReference Include="Qdrant.Client" Version="1.16.1" />
<PackageReference Include="ReverseMarkdown" Version="4.7.1" />
@ -62,12 +62,6 @@
<ProjectReference Include="..\SourceCodeRules\SourceCodeRules\SourceCodeRules.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
</ItemGroup>
<ItemGroup>
<Content Include="Dialogs\PandocDocumentCheckDialog.razor.cs">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
</ItemGroup>
<!-- Read the meta data file -->
<Target Name="ReadMetaData" BeforeTargets="BeforeBuild">
<Error Text="The ../../metadata.txt file was not found!" Condition="!Exists('../../metadata.txt')" />

View File

@ -10,59 +10,87 @@
<InnerScrolling>
<MudText Typo="Typo.h4" Class="mb-2 mr-3">
@T("General")
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogTextSummarizer" Name="@T("Text Summarizer")" Description="@T("Use an LLM to summarize a given text.")" Icon="@Icons.Material.Filled.TextSnippet" Link="@Routes.ASSISTANT_SUMMARIZER"/>
<AssistantBlock TSettings="SettingsDialogTranslation" Name="@T("Translation")" Description="@T("Translate text into another language.")" Icon="@Icons.Material.Filled.Translate" Link="@Routes.ASSISTANT_TRANSLATION"/>
<AssistantBlock TSettings="SettingsDialogGrammarSpelling" Name="@T("Grammar & Spelling")" Description="@T("Check grammar and spelling of a given text.")" Icon="@Icons.Material.Filled.Edit" Link="@Routes.ASSISTANT_GRAMMAR_SPELLING"/>
<AssistantBlock TSettings="SettingsDialogRewrite" Name="@T("Rewrite & Improve")" Description="@T("Rewrite and improve a given text for a chosen style.")" Icon="@Icons.Material.Filled.Edit" Link="@Routes.ASSISTANT_REWRITE"/>
<AssistantBlock TSettings="SettingsDialogSynonyms" Name="@T("Synonyms")" Description="@T("Find synonyms for a given word or phrase.")" Icon="@Icons.Material.Filled.Spellcheck" Link="@Routes.ASSISTANT_SYNONYMS"/>
</MudStack>
@if (this.SettingsManager.IsAnyCategoryAssistantVisible("General",
(Components.TEXT_SUMMARIZER_ASSISTANT, PreviewFeatures.NONE),
(Components.TRANSLATION_ASSISTANT, PreviewFeatures.NONE),
(Components.GRAMMAR_SPELLING_ASSISTANT, PreviewFeatures.NONE),
(Components.REWRITE_ASSISTANT, PreviewFeatures.NONE),
(Components.SYNONYMS_ASSISTANT, PreviewFeatures.NONE)
))
{
<MudText Typo="Typo.h4" Class="mb-2 mr-3">
@T("General")
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogTextSummarizer" Component="Components.TEXT_SUMMARIZER_ASSISTANT" Name="@T("Text Summarizer")" Description="@T("Use an LLM to summarize a given text.")" Icon="@Icons.Material.Filled.TextSnippet" Link="@Routes.ASSISTANT_SUMMARIZER"/>
<AssistantBlock TSettings="SettingsDialogTranslation" Component="Components.TRANSLATION_ASSISTANT" Name="@T("Translation")" Description="@T("Translate text into another language.")" Icon="@Icons.Material.Filled.Translate" Link="@Routes.ASSISTANT_TRANSLATION"/>
<AssistantBlock TSettings="SettingsDialogGrammarSpelling" Component="Components.GRAMMAR_SPELLING_ASSISTANT" Name="@T("Grammar & Spelling")" Description="@T("Check grammar and spelling of a given text.")" Icon="@Icons.Material.Filled.Edit" Link="@Routes.ASSISTANT_GRAMMAR_SPELLING"/>
<AssistantBlock TSettings="SettingsDialogRewrite" Component="Components.REWRITE_ASSISTANT" Name="@T("Rewrite & Improve")" Description="@T("Rewrite and improve a given text for a chosen style.")" Icon="@Icons.Material.Filled.Edit" Link="@Routes.ASSISTANT_REWRITE"/>
<AssistantBlock TSettings="SettingsDialogSynonyms" Component="Components.SYNONYMS_ASSISTANT" Name="@T("Synonyms")" Description="@T("Find synonyms for a given word or phrase.")" Icon="@Icons.Material.Filled.Spellcheck" Link="@Routes.ASSISTANT_SYNONYMS"/>
</MudStack>
}
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
@T("Business")
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogWritingEMails" Name="@T("E-Mail")" Description="@T("Generate an e-mail for a given context.")" Icon="@Icons.Material.Filled.Email" Link="@Routes.ASSISTANT_EMAIL"/>
@if (this.SettingsManager.IsAnyCategoryAssistantVisible("Business",
(Components.EMAIL_ASSISTANT, PreviewFeatures.NONE),
(Components.DOCUMENT_ANALYSIS_ASSISTANT, PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025),
(Components.MY_TASKS_ASSISTANT, PreviewFeatures.NONE),
(Components.AGENDA_ASSISTANT, PreviewFeatures.NONE),
(Components.JOB_POSTING_ASSISTANT, PreviewFeatures.NONE),
(Components.LEGAL_CHECK_ASSISTANT, PreviewFeatures.NONE),
(Components.ICON_FINDER_ASSISTANT, PreviewFeatures.NONE)
))
{
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
@T("Business")
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogWritingEMails" Component="Components.EMAIL_ASSISTANT" Name="@T("E-Mail")" Description="@T("Generate an e-mail for a given context.")" Icon="@Icons.Material.Filled.Email" Link="@Routes.ASSISTANT_EMAIL"/>
<AssistantBlock TSettings="SettingsDialogDocumentAnalysis" Component="Components.DOCUMENT_ANALYSIS_ASSISTANT" RequiredPreviewFeature="PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025" Name="@T("Document Analysis")" Description="@T("Analyze a document regarding defined rules and extract key information.")" Icon="@Icons.Material.Filled.DocumentScanner" Link="@Routes.ASSISTANT_DOCUMENT_ANALYSIS"/>
<AssistantBlock TSettings="SettingsDialogMyTasks" Component="Components.MY_TASKS_ASSISTANT" Name="@T("My Tasks")" Description="@T("Analyze a text or an email for tasks you need to complete.")" Icon="@Icons.Material.Filled.Task" Link="@Routes.ASSISTANT_MY_TASKS"/>
<AssistantBlock TSettings="SettingsDialogAgenda" Component="Components.AGENDA_ASSISTANT" Name="@T("Agenda Planner")" Description="@T("Generate an agenda for a given meeting, seminar, etc.")" Icon="@Icons.Material.Filled.CalendarToday" Link="@Routes.ASSISTANT_AGENDA"/>
<AssistantBlock TSettings="SettingsDialogJobPostings" Component="Components.JOB_POSTING_ASSISTANT" Name="@T("Job Posting")" Description="@T("Generate a job posting for a given job description.")" Icon="@Icons.Material.Filled.Work" Link="@Routes.ASSISTANT_JOB_POSTING"/>
<AssistantBlock TSettings="SettingsDialogLegalCheck" Component="Components.LEGAL_CHECK_ASSISTANT" Name="@T("Legal Check")" Description="@T("Ask a question about a legal document.")" Icon="@Icons.Material.Filled.Gavel" Link="@Routes.ASSISTANT_LEGAL_CHECK"/>
<AssistantBlock TSettings="SettingsDialogIconFinder" Component="Components.ICON_FINDER_ASSISTANT" Name="@T("Icon Finder")" Description="@T("Use an LLM to find an icon for a given context.")" Icon="@Icons.Material.Filled.FindInPage" Link="@Routes.ASSISTANT_ICON_FINDER"/>
</MudStack>
}
@if (PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025.IsEnabled(this.SettingsManager))
{
<AssistantBlock TSettings="SettingsDialogDocumentAnalysis" Name="@T("Document Analysis")" Description="@T("Analyze a document regarding defined rules and extract key information.")" Icon="@Icons.Material.Filled.DocumentScanner" Link="@Routes.ASSISTANT_DOCUMENT_ANALYSIS"/>
}
@if (this.SettingsManager.IsAnyCategoryAssistantVisible("Learning",
(Components.BIAS_DAY_ASSISTANT, PreviewFeatures.NONE)
))
{
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
@T("Learning")
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogAssistantBias" Component="Components.BIAS_DAY_ASSISTANT" Name="@T("Bias of the Day")" Description="@T("Learn about one cognitive bias every day.")" Icon="@Icons.Material.Filled.Psychology" Link="@Routes.ASSISTANT_BIAS"/>
</MudStack>
}
<AssistantBlock TSettings="SettingsDialogMyTasks" Name="@T("My Tasks")" Description="@T("Analyze a text or an email for tasks you need to complete.")" Icon="@Icons.Material.Filled.Task" Link="@Routes.ASSISTANT_MY_TASKS"/>
<AssistantBlock TSettings="SettingsDialogAgenda" Name="@T("Agenda Planner")" Description="@T("Generate an agenda for a given meeting, seminar, etc.")" Icon="@Icons.Material.Filled.CalendarToday" Link="@Routes.ASSISTANT_AGENDA"/>
<AssistantBlock TSettings="SettingsDialogJobPostings" Name="@T("Job Posting")" Description="@T("Generate a job posting for a given job description.")" Icon="@Icons.Material.Filled.Work" Link="@Routes.ASSISTANT_JOB_POSTING"/>
<AssistantBlock TSettings="SettingsDialogLegalCheck" Name="@T("Legal Check")" Description="@T("Ask a question about a legal document.")" Icon="@Icons.Material.Filled.Gavel" Link="@Routes.ASSISTANT_LEGAL_CHECK"/>
<AssistantBlock TSettings="SettingsDialogIconFinder" Name="@T("Icon Finder")" Description="@T("Use an LLM to find an icon for a given context.")" Icon="@Icons.Material.Filled.FindInPage" Link="@Routes.ASSISTANT_ICON_FINDER"/>
</MudStack>
@if (this.SettingsManager.IsAnyCategoryAssistantVisible("Software Engineering",
(Components.CODING_ASSISTANT, PreviewFeatures.NONE),
(Components.ERI_ASSISTANT, PreviewFeatures.PRE_RAG_2024)
))
{
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
@T("Software Engineering")
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogCoding" Component="Components.CODING_ASSISTANT" Name="@T("Coding")" Description="@T("Get coding and debugging support from an LLM.")" Icon="@Icons.Material.Filled.Code" Link="@Routes.ASSISTANT_CODING"/>
<AssistantBlock TSettings="SettingsDialogERIServer" Component="Components.ERI_ASSISTANT" RequiredPreviewFeature="PreviewFeatures.PRE_RAG_2024" Name="@T("ERI Server")" Description="@T("Generate an ERI server to integrate business systems.")" Icon="@Icons.Material.Filled.PrivateConnectivity" Link="@Routes.ASSISTANT_ERI"/>
</MudStack>
}
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
@T("Learning")
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogAssistantBias" Name="@T("Bias of the Day")" Description="@T("Learn about one cognitive bias every day.")" Icon="@Icons.Material.Filled.Psychology" Link="@Routes.ASSISTANT_BIAS"/>
</MudStack>
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
@T("Software Engineering")
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogCoding" Name="@T("Coding")" Description="@T("Get coding and debugging support from an LLM.")" Icon="@Icons.Material.Filled.Code" Link="@Routes.ASSISTANT_CODING"/>
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
<AssistantBlock TSettings="SettingsDialogERIServer" Name="@T("ERI Server")" Description="@T("Generate an ERI server to integrate business systems.")" Icon="@Icons.Material.Filled.PrivateConnectivity" Link="@Routes.ASSISTANT_ERI"/>
}
</MudStack>
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
@T("AI Studio Development")
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogI18N" Name="@T("Localization")" Description="@T("Translate AI Studio text content into other languages")" Icon="@Icons.Material.Filled.Translate" Link="@Routes.ASSISTANT_AI_STUDIO_I18N"/>
</MudStack>
@if (this.SettingsManager.IsAnyCategoryAssistantVisible("AI Studio Development",
(Components.I18N_ASSISTANT, PreviewFeatures.NONE)
))
{
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
@T("AI Studio Development")
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogI18N" Component="Components.I18N_ASSISTANT" Name="@T("Localization")" Description="@T("Translate AI Studio text content into other languages")" Icon="@Icons.Material.Filled.Translate" Link="@Routes.ASSISTANT_AI_STUDIO_I18N"/>
</MudStack>
}
</InnerScrolling>
</div>

View File

@ -16,9 +16,17 @@
}
</MudText>
<MudTooltip Text="@T("Show the chat options")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Variant="Variant.Text" Icon="@Icons.Material.Filled.Settings" Color="Color.Default" OnClick="@this.OpenChatSettingsDialog"/>
</MudTooltip>
<MudToolBar WrapContent="false" Gutters="false">
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.DISABLE_WORKSPACES)
{
<MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/>
</MudTooltip>
}
<MudTooltip Text="@T("Show the chat options")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Settings" Color="Color.Default" OnClick="@this.OpenChatSettingsDialog"/>
</MudTooltip>
</MudToolBar>
</MudStack>
<ProviderSelection @bind-ProviderSettings="@this.providerSettings"/>
@ -36,10 +44,10 @@
@T("Your workspaces")
</MudText>
<MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="async () => await this.OpenWorkspacesSettingsDialog()"/>
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/>
</MudTooltip>
<MudTooltip Text="@T("Hide your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Size="Size.Medium" Icon="@this.WorkspaceSidebarToggleIcon" Class="me-1" OnClick="() => this.ToggleWorkspaceSidebar()"/>
<MudIconButton Size="Size.Medium" Icon="@this.WorkspaceSidebarToggleIcon" Class="me-1" OnClick="@(() => this.ToggleWorkspaceSidebar())"/>
</MudTooltip>
</MudStack>
</HeaderContent>
@ -59,7 +67,7 @@
</MudText>
<MudSpacer/>
<MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="async () => await this.OpenWorkspacesSettingsDialog()"/>
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/>
</MudTooltip>
</MudStack>
</HeaderContent>
@ -85,13 +93,13 @@
<MudPaper Class="border border-solid rounded-lg mb-3 d-flex">
<MudStack Row="false" AlignItems="AlignItems.Center" StretchItems="StretchItems.Middle" Wrap="Wrap.NoWrap">
<MudTooltip Text="@T("Show your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Size="Size.Medium" Icon="@this.WorkspaceSidebarToggleIcon" OnClick="() => this.ToggleWorkspaceSidebar()"/>
<MudIconButton Size="Size.Medium" Icon="@this.WorkspaceSidebarToggleIcon" OnClick="@(() => this.ToggleWorkspaceSidebar())"/>
</MudTooltip>
<MudText Typo="Typo.h6" Style="writing-mode: vertical-lr; word-spacing: 0.5em;">
@T("Your workspaces")
</MudText>
<MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="async () => await this.OpenWorkspacesSettingsDialog()"/>
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/>
</MudTooltip>
</MudStack>
</MudPaper>
@ -125,9 +133,9 @@
</MudText>
<MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="async () => await this.OpenWorkspacesSettingsDialog()"/>
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/>
</MudTooltip>
<MudIconButton Icon="@Icons.Material.Filled.Close" Color="Color.Error" Size="Size.Medium" OnClick="() => this.ToggleWorkspacesOverlay()"/>
<MudIconButton Icon="@Icons.Material.Filled.Close" Color="Color.Error" Size="Size.Medium" OnClick="@(() => this.ToggleWorkspacesOverlay())"/>
</MudStack>
</MudDrawerHeader>
<MudDrawerContainer Class="ml-6">

View File

@ -31,7 +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("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), 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("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.")),
new(this.T("Cost-effective"), this.T("You only pay for what you use, which can be cheaper than monthly subscription services like ChatGPT Plus, especially if used infrequently. But beware, here be dragons: For extremely intensive usage, the API costs can be significantly higher. Unfortunately, providers currently do not offer a way to display current costs in the app. Therefore, check your account with the respective provider to see how your costs are developing. When available, use prepaid and set a cost limit.")),

View File

@ -4,7 +4,7 @@
<div class="inner-scrolling-context">
<MudText Typo="Typo.h3" Class="mb-2">
@T("About MindWork AI Studio")
@T("Information about MindWork AI Studio")
</MudText>
<InnerScrolling>

View File

@ -16,7 +16,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Pages;
public partial class About : MSGComponentBase
public partial class Information : MSGComponentBase
{
[Inject]
private RustService RustService { get; init; } = null!;
@ -36,7 +36,7 @@ public partial class About : MSGComponentBase
private static readonly MetaDataLibrariesAttribute META_DATA_LIBRARIES = ASSEMBLY.GetCustomAttribute<MetaDataLibrariesAttribute>()!;
private static readonly MetaDataDatabasesAttribute META_DATA_DATABASES = ASSEMBLY.GetCustomAttribute<MetaDataDatabasesAttribute>()!;
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(About).Namespace, nameof(About));
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(Information).Namespace, nameof(Information));
private string osLanguage = string.Empty;
@ -211,7 +211,7 @@ public partial class About : MSGComponentBase
## Notice
Copyright 2025 Thorsten Sommer
Copyright 2026 Thorsten Sommer
## Terms and Conditions

View File

@ -12,18 +12,23 @@
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
<SettingsPanelEmbeddings AvailableLLMProvidersFunc="() => this.availableLLMProviders" @bind-AvailableEmbeddingProviders="@this.availableEmbeddingProviders"/>
<SettingsPanelEmbeddings AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)" @bind-AvailableEmbeddingProviders="@this.availableEmbeddingProviders"/>
}
<SettingsPanelApp AvailableLLMProvidersFunc="() => this.availableLLMProviders"/>
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
{
<SettingsPanelTranscription AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)" @bind-AvailableTranscriptionProviders="@this.availableTranscriptionProviders"/>
}
<SettingsPanelApp AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)"/>
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
<SettingsPanelAgentDataSourceSelection AvailableLLMProvidersFunc="() => this.availableLLMProviders"/>
<SettingsPanelAgentRetrievalContextValidation AvailableLLMProvidersFunc="() => this.availableLLMProviders"/>
<SettingsPanelAgentDataSourceSelection AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)"/>
<SettingsPanelAgentRetrievalContextValidation AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)"/>
}
<SettingsPanelAgentContentCleaner AvailableLLMProvidersFunc="() => this.availableLLMProviders"/>
<SettingsPanelAgentContentCleaner AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)"/>
</MudExpansionPanels>
</InnerScrolling>
</div>

View File

@ -9,6 +9,7 @@ public partial class Settings : MSGComponentBase
{
private List<ConfigurationSelectData<string>> availableLLMProviders = new();
private List<ConfigurationSelectData<string>> availableEmbeddingProviders = new();
private List<ConfigurationSelectData<string>> availableTranscriptionProviders = new();
#region Overrides of ComponentBase

View File

@ -70,6 +70,42 @@ CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = {
}
}
-- Transcription providers for voice-to-text functionality:
CONFIG["TRANSCRIPTION_PROVIDERS"] = {}
-- An example of a transcription provider configuration:
-- CONFIG["TRANSCRIPTION_PROVIDERS"][#CONFIG["TRANSCRIPTION_PROVIDERS"]+1] = {
-- ["Id"] = "00000000-0000-0000-0000-000000000000",
-- ["Name"] = "<user-friendly name for the transcription provider>",
-- ["UsedLLMProvider"] = "SELF_HOSTED",
--
-- -- Allowed values for Host are: LM_STUDIO, LLAMACPP, OLLAMA, VLLM, and WHISPER_CPP
-- ["Host"] = "WHISPER_CPP",
-- ["Hostname"] = "<https address of the server>",
-- ["Model"] = {
-- ["Id"] = "<the model ID>",
-- ["DisplayName"] = "<user-friendly name of the model>",
-- }
-- }
-- Embedding providers for local RAG (Retrieval-Augmented Generation) functionality:
CONFIG["EMBEDDING_PROVIDERS"] = {}
-- An example of an embedding provider configuration:
-- CONFIG["EMBEDDING_PROVIDERS"][#CONFIG["EMBEDDING_PROVIDERS"]+1] = {
-- ["Id"] = "00000000-0000-0000-0000-000000000000",
-- ["Name"] = "<user-friendly name for the embedding provider>",
-- ["UsedLLMProvider"] = "SELF_HOSTED",
--
-- -- Allowed values for Host are: LM_STUDIO, LLAMACPP, OLLAMA, and VLLM
-- ["Host"] = "OLLAMA",
-- ["Hostname"] = "<https address of the server>",
-- ["Model"] = {
-- ["Id"] = "<the model ID, e.g., nomic-embed-text>",
-- ["DisplayName"] = "<user-friendly name of the model>",
-- }
-- }
CONFIG["SETTINGS"] = {}
-- Configure the update check interval:
@ -101,6 +137,22 @@ CONFIG["SETTINGS"] = {}
-- Please note: using an empty string ("") will lock the preselected profile selection, even though no valid preselected profile is found.
-- CONFIG["SETTINGS"]["DataApp.PreselectedProfile"] = "00000000-0000-0000-0000-000000000000"
-- Configure the transcription provider for voice-to-text functionality.
-- It must be one of the transcription provider IDs defined in CONFIG["TRANSCRIPTION_PROVIDERS"].
-- Without a selected transcription provider, dictation and transcription features will be disabled.
-- Please note: using an empty string ("") will lock the selection and disable dictation/transcription.
-- CONFIG["SETTINGS"]["DataApp.UseTranscriptionProvider"] = "00000000-0000-0000-0000-000000000000"
-- Configure which assistants should be hidden from the UI.
-- Allowed values are:
-- GRAMMAR_SPELLING_ASSISTANT, ICON_FINDER_ASSISTANT, REWRITE_ASSISTANT,
-- TRANSLATION_ASSISTANT, AGENDA_ASSISTANT, CODING_ASSISTANT,
-- TEXT_SUMMARIZER_ASSISTANT, EMAIL_ASSISTANT, LEGAL_CHECK_ASSISTANT,
-- SYNONYMS_ASSISTANT, MY_TASKS_ASSISTANT, JOB_POSTING_ASSISTANT,
-- BIAS_DAY_ASSISTANT, ERI_ASSISTANT, DOCUMENT_ANALYSIS_ASSISTANT,
-- I18N_ASSISTANT
-- CONFIG["SETTINGS"]["DataApp.HiddenAssistants"] = { "ERI_ASSISTANT", "I18N_ASSISTANT" }
-- Example chat templates for this configuration:
CONFIG["CHAT_TEMPLATES"] = {}
@ -125,6 +177,33 @@ CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = {
}
}
-- An example chat template with file attachments:
-- This template automatically attaches specified files when the user selects it.
CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = {
["Id"] = "00000000-0000-0000-0000-000000000001",
["Name"] = "Document Analysis Template",
["SystemPrompt"] = "You are an expert document analyst. Please analyze the attached documents and provide insights.",
["PredefinedUserPrompt"] = "Please analyze the attached company guidelines and summarize the key points.",
["AllowProfileUsage"] = true,
-- Optional: Pre-attach files that will be automatically included when using this template.
-- These files will be loaded when the user selects this chat template.
-- Note: File paths must be absolute paths and accessible to all users.
["FileAttachments"] = {
"G:\\Company\\Documents\\Guidelines.pdf",
"G:\\Company\\Documents\\CompanyPolicies.docx"
},
["ExampleConversation"] = {
{
["Role"] = "USER",
["Content"] = "I have attached the company documents for analysis."
},
{
["Role"] = "AI",
["Content"] = "Thank you. I'll analyze the documents and provide a comprehensive summary."
}
}
}
-- Profiles for this configuration:
CONFIG["PROFILES"] = {}

View File

@ -119,7 +119,6 @@ internal sealed class Program
var databaseClient = new QdrantClientImplementation("Qdrant", qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken);
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(kestrelServerOptions =>
{
kestrelServerOptions.ConfigureEndpointDefaults(listenOptions =>
@ -223,6 +222,7 @@ internal sealed class Program
var rustLogger = app.Services.GetRequiredService<ILogger<RustService>>();
rust.SetLogger(rustLogger);
rust.SetEncryptor(encryption);
TerminalLogger.SetRustService(rust);
RUST_SERVICE = rust;
ENCRYPTION = encryption;
@ -233,6 +233,7 @@ internal sealed class Program
programLogger.LogInformation("Initialize internal file system.");
app.Use(Redirect.HandlerContentAsync);
app.Use(FileHandler.HandlerAsync);
#if DEBUG
app.UseStaticFiles();

View File

@ -9,7 +9,7 @@ using AIStudio.Settings;
namespace AIStudio.Provider.AlibabaCloud;
public sealed class ProviderAlibabaCloud() : BaseProvider("https://dashscope-intl.aliyuncs.com/compatible-mode/v1/", LOGGER)
public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_CLOUD, "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/", LOGGER)
{
private static readonly ILogger<ProviderAlibabaCloud> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ProviderAlibabaCloud>();
@ -25,12 +25,12 @@ public sealed class ProviderAlibabaCloud() : BaseProvider("https://dashscope-int
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);
var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER);
if(!requestedSecret.Success)
yield break;
// Prepare the system prompt:
var systemPrompt = new Message
var systemPrompt = new TextMessage
{
Role = "system",
Content = chatThread.PrepareSystemPrompt(settingsManager, chatThread),
@ -40,24 +40,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider("https://dashscope-int
var apiParameters = this.ParseAdditionalApiParameters();
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessages(async n => new Message
{
Role = n.Role switch
{
ChatRole.USER => "user",
ChatRole.AI => "assistant",
ChatRole.AGENT => "assistant",
ChatRole.SYSTEM => "system",
_ => "user",
},
Content = n.Content switch
{
ContentText text => await text.PrepareContentForAI(),
_ => string.Empty,
}
});
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
// Prepare the AlibabaCloud HTTP chat request:
var alibabaCloudChatRequest = JsonSerializer.Serialize(new ChatCompletionAPIRequest
@ -98,6 +81,12 @@ public sealed class ProviderAlibabaCloud() : BaseProvider("https://dashscope-int
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public override Task<string> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
{
return Task.FromResult(string.Empty);
}
/// <inheritdoc />
public override Task<IEnumerable<Model>> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
@ -128,7 +117,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider("https://dashscope-int
new Model("qwen2.5-vl-3b-instruct", "Qwen2.5-VL 3b"),
};
return this.LoadModels(["q"],token, apiKeyProvisional).ContinueWith(t => t.Result.Concat(additionalModels).OrderBy(x => x.Id).AsEnumerable(), token);
return this.LoadModels(["q"], SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional).ContinueWith(t => t.Result.Concat(additionalModels).OrderBy(x => x.Id).AsEnumerable(), token);
}
/// <inheritdoc />
@ -146,18 +135,27 @@ public sealed class ProviderAlibabaCloud() : BaseProvider("https://dashscope-int
new Model("text-embedding-v3", "text-embedding-v3"),
};
return this.LoadModels(["text-embedding-"], token, apiKeyProvisional).ContinueWith(t => t.Result.Concat(additionalModels).OrderBy(x => x.Id).AsEnumerable(), token);
return this.LoadModels(["text-embedding-"], SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional).ContinueWith(t => t.Result.Concat(additionalModels).OrderBy(x => x.Id).AsEnumerable(), token);
}
#region Overrides of BaseProvider
/// <inheritdoc />
public override Task<IEnumerable<Model>> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(Enumerable.Empty<Model>());
}
#endregion
private async Task<IEnumerable<Model>> LoadModels(string[] prefixes, CancellationToken token, string? apiKeyProvisional = null)
#endregion
private async Task<IEnumerable<Model>> LoadModels(string[] prefixes, SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null)
{
var secretKey = apiKeyProvisional switch
{
not null => apiKeyProvisional,
_ => await RUST_SERVICE.GetAPIKey(this) switch
_ => await RUST_SERVICE.GetAPIKey(this, storeType) switch
{
{ Success: true } result => await result.Secret.Decrypt(ENCRYPTION),
_ => null,

View File

@ -1,5 +1,4 @@
using System.Text.Json.Serialization;
using AIStudio.Provider.OpenAI;
namespace AIStudio.Provider.Anthropic;
@ -13,7 +12,7 @@ namespace AIStudio.Provider.Anthropic;
/// <param name="System">The system prompt for the chat completion.</param>
public readonly record struct ChatRequest(
string Model,
IList<Message> Messages,
IList<IMessageBase> Messages,
int MaxTokens,
bool Stream,
string System

View File

@ -0,0 +1,9 @@
namespace AIStudio.Provider.Anthropic;
public interface ISubContentImageSource
{
/// <summary>
/// The type of the sub-content image.
/// </summary>
public SubContentImageType Type { get; }
}

View File

@ -9,7 +9,7 @@ using AIStudio.Settings;
namespace AIStudio.Provider.Anthropic;
public sealed class ProviderAnthropic() : BaseProvider("https://api.anthropic.com/v1/", LOGGER)
public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, "https://api.anthropic.com/v1/", LOGGER)
{
private static readonly ILogger<ProviderAnthropic> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ProviderAnthropic>();
@ -23,7 +23,7 @@ public sealed class ProviderAnthropic() : BaseProvider("https://api.anthropic.co
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);
var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER);
if(!requestedSecret.Success)
yield break;
@ -31,9 +31,11 @@ public sealed class ProviderAnthropic() : BaseProvider("https://api.anthropic.co
var apiParameters = this.ParseAdditionalApiParameters("system");
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessages(async n => new Message
{
Role = n.Role switch
var messages = await chatThread.Blocks.BuildMessagesAsync(
this.Provider, chatModel,
// Anthropic-specific role mapping:
role => role switch
{
ChatRole.USER => "user",
ChatRole.AI => "assistant",
@ -42,12 +44,25 @@ public sealed class ProviderAnthropic() : BaseProvider("https://api.anthropic.co
_ => "user",
},
Content = n.Content switch
// Anthropic uses the standard text sub-content:
text => new SubContentText
{
ContentText text => await text.PrepareContentForAI(),
_ => string.Empty,
Text = text,
},
// Anthropic-specific image sub-content:
async attachment => new SubContentImage
{
Source = new SubContentBase64Image
{
Data = await attachment.TryAsBase64(token: token) is (true, var base64Content)
? base64Content
: string.Empty,
MediaType = attachment.DetermineMimeType(),
}
}
});
);
// Prepare the Anthropic HTTP chat request:
var chatRequest = JsonSerializer.Serialize(new ChatRequest
@ -93,6 +108,12 @@ public sealed class ProviderAnthropic() : BaseProvider("https://api.anthropic.co
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public override Task<string> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
{
return Task.FromResult(string.Empty);
}
/// <inheritdoc />
public override Task<IEnumerable<Model>> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
@ -106,7 +127,7 @@ public sealed class ProviderAnthropic() : BaseProvider("https://api.anthropic.co
new Model("claude-3-opus-latest", "Claude 3 Opus (Latest)"),
};
return this.LoadModels(token, apiKeyProvisional).ContinueWith(t => t.Result.Concat(additionalModels).OrderBy(x => x.Id).AsEnumerable(), token);
return this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional).ContinueWith(t => t.Result.Concat(additionalModels).OrderBy(x => x.Id).AsEnumerable(), token);
}
/// <inheritdoc />
@ -121,14 +142,20 @@ public sealed class ProviderAnthropic() : BaseProvider("https://api.anthropic.co
return Task.FromResult(Enumerable.Empty<Model>());
}
/// <inheritdoc />
public override Task<IEnumerable<Model>> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(Enumerable.Empty<Model>());
}
#endregion
private async Task<IEnumerable<Model>> LoadModels(CancellationToken token, string? apiKeyProvisional = null)
private async Task<IEnumerable<Model>> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null)
{
var secretKey = apiKeyProvisional switch
{
not null => apiKeyProvisional,
_ => await RUST_SERVICE.GetAPIKey(this) switch
_ => await RUST_SERVICE.GetAPIKey(this, storeType) switch
{
{ Success: true } result => await result.Secret.Decrypt(ENCRYPTION),
_ => null,

View File

@ -0,0 +1,10 @@
namespace AIStudio.Provider.Anthropic;
public record SubContentBase64Image : ISubContentImageSource
{
public SubContentImageType Type => SubContentImageType.BASE64;
public string MediaType { get; init; } = string.Empty;
public string Data { get; init; } = string.Empty;
}

View File

@ -0,0 +1,10 @@
using AIStudio.Provider.OpenAI;
namespace AIStudio.Provider.Anthropic;
public record SubContentImage(SubContentType Type, ISubContentImageSource Source) : ISubContent
{
public SubContentImage() : this(SubContentType.IMAGE, new SubContentImageUrl())
{
}
}

View File

@ -0,0 +1,32 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AIStudio.Provider.Anthropic;
/// <summary>
/// Custom JSON converter for the ISubContentImageSource interface to handle polymorphic serialization.
/// </summary>
/// <remarks>
/// This converter ensures that when serializing ISubContentImageSource objects, all properties
/// of the concrete implementation (e.g., SubContentBase64Image, SubContentImageUrl) are serialized,
/// not just the properties defined in the ISubContentImageSource interface.
/// </remarks>
public sealed class SubContentImageSourceConverter : JsonConverter<ISubContentImageSource>
{
private static readonly ILogger<SubContentImageSourceConverter> LOGGER = Program.LOGGER_FACTORY.CreateLogger<SubContentImageSourceConverter>();
public override ISubContentImageSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Deserialization is not needed for request objects, as sub-content image sources are only serialized
// when sending requests to LLM providers.
LOGGER.LogError("Deserializing ISubContentImageSource is not supported. This converter is only used for serializing request messages.");
return null;
}
public override void Write(Utf8JsonWriter writer, ISubContentImageSource value, JsonSerializerOptions options)
{
// Serialize the actual concrete type (e.g., SubContentBase64Image, SubContentImageUrl) instead of just the ISubContentImageSource interface.
// This ensures all properties of the concrete type are included in the JSON output.
JsonSerializer.Serialize(writer, value, value.GetType(), options);
}
}

View File

@ -0,0 +1,7 @@
namespace AIStudio.Provider.Anthropic;
public enum SubContentImageType
{
URL,
BASE64
}

View File

@ -0,0 +1,8 @@
namespace AIStudio.Provider.Anthropic;
public record SubContentImageUrl : ISubContentImageSource
{
public SubContentImageType Type => SubContentImageType.URL;
public string Url { get; init; } = string.Empty;
}

View File

@ -1,13 +1,21 @@
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using AIStudio.Chat;
using AIStudio.Provider.Anthropic;
using AIStudio.Provider.OpenAI;
using AIStudio.Provider.SelfHosted;
using AIStudio.Settings;
using AIStudio.Tools.MIME;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Host = AIStudio.Provider.SelfHosted.Host;
namespace AIStudio.Provider;
/// <summary>
@ -40,18 +48,28 @@ public abstract class BaseProvider : IProvider, ISecretId
protected static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
Converters = { new AnnotationConverter() },
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower),
new AnnotationConverter(),
new MessageBaseConverter(),
new SubContentConverter(),
new SubContentImageSourceConverter(),
new SubContentImageUrlConverter(),
},
AllowTrailingCommas = false
};
/// <summary>
/// Constructor for the base provider.
/// </summary>
/// <param name="provider">The provider enum value.</param>
/// <param name="url">The base URL for the provider.</param>
/// <param name="logger">The logger to use.</param>
protected BaseProvider(string url, ILogger logger)
protected BaseProvider(LLMProviders provider, string url, ILogger logger)
{
this.logger = logger;
this.Provider = provider;
// Set the base URL:
this.httpClient.BaseAddress = new(url);
@ -59,6 +77,9 @@ public abstract class BaseProvider : IProvider, ISecretId
#region Handling of IProvider, which all providers must implement
/// <inheritdoc />
public LLMProviders Provider { get; }
/// <inheritdoc />
public abstract string Id { get; }
@ -74,6 +95,9 @@ public abstract class BaseProvider : IProvider, ISecretId
/// <inheritdoc />
public abstract IAsyncEnumerable<ImageURL> StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, CancellationToken token = default);
/// <inheritdoc />
public abstract Task<string> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default);
/// <inheritdoc />
public abstract Task<IEnumerable<Model>> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default);
@ -83,6 +107,9 @@ public abstract class BaseProvider : IProvider, ISecretId
/// <inheritdoc />
public abstract Task<IEnumerable<Model>> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default);
/// <inheritdoc />
public abstract Task<IEnumerable<Model>> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default);
#endregion
#region Implementation of ISecretId
@ -130,7 +157,7 @@ public abstract class BaseProvider : IProvider, ISecretId
if (nextResponse.StatusCode is HttpStatusCode.Forbidden)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Block, string.Format(TB("Tried to communicate with the LLM provider '{0}'. You might not be able to use this provider from your location. The provider message is: '{1}'"), this.InstanceName, nextResponse.ReasonPhrase)));
this.logger.LogError("Failed request with status code {ResposeStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
errorMessage = nextResponse.ReasonPhrase;
break;
}
@ -138,7 +165,7 @@ public abstract class BaseProvider : IProvider, ISecretId
if(nextResponse.StatusCode is HttpStatusCode.BadRequest)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("Tried to communicate with the LLM provider '{0}'. The required message format might be changed. The provider message is: '{1}'"), this.InstanceName, nextResponse.ReasonPhrase)));
this.logger.LogError("Failed request with status code {ResposeStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
errorMessage = nextResponse.ReasonPhrase;
break;
}
@ -146,7 +173,7 @@ public abstract class BaseProvider : IProvider, ISecretId
if(nextResponse.StatusCode is HttpStatusCode.NotFound)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("Tried to communicate with the LLM provider '{0}'. Something was not found. The provider message is: '{1}'"), this.InstanceName, nextResponse.ReasonPhrase)));
this.logger.LogError("Failed request with status code {ResposeStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
errorMessage = nextResponse.ReasonPhrase;
break;
}
@ -154,7 +181,7 @@ public abstract class BaseProvider : IProvider, ISecretId
if(nextResponse.StatusCode is HttpStatusCode.Unauthorized)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Key, string.Format(TB("Tried to communicate with the LLM provider '{0}'. The API key might be invalid. The provider message is: '{1}'"), this.InstanceName, nextResponse.ReasonPhrase)));
this.logger.LogError("Failed request with status code {ResposeStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
errorMessage = nextResponse.ReasonPhrase;
break;
}
@ -162,7 +189,7 @@ public abstract class BaseProvider : IProvider, ISecretId
if(nextResponse.StatusCode is HttpStatusCode.InternalServerError)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("Tried to communicate with the LLM provider '{0}'. The server might be down or having issues. The provider message is: '{1}'"), this.InstanceName, nextResponse.ReasonPhrase)));
this.logger.LogError("Failed request with status code {ResposeStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
errorMessage = nextResponse.ReasonPhrase;
break;
}
@ -170,7 +197,7 @@ public abstract class BaseProvider : IProvider, ISecretId
if(nextResponse.StatusCode is HttpStatusCode.ServiceUnavailable)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("Tried to communicate with the LLM provider '{0}'. The provider is overloaded. The message is: '{1}'"), this.InstanceName, nextResponse.ReasonPhrase)));
this.logger.LogError("Failed request with status code {ResposeStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
errorMessage = nextResponse.ReasonPhrase;
break;
}
@ -518,6 +545,78 @@ public abstract class BaseProvider : IProvider, ISecretId
streamReader.Dispose();
}
protected async Task<string> PerformStandardTranscriptionRequest(RequestedSecret requestedSecret, Model transcriptionModel, string audioFilePath, Host host = Host.NONE, CancellationToken token = default)
{
try
{
using var form = new MultipartFormDataContent();
var mimeType = Builder.FromFilename(audioFilePath);
await using var fileStream = File.OpenRead(audioFilePath);
using var fileContent = new StreamContent(fileStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType);
form.Add(fileContent, "file", Path.GetFileName(audioFilePath));
form.Add(new StringContent(transcriptionModel.Id), "model");
using var request = new HttpRequestMessage(HttpMethod.Post, host.TranscriptionURL());
request.Content = form;
// Handle the authorization header based on the provider:
switch (this.Provider)
{
case LLMProviders.SELF_HOSTED:
if(requestedSecret.Success)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
break;
case LLMProviders.FIREWORKS:
if(!requestedSecret.Success)
{
this.logger.LogError("No valid API key available for transcription request.");
return string.Empty;
}
request.Headers.Add("Authorization", await requestedSecret.Secret.Decrypt(ENCRYPTION));
break;
default:
if(!requestedSecret.Success)
{
this.logger.LogError("No valid API key available for transcription request.");
return string.Empty;
}
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
break;
}
using var response = await this.httpClient.SendAsync(request, token);
var responseBody = response.Content.ReadAsStringAsync(token).Result;
if (!response.IsSuccessStatusCode)
{
this.logger.LogError("Transcription request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody);
return string.Empty;
}
var transcriptionResponse = JsonSerializer.Deserialize<TranscriptionResponse>(responseBody, JSON_SERIALIZER_OPTIONS);
if(transcriptionResponse is null)
{
this.logger.LogError("Was not able to deserialize the transcription response.");
return string.Empty;
}
return transcriptionResponse.Text;
}
catch (Exception e)
{
this.logger.LogError("Failed to perform transcription request: '{Message}'.", e.Message);
return string.Empty;
}
}
/// <summary>
/// Parse and convert API parameters from a provided JSON string into a dictionary,
/// optionally merging additional parameters and removing specific keys.

View File

@ -9,7 +9,7 @@ using AIStudio.Settings;
namespace AIStudio.Provider.DeepSeek;
public sealed class ProviderDeepSeek() : BaseProvider("https://api.deepseek.com/", LOGGER)
public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, "https://api.deepseek.com/", LOGGER)
{
private static readonly ILogger<ProviderDeepSeek> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ProviderDeepSeek>();
@ -25,12 +25,12 @@ public sealed class ProviderDeepSeek() : BaseProvider("https://api.deepseek.com/
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);
var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER);
if(!requestedSecret.Success)
yield break;
// Prepare the system prompt:
var systemPrompt = new Message
var systemPrompt = new TextMessage
{
Role = "system",
Content = chatThread.PrepareSystemPrompt(settingsManager, chatThread),
@ -40,24 +40,7 @@ public sealed class ProviderDeepSeek() : BaseProvider("https://api.deepseek.com/
var apiParameters = this.ParseAdditionalApiParameters();
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessages(async n => new Message
{
Role = n.Role switch
{
ChatRole.USER => "user",
ChatRole.AI => "assistant",
ChatRole.AGENT => "assistant",
ChatRole.SYSTEM => "system",
_ => "user",
},
Content = n.Content switch
{
ContentText text => await text.PrepareContentForAI(),
_ => string.Empty,
}
});
var messages = await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.Provider, chatModel);
// Prepare the DeepSeek HTTP chat request:
var deepSeekChatRequest = JsonSerializer.Serialize(new ChatCompletionAPIRequest
@ -98,10 +81,16 @@ public sealed class ProviderDeepSeek() : BaseProvider("https://api.deepseek.com/
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public override Task<string> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
{
return Task.FromResult(string.Empty);
}
/// <inheritdoc />
public override Task<IEnumerable<Model>> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(token, apiKeyProvisional);
return this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
}
/// <inheritdoc />
@ -116,15 +105,20 @@ public sealed class ProviderDeepSeek() : BaseProvider("https://api.deepseek.com/
return Task.FromResult(Enumerable.Empty<Model>());
}
/// <inheritdoc />
public override Task<IEnumerable<Model>> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(Enumerable.Empty<Model>());
}
#endregion
private async Task<IEnumerable<Model>> LoadModels(CancellationToken token, string? apiKeyProvisional = null)
private async Task<IEnumerable<Model>> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null)
{
var secretKey = apiKeyProvisional switch
{
not null => apiKeyProvisional,
_ => await RUST_SERVICE.GetAPIKey(this) switch
_ => await RUST_SERVICE.GetAPIKey(this, storeType) switch
{
{ Success: true } result => await result.Secret.Decrypt(ENCRYPTION),
_ => null,

View File

@ -10,7 +10,7 @@ namespace AIStudio.Provider.Fireworks;
/// <param name="Stream">Whether to stream the chat completion.</param>
public readonly record struct ChatRequest(
string Model,
IList<Message> Messages,
IList<IMessageBase> Messages,
bool Stream
)
{

View File

@ -1,8 +0,0 @@
namespace AIStudio.Provider.Fireworks;
/// <summary>
/// Chat message model.
/// </summary>
/// <param name="Content">The text content of the message.</param>
/// <param name="Role">The role of the message.</param>
public readonly record struct Message(string Content, string Role);

View File

@ -9,7 +9,7 @@ using AIStudio.Settings;
namespace AIStudio.Provider.Fireworks;
public class ProviderFireworks() : BaseProvider("https://api.fireworks.ai/inference/v1/", LOGGER)
public class ProviderFireworks() : BaseProvider(LLMProviders.FIREWORKS, "https://api.fireworks.ai/inference/v1/", LOGGER)
{
private static readonly ILogger<ProviderFireworks> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ProviderFireworks>();
@ -25,12 +25,12 @@ public class ProviderFireworks() : BaseProvider("https://api.fireworks.ai/infere
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);
var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER);
if(!requestedSecret.Success)
yield break;
// Prepare the system prompt:
var systemPrompt = new Message
var systemPrompt = new TextMessage
{
Role = "system",
Content = chatThread.PrepareSystemPrompt(settingsManager, chatThread),
@ -40,24 +40,7 @@ public class ProviderFireworks() : BaseProvider("https://api.fireworks.ai/infere
var apiParameters = this.ParseAdditionalApiParameters();
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessages(async n => new Message
{
Role = n.Role switch
{
ChatRole.USER => "user",
ChatRole.AI => "assistant",
ChatRole.AGENT => "assistant",
ChatRole.SYSTEM => "system",
_ => "user",
},
Content = n.Content switch
{
ContentText text => await text.PrepareContentForAI(),
_ => string.Empty,
}
});
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
// Prepare the Fireworks HTTP chat request:
var fireworksChatRequest = JsonSerializer.Serialize(new ChatRequest
@ -99,6 +82,13 @@ public class ProviderFireworks() : BaseProvider("https://api.fireworks.ai/infere
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public override async Task<string> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
{
var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER);
return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token);
}
/// <inheritdoc />
public override Task<IEnumerable<Model>> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
@ -117,5 +107,17 @@ public class ProviderFireworks() : BaseProvider("https://api.fireworks.ai/infere
return Task.FromResult(Enumerable.Empty<Model>());
}
/// <inheritdoc />
public override Task<IEnumerable<Model>> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
// Source: https://docs.fireworks.ai/api-reference/audio-transcriptions#param-model
return Task.FromResult<IEnumerable<Model>>(
new List<Model>
{
new("whisper-v3", "Whisper v3"),
// new("whisper-v3-turbo", "Whisper v3 Turbo"), // does not work
});
}
#endregion
}

Some files were not shown because too many files have changed in this diff Show More