Compare commits

..
Author SHA1 Message Date
Paul KoudelkaandThorsten Sommer cbc4ce5413 Added a way to read what the AI received from your sources (#1100)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
2026-10-26 10:00:01 +01:00
Thorsten Sommer e0d8e18f34 Added progress messages and quick access to exported files (#1112) 2026-10-25 22:10:38 +01:00
Thorsten Sommer 7f72da628e Fixed assistants undoing your changes while a run you left continues (#1111) 2026-10-25 21:09:23 +01:00
Thorsten Sommer 31c271eb7b Fixed assistants doing nothing when started without a provider (#1110) 2026-10-25 20:44:23 +01:00
Thorsten Sommer c7be04b727 Fixed AI Studio discarding answers of the AI written in an unexpected JSON form (#1109) 2026-10-25 20:13:02 +01:00
Thorsten Sommer d26139936a Avoided needless Semantic Search calls when no data source can be searched (#1108) 2026-10-25 19:38:52 +01:00
Thorsten Sommer 8d9b15bd5f Fixed CI building with a newer .NET SDK than the metadata names (#1107) 2026-10-25 19:10:21 +01:00
Sabrina-devopsandThorsten Sommer 6eb23ebbcb Added PowerPoint templates to the Slide Assistant (#1055)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
2026-10-25 18:52:39 +01:00
Thorsten Sommer 8c5cfa78b6 Outbound data restrictions after searching data sources (#1106) 2026-10-23 09:27:22 +02:00
Thorsten Sommer 7ba5f01388 Added a way for organizations to prohibit providers (#1105) 2026-10-22 18:57:12 +02:00
nilskruthoffandThorsten Sommer 052868b24e Added a settings dialog with organization-managed defaults to the Assistant Builder (#908)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
2026-10-22 15:26:04 +02:00
Thorsten Sommer 4695fb3fef Added the prototype badge to the mailbox dialog (#1103) 2026-10-22 14:48:25 +02:00
Peer HogeterpandThorsten Sommer 24c4c31035 Added profile, provider, and workspace preselection to chat templates (#1099)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
2026-10-22 14:15:10 +02:00
Thorsten Sommer ab9df99cf0 Added the page jump for PdfCraft on Windows, macOS, and Linux (#1102) 2026-10-22 12:08:42 +02:00
Thorsten Sommer ba99bf6fae Scaled down and converted images before sending them to AI models (#1101) 2026-10-22 10:57:04 +02:00
Thorsten Sommer 1c8632bbe0 Fixed the Batch Processing assistant with an output folder above the input folder (#1098) 2026-10-21 15:51:22 +02:00
Thorsten Sommer 7fbb894d5b Upgraded to Rust v1.99.1 (#1097) 2026-10-21 14:53:16 +02:00
Peer HogeterpandThorsten Sommer e019c24ea2 Allowed the Batch Processing assistant to process images (#1087)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
2026-10-21 13:52:26 +02:00
Paul KoudelkaandThorsten Sommer 5d6e48ef93 Allowed moving local data sources without embedding them again (#1091)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
2026-10-20 18:50:09 +02:00
563 changed files with 40519 additions and 5124 deletions

No files matched your search

+24
View File
@@ -803,6 +803,18 @@ jobs:
cache: true cache: true
cache-dependency-path: 'app/MindWork AI Studio/packages.lock.json' cache-dependency-path: 'app/MindWork AI Studio/packages.lock.json'
- name: Pin the .NET SDK from the metadata
shell: bash
run: |
# The runner images bring several .NET SDKs along, newer ones among them. Without a
# global.json, the newest of them builds instead of the one installed above from the
# metadata, and with it comes a newer C# version than the one we build with locally.
# The file exists in the pipeline only, so metadata.txt stays the one place which names
# the SDK. Bash runs on Windows as well, so that a failing command fails the step:
dotnet new globaljson --sdk-version "${DOTNET_SDK_VERSION}" --roll-forward disable --force
used_dotnet_sdk_version=$(dotnet --version)
echo ".NET SDK in use: '${used_dotnet_sdk_version}'"
- name: Cache Rust - name: Cache Rust
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -1055,6 +1067,18 @@ jobs:
cache: true cache: true
cache-dependency-path: 'app/MindWork AI Studio/packages.lock.json' cache-dependency-path: 'app/MindWork AI Studio/packages.lock.json'
- name: Pin the .NET SDK from the metadata
shell: bash
run: |
# The runner images bring several .NET SDKs along, newer ones among them. Without a
# global.json, the newest of them builds instead of the one installed above from the
# metadata, and with it comes a newer C# version than the one we build with locally.
# The file exists in the pipeline only, so metadata.txt stays the one place which names
# the SDK. Bash runs on Windows as well, so that a failing command fails the step:
dotnet new globaljson --sdk-version "${DOTNET_SDK_VERSION}" --roll-forward disable --force
used_dotnet_sdk_version=$(dotnet --version)
echo ".NET SDK in use: '${used_dotnet_sdk_version}'"
- name: Deploy PDFium (Unix) - name: Deploy PDFium (Unix)
if: matrix.platform != 'windows-latest' if: matrix.platform != 'windows-latest'
env: env:
+1
View File
@@ -5,6 +5,7 @@ startup.env
libpdfium.dylib libpdfium.dylib
libpdfium.so libpdfium.so
libpdfium.dll libpdfium.dll
pdfium.dll
# Ignore qdrant database: # Ignore qdrant database:
qdrant-aarch64-apple-darwin qdrant-aarch64-apple-darwin
+1
View File
@@ -39,6 +39,7 @@ Therefore, before you plan, read, or change anything in one of these areas, read
| anything under `app/MindWork AI Studio/Models/` or `app/Tests/Models/` | `app/MindWork AI Studio/Models/AGENTS.md` | | anything under `app/MindWork AI Studio/Models/` or `app/Tests/Models/` | `app/MindWork AI Studio/Models/AGENTS.md` |
| anything under `app/MindWork AI Studio/Tools/Services/Indexing/`, or `DataSourceEmbeddingService` | `app/MindWork AI Studio/Tools/Services/Indexing/AGENTS.md` | | anything under `app/MindWork AI Studio/Tools/Services/Indexing/`, or `DataSourceEmbeddingService` | `app/MindWork AI Studio/Tools/Services/Indexing/AGENTS.md` |
| anything under `app/MindWork AI Studio/Tools/Databases/` | `app/MindWork AI Studio/Tools/Databases/AGENTS.md` | | anything under `app/MindWork AI Studio/Tools/Databases/` | `app/MindWork AI Studio/Tools/Databases/AGENTS.md` |
| anything under `app/MindWork AI Studio/Tools/PowerPoint/` or `app/Tests/Tools/PowerPoint/`, or the PowerPoint part of the Slide Assistant | `app/MindWork AI Studio/Tools/PowerPoint/AGENTS.md` |
This file keeps only what applies to the whole repository. `app/AGENTS.md` keeps what applies to all of the This file keeps only what applies to the whole repository. `app/AGENTS.md` keeps what applies to all of the
.NET app, such as the rules for code which calls into one of the areas below it. Add instructions about a .NET app, such as the rules for code which calls into one of the areas below it. Add instructions about a
+10 -1
View File
@@ -32,6 +32,7 @@ Key structure:
- **Prompt injection reports** - The user hears once per action about what the filter removed, as soon as that action ends. Content which belongs to an action carries its scope from `PromptInjectionGuardService.BeginAction` down to where it is read or filtered; whatever is filtered without a scope is reported right away. Pass the scope as a parameter, never through a field or an `AsyncLocal`: the service serves the whole app, and the message bus delivers to other receivers in the execution context of whoever sends. - **Prompt injection reports** - The user hears once per action about what the filter removed, as soon as that action ends. Content which belongs to an action carries its scope from `PromptInjectionGuardService.BeginAction` down to where it is read or filtered; whatever is filtered without a scope is reported right away. Pass the scope as a parameter, never through a field or an `AsyncLocal`: the service serves the whole app, and the message bus delivers to other receivers in the execution context of whoever sends.
- **Paths in stored data** - A stored path to a file which AI Studio keeps below the data directory, such as a transcript or a tokenizer copy, is relative to that directory, so that a backup or a copy of the data directory works on another computer, under another user name, or on another OS. Mark such a property with `[JsonConverter(typeof(DataDirectoryRelativePathConverter))]`, for a positional record parameter with `[property: ...]`; in memory, the path stays absolute. Better still, derive the path from IDs and store none, as `VisualBriefingStore` does. Paths to files of the user stay absolute. Chats stored before v26.10.1 are migrated by a compatibility shim, see `documentation/compatibility-shims/2026-10-relative-data-paths.md`. - **Paths in stored data** - A stored path to a file which AI Studio keeps below the data directory, such as a transcript or a tokenizer copy, is relative to that directory, so that a backup or a copy of the data directory works on another computer, under another user name, or on another OS. Mark such a property with `[JsonConverter(typeof(DataDirectoryRelativePathConverter))]`, for a positional record parameter with `[property: ...]`; in memory, the path stays absolute. Better still, derive the path from IDs and store none, as `VisualBriefingStore` does. Paths to files of the user stay absolute. Chats stored before v26.10.1 are migrated by a compatibility shim, see `documentation/compatibility-shims/2026-10-relative-data-paths.md`.
- **New files and folders in the data or configuration directory** - Whoever adds a file or folder directly to the data or configuration directory, or below `plugins/` or `databases/` one which needs a decision of its own, lists it in `DataBackupContents.ENTRIES`: as part of a backup, or with the reason a backup leaves it out. A backup takes along only what is listed as included, and a restore replaces only that. An entry included in a backup also goes into `REPLACEABLE_ELEMENTS` in `runtime/src/data_restore.rs`; otherwise the runtime refuses to restore it. The folder panel on the information page shows the list to users who back up these folders with a tool of their own. - **New files and folders in the data or configuration directory** - Whoever adds a file or folder directly to the data or configuration directory, or below `plugins/` or `databases/` one which needs a decision of its own, lists it in `DataBackupContents.ENTRIES`: as part of a backup, or with the reason a backup leaves it out. A backup takes along only what is listed as included, and a restore replaces only that. An entry included in a backup also goes into `REPLACEABLE_ELEMENTS` in `runtime/src/data_restore.rs`; otherwise the runtime refuses to restore it. The folder panel on the information page shows the list to users who back up these folders with a tool of their own.
- **JSON in model answers** - Read the JSON part of a model answer with `ModelAnswerJson`, never with a scanner of your own. It skips the prose and code fences around the JSON but checks nothing, so broken JSON reaches the deserializer unchanged and its message can go back to the model.
- **Encryption** - Initialized before Rust service is marked ready - **Encryption** - Initialized before Rust service is marked ready
- **Debug environment** - Reads `startup.env` file with IPC credentials - **Debug environment** - Reads `startup.env` file with IPC credentials
- **Production environment** - Runtime launches .NET sidecar with environment variables - **Production environment** - Runtime launches .NET sidecar with environment variables
@@ -103,7 +104,7 @@ knowledge.
RAG is available as a beta preview feature. Architecture: RAG is available as a beta preview feature. Architecture:
- **External Retrieval Interface (ERI)** - Contract for integrating external data sources - **External Retrieval Interface (ERI)** - Contract for integrating external data sources
- **Data Sources** - Local files and external data via ERI servers - **Data Sources** - Local files and external data via ERI servers
- **Two ways to search** - By default, the chat model searches the data sources itself through the tool `semantic_search`, whenever a question calls for it. The classic process (`AISrcSelWithRetCtxVal`) searches them with every message instead, when the user chose so per chat (`DataSourceOptions.RetrievalMode`) or whenever the tool cannot be offered. `ToolRegistry.GetEffectiveRetrievalModeAsync` decides between the two; pass its answer to `DataSourceService`, because the agents only count as providers that see the data when they actually run. See "Searching Data Sources" in `documentation/Tools.md`. - **Two ways to search** - By default, the chat model searches the data sources itself through the tool `semantic_search`, whenever a question calls for it. The classic process (`AISrcSelWithRetCtxVal`) searches them with every message instead, when the user chose so per chat (`DataSourceOptions.RetrievalMode`) or whenever the tool cannot be offered. `ToolRegistry.GetEffectiveRetrievalModeAsync` decides between the two; pass its answer to `DataSourceService`, because the agents only count as providers that see the data when they actually run. Both ways decide where a chat may still send data once their passages reached it through `DataSourceRequirements.Of`, never with a check of their own: a way which demands less leaves the other one open. See "Searching Data Sources" in `documentation/Tools.md`.
- **Agents** - AI agents select data sources and validate retrieval quality, in the classic process only - **Agents** - AI agents select data sources and validate retrieval quality, in the classic process only
- **Embedding providers** - Support for various embedding models - **Embedding providers** - Support for various embedding models
- **Vector database** - Qdrant Edge, embedded in the Rust runtime; see "Databases" below - **Vector database** - Qdrant Edge, embedded in the Rust runtime; see "Databases" below
@@ -136,6 +137,14 @@ active language plugin. Storage sizes are the exception: they keep using the byt
**Area guide:** `app/MindWork AI Studio/Tools/Databases/AGENTS.md`, with both stores and what to keep in **Area guide:** `app/MindWork AI Studio/Tools/Databases/AGENTS.md`, with both stores and what to keep in
mind when working on them, EF Core migrations included. mind when working on them, EF Core migrations included.
## Slide Assistant and PowerPoint
The Slide Assistant plans slides for the layouts of a PowerPoint template and writes the
presentation with the Open XML SDK (`DocumentFormat.OpenXml`), in `app/MindWork AI Studio/Tools/PowerPoint/`.
**Area guide:** `app/MindWork AI Studio/Tools/PowerPoint/AGENTS.md`, with how a presentation comes about
and the rules which are easy to break.
## Enterprise IT Support ## Enterprise IT Support
AI Studio supports centralized configuration for enterprise environments: AI Studio supports centralized configuration for enterprise environments:
@@ -266,7 +266,7 @@ public sealed class AgentDataSourceSelection (ILogger<AgentDataSourceSelection>
// We know how bad LLM may be in generating JSON without surrounding text. // We know how bad LLM may be in generating JSON without surrounding text.
// Thus, we expect the worst and try to extract the JSON list from the text: // Thus, we expect the worst and try to extract the JSON list from the text:
// //
var json = ExtractJson(selectedDataSourcesJson); var json = ModelAnswerJson.ExtractObjectList(selectedDataSourcesJson);
try try
{ {
@@ -293,64 +293,4 @@ public sealed class AgentDataSourceSelection (ILogger<AgentDataSourceSelection>
return []; return [];
} }
} }
/// <summary>
/// Extracts the JSON list from the given text. The text may contain additional
/// information around the JSON list. The method tries to extract the JSON list
/// from the text.
/// </summary>
/// <remarks>
/// Algorithm: The method searches for the first line that contains only a '[' character.
/// Then, it searches for the first line that contains only a ']' character. The method
/// returns the text between these two lines (including the brackets). When the method
/// cannot find the JSON list, it returns an empty string.
/// </remarks>
/// <param name="text">The text that may contain the JSON list.</param>
/// <returns>The extracted JSON list.</returns>
private static ReadOnlySpan<char> ExtractJson(ReadOnlySpan<char> text)
{
var startIndex = -1;
var endIndex = -1;
var foundStart = false;
var foundEnd = false;
var lineStart = 0;
for (var i = 0; i <= text.Length; i++)
{
// Handle the end of the line or the end of the text:
if (i == text.Length || text[i] == '\n')
{
if (IsCharacterAloneInLine(text, lineStart, i, '[') && !foundStart)
{
startIndex = lineStart;
foundStart = true;
}
else if (IsCharacterAloneInLine(text, lineStart, i, ']') && foundStart && !foundEnd)
{
endIndex = i;
foundEnd = true;
break;
}
lineStart = i + 1;
}
}
if (foundStart && foundEnd)
{
// Adjust endIndex for slicing, ensuring it's within bounds:
return text.Slice(startIndex, Math.Min(text.Length, endIndex + 1) - startIndex);
}
return ReadOnlySpan<char>.Empty;
}
private static bool IsCharacterAloneInLine(ReadOnlySpan<char> text, int lineStart, int lineEnd, char character)
{
for (var i = lineStart; i < lineEnd; i++)
if (!char.IsWhiteSpace(text[i]) && text[i] != character)
return false;
return true;
}
} }
@@ -5,7 +5,6 @@ using AIStudio.Provider;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.RAG; using AIStudio.Tools.RAG;
using AIStudio.Tools.Security;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
namespace AIStudio.Agents; namespace AIStudio.Agents;
@@ -165,11 +164,10 @@ public sealed class AgentRetrievalContextValidation (ILogger<AgentRetrievalConte
/// </summary> /// </summary>
/// <param name="lastUserPrompt">The last user prompt.</param> /// <param name="lastUserPrompt">The last user prompt.</param>
/// <param name="chatThread">The chat thread.</param> /// <param name="chatThread">The chat thread.</param>
/// <param name="retrievalContexts">All retrieval contexts to validate.</param> /// <param name="retrievalContexts">All retrieval contexts to validate, prepared for the AI and thus already filtered for prompt injections.</param>
/// <param name="reportingScope">The action the contexts are validated for, or null when they are validated for none.</param>
/// <param name="token">The cancellation token.</param> /// <param name="token">The cancellation token.</param>
/// <returns>The validation results.</returns> /// <returns>The validation results.</returns>
public async Task<IReadOnlyList<RetrievalContextValidationResult>> ValidateRetrievalContextsAsync(IContent lastUserPrompt, ChatThread chatThread, IReadOnlyList<IRetrievalContext> retrievalContexts, PromptInjectionReportingScope? reportingScope = null, CancellationToken token = default) public async Task<IReadOnlyList<RetrievalContextValidationResult>> ValidateRetrievalContextsAsync(IContent lastUserPrompt, ChatThread chatThread, IReadOnlyList<PreparedRetrievalContext> retrievalContexts, CancellationToken token = default)
{ {
// Check if the retrieval context validation is enabled: // Check if the retrieval context validation is enabled:
if (!this.SettingsManager.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation) if (!this.SettingsManager.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation)
@@ -195,7 +193,7 @@ public sealed class AgentRetrievalContextValidation (ILogger<AgentRetrievalConte
await semaphore.WaitAsync(token); await semaphore.WaitAsync(token);
// Start the next validation task: // Start the next validation task:
validationTasks.Add(this.ValidateRetrievalContextAsync(lastUserPrompt, chatThread, retrievalContext, semaphore, reportingScope, token)); validationTasks.Add(this.ValidateRetrievalContextAsync(lastUserPrompt, chatThread, retrievalContext, semaphore, token));
} }
// Wait for all validation tasks to complete: // Wait for all validation tasks to complete:
@@ -212,12 +210,11 @@ public sealed class AgentRetrievalContextValidation (ILogger<AgentRetrievalConte
/// </remarks> /// </remarks>
/// <param name="lastUserPrompt">The last user prompt.</param> /// <param name="lastUserPrompt">The last user prompt.</param>
/// <param name="chatThread">The chat thread.</param> /// <param name="chatThread">The chat thread.</param>
/// <param name="retrievalContext">The retrieval context to validate.</param> /// <param name="retrievalContext">The retrieval context to validate, prepared for the AI and thus already filtered for prompt injections.</param>
/// <param name="semaphore">The optional semaphore to limit the number of parallel validations.</param> /// <param name="semaphore">The optional semaphore to limit the number of parallel validations.</param>
/// <param name="reportingScope">The action the context is validated for, or null when it is validated for none.</param>
/// <param name="token">The cancellation token.</param> /// <param name="token">The cancellation token.</param>
/// <returns>The validation result.</returns> /// <returns>The validation result.</returns>
public async Task<RetrievalContextValidationResult> ValidateRetrievalContextAsync(IContent lastUserPrompt, ChatThread chatThread, IRetrievalContext retrievalContext, SemaphoreSlim? semaphore = null, PromptInjectionReportingScope? reportingScope = null, CancellationToken token = default) public async Task<RetrievalContextValidationResult> ValidateRetrievalContextAsync(IContent lastUserPrompt, ChatThread chatThread, PreparedRetrievalContext retrievalContext, SemaphoreSlim? semaphore = null, CancellationToken token = default)
{ {
try try
{ {
@@ -252,11 +249,11 @@ public sealed class AgentRetrievalContextValidation (ILogger<AgentRetrievalConte
} }
// //
// 2. Prepare the retrieval context for the agent: // 2. Prepare the retrieval context for the agent. It reads the context exactly as the
// AI would, filtered once for both, so the check judges what is actually sent:
// //
var additionalData = new Dictionary<string, string>(); var additionalData = new Dictionary<string, string>();
var markdownRetrievalContext = await retrievalContext.AsMarkdown(reportingScope: reportingScope, token: token); additionalData.Add("retrievalContext", retrievalContext.ToMarkdown());
additionalData.Add("retrievalContext", markdownRetrievalContext);
// //
// 3. Let the agent validate the retrieval context: // 3. Let the agent validate the retrieval context:
@@ -308,9 +305,9 @@ public sealed class AgentRetrievalContextValidation (ILogger<AgentRetrievalConte
// //
// We know how bad LLM may be in generating JSON without surrounding text. // We know how bad LLM may be in generating JSON without surrounding text.
// Thus, we expect the worst and try to extract the JSON list from the text: // Thus, we expect the worst and try to extract the JSON object from the text:
// //
var json = ExtractJson(validationJson); var json = ModelAnswerJson.ExtractObject(validationJson);
try try
{ {
@@ -343,68 +340,4 @@ public sealed class AgentRetrievalContextValidation (ILogger<AgentRetrievalConte
semaphore?.Release(); semaphore?.Release();
} }
} }
private static ReadOnlySpan<char> ExtractJson(ReadOnlySpan<char> input)
{
//
// 1. Expect the best case ;-)
//
if (CheckJsonObjectStart(input))
return ExtractJsonPart(input);
//
// 2. Okay, we have some garbage before the
// JSON object. We expected that...
//
for (var index = 0; index < input.Length; index++)
{
if (input[index] is '{' && CheckJsonObjectStart(input[index..]))
return ExtractJsonPart(input[index..]);
}
return [];
}
private static bool CheckJsonObjectStart(ReadOnlySpan<char> area)
{
char[] expectedSymbols = ['{', '"', 'd'];
var symbolIndex = 0;
foreach (var c in area)
{
if (symbolIndex >= expectedSymbols.Length)
return true;
if (char.IsWhiteSpace(c))
continue;
if (c == expectedSymbols[symbolIndex++])
continue;
return false;
}
return true;
}
private static ReadOnlySpan<char> ExtractJsonPart(ReadOnlySpan<char> input)
{
var insideString = false;
for (var index = 0; index < input.Length; index++)
{
if (input[index] is '"')
{
insideString = !insideString;
continue;
}
if (insideString)
continue;
if (input[index] is '}')
return input[..++index];
}
return [];
}
} }
@@ -50,7 +50,7 @@ public sealed class AssistantAuditAgent(ILogger<AssistantAuditAgent> logger, ILo
"severity": "critical | medium | low", "severity": "critical | medium | low",
"category": "brief category", "category": "brief category",
"location": "system prompt | BuildPrompt | component name | plugin.lua", "location": "system prompt | BuildPrompt | component name | plugin.lua",
"description": "what is risky", "description": "what is risky"
} }
] ]
} }
@@ -259,7 +259,7 @@ public sealed class AssistantAuditAgent(ILogger<AssistantAuditAgent> logger, ILo
}; };
} }
var json = ExtractJson(content.Text); var json = ModelAnswerJson.ExtractObject(content.Text);
try try
{ {
var result = JsonSerializer.Deserialize<AssistantAuditResult>(json, JSON_SERIALIZER_OPTIONS); var result = JsonSerializer.Deserialize<AssistantAuditResult>(json, JSON_SERIALIZER_OPTIONS);
@@ -302,44 +302,6 @@ public sealed class AssistantAuditAgent(ILogger<AssistantAuditAgent> logger, ILo
}; };
} }
/// <summary>
/// Extracts the first complete JSON object from a model response that may contain surrounding text.
/// </summary>
/// <param name="input">The raw model response.</param>
/// <returns>The first complete JSON object, or an empty span when none can be found.</returns>
private static ReadOnlySpan<char> ExtractJson(ReadOnlySpan<char> input)
{
var start = input.IndexOf('{');
if (start < 0)
return [];
var depth = 0;
var insideString = false;
for (var index = start; index < input.Length; index++)
{
if (input[index] == '"' && (index == 0 || input[index - 1] != '\\'))
insideString = !insideString;
if (insideString)
continue;
switch (input[index])
{
case '{':
depth++;
break;
case '}':
depth--;
break;
}
if (depth == 0)
return input[start..(index + 1)];
}
return [];
}
/// <summary> /// <summary>
/// Names the tools a plugin requests, so the auditor can weigh them against its stated purpose. /// Names the tools a plugin requests, so the auditor can weigh them against its stated purpose.
/// </summary> /// </summary>
@@ -8,5 +8,5 @@ namespace AIStudio.Agents;
/// <param name="Decision">Whether the retrieval context is useful or not.</param> /// <param name="Decision">Whether the retrieval context is useful or not.</param>
/// <param name="Reason">The reason for the decision.</param> /// <param name="Reason">The reason for the decision.</param>
/// <param name="Confidence">The confidence of the decision.</param> /// <param name="Confidence">The confidence of the decision.</param>
/// <param name="RetrievalContext">The retrieval context that was validated.</param> /// <param name="RetrievalContext">The retrieval context that was validated, as it is prepared for the AI.</param>
public readonly record struct RetrievalContextValidationResult(bool Decision, string Reason, float Confidence, IRetrievalContext? RetrievalContext) : IConfidence; public readonly record struct RetrievalContextValidationResult(bool Decision, string Reason, float Confidence, PreparedRetrievalContext? RetrievalContext) : IConfidence;
@@ -68,9 +68,28 @@
<Issues IssuesData="@(this.InputIssues)"/> <Issues IssuesData="@(this.InputIssues)"/>
@if (this.ShowDedicatedProgress && this.IsProcessing) @if (this.ShowDedicatedProgress && this.IsProcessing)
{
@if (this.processingStartedAt is null && string.IsNullOrWhiteSpace(this.ProgressText))
{ {
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-6" /> <MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-6" />
} }
else
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-2" />
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="3" Class="mb-6">
@if (this.processingStartedAt is { } startedAt)
{
<ElapsedTime Start="@startedAt"/>
}
@if (!string.IsNullOrWhiteSpace(this.ProgressText))
{
<MudText Typo="Typo.body2">
@this.ProgressText
</MudText>
}
</MudStack>
}
}
<div id="@RESULT_DIV_ID" class="mr-2 mt-3"> <div id="@RESULT_DIV_ID" class="mr-2 mt-3">
</div> </div>
@@ -108,6 +108,11 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected virtual bool ShowDedicatedProgress => false; protected virtual bool ShowDedicatedProgress => false;
/// <summary>
/// What the assistant is doing right now, shown below the dedicated progress bar; empty for no text.
/// </summary>
protected virtual string ProgressText => string.Empty;
protected virtual bool ShowSendTo => true; protected virtual bool ShowSendTo => true;
protected virtual bool ShowCopyResult => true; protected virtual bool ShowCopyResult => true;
@@ -124,6 +129,15 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected virtual bool ShowReset => true; protected virtual bool ShowReset => true;
/// <summary>
/// Whether the system prompt starts with the current date and time.
/// </summary>
/// <remarks>
/// Off for most assistants, whose results do not depend on the date. An assistant which writes
/// the date into its result, or plans around it, turns this on rather than guessing the date.
/// </remarks>
protected virtual bool IncludeDateTime => false;
protected virtual string? SendToChatVisibleUserPromptPrefix => null; protected virtual string? SendToChatVisibleUserPromptPrefix => null;
protected virtual string? SendToChatVisibleUserPromptContent => null; protected virtual string? SendToChatVisibleUserPromptContent => null;
@@ -166,7 +180,16 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
private AssistantSessionKey assistantSessionKey; private AssistantSessionKey assistantSessionKey;
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(this.assistantSessionKey); private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(this.assistantSessionKey);
private Guid? assistantSessionId; private Guid? assistantSessionId;
/// <summary>
/// When the running session started, which the time below the progress bar counts from; null while
/// no session runs. It comes from the session, so an instance which returns to a running session
/// goes on counting where the first one was.
/// </summary>
private DateTimeOffset? processingStartedAt;
private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot; private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot;
private AssistantSessionSnapshot? lastAttachedAssistantSessionSnapshot;
/// <summary> /// <summary>
/// Gets whether the Blazor component instance has already been disposed. /// Gets whether the Blazor component instance has already been disposed.
@@ -270,7 +293,10 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
{ {
await this.RefreshProviderSelectionFromConfigurationAsync(); await this.RefreshProviderSelectionFromConfigurationAsync();
if (this.ProviderSettings == Settings.Provider.NONE) if (this.ProviderSettings == Settings.Provider.NONE)
{
await this.ReportMissingProviderAsync();
return; return;
}
if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)) if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
return; return;
@@ -293,6 +319,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
} }
this.assistantSessionId = startedSession.SessionId; this.assistantSessionId = startedSession.SessionId;
this.processingStartedAt = startedSession.StartedAt;
await this.RefreshAssistantUIAsync(); await this.RefreshAssistantUIAsync();
var sessionStatus = AssistantSessionStatus.COMPLETED; var sessionStatus = AssistantSessionStatus.COMPLETED;
@@ -325,6 +352,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
finally finally
{ {
this.IsProcessing = false; this.IsProcessing = false;
this.processingStartedAt = null;
var sessionCancellationTokenSource = this.CancellationTokenSource; var sessionCancellationTokenSource = this.CancellationTokenSource;
this.CancellationTokenSource = null; this.CancellationTokenSource = null;
if (this.assistantSessionId is { } sessionId) if (this.assistantSessionId is { } sessionId)
@@ -338,6 +366,25 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
} }
} }
/// <summary>
/// Tells the user that the assistant cannot start without a provider, together with whatever else is missing.
/// </summary>
/// <remarks>
/// Most assistants validate their form only in their submit action, which never runs without a
/// provider. Validating here marks the provider selection and lists the other issues as well.
/// The form does not report the missing provider when it holds no provider selection, as with an
/// assistant plugin without one, or when its selection still shows the provider which the refresh
/// at the start has just dropped. Then the issue is added on its own.
/// </remarks>
private async Task ReportMissingProviderAsync()
{
if (this.Form is not null)
await this.Form.Validate();
if (this.ValidatingProvider(this.ProviderSettings) is { } issue && !this.InputIssues.Contains(issue))
this.AddInputIssue(issue);
}
private void TriggerFormChange(FormFieldChangedEventArgs _) private void TriggerFormChange(FormFieldChangedEventArgs _)
{ {
this.formChangeTimer.Stop(); this.formChangeTimer.Stop();
@@ -385,7 +432,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
{ {
this.ChatThread = new() this.ChatThread = new()
{ {
IncludeDateTime = false, IncludeDateTime = this.IncludeDateTime,
SelectedProvider = this.ProviderSettings.Id, SelectedProvider = this.ProviderSettings.Id,
SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id, SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id,
SystemPrompt = this.SystemPrompt, SystemPrompt = this.SystemPrompt,
@@ -403,7 +450,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
var chatId = Guid.NewGuid(); var chatId = Guid.NewGuid();
this.ChatThread = new() this.ChatThread = new()
{ {
IncludeDateTime = false, IncludeDateTime = this.IncludeDateTime,
SelectedProvider = this.ProviderSettings.Id, SelectedProvider = this.ProviderSettings.Id,
SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id, SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id,
SystemPrompt = this.SystemPrompt, SystemPrompt = this.SystemPrompt,
@@ -527,10 +574,46 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
InitialRemoteWait = true, InitialRemoteWait = true,
}; };
//
// The stream calls this for every chunk and waits for it. A checkpoint captures the whole
// state of the assistant and makes the main layout render as well, so both happen only now
// and then. When the stream is done, both happen in any case:
//
var lastRendering = DateTimeOffset.MinValue;
var lastCheckpoint = DateTimeOffset.MinValue;
string? lastProgressText = null;
aiText.StreamingEvent = async () => aiText.StreamingEvent = async () =>
{ {
var now = DateTimeOffset.Now;
if (now - lastCheckpoint >= STREAMING_CHECKPOINT_MIN_TIME)
{
lastCheckpoint = now;
await this.CheckpointAssistantSession(); await this.CheckpointAssistantSession();
}
if (now - lastRendering < STREAMING_RENDER_MIN_TIME)
return;
lastRendering = now;
if (!hideContentFromUser)
{
await this.RefreshAssistantUIAsync(); await this.RefreshAssistantUIAsync();
return;
}
//
// A hidden answer shows nothing but the progress text, so the whole form renders only
// when that text changed. Thinking models stream for minutes before they write a word:
//
await this.RefreshAssistantUIAsync(() =>
{
var progressText = this.ProgressText;
if (progressText == lastProgressText)
return false;
lastProgressText = progressText;
return true;
});
}; };
aiText.StreamingDone = async () => aiText.StreamingDone = async () =>
@@ -768,6 +851,11 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
{ {
var convertedChatThread = this.ConvertToChatThread; var convertedChatThread = this.ConvertToChatThread;
convertedChatThread = convertedChatThread with { SelectedProvider = this.ProviderSettings.Id }; convertedChatThread = convertedChatThread with { SelectedProvider = this.ProviderSettings.Id };
// Some assistants start the chat under an id of its own, so what their answers received goes along. A chat without an id received nothing:
if (this.ChatThread is { } assistantChat && convertedChatThread.ChatId != Guid.Empty && assistantChat.ChatId != convertedChatThread.ChatId)
await ChatProvenance.CopyChatAsync(assistantChat.ChatId, convertedChatThread.ChatId);
MessageBus.INSTANCE.DeferMessage(this, sendToData.Event, new ChatStartRequest(convertedChatThread)); MessageBus.INSTANCE.DeferMessage(this, sendToData.Event, new ChatStartRequest(convertedChatThread));
} }
break; break;
@@ -970,15 +1058,24 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
/// <summary> /// <summary>
/// Applies an assistant session snapshot to this component instance. /// Applies an assistant session snapshot to this component instance.
/// </summary> /// </summary>
/// <remarks>
/// When the user leaves an assistant while it runs and comes back, this component only shows the run.
/// The component which started it keeps working out of sight and reports every few seconds, yet its
/// form stays as it was when the user left. So once this component has taken over a run, it takes over
/// only what the running assistant changed since, such as the progress and the result. What the user
/// changes here stays, just as it would in the component which started the run.
/// </remarks>
/// <param name="snapshot">The snapshot to attach.</param> /// <param name="snapshot">The snapshot to attach.</param>
/// <param name="restoreClientOnlyContent">Whether derived assistants should restore client-only UI state.</param> /// <param name="restoreClientOnlyContent">Whether derived assistants should restore client-only UI state.</param>
/// <returns>A task that completes after the component was refreshed.</returns> /// <returns>A task that completes after the component was refreshed.</returns>
private async Task AttachAssistantSession(AssistantSessionSnapshot snapshot, bool restoreClientOnlyContent) private async Task AttachAssistantSession(AssistantSessionSnapshot snapshot, bool restoreClientOnlyContent)
{ {
this.assistantSessionId = snapshot.SessionId; this.assistantSessionId = snapshot.SessionId;
this.ImportAssistantSessionState(snapshot.State); this.ImportAssistantSessionState(snapshot.StateChangedSince(this.lastAttachedAssistantSessionSnapshot));
this.lastAttachedAssistantSessionSnapshot = snapshot;
this.ChatThread = snapshot.ChatThread ?? this.ChatThread; this.ChatThread = snapshot.ChatThread ?? this.ChatThread;
this.IsProcessing = snapshot.IsActive; this.IsProcessing = snapshot.IsActive;
this.processingStartedAt = snapshot.IsActive ? snapshot.StartedAt : null;
if (!snapshot.IsActive) if (!snapshot.IsActive)
this.CancellationTokenSource = null; this.CancellationTokenSource = null;
@@ -995,15 +1092,20 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
/// <summary> /// <summary>
/// Refreshes the component when it is still mounted. /// Refreshes the component when it is still mounted.
/// </summary> /// </summary>
/// <param name="hasChanged">Decides whether anything visible changed, asked on the thread of the renderer, so it may read what the rendering reads; null to render in any case.</param>
/// <returns>A task that completes after the renderer was notified.</returns> /// <returns>A task that completes after the renderer was notified.</returns>
protected async Task RefreshAssistantUIAsync() protected async Task RefreshAssistantUIAsync(Func<bool>? hasChanged = null)
{ {
if (this.isDisposed) if (this.isDisposed)
return; return;
try try
{ {
await this.InvokeAsync(this.StateHasChanged); await this.InvokeAsync(() =>
{
if (hasChanged is null || hasChanged())
this.StateHasChanged();
});
} }
catch (InvalidOperationException) catch (InvalidOperationException)
{ {
@@ -13,6 +13,21 @@ public abstract class AssistantLowerBase : MSGComponentBase
internal const string BEFORE_RESULT_DIV_ID = "beforeAssistantResult"; internal const string BEFORE_RESULT_DIV_ID = "beforeAssistantResult";
internal const string AFTER_RESULT_DIV_ID = "afterAssistantResult"; internal const string AFTER_RESULT_DIV_ID = "afterAssistantResult";
/// <summary>
/// The minimum time between two renderings of the assistant while an answer streams in.
/// </summary>
/// <remarks>
/// The stream waits for each rendering, and an assistant renders its whole form, which may hold
/// more than a hundred fields. Ten renderings a second still read as a live stream.
/// </remarks>
private protected static readonly TimeSpan STREAMING_RENDER_MIN_TIME = TimeSpan.FromMilliseconds(100);
/// <summary>
/// The minimum time between two checkpoints of the assistant session while an answer streams in,
/// as in the chat.
/// </summary>
private protected static readonly TimeSpan STREAMING_CHECKPOINT_MIN_TIME = TimeSpan.FromSeconds(3);
protected static readonly AssistantSessionStateKey<AIStudio.Settings.Provider> PROVIDER_SETTINGS_STATE_KEY = new(nameof(ProviderSettings)); protected static readonly AssistantSessionStateKey<AIStudio.Settings.Provider> PROVIDER_SETTINGS_STATE_KEY = new(nameof(ProviderSettings));
protected static readonly AssistantSessionStateKey<bool> INPUT_IS_VALID_STATE_KEY = new(nameof(InputIsValid)); protected static readonly AssistantSessionStateKey<bool> INPUT_IS_VALID_STATE_KEY = new(nameof(InputIsValid));
protected static readonly AssistantSessionStateKey<Profile> CURRENT_PROFILE_STATE_KEY = new(nameof(CurrentProfile)); protected static readonly AssistantSessionStateKey<Profile> CURRENT_PROFILE_STATE_KEY = new(nameof(CurrentProfile));
@@ -3,7 +3,7 @@
@using AIStudio.Settings.DataModel @using AIStudio.Settings.DataModel
@using AIStudio.Tools.Rust @using AIStudio.Tools.Rust
<MudText Typo="Typo.h6" Class="mb-3"> <MudText Typo="Typo.h5" Class="mb-3">
@T("Input") @T("Input")
</MudText> </MudText>
@@ -18,40 +18,37 @@
</MudJustifiedText> </MudJustifiedText>
} }
<div class="mt-6"> <MudTextField T="string" @bind-Text="@this.filePatterns" Validation="@this.ValidateFilePatterns" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("File patterns")" HelperText="@T("Which files should be processed? Separate multiple patterns with a semicolon, e.g., *.pdf;*.docx. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file.")" AdornmentIcon="@Icons.Material.Filled.FilterAlt" Adornment="Adornment.Start" Variant="Variant.Outlined" Margin="Margin.Normal" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<FilePatternSelection> <RestoreDefaultButton Text="@T("Restore default patterns")" Disabled="@this.isProcessingBatch" OnClick="@this.RestoreDefaultFilePatterns" Class="mb-1"/>
<AllowedContent>
<MudTextField T="string" @bind-Text="@this.filePatterns" Validation="@this.ValidateFilePatterns" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("Files to include")" AdornmentIcon="@Icons.Material.Filled.Restore" Adornment="Adornment.End" OnAdornmentClick="@this.RestoreDefaultFilePatterns" AdornmentAriaLabel="@T("Restore default patterns")" Lines="1" AutoGrow="@true" MaxLines="0" Class="pattern-text-field" Variant="Variant.Outlined" Margin="Margin.Dense" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
</AllowedContent>
</FilePatternSelection>
</div>
<MudJustifiedText Typo="Typo.body2" Class="mb-3"> <MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Supported audio and video files are transcribed automatically without an additional dialog. Each transcript is stored next to its media file as '<media-file>.transcript.md' and reused when an interrupted run is continued.") @T("Supported audio and video files are transcribed automatically without an additional dialog. Each transcript is stored next to its media file as '<media-file>.transcript.md' and reused when an interrupted run is continued.")
</MudJustifiedText> </MudJustifiedText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Images in JPG, PNG, WebP, GIF, BMP, or TIFF format are given to the model as images, so you need a model that understands images. If the selected model cannot read images, remove the image patterns, such as *.jpg or *.png, before you start.")
</MudJustifiedText>
<MudStack Class="mb-3"> <MudStack Class="mb-3">
<MudButton OnClick="@this.ToggleExpertSettings" Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Tune"> <MudButton OnClick="@this.ToggleExpertSettings" Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Tune">
@(this.showExpertSettings ? T("Hide Expert Settings") : T("Show Expert Settings")) @(this.showExpertSettings ? T("Hide Expert Settings") : T("Show Expert Settings"))
</MudButton> </MudButton>
<MudDivider/> <MudDivider/>
<MudCollapse Expanded="@this.showExpertSettings" Class="@this.GetExpertStyles"> <MudCollapse Expanded="@this.showExpertSettings" Class="@this.GetExpertStyles">
<MudText Typo="Typo.subtitle2" Class="mb-2">@T("Hidden files and folders")</MudText> <MudTextSwitch Label="@T("Include hidden files and folders?")" Disabled="@this.isProcessingBatch" Value="@this.includeHiddenFilesAndFolders" ValueChanged="@(v => this.includeHiddenFilesAndFolders = v)" LabelOn="@T("Yes, process hidden files and folders as well")" LabelOff="@T("No, ignore hidden files and folders")"/>
<MudTextSwitch Label="@T("Include hidden files and folders?")" Disabled="@this.isProcessingBatch" Value="@this.includeHiddenFilesAndFolders" ValueChanged="@(v => this.includeHiddenFilesAndFolders = v)" LabelOn="@T("Yes, include hidden files and folders")" LabelOff="@T("No, ignore hidden files and folders")"/>
<MudJustifiedText Typo="Typo.body2" Class="mb-3"> <MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.") @T("Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.")
</MudJustifiedText> </MudJustifiedText>
<div class="mt-4"> @* Without an icon: in a field of several lines, it floats in the middle of the text. *@
<FileExclusionEditor @bind-Text="@this.excludePatterns" Disabled="@this.isProcessingBatch" Label="@T("Excluded files and folders")" ResetButtonText="@T("Restore default")"/> <MudTextField T="string" @bind-Text="@this.excludePatterns" Validation="@this.ValidateExcludePatterns" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("Excluded files and folders")" Lines="@FileExclusions.DEFAULT_LINE_COUNT" AutoGrow="@true" MaxLines="@FileExclusions.MAX_LINE_COUNT" HelperText="@T("These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.")" Variant="Variant.Outlined" Margin="Margin.Normal" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
</div> <RestoreDefaultButton Text="@T("Restore default")" Disabled="@this.isProcessingBatch" OnClick="@this.RestoreDefaultExcludePatterns" Class="mb-1"/>
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">@T("File size limit")</MudText>
<MudNumericField T="int" @bind-Value="@this.maxFileSizeMegabytes" Min="@FolderFileSelection.SMALLEST_MAX_FILE_SIZE_MEGABYTES" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("Ignore files larger than")" HelperText="@T("Matching files above this size are left out, e.g., images of virtual machines.")" Adornment="Adornment.End" AdornmentText="MB" Variant="Variant.Outlined" Margin="Margin.Normal"/> <MudNumericField T="int" @bind-Value="@this.maxFileSizeMegabytes" Min="@FolderFileSelection.SMALLEST_MAX_FILE_SIZE_MEGABYTES" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("Ignore files larger than")" HelperText="@T("Matching files above this size are left out, e.g., images of virtual machines.")" Adornment="Adornment.End" AdornmentText="MB" Variant="Variant.Outlined" Margin="Margin.Normal"/>
</MudCollapse> </MudCollapse>
</MudStack> </MudStack>
<MudText Typo="Typo.h6" Class="mb-3 mt-6"> <MudText Typo="Typo.h5" Class="mb-3 mt-6">
@T("Instructions") @T("Instructions")
</MudText> </MudText>
@@ -137,7 +134,7 @@ else
} }
} }
<MudText Typo="Typo.h6" Class="mb-3 mt-6"> <MudText Typo="Typo.h5" Class="mb-3 mt-6">
@T("Output") @T("Output")
</MudText> </MudText>
@@ -192,7 +189,7 @@ else
@T("We always write a semicolon-separated log named log.csv, which lists every document with its processing time, the model, the status, and the details of any error. When you start another run on the same output folder, we ask you whether to continue that run: documents which failed or are missing in the log are then processed again. When no output folder is selected, everything is written to the subfolder 'ai-results' within the input folder.") @T("We always write a semicolon-separated log named log.csv, which lists every document with its processing time, the model, the status, and the details of any error. When you start another run on the same output folder, we ask you whether to continue that run: documents which failed or are missing in the log are then processed again. When no output folder is selected, everything is written to the subfolder 'ai-results' within the input folder.")
</MudJustifiedText> </MudJustifiedText>
<MudText Typo="Typo.h6" Class="mb-3 mt-6"> <MudText Typo="Typo.h5" Class="mb-3 mt-6">
@T("Processing pace") @T("Processing pace")
</MudText> </MudText>
@@ -233,7 +230,7 @@ else
@if (this.fileResults.Count > 0) @if (this.fileResults.Count > 0)
{ {
<MudText Typo="Typo.h6" Class="mb-3 mt-6"> <MudText Typo="Typo.h5" Class="mb-3 mt-6">
@T("Progress") @T("Progress")
</MudText> </MudText>
@@ -1,18 +1,90 @@
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools.Media; using AIStudio.Tools.Media;
using AIStudio.Tools.Rust;
namespace AIStudio.Assistants.BatchProcessing; namespace AIStudio.Assistants.BatchProcessing;
public partial class AssistantBatchProcessing public partial class AssistantBatchProcessing
{ {
/// <summary> /// <summary>
/// Loads a document through the Rust content stream or resolves a persistent /// What one file of the batch gives to the model: either text or an image.
/// transcript for an audio or video file.
/// </summary> /// </summary>
private Task<string?> LoadInputContentAsync(BatchProcessingFileResult fileResult, CancellationToken token) private readonly record struct BatchInput(string Text, FileAttachmentImage? Image);
/// <summary>
/// Loads a document through the Rust content stream, resolves a persistent
/// transcript for an audio or video file, or prepares an image for the model.
/// </summary>
/// <returns>The input for the model, or <c>null</c> when the file already got its final status.</returns>
private async Task<BatchInput?> LoadInputContentAsync(BatchProcessingFileResult fileResult, CancellationToken token)
{ {
return IsTranscribableMedia(fileResult.FilePath) if (IsImage(fileResult.FilePath))
? this.LoadMediaTranscriptAsync(fileResult, token) return await this.LoadImageAsync(fileResult, token) is { } image ? new BatchInput(string.Empty, image) : null;
: this.LoadDocumentContentAsync(fileResult, token);
var text = IsTranscribableMedia(fileResult.FilePath)
? await this.LoadMediaTranscriptAsync(fileResult, token)
: await this.LoadDocumentContentAsync(fileResult, token);
return text is null ? null : new BatchInput(text, null);
}
/// <summary>
/// Prepares an image for the model before the request is built.
/// </summary>
/// <remarks>
/// The request itself would only skip an image it cannot send, with a message, and ask the model
/// anyway. The model would then answer about nothing, and the file would count as done, so that
/// continuing the run would skip it as well. Preparing the image here lets it fail as a file
/// instead, with the reason, e.g., an image format AI Studio cannot decode. The request then finds
/// the prepared image in the cache of ModelImageService, so it is not prepared twice. A failed
/// image is processed again when the run is continued, e.g., with a model that can read images.
///
/// How large an image may be needs no check of its own either: the preparation scales it down
/// until it fits the limit of the provider, and fails only when even that is not enough.
///
/// How many images a model accepts needs no check of its own: every file gets a thread of its own,
/// see CallAIAsync, so each request carries exactly one image, in every round with the model. A
/// limit of one image is therefore always met, and SupportsImageInput already refuses a limit of zero.
///
/// Unlike the text of documents and transcripts, an image does not pass the prompt injection
/// filter of the runtime, which reads text only. Images in a chat do not either. Instructions
/// within an image therefore meet only the safeguards behind the filter: the system prompt tells
/// the model to ignore them, a thread which read internal content, such as a mailbox, may no
/// longer send data out, and without free address choice, its default, the Read Web Page tool
/// refuses an address which appears only in the image.
/// </remarks>
private async Task<FileAttachmentImage?> LoadImageAsync(BatchProcessingFileResult fileResult, CancellationToken token)
{
if (!this.ProviderSettings.SupportsImageInput())
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("The selected model cannot read images. Select a model with image support to process this image."));
return null;
}
PreparedModelImage prepared;
FileAttachmentImage image;
try
{
image = new FileAttachmentImage(fileResult.FileName, fileResult.FilePath, new FileInfo(fileResult.FilePath).Length);
prepared = await this.ModelImageService.PrepareAsync(image, this.ProviderSettings, token);
}
catch (OperationCanceledException)
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.CANCELED, T("The batch run was canceled."));
return null;
}
catch (Exception e)
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, string.Format(T("Was not able to read the file: {0}"), e.Message), e);
return null;
}
if (prepared.IsPrepared)
return image;
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, prepared.ErrorCode.ToUserMessage(fileResult.FileName));
return null;
} }
private async Task<string?> LoadDocumentContentAsync(BatchProcessingFileResult fileResult, CancellationToken token) private async Task<string?> LoadDocumentContentAsync(BatchProcessingFileResult fileResult, CancellationToken token)
@@ -109,5 +181,7 @@ public partial class AssistantBatchProcessing
private static bool IsTranscribableMedia(string filePath) => PersistentMediaTranscript.IsMediaPath(filePath); private static bool IsTranscribableMedia(string filePath) => PersistentMediaTranscript.IsMediaPath(filePath);
private static bool IsImage(string filePath) => FileTypes.IsAllowedPath(filePath, FileTypes.IMAGE);
private static bool HasReusableTranscript(string mediaFilePath) => MediaSidecarTranscripts.HasReusableTranscript(mediaFilePath); private static bool HasReusableTranscript(string mediaFilePath) => MediaSidecarTranscripts.HasReusableTranscript(mediaFilePath);
} }
@@ -56,6 +56,8 @@ public partial class AssistantBatchProcessing
# Task description # Task description
You are a batch document processing agent. Each request contains exactly one DOCUMENT. You are a batch document processing agent. Each request contains exactly one DOCUMENT.
Your task is to process this DOCUMENT strictly according to the instructions below. Your task is to process this DOCUMENT strictly according to the instructions below.
A DOCUMENT may also be an image attached to the request. Then, the content of the
DOCUMENT is what the image shows, including any text within it.
# Scope and precedence # Scope and precedence
Use only information explicitly contained in the DOCUMENT and the instructions. Use only information explicitly contained in the DOCUMENT and the instructions.
@@ -88,11 +90,20 @@ public partial class AssistantBatchProcessing
"""; """;
} }
private static string BuildImageUserPrompt(string fileName)
{
return $"""
# DOCUMENT
File name: {fileName}
Content: The DOCUMENT is the image attached to this request.
""";
}
/// <param name="fileName">The name of the document being processed.</param> /// <param name="fileName">The name of the document being processed.</param>
/// <param name="fileContent">The content handed to the model.</param> /// <param name="input">The text or the image handed to the model.</param>
/// <param name="token">The cancellation token.</param> /// <param name="token">The cancellation token.</param>
/// <returns>The answer of the model, and which tools it used to get there.</returns> /// <returns>The answer of the model, and which tools it used to get there.</returns>
private async Task<(string Answer, string UsedTools)> CallAIAsync(string fileName, string fileContent, CancellationToken token) private async Task<(string Answer, string UsedTools)> CallAIAsync(string fileName, BatchInput input, CancellationToken token)
{ {
// //
// Every file of the batch gets the tools the user picked for the job. The batch builds its // Every file of the batch gets the tools the user picked for the job. The batch builds its
@@ -120,10 +131,9 @@ public partial class AssistantBatchProcessing
}; };
this.AssignRuntimeIdentity(chatThread); this.AssignRuntimeIdentity(chatThread);
var userPrompt = new ContentText var userPrompt = input.Image is null
{ ? new ContentText { Text = BuildUserPrompt(fileName, input.Text) }
Text = BuildUserPrompt(fileName, fileContent), : new ContentText { Text = BuildImageUserPrompt(fileName), FileAttachments = [input.Image] };
};
chatThread.Blocks.Add(new ContentBlock chatThread.Blocks.Add(new ContentBlock
{ {
@@ -1,6 +1,8 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using AIStudio.Settings;
namespace AIStudio.Assistants.BatchProcessing; namespace AIStudio.Assistants.BatchProcessing;
public partial class AssistantBatchProcessing public partial class AssistantBatchProcessing
@@ -42,6 +44,7 @@ public partial class AssistantBatchProcessing
} }
this.PrepareFileResults(resolvedOutputDirectory, files, previousLog, previousResults); this.PrepareFileResults(resolvedOutputDirectory, files, previousLog, previousResults);
await this.WarnAboutUnreadableImagesAsync();
await this.CheckpointAssistantSession(); await this.CheckpointAssistantSession();
await this.RunBatchAsync(resolvedOutputDirectory); await this.RunBatchAsync(resolvedOutputDirectory);
} }
@@ -88,6 +91,27 @@ public partial class AssistantBatchProcessing
} }
} }
/// <summary>
/// Tells the user before the first file when the images of the run will fail, because the
/// selected model cannot read images.
/// </summary>
/// <remarks>
/// Counts only the images which this run still processes. Images done by an earlier run of a
/// continued batch keep their results and do not fail.
/// </remarks>
private async Task WarnAboutUnreadableImagesAsync()
{
var numImages = this.fileResults.Count(fileResult => fileResult.Status is not BatchProcessingFileStatus.DONE && IsImage(fileResult.FilePath));
if (numImages == 0 || this.ProviderSettings.SupportsImageInput())
return;
var warning = numImages == 1
? T("The selected model cannot read images, so the image among the selected files will fail. All other files are processed as usual.")
: string.Format(T("The selected model cannot read images, so the {0} images among the selected files will fail. All other files are processed as usual."), numImages);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.ImageNotSupported, warning));
}
/// <summary> /// <summary>
/// Processes all documents which are not restored from a previous run. /// Processes all documents which are not restored from a previous run.
/// </summary> /// </summary>
@@ -188,14 +212,14 @@ public partial class AssistantBatchProcessing
/// </remarks> /// </remarks>
private async Task ProcessOneFileAsync(BatchProcessingFileResult fileResult, string resolvedOutputDirectory, CancellationToken token) private async Task ProcessOneFileAsync(BatchProcessingFileResult fileResult, string resolvedOutputDirectory, CancellationToken token)
{ {
var fileContent = await this.LoadInputContentAsync(fileResult, token); var input = await this.LoadInputContentAsync(fileResult, token);
if (fileContent is null) if (input is null)
return; return;
string aiAnswer; string aiAnswer;
try try
{ {
(aiAnswer, fileResult.UsedTools) = await this.CallAIAsync(fileResult.FileName, fileContent, token); (aiAnswer, fileResult.UsedTools) = await this.CallAIAsync(fileResult.FileName, input.Value, token);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -217,6 +241,17 @@ public partial class AssistantBatchProcessing
return; return;
} }
//
// The request reads the image anew in every round with the model, e.g., after each tool
// call, and quietly leaves out an image which is gone by then. The answer would be about
// nothing and must not count as a result, so that continuing the run processes the image again:
//
if (input.Value.Image is { Exists: false })
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("The image was no longer available while the model was asked about it."));
return;
}
if (string.IsNullOrWhiteSpace(aiAnswer)) if (string.IsNullOrWhiteSpace(aiAnswer))
{ {
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("The AI answer was empty.")); this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("The AI answer was empty."));
@@ -1,3 +1,4 @@
using AIStudio.Settings;
using AIStudio.Tools.Media; using AIStudio.Tools.Media;
namespace AIStudio.Assistants.BatchProcessing; namespace AIStudio.Assistants.BatchProcessing;
@@ -82,39 +83,18 @@ public partial class AssistantBatchProcessing
private IReadOnlyList<string> FindInputFiles(string resolvedOutputDirectory) private IReadOnlyList<string> FindInputFiles(string resolvedOutputDirectory)
{ {
var selection = new FolderFileSelection(FileNamePatterns.Parse(this.filePatterns), FileExclusions.Parse(this.excludePatterns), this.includeSubdirectories, this.includeHiddenFilesAndFolders, this.maxFileSizeMegabytes); var selection = new FolderFileSelection(FileNamePatterns.Parse(this.filePatterns), FileExclusions.Parse(this.excludePatterns), this.includeSubdirectories, this.includeHiddenFilesAndFolders, this.maxFileSizeMegabytes);
var outputFolder = new BatchProcessingOutputFolder(this.inputDirectory, resolvedOutputDirectory, this.ResolveResultsFileName());
var files = new SortedSet<string>(StringComparer.OrdinalIgnoreCase); var files = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
this.numTooLargeFiles = 0; this.numTooLargeFiles = 0;
var normalizedInputDirectory = TrimDirectorySeparator(Path.GetFullPath(this.inputDirectory));
var normalizedOutputDirectory = TrimDirectorySeparator(Path.GetFullPath(resolvedOutputDirectory));
// When the output folder is a folder of its own, we skip everything
// inside it. When it is the input folder itself, we must not skip the
// whole folder: we would not find any document at all. We then skip
// our own output artifacts instead.
var isOutputSeparateFolder = !string.Equals(normalizedInputDirectory, normalizedOutputDirectory, StringComparison.OrdinalIgnoreCase);
// The separator is essential: without it, an output folder named 'out'
// would also exclude a document named 'output-notes.md':
var outputDirectoryPrefix = normalizedOutputDirectory + Path.DirectorySeparatorChar;
foreach (var file in selection.EnumerateCandidates( foreach (var file in selection.EnumerateCandidates(
this.inputDirectory, this.inputDirectory,
includeDirectory: directory => !isOutputSeparateFolder includeDirectory: outputFolder.Enters,
|| !string.Equals(TrimDirectorySeparator(Path.GetFullPath(directory)), normalizedOutputDirectory, StringComparison.OrdinalIgnoreCase),
reportInaccessibleDirectory: (directory, exception) => reportInaccessibleDirectory: (directory, exception) =>
throw new IOException($"Could not access directory '{directory}'.", exception))) throw new IOException($"Could not access directory '{directory}'.", exception)))
{ {
var normalizedFile = Path.GetFullPath(file); var normalizedFile = Path.GetFullPath(file);
if (!selection.Selects(normalizedFile) || IsTranscriptArtifact(normalizedFile)) if (!selection.Selects(normalizedFile) || IsTranscriptArtifact(normalizedFile) || outputFolder.IsOutputOfRun(normalizedFile))
continue;
if (isOutputSeparateFolder)
{
if (normalizedFile.StartsWith(outputDirectoryPrefix, StringComparison.OrdinalIgnoreCase))
continue;
}
else if (this.IsOwnOutputArtifact(normalizedFile))
continue; continue;
// //
@@ -136,25 +116,6 @@ public partial class AssistantBatchProcessing
return [.. files]; return [.. files];
} }
private static string TrimDirectorySeparator(string path) => path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
/// <summary>
/// Checks whether a file is an output artifact of this assistant. We need
/// this when the output folder is the input folder: without it, the results
/// of a previous run would be processed as documents.
/// </summary>
private bool IsOwnOutputArtifact(string filePath)
{
var fileName = Path.GetFileName(filePath);
if (string.Equals(fileName, LOG_FILENAME, StringComparison.OrdinalIgnoreCase))
return true;
if (string.Equals(fileName, this.ResolveResultsFileName(), StringComparison.OrdinalIgnoreCase))
return true;
return fileName.EndsWith(RESULT_FILE_SUFFIX, StringComparison.OrdinalIgnoreCase);
}
/// <summary> /// <summary>
/// Checks for persistent or interrupted media transcript artifacts. They /// Checks for persistent or interrupted media transcript artifacts. They
/// always live beside their source file, independently of the output folder. /// always live beside their source file, independently of the output folder.
@@ -217,6 +178,17 @@ public partial class AssistantBatchProcessing
return null; return null;
} }
//
// A model that cannot read images would get the text of the request alone, without the
// image, and answer about nothing. Such images fail one by one, see LoadImageAsync, while the
// documents next to them are processed. A run of nothing but images does not start at all:
//
if (files.All(IsImage) && !this.ProviderSettings.SupportsImageInput())
{
this.AddInputIssue(T("All selected files are images, but the selected model cannot read images. Select a model with image support."));
return null;
}
try try
{ {
Directory.CreateDirectory(resolvedOutputDirectory); Directory.CreateDirectory(resolvedOutputDirectory);
@@ -15,10 +15,13 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
[Inject] [Inject]
private PandocAvailabilityService PandocAvailability { get; init; } = null!; private PandocAvailabilityService PandocAvailability { get; init; } = null!;
[Inject]
private ModelImageService ModelImageService { get; init; } = null!;
private const string DEFAULT_OUTPUT_DIRECTORY_NAME = "ai-results"; private const string DEFAULT_OUTPUT_DIRECTORY_NAME = "ai-results";
private const string DEFAULT_RESULTS_FILENAME = "batch-results.csv"; private const string DEFAULT_RESULTS_FILENAME = "batch-results.csv";
private const string CSV_EXTENSION = ".csv"; private const string CSV_EXTENSION = ".csv";
private const string RESULT_FILE_SUFFIX = "_result"; internal const string RESULT_FILE_SUFFIX = "_result";
private const string TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; private const string TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
private const char LOG_SEPARATOR = ';'; private const char LOG_SEPARATOR = ';';
@@ -26,7 +29,7 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
/// The name of the log file. It is fixed, so that a later batch run finds /// The name of the log file. It is fixed, so that a later batch run finds
/// the log of a previous run and can continue it. /// the log of a previous run and can continue it.
/// </summary> /// </summary>
private const string LOG_FILENAME = "log.csv"; internal const string LOG_FILENAME = "log.csv";
protected override Tools.Components Component => Tools.Components.BATCH_PROCESSING_ASSISTANT; protected override Tools.Components Component => Tools.Components.BATCH_PROCESSING_ASSISTANT;
@@ -168,6 +171,8 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
private void RestoreDefaultFilePatterns() => this.filePatterns = DataBatchProcessing.DEFAULT_FILE_PATTERNS; private void RestoreDefaultFilePatterns() => this.filePatterns = DataBatchProcessing.DEFAULT_FILE_PATTERNS;
private void RestoreDefaultExcludePatterns() => this.excludePatterns = FileExclusions.DEFAULT_PATTERNS;
private void ToggleExpertSettings() => this.showExpertSettings = !this.showExpertSettings; private void ToggleExpertSettings() => this.showExpertSettings = !this.showExpertSettings;
private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty; private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty;
@@ -288,13 +293,13 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
var minimumLevel = this.GetMinimumConfidenceLevel(); var minimumLevel = this.GetMinimumConfidenceLevel();
var policyProvider = this.SettingsManager.GetPreselectedProvider(this.Component, this.selectedPolicy.PreselectedProvider); var policyProvider = this.SettingsManager.GetPreselectedProvider(this.Component, this.selectedPolicy.PreselectedProvider);
if (policyProvider != Settings.Provider.NONE if (policyProvider != Settings.Provider.NONE
&& policyProvider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel) && this.SettingsManager.IsProviderUsable(policyProvider.UsedLLMProvider, minimumLevel))
this.ProviderSettings = policyProvider; this.ProviderSettings = policyProvider;
else else
{ {
var fallbackProvider = this.SettingsManager.GetPreselectedProvider(this.Component, usePreselectionBeforeCurrentProvider: true); var fallbackProvider = this.SettingsManager.GetPreselectedProvider(this.Component, usePreselectionBeforeCurrentProvider: true);
this.ProviderSettings = fallbackProvider != Settings.Provider.NONE this.ProviderSettings = fallbackProvider != Settings.Provider.NONE
&& fallbackProvider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel && this.SettingsManager.IsProviderUsable(fallbackProvider.UsedLLMProvider, minimumLevel)
? fallbackProvider ? fallbackProvider
: Settings.Provider.NONE; : Settings.Provider.NONE;
} }
@@ -0,0 +1,88 @@
namespace AIStudio.Assistants.BatchProcessing;
/// <summary>
/// Tells the search for documents which of the files it finds a batch run wrote itself.
/// </summary>
/// <remarks>
/// A run writes its result files, its log, and its results table directly into the output folder.
/// What the search must leave out therefore depends on where this folder lies. Below the input
/// folder, the search leaves out all of it. When it is the input folder itself, leaving out the
/// whole folder would leave no document at all, so the search leaves out what a run writes, by
/// name. Anywhere else, above the input folder included, the search never comes across the output
/// folder and leaves out nothing.
/// </remarks>
/// <param name="inputDirectory">The folder the run reads its documents from.</param>
/// <param name="outputDirectory">The folder the run writes into.</param>
/// <param name="resultsFileName">The file name of the results table.</param>
internal sealed class BatchProcessingOutputFolder(string inputDirectory, string outputDirectory, string resultsFileName)
{
private readonly string fullOutputDirectory = Path.GetFullPath(outputDirectory);
private readonly bool isInputFolder = IsSameFolder(inputDirectory, outputDirectory);
//
// The same folder with a separator at its end would count as lying below itself:
//
private readonly bool liesBelowInputFolder = !IsSameFolder(inputDirectory, outputDirectory) && PathTools.IsInsideDirectory(inputDirectory, outputDirectory);
/// <summary>
/// Whether the search enters a subfolder of the input folder: every one but the output folder.
/// </summary>
/// <param name="directory">The subfolder.</param>
public bool Enters(string directory) => !this.liesBelowInputFolder || !IsSameFolder(directory, this.fullOutputDirectory);
/// <summary>
/// Whether a file the search found was written by a run instead of being a document.
/// </summary>
/// <param name="filePath">The full path of the file.</param>
public bool IsOutputOfRun(string filePath)
{
//
// Checked with the separator after the name of the folder: otherwise, an output folder
// named 'out' would also leave out a document named 'output-notes.md':
//
if (this.liesBelowInputFolder)
return PathTools.IsInsideDirectory(this.fullOutputDirectory, filePath);
//
// A run writes into the output folder itself, never into one of its subfolders:
//
return this.isInputFolder
&& Path.GetDirectoryName(Path.GetFullPath(filePath)) is { } fileDirectory
&& IsSameFolder(fileDirectory, this.fullOutputDirectory)
&& this.IsOwnOutputArtifact(filePath);
}
/// <summary>
/// Checks whether a file is an output artifact of the assistant. We need
/// this when the output folder is the input folder: without it, the results
/// of a previous run would be processed as documents.
/// </summary>
/// <remarks>
/// A result file is recognized by its name without the extension, e.g., report_result.md or
/// report_result_2.docx, cf. AssistantBatchProcessing.CreateResultFileName. Its extension does
/// not count, because the user may have chosen another file format since a previous run.
/// </remarks>
private bool IsOwnOutputArtifact(string filePath)
{
var fileName = Path.GetFileName(filePath);
if (string.Equals(fileName, AssistantBatchProcessing.LOG_FILENAME, StringComparison.OrdinalIgnoreCase)
|| string.Equals(fileName, resultsFileName, StringComparison.OrdinalIgnoreCase))
return true;
var stem = Path.GetFileNameWithoutExtension(fileName);
var suffixIndex = stem.LastIndexOf(AssistantBatchProcessing.RESULT_FILE_SUFFIX, StringComparison.OrdinalIgnoreCase);
if (suffixIndex < 0)
return false;
//
// A second result of the same name gets a counter, e.g., report_result_2.md:
//
var counter = stem[(suffixIndex + AssistantBatchProcessing.RESULT_FILE_SUFFIX.Length)..];
return counter.Length is 0 || (counter.Length > 1 && counter[0] is '_' && counter[1..].All(char.IsAsciiDigit));
}
private static bool IsSameFolder(string firstDirectory, string secondDirectory) => string.Equals(TrimDirectorySeparator(Path.GetFullPath(firstDirectory)), TrimDirectorySeparator(Path.GetFullPath(secondDirectory)), PathTools.COMPARISON);
private static string TrimDirectorySeparator(string path) => path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
@@ -1,7 +1,7 @@
@attribute [Route(Routes.ASSISTANT_META_ASSISTANT)] @attribute [Route(Routes.ASSISTANT_META_ASSISTANT)]
@using AIStudio.Agents.AssistantAudit @using AIStudio.Agents.AssistantAudit
@using AIStudio.Tools.PluginSystem.Assistants.DataModel @using AIStudio.Tools.PluginSystem.Assistants.DataModel
@inherits AssistantBaseCore<AIStudio.Dialogs.Settings.NoSettingsPanel> @inherits AssistantBaseCore<AIStudio.Dialogs.Settings.SettingsDialogAssistantBuilder>
<PreviewBeta ApplyInnerScrollingFix="true"/> <PreviewBeta ApplyInnerScrollingFix="true"/>
@@ -43,17 +43,7 @@
</MudPaper> </MudPaper>
} }
<MudExpansionPanels Dense="@true" Elevation="0" Class="mb-3 rounded"> <CompactExpansionPanel HeaderText="@T("Advanced Options")" HeaderIcon="@Icons.Material.Filled.Tune" Class="mb-3 rounded">
<MudExpansionPanel Dense="@true" Class="border-solid border rounded pt-n4" Style="border-color: #BDBDBD">
<TitleContent>
<div class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Tune" Class="mr-3"/>
<MudText Typo="Typo.button">
@T("Advanced Options")
</MudText>
</div>
</TitleContent>
<ChildContent>
@* A launcher shows this field inside its own frame above, next to the chat settings @* A launcher shows this field inside its own frame above, next to the chat settings
it belongs with. *@ it belongs with. *@
@if (!this.createChatLauncher) @if (!this.createChatLauncher)
@@ -78,9 +68,7 @@
<MudTextField T="string" @bind-Text="@this.extraRules" AdornmentIcon="@Icons.Material.Filled.Rule" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Additional rules (Optional)")" Placeholder="@T("What to avoid or consider, e.g. do not invent missing facts")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="10" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/> <MudTextField T="string" @bind-Text="@this.extraRules" AdornmentIcon="@Icons.Material.Filled.Rule" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Additional rules (Optional)")" Placeholder="@T("What to avoid or consider, e.g. do not invent missing facts")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="10" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudTextField T="string" @bind-Text="@this.exampleRequest" AdornmentIcon="@Icons.Material.Filled.Lightbulb" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Example prompt (Optional)")" Placeholder="@T("An expected user prompt, e.g. summarize this document")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="10" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/> <MudTextField T="string" @bind-Text="@this.exampleRequest" AdornmentIcon="@Icons.Material.Filled.Lightbulb" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Example prompt (Optional)")" Placeholder="@T("An expected user prompt, e.g. summarize this document")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="10" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
} }
</ChildContent> </CompactExpansionPanel>
</MudExpansionPanel>
</MudExpansionPanels>
<ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/> <ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/>
<MudAlert Severity="Severity.Info" Square="true" Class="mb-2 mt-n1" Style="width: max-content">@this.HighPerformanceLLMInfo</MudAlert> <MudAlert Severity="Severity.Info" Square="true" Class="mb-2 mt-n1" Style="width: max-content">@this.HighPerformanceLLMInfo</MudAlert>
@@ -122,21 +110,9 @@ else
@code { @code {
private protected override RenderFragment? BelowSubmitContent => this.step is BuilderStep.DONE && !string.IsNullOrWhiteSpace(this.generatedLuaAssistant) private protected override RenderFragment? BelowSubmitContent => this.step is BuilderStep.DONE && !string.IsNullOrWhiteSpace(this.generatedLuaAssistant)
? @<MudStack Spacing="3" Class="mb-3"> ? @<MudStack Spacing="3" Class="mb-3">
<MudExpansionPanels Dense="@true" Elevation="0" Class="rounded"> <CompactExpansionPanel HeaderText="@T("Generated Lua plugin")" HeaderIcon="@Icons.Material.Filled.Code" IconColor="Color.Primary" Class="rounded">
<MudExpansionPanel Dense="@true" Class="border-solid border rounded pt-n4" Style="border-color: #BDBDBD">
<TitleContent>
<div class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Code" Class="mr-3" Color="Color.Primary"/>
<MudText Typo="Typo.button">
@T("Generated Lua plugin")
</MudText>
</div>
</TitleContent>
<ChildContent>
<MudTextField T="string" Text="@this.generatedLuaAssistant" ReadOnly="true" Variant="Variant.Outlined" Lines="18" Class="mt-2" Style="font-family: monospace"/> <MudTextField T="string" Text="@this.generatedLuaAssistant" ReadOnly="true" Variant="Variant.Outlined" Lines="18" Class="mt-2" Style="font-family: monospace"/>
</ChildContent> </CompactExpansionPanel>
</MudExpansionPanel>
</MudExpansionPanels>
<MudStepperWithoutActions @bind-ActiveIndex="@this.stepperIndex" Class="mb-3"> <MudStepperWithoutActions @bind-ActiveIndex="@this.stepperIndex" Class="mb-3">
<ChildContent> <ChildContent>
@@ -11,7 +11,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Assistants.Builder; namespace AIStudio.Assistants.Builder;
public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel> public partial class AssistantBuilder : AssistantBaseCore<SettingsDialogAssistantBuilder>
{ {
[Inject] [Inject]
private IDialogService DialogService { get; init; } = null!; private IDialogService DialogService { get; init; } = null!;
@@ -82,8 +82,6 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
// The result is the Lua code of a plugin, and a notice below it would make that code invalid: // The result is the Lua code of a plugin, and a notice below it would make that code invalid:
protected override bool AddsAIGeneratedContentDisclosure => false; protected override bool AddsAIGeneratedContentDisclosure => false;
protected override bool HasSettingsPanel => false;
protected override Func<string> Result2Copy => () => !string.IsNullOrWhiteSpace(this.generatedLuaAssistant) ? this.generatedLuaAssistant : this.generatedAssistantSpec; protected override Func<string> Result2Copy => () => !string.IsNullOrWhiteSpace(this.generatedLuaAssistant) ? this.generatedLuaAssistant : this.generatedAssistantSpec;
private BuilderStep step = BuilderStep.DESCRIBE; private BuilderStep step = BuilderStep.DESCRIBE;
@@ -251,8 +249,6 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
this.launcherDataSourceIds = []; this.launcherDataSourceIds = [];
this.launcherToolIds = []; this.launcherToolIds = [];
this.selectedAssistantComponents = []; this.selectedAssistantComponents = [];
this.selectedOutputLanguage = CommonLanguages.AS_IS;
this.customOutputLanguage = string.Empty;
this.allowGeneratedAssistantProfiles = true; this.allowGeneratedAssistantProfiles = true;
this.extraRules = string.Empty; this.extraRules = string.Empty;
this.exampleRequest = string.Empty; this.exampleRequest = string.Empty;
@@ -260,9 +256,25 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
this.reviewNotes = string.Empty; this.reviewNotes = string.Empty;
this.generatedLuaAssistant = string.Empty; this.generatedLuaAssistant = string.Empty;
this.ResetInstallFlow(); this.ResetInstallFlow();
if (!this.MightPreselectValues())
{
this.selectedOutputLanguage = CommonLanguages.AS_IS;
this.customOutputLanguage = string.Empty;
}
} }
protected override bool MightPreselectValues() => false; protected override bool MightPreselectValues()
{
if (this.SettingsManager.ConfigurationData.AssistantBuilder.PreselectOptions)
{
this.selectedOutputLanguage = this.SettingsManager.ConfigurationData.AssistantBuilder.PreselectedOutputLanguage;
this.customOutputLanguage = this.SettingsManager.ConfigurationData.AssistantBuilder.PreselectedOtherOutputLanguage;
return true;
}
return false;
}
/// <inheritdoc /> /// <inheritdoc />
protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state)
@@ -45,8 +45,8 @@ internal sealed class LauncherTextsResponse
error = LuaResponseParseError.NONE; error = LuaResponseParseError.NONE;
technicalDetails = string.Empty; technicalDetails = string.Empty;
var json = LuaResponse.ExtractJson(modelResponse); var json = ModelAnswerJson.ExtractObject(modelResponse);
if (string.IsNullOrWhiteSpace(json)) if (json.IsWhiteSpace())
{ {
error = LuaResponseParseError.MISSING_JSON_OBJECT; error = LuaResponseParseError.MISSING_JSON_OBJECT;
return false; return false;
@@ -18,8 +18,8 @@ internal sealed partial class LuaResponse
error = LuaResponseParseError.NONE; error = LuaResponseParseError.NONE;
technicalDetails = string.Empty; technicalDetails = string.Empty;
var json = ExtractJson(modelResponse); var json = ModelAnswerJson.ExtractObject(modelResponse);
if (string.IsNullOrWhiteSpace(json)) if (json.IsWhiteSpace())
{ {
error = LuaResponseParseError.MISSING_JSON_OBJECT; error = LuaResponseParseError.MISSING_JSON_OBJECT;
return false; return false;
@@ -158,53 +158,4 @@ internal sealed partial class LuaResponse
private static bool IsOptionalGuid(string? value, bool allowEmpty) => value is null || private static bool IsOptionalGuid(string? value, bool allowEmpty) => value is null ||
Guid.TryParse(value, out var parsed) && (allowEmpty || parsed != Guid.Empty); Guid.TryParse(value, out var parsed) && (allowEmpty || parsed != Guid.Empty);
/// <summary>
/// Reads the first complete JSON object out of a model answer that may carry text around it.
/// </summary>
/// <remarks>
/// Shared with the launcher texts response, which is a different shape but arrives the same
/// way, wrapped in whatever prose the model felt like adding.
/// </remarks>
internal static string ExtractJson(string input)
{
var start = input.IndexOf('{');
if (start < 0)
return string.Empty;
var depth = 0;
var insideString = false;
for (var index = start; index < input.Length; index++)
{
if (input[index] == '"' && !IsEscaped(input, index))
insideString = !insideString;
if (insideString)
continue;
switch (input[index])
{
case '{':
depth++;
break;
case '}':
depth--;
break;
}
if (depth == 0)
return input[start..(index + 1)];
}
return string.Empty;
}
private static bool IsEscaped(string input, int index)
{
var backslashCount = 0;
for (var i = index - 1; i >= 0 && input[i] == '\\'; i--)
backslashCount++;
return backslashCount % 2 == 1;
}
} }
@@ -619,7 +619,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
{ {
// Keep the current provider if it still satisfies the minimum confidence: // Keep the current provider if it still satisfies the minimum confidence:
if (this.ProviderSettings != Settings.Provider.NONE && if (this.ProviderSettings != Settings.Provider.NONE &&
this.ProviderSettings.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel) this.SettingsManager.IsProviderUsable(this.ProviderSettings.UsedLLMProvider, minimumLevel))
{ {
this.CurrentProfile = this.ResolveProfileSelection(); this.CurrentProfile = this.ResolveProfileSelection();
return; return;
@@ -628,7 +628,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
// Try to apply the policy preselection: // Try to apply the policy preselection:
var policyProvider = this.SettingsManager.GetProviderById(this.selectedPolicy.PreselectedProvider); var policyProvider = this.SettingsManager.GetProviderById(this.selectedPolicy.PreselectedProvider);
if (policyProvider != Settings.Provider.NONE && policyProvider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel) if (policyProvider != Settings.Provider.NONE && this.SettingsManager.IsProviderUsable(policyProvider.UsedLLMProvider, minimumLevel))
{ {
this.ProviderSettings = policyProvider; this.ProviderSettings = policyProvider;
this.CurrentProfile = this.ResolveProfileSelection(); this.CurrentProfile = this.ResolveProfileSelection();
@@ -637,7 +637,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
var fallbackProvider = this.SettingsManager.GetPreselectedProvider(this.Component, this.ProviderSettings.Id); var fallbackProvider = this.SettingsManager.GetPreselectedProvider(this.Component, this.ProviderSettings.Id);
if (fallbackProvider != Settings.Provider.NONE && if (fallbackProvider != Settings.Provider.NONE &&
fallbackProvider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level < minimumLevel) !this.SettingsManager.IsProviderUsable(fallbackProvider.UsedLLMProvider, minimumLevel))
fallbackProvider = Settings.Provider.NONE; fallbackProvider = Settings.Provider.NONE;
this.ProviderSettings = fallbackProvider; this.ProviderSettings = fallbackProvider;
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,4 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using AIStudio.Chat; using AIStudio.Chat;
using AIStudio.Dialogs; using AIStudio.Dialogs;
@@ -17,10 +16,6 @@ namespace AIStudio.Assistants.PromptOptimizer;
public partial class AssistantPromptOptimizer : AssistantBaseCore<SettingsDialogPromptOptimizer> public partial class AssistantPromptOptimizer : AssistantBaseCore<SettingsDialogPromptOptimizer>
{ {
private static readonly Regex JSON_CODE_FENCE_REGEX = new(
pattern: """```(?:json)?\s*(?<json>\{[\s\S]*\})\s*```""",
options: RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly JsonSerializerOptions JSON_OPTIONS = new() private static readonly JsonSerializerOptions JSON_OPTIONS = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
@@ -384,34 +379,8 @@ public partial class AssistantPromptOptimizer : AssistantBaseCore<SettingsDialog
{ {
parsedResult = new(); parsedResult = new();
if (TryDeserialize(rawResponse, out parsedResult)) var json = ModelAnswerJson.ExtractObject(rawResponse);
return true; if (json.IsWhiteSpace())
var codeFenceMatch = JSON_CODE_FENCE_REGEX.Match(rawResponse);
if (codeFenceMatch.Success)
{
var codeFenceJson = codeFenceMatch.Groups["json"].Value;
if (TryDeserialize(codeFenceJson, out parsedResult))
return true;
}
var firstBrace = rawResponse.IndexOf('{');
var lastBrace = rawResponse.LastIndexOf('}');
if (firstBrace >= 0 && lastBrace > firstBrace)
{
var objectText = rawResponse[firstBrace..(lastBrace + 1)];
if (TryDeserialize(objectText, out parsedResult))
return true;
}
return false;
}
private static bool TryDeserialize(string json, out PromptOptimizationResult parsedResult)
{
parsedResult = new();
if (string.IsNullOrWhiteSpace(json))
return false; return false;
try try
@@ -0,0 +1,30 @@
namespace AIStudio.Assistants.SlideBuilder;
/// <summary>
/// The occasion of a presentation, which shapes how the LLM structures it.
/// </summary>
/// <remarks>
/// The list covers the common occasions only; OTHER takes any other one as text.
/// </remarks>
public enum PresentationOccasion
{
UNSPECIFIED = 0,
TALK,
KEYNOTE,
CONFERENCE_TALK,
LECTURE,
TRAINING,
WORKSHOP,
MULTI_DAY_WORKSHOP,
THESIS_DEFENSE,
RESEARCH_RESULTS,
PROJECT_REPORT,
MANAGEMENT_REPORT,
PITCH,
PRODUCT_PRESENTATION,
KICK_OFF,
TEAM_MEETING,
OTHER,
}
@@ -0,0 +1,56 @@
namespace AIStudio.Assistants.SlideBuilder;
public static class PresentationOccasionExtensions
{
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(PresentationOccasionExtensions).Namespace, nameof(PresentationOccasionExtensions));
public static string Name(this PresentationOccasion occasion) => occasion switch
{
PresentationOccasion.UNSPECIFIED => TB("Unspecified occasion"),
PresentationOccasion.TALK => TB("Talk"),
PresentationOccasion.KEYNOTE => TB("Keynote"),
PresentationOccasion.CONFERENCE_TALK => TB("Conference talk"),
PresentationOccasion.LECTURE => TB("Lecture"),
PresentationOccasion.TRAINING => TB("Training"),
PresentationOccasion.WORKSHOP => TB("Workshop of one day"),
PresentationOccasion.MULTI_DAY_WORKSHOP => TB("Workshop of several days"),
PresentationOccasion.THESIS_DEFENSE => TB("Defense of a thesis or dissertation"),
PresentationOccasion.RESEARCH_RESULTS => TB("Presentation of research results"),
PresentationOccasion.PROJECT_REPORT => TB("Project or status report"),
PresentationOccasion.MANAGEMENT_REPORT => TB("Report to management"),
PresentationOccasion.PITCH => TB("Pitch"),
PresentationOccasion.PRODUCT_PRESENTATION => TB("Product presentation"),
PresentationOccasion.KICK_OFF => TB("Kick-off"),
PresentationOccasion.TEAM_MEETING => TB("Team meeting"),
PresentationOccasion.OTHER => TB("Other occasion"),
_ => TB("Unspecified occasion"),
};
/// <summary>
/// Tells the model what the occasion means for the structure of the presentation.
/// </summary>
/// <param name="occasion">The occasion.</param>
/// <param name="customOccasion">The occasion the user described, for OTHER.</param>
public static string Prompt(this PresentationOccasion occasion, string customOccasion) => occasion switch
{
PresentationOccasion.TALK => "The presentation accompanies a talk. Structure it for a speaker who talks the audience through it.",
PresentationOccasion.KEYNOTE => "The presentation accompanies a keynote. Favor few words per slide, strong key messages, and a story which carries the audience.",
PresentationOccasion.CONFERENCE_TALK => "The presentation accompanies a talk at a conference. Keep to the essentials of a short time slot, and end with the key messages and room for questions.",
PresentationOccasion.LECTURE => "The presentation accompanies a lecture. Teach step by step, with examples, and where it suits, end chapters with an interim summary or with room for questions.",
PresentationOccasion.TRAINING => "The presentation accompanies a training. Teach practical skills step by step, with examples and exercises where the content offers them.",
PresentationOccasion.WORKSHOP => "The presentation accompanies a workshop of one day. Plan for interaction, e.g., exercises, group work, or discussions, and for the breaks the schedule of the content has.",
PresentationOccasion.MULTI_DAY_WORKSHOP => "The presentation accompanies a workshop of several days. Plan for interaction, mark where a day ends and the next one starts, and look back at the start of each day, following the schedule of the content.",
PresentationOccasion.THESIS_DEFENSE => "The presentation defends a thesis or a dissertation before a committee. Present the research questions, the method, the results, and the contribution precisely, and anticipate critical questions.",
PresentationOccasion.RESEARCH_RESULTS => "The presentation presents research results. Present the questions, the method, the results, and their meaning precisely and comprehensibly.",
PresentationOccasion.PROJECT_REPORT => "The presentation reports on a project. Present the status, the progress, the problems, and the next steps clearly.",
PresentationOccasion.MANAGEMENT_REPORT => "The presentation reports to management and may prepare a decision. Lead with the key messages, keep details short, and state clearly what is to be decided.",
PresentationOccasion.PITCH => "The presentation pitches an idea, a product, or an offer. Lead with the benefit for the audience, and end with a clear call to action.",
PresentationOccasion.PRODUCT_PRESENTATION => "The presentation presents a product. Show what it does and what it brings the audience, with examples.",
PresentationOccasion.KICK_OFF => "The presentation opens a project at its kick-off. Present the goals, the team, the plan, and how the work is organized.",
PresentationOccasion.TEAM_MEETING => "The presentation serves a team meeting. Keep it short and practical, with clear points and next steps.",
PresentationOccasion.OTHER => $"The presentation is for this occasion: {customOccasion}. Structure it accordingly.",
_ => "No particular occasion is given. Structure the presentation by its content.",
};
}
@@ -1,4 +1,7 @@
@attribute [Route(Routes.ASSISTANT_SLIDE_BUILDER)] @attribute [Route(Routes.ASSISTANT_SLIDE_BUILDER)]
@using AIStudio.Settings.DataModel
@using AIStudio.Tools.PowerPoint
@using AIStudio.Tools.Rust
@inherits AssistantBaseCore<AIStudio.Dialogs.Settings.SettingsDialogSlideBuilder> @inherits AssistantBaseCore<AIStudio.Dialogs.Settings.SettingsDialogSlideBuilder>
<MudText Typo="Typo.h5" Class="mb-1 mt-3"> @T("Content to derive slide from")</MudText> <MudText Typo="Typo.h5" Class="mb-1 mt-3"> @T("Content to derive slide from")</MudText>
@@ -7,8 +10,43 @@
</MudJustifiedText> </MudJustifiedText>
<MudTextField T="string" @bind-Text="@this.inputContent" Validation="@this.ValidatingContext" Adornment="Adornment.Start" Lines="6" MaxLines="12" AutoGrow="@false" Label="@T("Text content")" Variant="Variant.Outlined" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/> <MudTextField T="string" @bind-Text="@this.inputContent" Validation="@this.ValidatingContext" Adornment="Adornment.Start" Lines="6" MaxLines="12" AutoGrow="@false" Label="@T("Text content")" Variant="Variant.Outlined" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudText Typo="Typo.h6" Class="mb-1 mt-1"> @T("Attach documents")</MudText> @* Side by side where the window is wide enough; each column ends with its zone, so both zones line up below explanations of different length: *@
<MudGrid Class="mt-1">
<MudItem xs="12" lg="6" Class="d-flex flex-column">
<MudText Typo="Typo.h6" Class="mb-1"> @T("Attach documents")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2 flex-grow-1">
@T("Documents and images attached here are source material: the LLM reads them, but puts none of them on a slide.")
</MudJustifiedText>
<AttachDocuments Name="Documents for input" @bind-DocumentPaths="@this.loadedDocumentPaths" OnChange="@this.OnDocumentsChanged" CatchAllDocuments="true" UseSmallForm="false" Provider="@this.ProviderSettings"/> <AttachDocuments Name="Documents for input" @bind-DocumentPaths="@this.loadedDocumentPaths" OnChange="@this.OnDocumentsChanged" CatchAllDocuments="true" UseSmallForm="false" Provider="@this.ProviderSettings"/>
</MudItem>
<MudItem xs="12" lg="6" Class="d-flex flex-column">
<MudText Typo="Typo.h6" Class="mb-1">@T("Pictures for the slides")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2 flex-grow-1">
@T("Attach the pictures which the presentation must show, e.g., photos, diagrams, or screenshots. Each of them goes on exactly one slide with room for a picture. Only PNG and JPEG pictures are possible. An LLM which can read images sees them; any other LLM knows only their file names, so clear names help.")
</MudJustifiedText>
<AttachDocuments Name="Pictures for the slides" @bind-DocumentPaths="@this.slidePictures" OnChange="@this.OnSlidePicturesChanged" CatchAllDocuments="false" UseSmallForm="false" AllowedFileTypes="@([FileTypes.SLIDE_PICTURE])" Provider="@this.ProviderSettings"/>
</MudItem>
</MudGrid>
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Picture library")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@SlideAssistantTexts.WhatAPictureLibraryOffers
</MudJustifiedText>
<SelectDirectory Directory="@this.pictureLibraryPath" DirectoryChanged="@this.OnPictureLibraryChanged" Label="@T("(Optional) Picture library")" DirectoryDialogTitle="@T("Select a picture library")" Validation="@this.ValidatePictureLibrary" Disabled="@this.IsPictureLibraryLocked" EnableDragDrop="true"/>
@if (this.IsLockedPictureLibraryUnavailable)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">@T("The picture library of your organization is not available right now, e.g., because its network drive cannot be reached. Until it is, the assistant plans the slides without it.")</MudAlert>
}
@if (this.PictureLibraryHint is { Length: > 0 } pictureLibraryHint)
{
<MudText Typo="Typo.body2" Class="mb-3">@pictureLibraryHint</MudText>
}
@if (!string.IsNullOrWhiteSpace(this.pictureLibraryPath) && !this.IsPictureLibraryLocked)
{
<MudButton StartIcon="@Icons.Material.Filled.FolderOff" Variant="Variant.Outlined" OnClick="@this.UseNoPictureLibrary" Class="mb-3">
@T("Use no picture library")
</MudButton>
}
<MudText Typo="Typo.h5" Class="mb-3 mt-6"> @T("Details about the desired presentation")</MudText> <MudText Typo="Typo.h5" Class="mb-3 mt-6"> @T("Details about the desired presentation")</MudText>
@@ -22,11 +60,11 @@
<MudJustifiedText Typo="Typo.body1" Class="mb-2"> <MudJustifiedText Typo="Typo.body1" Class="mb-2">
@T("You might want to specify important aspects that the LLM should consider when creating the slides. For example, the use of emojis or specific topics that should be highlighted.") @T("You might want to specify important aspects that the LLM should consider when creating the slides. For example, the use of emojis or specific topics that should be highlighted.")
</MudJustifiedText> </MudJustifiedText>
<MudTextField T="string" AutoGrow="true" Lines="3" @bind-Text="@this.importantAspects" class="mb-1" Label="@T("(Optional) Important Aspects")" HelperText="@T("(Optional) Specify aspects that the LLM should consider when creating the slides. For example, the use of emojis or specific topics that should be highlighted.")" ShrinkLabel="true" Variant="Variant.Outlined" AdornmentIcon="@Icons.Material.Filled.List" Adornment="Adornment.Start" UserAttributes="@USER_INPUT_ATTRIBUTES"/> <MudTextField T="string" AutoGrow="true" Lines="3" @bind-Text="@this.importantAspects" class="mb-1" Label="@T("(Optional) Important Aspects")" ShrinkLabel="true" Variant="Variant.Outlined" AdornmentIcon="@Icons.Material.Filled.List" Adornment="Adornment.Start" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Extent of the planned presentation")</MudText> <MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Extent of the planned presentation")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2"> <MudJustifiedText Typo="Typo.body1" Class="mb-2">
@T("Please specify the extent of the planned presentation. This can be the number of slides, the number of bullet points per slide, or the time specification for the presentation. This will help the LLM to create a presentation that fits your needs. Leave the default values if you don't have specific requirements regarding the extent of the presentation. You might only want to specify one of these parameters, for example the time specification, and leave the others at their default values.") @T("Please specify the extent of the planned presentation. This can be the number of slides, the maximum number of bullet points per slide, or the time specification for the presentation. This will help the LLM to create a presentation that fits your needs. Leave the default values if you don't have specific requirements regarding the extent of the presentation. You might only want to specify one of these parameters, for example the time specification, and leave the others at their default values.")
</MudJustifiedText> </MudJustifiedText>
<MudGrid> <MudGrid>
<MudItem xs="4"> <MudItem xs="4">
@@ -39,10 +77,11 @@
<MudItem xs="4"> <MudItem xs="4">
<MudNumericField @bind-Value="@this.numberOfBulletPoints" <MudNumericField @bind-Value="@this.numberOfBulletPoints"
Label="@T("Number of bullet points")" Label="@T("Maximum number of bullet points per slide")"
Variant="Variant.Outlined" Variant="Variant.Outlined"
Min="0" Min="0"
Max="7" Max="@DataSlideBuilder.MAX_BULLET_POINTS_PER_SLIDE"
Disabled="@this.IsBulletPointMaximumLocked"
Class="mb-3" /> Class="mb-3" />
</MudItem> </MudItem>
@@ -58,6 +97,12 @@
<MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Language")</MudText> <MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Language")</MudText>
<EnumSelection T="CommonLanguages" NameFunc="@(language => language.Name())" @bind-Value="@this.selectedTargetLanguage" Icon="@Icons.Material.Filled.Translate" Label="@T("Target language")" AllowOther="@true" @bind-OtherInput="@this.customTargetLanguage" OtherValue="CommonLanguages.OTHER" LabelOther="@T("Custom target language")" ValidateOther="@this.ValidateCustomLanguage" /> <EnumSelection T="CommonLanguages" NameFunc="@(language => language.Name())" @bind-Value="@this.selectedTargetLanguage" Icon="@Icons.Material.Filled.Translate" Label="@T("Target language")" AllowOther="@true" @bind-OtherInput="@this.customTargetLanguage" OtherValue="CommonLanguages.OTHER" LabelOther="@T("Custom target language")" ValidateOther="@this.ValidateCustomLanguage" />
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Occasion")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@T("The occasion helps the LLM to structure the presentation. A lecture, for example, may close its chapters with an interim summary or with room for questions, and a report to management leads with the key messages.")
</MudJustifiedText>
<EnumSelection T="PresentationOccasion" NameFunc="@(occasion => occasion.Name())" @bind-Value="@this.selectedOccasion" Icon="@Icons.Material.Filled.Event" Label="@T("Occasion of the presentation")" AllowOther="@true" @bind-OtherInput="@this.customOccasion" OtherValue="PresentationOccasion.OTHER" LabelOther="@T("Other occasion")" ValidateOther="@this.ValidateCustomOccasion" />
<MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Audience")</MudText> <MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Audience")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2"> <MudJustifiedText Typo="Typo.body1" Class="mb-2">
@T("Please specify the audience for the planned presentation. This will help the LLM to create a presentation that fits your needs. You can specify the audience profile, the age group, organizational level, and the expertise. You don't have to specify all of these parameters, for example you might only want to specify the audience profile and leave the other parameters at their default values.") @T("Please specify the audience for the planned presentation. This will help the LLM to create a presentation that fits your needs. You can specify the audience profile, the age group, organizational level, and the expertise. You don't have to specify all of these parameters, for example you might only want to specify the audience profile and leave the other parameters at their default values.")
@@ -66,4 +111,77 @@
<EnumSelection T="AudienceAgeGroup" NameFunc="@(ageGroup => ageGroup.Name())" @bind-Value="@this.selectedAudienceAgeGroup" Icon="@Icons.Material.Filled.Cake" Label="@T("Audience age group")" /> <EnumSelection T="AudienceAgeGroup" NameFunc="@(ageGroup => ageGroup.Name())" @bind-Value="@this.selectedAudienceAgeGroup" Icon="@Icons.Material.Filled.Cake" Label="@T("Audience age group")" />
<EnumSelection T="AudienceOrganizationalLevel" NameFunc="@(level => level.Name())" @bind-Value="@this.selectedAudienceOrganizationalLevel" Icon="@Icons.Material.Filled.AccountTree" Label="@T("Audience organizational level")" /> <EnumSelection T="AudienceOrganizationalLevel" NameFunc="@(level => level.Name())" @bind-Value="@this.selectedAudienceOrganizationalLevel" Icon="@Icons.Material.Filled.AccountTree" Label="@T("Audience organizational level")" />
<EnumSelection T="AudienceExpertise" NameFunc="@(expertise => expertise.Name())" @bind-Value="@this.selectedAudienceExpertise" Icon="@Icons.Material.Filled.School" Label="@T("Audience expertise")" /> <EnumSelection T="AudienceExpertise" NameFunc="@(expertise => expertise.Name())" @bind-Value="@this.selectedAudienceExpertise" Icon="@Icons.Material.Filled.School" Label="@T("Audience expertise")" />
<MudText Typo="Typo.h5" Class="mb-1 mt-6"> @T("PowerPoint template")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@T("Choose a PowerPoint presentation or template (.pptx, .potx) whose layouts the slides should use. The LLM then plans every slide for one of these layouts, and you can create a PowerPoint file (.pptx) in the design of your template, which other programs such as LibreOffice Impress open as well. OpenDocument presentations (.odp, .otp) cannot serve as a template yet. Without a template, you get an outline, which you can copy or export.")
</MudJustifiedText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@SlideAssistantTexts.WhatTheLLMKnowsOfLayouts
</MudJustifiedText>
<SelectFile File="@this.templatePath" FileChanged="@this.OnTemplateChanged" Label="@T("PowerPoint template")" FileDialogTitle="@T("Select a PowerPoint template")" Filter="@([FileTypes.POWER_POINT_TEMPLATE])" Validation="@this.ValidateTemplate" Disabled="@this.IsTemplateLocked" EnableDragDrop="true"/>
@if (this.IsLockedTemplateUnavailable)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">@T("The PowerPoint template of your organization is not available right now, e.g., because its network drive cannot be reached. Until it is, the assistant plans an outline only.")</MudAlert>
}
@if (!string.IsNullOrWhiteSpace(this.templatePath) && !this.IsTemplateLocked)
{
<MudButton StartIcon="@Icons.Material.Filled.LayersClear" Variant="Variant.Outlined" OnClick="@this.UseNoTemplate" Class="mb-3">
@T("Use no template")
</MudButton>
}
@* The select renders its items again on its own, e.g., while the form validates, when the template may be gone already: *@
@if (this.template is { } loadedTemplate)
{
<CompactExpansionPanel HeaderText="@T("Advanced Options")" HeaderIcon="@Icons.Material.Filled.Tune" Class="mb-3 rounded" @bind-Expanded="@this.advancedOptionsExpanded">
@if (loadedTemplate.VariantLabels.Count > 0)
{
<MudSelect T="string" @bind-Value="@this.variantLabel" Label="@T("Color variant")" HelperText="@T("Your template offers its layouts in several colors. Choose one for the presentation.")" Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Palette" Class="mb-3">
@foreach (var label in loadedTemplate.VariantLabels)
{
<MudSelectItem T="string" Value="@label">@label</MudSelectItem>
}
</MudSelect>
}
<MudTextField T="string" @bind-Text="@this.footerText" Label="@T("(Optional) Footer")" HelperText="@T("Shown on every slide whose layout has a footer, e.g., your name, organization, and date.")" Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.ShortText" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudSwitch T="bool" @bind-Value="@this.keepEmptyPicturePlaceholders" Color="Color.Primary" Class="mx-1">
@T("Keep empty picture placeholders")
</MudSwitch>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@SlideAssistantTexts.WhatKeepingEmptyPicturePlaceholdersDoes
</MudJustifiedText>
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Layouts for kinds of slides")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@SlideAssistantTexts.WhatALayoutChoiceDoes
</MudJustifiedText>
<LayoutChoicePanels IsChosen="@(purpose => this.LayoutNameOf(purpose).Length > 0)">
<ChoiceContent Context="purpose">
<MudSelect T="string" Value="@this.LayoutNameOf(purpose)" ValueChanged="@(name => this.SetLayoutName(purpose, name))" Disabled="@this.AreLayoutsLocked" Label="@purpose.Label()" HelperText="@purpose.Help()" Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.ViewQuilt" Class="mb-3">
<MudSelectItem T="string" Value="@string.Empty">@LayoutPurposeExtensions.NoPreference</MudSelectItem>
@foreach (var layoutName in loadedTemplate.FamilyNames)
{
<MudSelectItem T="string" Value="@layoutName">@layoutName</MudSelectItem>
}
</MudSelect>
</ChoiceContent>
</LayoutChoicePanels>
@if (loadedTemplate.Slides.Count > 0)
{
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Slides of the template")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@SlideAssistantTexts.WhatHappensToTemplateSlides
</MudJustifiedText>
@foreach (var templateSlide in loadedTemplate.Slides)
{
<EnumSelection T="TemplateSlideUse" NameFunc="@(use => use.Name())" Value="@this.TemplateSlideUseOf(templateSlide.Number)" ValueChanged="@(use => this.SetTemplateSlideUse(templateSlide.Number, use))" ValidateSelection="@(use => this.ValidateTemplateSlideUse(templateSlide, use))" Disabled="@this.AreTemplateSlideUsesLocked" Icon="@Icons.Material.Filled.Slideshow" Label="@templateSlide.Label()"/>
}
}
</CompactExpansionPanel>
}
<ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/> <ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/>
@@ -0,0 +1,257 @@
using AIStudio.Settings;
using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.PowerPoint;
using AIStudio.Tools.Security;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Assistants.SlideBuilder;
/// <summary>
/// The part of the Slide Assistant which offers the LLM a folder of pictures and icons to choose from.
/// </summary>
/// <remarks>
/// The LLM reads only the names of the pictures, never the pictures themselves, and need not use
/// any of them; see PictureLibrary. The library is read anew for each planning, so that a large
/// one offers another random sample each time, and once whenever the user chooses a folder, to tell
/// how many pictures it holds.
/// </remarks>
public partial class SlideAssistant
{
[Inject]
private ThreadSafeRandom RandomSource { get; init; } = null!;
private string pictureLibraryPath = string.Empty;
private static readonly AssistantSessionStateKey<string> PICTURE_LIBRARY_PATH_STATE_KEY = new(nameof(pictureLibraryPath));
/// <summary>
/// How many pictures the chosen library holds, for the hint below the field; it holds no sample.
/// </summary>
private PictureLibraryContent pictureLibraryOverview = PictureLibraryContent.NONE;
private bool isReadingPictureLibrary;
private CancellationTokenSource? pictureLibraryReading;
/// <summary>
/// Whether the chosen library folder exists, as its last count found.
/// </summary>
/// <remarks>
/// The form asks this with every rendering, and an unreachable network drive takes seconds to
/// answer, so the folder is checked only when it changes, and away from the rendering.
/// </remarks>
private bool isPictureLibraryAvailable;
/// <summary>
/// What the library offers to the planning under way, while the system prompt is written for it.
/// </summary>
private PictureLibraryContent planningLibrary = PictureLibraryContent.NONE;
/// <summary>
/// Whether the organization dictates the picture library.
/// </summary>
/// <remarks>
/// Like the template, the library is a preselection, so a lock binds only while preselection is enabled.
/// </remarks>
private bool IsPictureLibraryLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedPictureLibraryPath, out var meta) && meta.IsLocked;
/// <summary>
/// Whether the picture library of the organization is locked, but cannot be read right now, e.g.,
/// because its network drive cannot be reached.
/// </summary>
/// <remarks>
/// The user cannot choose another folder then, so it must not block the form. The assistant plans
/// without a library instead, and the form says why.
/// </remarks>
private bool IsLockedPictureLibraryUnavailable => this.IsPictureLibraryLocked && this.pictureLibraryPath.Length > 0 && !this.isReadingPictureLibrary && !this.isPictureLibraryAvailable;
private async Task OnPictureLibraryChanged(string path)
{
this.pictureLibraryPath = path;
await this.ReadPictureLibraryOverviewAsync();
if (this.Form is not null)
await this.Form.Validate();
}
private async Task UseNoPictureLibrary() => await this.OnPictureLibraryChanged(string.Empty);
private string? ValidatePictureLibrary(string path)
{
// The warning below the field explains a locked folder, since the user cannot change it, and
// while the folder is still counted, it is too early to tell:
if (string.IsNullOrWhiteSpace(path) || this.IsPictureLibraryLocked || this.isReadingPictureLibrary)
return null;
return this.isPictureLibraryAvailable ? null : T("The folder does not exist.");
}
/// <summary>
/// Counts the pictures of the chosen library without waiting for it, e.g., after the library was preselected or restored.
/// </summary>
private void StartCountingPictureLibrary() => this.ReadPictureLibraryOverviewAsync().Observe($"{nameof(SlideAssistant)}: counting the pictures of the library");
/// <summary>
/// Counts the pictures of the chosen library for the hint, and cancels a count still running for another folder.
/// </summary>
private async Task ReadPictureLibraryOverviewAsync()
{
if (this.pictureLibraryReading is { } previousReading)
{
await previousReading.CancelAsync();
previousReading.Dispose();
}
this.pictureLibraryOverview = PictureLibraryContent.NONE;
this.isPictureLibraryAvailable = false;
var path = this.pictureLibraryPath;
if (path.Length == 0)
{
this.pictureLibraryReading = null;
return;
}
var reading = new CancellationTokenSource();
this.pictureLibraryReading = reading;
this.isReadingPictureLibrary = true;
this.StateHasChanged();
try
{
var isAvailable = await FolderExistsAsync(path, reading.Token);
var overview = isAvailable ? await PictureLibrary.ReadAsync(path, this.RandomSource, 0, this.ReportInaccessibleLibraryFolder, reading.Token) : PictureLibraryContent.NONE;
if (!reading.IsCancellationRequested)
{
this.isPictureLibraryAvailable = isAvailable;
this.pictureLibraryOverview = overview;
}
}
catch (OperationCanceledException)
{
// Another folder was chosen meanwhile, and its count is under way.
}
catch (Exception e)
{
this.Logger.LogWarning(e, "The picture library '{FolderPath}' cannot be read.", path);
}
finally
{
// A count which started without a user's action, e.g., for a preselected folder, ends
// without a rendering of its own:
if (ReferenceEquals(this.pictureLibraryReading, reading))
{
this.isReadingPictureLibrary = false;
this.StateHasChanged();
}
}
}
/// <summary>
/// Reads the library for a planning, with a new random sample when it is large.
/// </summary>
/// <returns>What the library offers; nothing when the user chose no library or it cannot be read.</returns>
private async Task<PictureLibraryContent> ReadPictureLibraryForPlanningAsync()
{
var path = this.pictureLibraryPath;
if (path.Length == 0)
return PictureLibraryContent.NONE;
var token = this.CancellationTokenSource?.Token ?? CancellationToken.None;
try
{
if (!await FolderExistsAsync(path, token))
{
this.Logger.LogWarning("The picture library '{FolderPath}' is not available, so the slides are planned without it.", path);
return PictureLibraryContent.NONE;
}
return await PictureLibrary.ReadAsync(path, this.RandomSource, PictureLibrary.MAX_LIBRARY_PICTURES, this.ReportInaccessibleLibraryFolder, token);
}
catch (Exception e) when (e is not OperationCanceledException)
{
this.Logger.LogWarning(e, "The picture library '{FolderPath}' cannot be read, so the slides are planned without it.", path);
return PictureLibraryContent.NONE;
}
}
/// <summary>
/// Whether a folder exists, asked on a thread of its own, since an unreachable network drive takes seconds to answer.
/// </summary>
private static Task<bool> FolderExistsAsync(string path, CancellationToken token) => Task.Run(() => Directory.Exists(path), token);
private void ReportInaccessibleLibraryFolder(string folderPath, Exception e) => this.Logger.LogWarning(e, "The folder '{FolderPath}' of the picture library cannot be read.", folderPath);
/// <summary>
/// What the chosen library holds, below the field; empty while there is nothing to tell.
/// </summary>
private string PictureLibraryHint
{
get
{
if (this.isReadingPictureLibrary)
return T("Counting the pictures of the folder...");
if (!this.isPictureLibraryAvailable)
return string.Empty;
var library = this.pictureLibraryOverview;
var hints = new List<string>
{
library.PictureCount == 0
? T("The folder holds no PNG or JPEG pictures.")
: string.Format(T("{0} pictures, {1} of them icons."), library.PictureCount.CompactCount(), library.IconCount.CompactCount()),
};
if (library.SkippedFileCount > 0)
hints.Add(string.Format(T("{0} other files are left out, because they are neither PNG nor JPEG."), library.SkippedFileCount.CompactCount()));
if (library.PictureCount > PictureLibrary.MAX_LIBRARY_PICTURES)
hints.Add(string.Format(T("For each planning, the LLM gets a random {0} of them."), SlideAssistantTexts.MaxLibraryPictures));
return string.Join(" ", hints);
}
}
/// <summary>
/// The pictures of the library by their numbers, which follow those of the pictures for the slides, for the user's request.
/// </summary>
/// <remarks>
/// The names come from a folder, which may be a network drive anybody can write to, so they pass
/// the prompt injection filter like the texts of a template.
/// </remarks>
/// <param name="guardService">The prompt injection filter.</param>
/// <param name="promptInjectionScope">The action of the request, which reports what the filter removed.</param>
/// <param name="library">What the library offers for this request.</param>
/// <param name="firstNumber">The number of the first picture of the library.</param>
/// <param name="folderPath">The library folder.</param>
/// <returns>The section of the request; empty without pictures in the library.</returns>
private static async Task<string> PromptPictureLibraryAsync(PromptInjectionGuardService guardService, PromptInjectionReportingScope promptInjectionScope, PictureLibraryContent library, int firstNumber, string folderPath)
{
if (library.Pictures.Count == 0)
return string.Empty;
var list = string.Join(Environment.NewLine, library.Pictures.Select((picture, index) => $"- Picture {firstNumber + index}: {picture.Name}{(picture.IsIcon ? " (icon)" : string.Empty)}"));
var sanitizedList = await guardService.SanitizeAsync(list, PromptInjectionSource.FileContent(folderPath), promptInjectionScope);
return $"""
# PICTURE_LIBRARY
{sanitizedList}
""";
}
/// <summary>
/// The rules for the pictures of the library; empty without them.
/// </summary>
private string PromptPictureLibraryRules() => this.planningLibrary.Pictures.Count == 0
? string.Empty
: """
- PICTURE_LIBRARY lists further pictures by their numbers and names; you know only their names. Fill the picture slots of your new slides from it: wherever a name fits the content of a slide, place that picture there by its number. Leave a picture slot empty only when no name fits. A picture which is not an icon appears at most once.
- Pictures marked "(icon)" are icons. An icon may appear on several slides, e.g., to mark each risk the same way, and suits picture slots of any size, except those marked as backdrop.
""";
/// <inheritdoc />
protected override void DisposeResources()
{
this.pictureLibraryReading?.Cancel();
this.pictureLibraryReading?.Dispose();
this.pictureLibraryReading = null;
base.DisposeResources();
}
}
@@ -0,0 +1,609 @@
using System.Globalization;
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.PowerPoint;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Security;
namespace AIStudio.Assistants.SlideBuilder;
/// <summary>
/// The part of the Slide Assistant which works with PowerPoint: the template, the slides the model
/// plans for its layouts, and the file written from them.
/// </summary>
public partial class SlideAssistant
{
/// <summary>
/// The layouts of a plain presentation, which the model plans with when no template was chosen.
/// </summary>
private static readonly PresentationTemplate DEFAULT_CATALOG = PresentationTemplate.CreateDefault();
/// <summary>
/// How many issues the repair request names at most.
/// </summary>
/// <remarks>
/// A model handed fifty issues fixes the first few and loses track of the rest; a dozen names
/// what is wrong in kind, and the model finds the like on its own.
/// </remarks>
private const int MAX_REPORTED_ISSUES = 12;
private string templatePath = string.Empty;
private string footerText = string.Empty;
private bool keepEmptyPicturePlaceholders;
private string? variantLabel;
private PresentationTemplate? template;
private SlideDeckSpec? slideDeck;
private bool isWritingPresentation;
/// <summary>
/// Whether the options of the template are open: its color variant, the footer, and its slides.
/// </summary>
private bool advancedOptionsExpanded;
/// <summary>
/// What the user chose for each slide of the template, by its number.
/// </summary>
private List<TemplateSlideUse> templateSlideUses = [];
/// <summary>
/// The name of the layout the user chose for each kind of slide; a kind left to the LLM is missing.
/// </summary>
/// <remarks>
/// Names stay when the user chooses another template, so that they apply again once a template
/// with these layouts is back. A name the current template does not have counts as no choice.
/// </remarks>
private readonly Dictionary<LayoutPurpose, string> layoutNames = new();
/// <summary>
/// The answer the slide deck was read from, which the session keeps to restore the deck.
/// </summary>
private string slideDeckAnswer = string.Empty;
/// <summary>
/// The template the slide deck was planned for; empty when it was planned without one.
/// </summary>
private string slideDeckTemplatePath = string.Empty;
/// <summary>
/// The pictures the slide deck may use, by the numbers the model gave them: every picture for the
/// slides, and those of the library which the deck uses, not the thousands it was offered.
/// </summary>
private readonly Dictionary<int, string> slideDeckPicturePaths = new();
/// <summary>
/// The numbers of the pictures in slideDeckPicturePaths which are icons.
/// </summary>
private readonly HashSet<int> slideDeckIconNumbers = [];
/// <summary>
/// What the slide deck was planned on: the layouts and template slides it uses, as the model read them.
/// </summary>
/// <remarks>
/// The template may change before the user saves the presentation, see SlideDeckSpecValidator.StillFits.
/// </remarks>
private readonly Dictionary<string, string> slideDeckBasis = new(StringComparer.Ordinal);
private static readonly AssistantSessionStateKey<string> TEMPLATE_PATH_STATE_KEY = new(nameof(templatePath));
private static readonly AssistantSessionStateKey<string> FOOTER_TEXT_STATE_KEY = new(nameof(footerText));
private static readonly AssistantSessionStateKey<bool> KEEP_EMPTY_PICTURE_PLACEHOLDERS_STATE_KEY = new(nameof(keepEmptyPicturePlaceholders));
private static readonly AssistantSessionStateKey<string> VARIANT_LABEL_STATE_KEY = new(nameof(variantLabel));
private static readonly AssistantSessionStateKey<bool> ADVANCED_OPTIONS_EXPANDED_STATE_KEY = new(nameof(advancedOptionsExpanded));
private static readonly AssistantSessionStateKey<List<TemplateSlideUse>> TEMPLATE_SLIDE_USES_STATE_KEY = new(nameof(templateSlideUses));
private static readonly AssistantSessionStateKey<Dictionary<LayoutPurpose, string>> LAYOUT_NAMES_STATE_KEY = new(nameof(layoutNames));
private static readonly AssistantSessionStateKey<string> SLIDE_DECK_ANSWER_STATE_KEY = new(nameof(slideDeckAnswer));
private static readonly AssistantSessionStateKey<string> SLIDE_DECK_TEMPLATE_PATH_STATE_KEY = new(nameof(slideDeckTemplatePath));
private static readonly AssistantSessionStateKey<Dictionary<int, string>> SLIDE_DECK_PICTURE_PATHS_STATE_KEY = new(nameof(slideDeckPicturePaths));
private static readonly AssistantSessionStateKey<HashSet<int>> SLIDE_DECK_ICON_NUMBERS_STATE_KEY = new(nameof(slideDeckIconNumbers));
private static readonly AssistantSessionStateKey<Dictionary<string, string>> SLIDE_DECK_BASIS_STATE_KEY = new(nameof(slideDeckBasis));
/// <summary>
/// The layouts the model plans with: those of the chosen template, or those of a plain presentation.
/// </summary>
private PresentationTemplate Catalog => this.template ?? DEFAULT_CATALOG;
/// <summary>
/// Whether the organization dictates the template.
/// </summary>
/// <remarks>
/// The template is a preselection, so a lock binds only while preselection is enabled. Without
/// preselection, the assistant starts without a template, and the user may choose one.
/// </remarks>
private bool IsTemplateLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.TemplatePath, out var meta) && meta.IsLocked;
/// <summary>
/// Whether the template of the organization is locked, but cannot be read right now, e.g.,
/// because its network drive cannot be reached.
/// </summary>
/// <remarks>
/// The user cannot choose another template then, so it must not block the form. The assistant
/// plans an outline instead, as without a template, and the form says why.
/// </remarks>
private bool IsLockedTemplateUnavailable => this.IsTemplateLocked && !string.IsNullOrWhiteSpace(this.templatePath) && this.template is null;
/// <summary>
/// Whether the organization locked the layouts for kinds of slides.
/// </summary>
/// <remarks>
/// Like the template, the layouts are a preselection, so a lock binds only while preselection is enabled.
/// </remarks>
private bool AreLayoutsLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedLayouts, out var meta) && meta.IsLocked;
/// <summary>
/// Whether the organization locked what the presentation does with the slides of the template.
/// </summary>
/// <remarks>
/// The lock binds only for the preselected template, whose slides the choices number. For any
/// other template, the user chooses freely.
/// </remarks>
private bool AreTemplateSlideUsesLocked => this.IsPreselectedTemplate && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedTemplateSlideUses, out var meta) && meta.IsLocked;
/// <summary>
/// Whether preselection is enabled and the chosen template is the preselected one.
/// </summary>
private bool IsPreselectedTemplate => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && this.template is not null && string.Equals(this.templatePath, this.SettingsManager.ConfigurationData.SlideBuilder.TemplatePath, StringComparison.Ordinal);
/// <summary>
/// What the user chose for the slides of the template, as the model and the check of its answer see it; empty without a template.
/// </summary>
private IReadOnlyList<TemplateSlideUse> ChosenTemplateSlideUses => this.template is null ? [] : this.templateSlideUses.ToList();
private bool HasChosenTemplateSlides => this.template is not null && this.templateSlideUses.Any(use => use is not TemplateSlideUse.OMIT);
/// <summary>
/// The layouts the user chose for kinds of slides, as the current template offers them; none without a template.
/// </summary>
private ChosenLayouts ChosenLayoutsOfTemplate => this.template is null ? ChosenLayouts.NONE : ChosenLayouts.Of(this.template, this.layoutNames);
/// <summary>
/// How many slides the user asked for, or 0 when the model may choose.
/// </summary>
/// <remarks>
/// A number of slides wins over a duration. A duration means one slide per one and a half
/// minutes, as the form has always counted it.
/// </remarks>
private int ExpectedSlideCount => this.numberOfSheets > 0
? this.numberOfSheets
: (int)Math.Round(this.timeSpecification / 1.5);
private bool PlannedForCurrentTemplate => this.template is not null && this.slideDeckTemplatePath.Length > 0 && string.Equals(this.slideDeckTemplatePath, this.templatePath, StringComparison.Ordinal);
private bool CanCreatePresentation => this.slideDeck is not null && this.PlannedForCurrentTemplate && !this.isWritingPresentation && !this.IsProcessing;
private string CreatePresentationTooltip
{
get
{
if (this.slideDeck is null)
return T("Please plan the slides first.");
if (this.template is null)
return T("Please choose a PowerPoint template to create a file from the slides.");
if (!this.PlannedForCurrentTemplate)
return T("The slides were planned for another template. Please plan them again.");
return T("Creates a PowerPoint file in the design of your template.");
}
}
/// <summary>
/// Reads the layouts of the chosen template.
/// </summary>
/// <remarks>
/// A template which cannot be read leaves no template at all. The form then says why, and
/// nothing is planned for layouts nobody can write.
/// </remarks>
private void LoadTemplate()
{
this.template = null;
if (string.IsNullOrWhiteSpace(this.templatePath))
return;
if (!File.Exists(this.templatePath))
{
this.Logger.LogWarning("The PowerPoint template '{TemplatePath}' does not exist.", this.templatePath);
return;
}
try
{
this.template = PresentationTemplate.ReadFile(this.templatePath);
}
catch (Exception e)
{
this.Logger.LogWarning(e, "The PowerPoint template '{TemplatePath}' cannot be read.", this.templatePath);
return;
}
this.FitChoicesToTemplate();
}
/// <summary>
/// Fits the color variant and the choices for the slides to the loaded template.
/// </summary>
/// <remarks>
/// A variant of another template means nothing for this one, nor do choices for its slides.
/// </remarks>
private void FitChoicesToTemplate()
{
if (this.template is null)
return;
if (this.variantLabel is null || !this.template.VariantLabels.Contains(this.variantLabel))
this.variantLabel = this.template.VariantLabels.FirstOrDefault();
if (this.templateSlideUses.Count != this.template.Slides.Count)
this.templateSlideUses = DefaultTemplateSlideUsesOf(this.template);
}
private async Task OnTemplateChanged(string path)
{
this.templatePath = path;
this.templateSlideUses = [];
this.LoadTemplate();
this.ApplyPreselectedTemplateSlideUses();
if (this.Form is not null)
await this.Form.Validate();
}
/// <summary>
/// What the presentation does with the slides of a template unless the user says otherwise.
/// </summary>
/// <remarks>
/// The presentation leaves every slide out. A template serves its design first, and the slides
/// of a file are as often samples and hints as an earlier presentation: whoever wants one of them
/// in the new presentation says so.
/// </remarks>
private static List<TemplateSlideUse> DefaultTemplateSlideUsesOf(PresentationTemplate template) => Enumerable.Repeat(TemplateSlideUse.OMIT, template.Slides.Count).ToList();
/// <summary>
/// Applies what the settings preselect for the slides of the template, as long as it is the preselected template.
/// </summary>
/// <remarks>
/// The choices number the slides of the preselected template, so they mean nothing for another
/// one. A choice which the template cannot follow, e.g., for a slide it lost since, is logged.
/// </remarks>
private void ApplyPreselectedTemplateSlideUses()
{
if (!this.IsPreselectedTemplate || this.template is null)
return;
var choices = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTemplateSlideUses;
this.templateSlideUses = choices.ToTemplateSlideUses(this.template);
foreach (var (number, use) in choices)
if (this.templateSlideUses.UseOf(number) != use)
this.Logger.LogWarning("The PowerPoint template '{TemplatePath}' cannot follow the preselected use '{Use}' of its slide {Number}: it has no such slide, or the slide has no placeholders for content.", this.templatePath, use, number);
}
private TemplateSlideUse TemplateSlideUseOf(int number) => this.templateSlideUses.UseOf(number);
private void SetTemplateSlideUse(int number, TemplateSlideUse use)
{
if (number >= 1 && number <= this.templateSlideUses.Count)
this.templateSlideUses[number - 1] = use;
}
private string? ValidateTemplateSlideUse(TemplateSlide slide, TemplateSlideUse use)
{
if (use is TemplateSlideUse.EDIT && !slide.IsEditable)
return T("The LLM cannot edit this slide, because its layout has no placeholders for content. Please keep it unchanged or leave it out.");
return null;
}
/// <summary>
/// The name of the layout chosen for a kind of slide, as the form shows it.
/// </summary>
/// <returns>The name, or empty when the LLM decides, which it also does when the template has no layout of this name.</returns>
private string LayoutNameOf(LayoutPurpose purpose) => this.template is not null && this.layoutNames.TryGetValue(purpose, out var name) && this.template.FindFamilyByName(name) is not null ? name : string.Empty;
private void SetLayoutName(LayoutPurpose purpose, string name)
{
if (name.Length == 0)
this.layoutNames.Remove(purpose);
else
this.layoutNames[purpose] = name;
}
/// <summary>
/// The rules for the layouts the user chose; empty when the LLM decides every layout.
/// </summary>
private string PromptChosenLayouts() => this.ChosenLayoutsOfTemplate.ToPromptRules();
private async Task UseNoTemplate() => await this.OnTemplateChanged(string.Empty);
private string? ValidateTemplate(string path)
{
// The warning below the field explains this case, since the user cannot change the template:
if (string.IsNullOrWhiteSpace(path) || this.IsLockedTemplateUnavailable)
return null;
if (!File.Exists(path))
return T("The template file does not exist.");
if (this.template is null)
return T("The template cannot be read. Please choose a PowerPoint presentation or template (.pptx, .potx).");
if (this.template.Families.Count == 0)
return T("The template has no layouts with room for content.");
return null;
}
/// <summary>
/// The pictures for the slides, in the order the model numbers them.
/// </summary>
private List<FileAttachment> PicturesOfRequest() => this.slidePictures.Where(n => n is { Exists: true, IsImage: true }).ToList();
/// <summary>
/// The images of the source material, which the model reads but never places.
/// </summary>
private List<FileAttachment> SourceImagesOfRequest() => this.loadedDocumentPaths.Where(n => n is { Exists: true, IsImage: true }).ToList();
private string PromptPictures()
{
var rules = new List<string>();
if (this.PicturesOfRequest().Count > 0)
rules.Add(" - PICTURES lists the pictures the presentation must show, by their numbers. Place each of them exactly once, by its number, on a slide with a picture slot whose content it fits.");
if (this.PromptPictureLibraryRules() is { Length: > 0 } libraryRules)
rules.Add(libraryRules);
if (rules.Count == 0)
rules.Add(" - No pictures for the slides are attached. Leave out \"pictures\" on every slide.");
if (this.SourceImagesOfRequest().Count > 0)
rules.Add(" - Images attached as source material are for reading only. Never place them on a slide.");
return string.Join(Environment.NewLine, rules);
}
/// <summary>
/// The pictures for the slides by their numbers, for the user's request.
/// </summary>
/// <remarks>
/// The names are those of the user's files, so they go with the request, like the paths of the
/// attached documents, rather than into the system prompt. A model which reads images gets the
/// pictures as well; any other model has only their names to go by.
/// </remarks>
/// <param name="pictures">The pictures taken at the start of the request, in the order the model numbers them.</param>
/// <param name="modelSeesPictures">Whether the pictures go to the model as images.</param>
/// <returns>The section of the request; empty without pictures.</returns>
private static string PromptPictureList(IReadOnlyList<FileAttachment> pictures, bool modelSeesPictures)
{
if (pictures.Count == 0)
return string.Empty;
var list = string.Join(Environment.NewLine, pictures.Select((picture, index) => $"- Picture {index + 1}: {picture.FileName}"));
var seeing = modelSeesPictures
? "The images attached to this request start with these pictures, in this order."
: "You cannot see these pictures. Place them by what their names say.";
return $"""
# PICTURES
{seeing}
{list}
""";
}
private string PromptSlideCount()
{
var expectedSlideCount = this.ExpectedSlideCount;
if (expectedSlideCount <= 0)
return " - Choose as many slides as the content needs.";
return this.HasChosenTemplateSlides
? $" - The presentation has exactly {expectedSlideCount} slides, the title slide and the template slides included."
: $" - The presentation has exactly {expectedSlideCount} slides, the title slide included.";
}
/// <summary>
/// The rules for the slides of the template the user chose; empty when the user chose none.
/// </summary>
/// <remarks>
/// The slides themselves come with the user's request, as TEMPLATE_SLIDES, since their texts are
/// content of a file rather than instructions.
/// </remarks>
private string PromptTemplateSlides()
{
if (!this.HasChosenTemplateSlides)
return string.Empty;
return """
# Template slides
The user chose slides of the template to use in the presentation. TEMPLATE_SLIDES lists them in their order, each one either to keep unchanged or to edit.
- Write a slide to keep as { "template_slide": <its number> } and nothing else. Add "starts_chapter": true where it opens a chapter.
- Write a slide to edit with "template_slide": <its number> and its new content in the usual fields, which fill the slots its entry names. Leave out "layout": the slide keeps its own. A slot you neither fill nor remove keeps the content of the template slide, and so does every element you do not remove.
- Where an entry gives a slot its place and size "on this slide", the slide moved or resized it, e.g., to make room for a picture of its own, or carries it as a copy of another slot, e.g., a further heading with its text. Fill such a slot like any other, and stay within the lines and characters stated there rather than those of the layout.
- An entry to edit also lists the elements the slide carries besides its slots, e.g., pictures, text boxes, charts, or tables of its own, with their place and the slots they overlap. List what the slide no longer shows in "remove", by the names the entry uses, e.g., "remove": ["element 2", "label 1"].
- Remove what does not fit the new content: samples and hints of the template, e.g., placeholder pictures, sample texts such as "Lorem ipsum", or a request to name the source of a picture; old content of a slot which no longer fits; and every element which overlaps a slot you fill, unless it covers most of the slide as a background. Keep what still belongs to the slide, e.g., a logo or a decoration. Never fill and remove the same slot, and never remove the title.
- Use every listed slide exactly once, in the listed order. Add new slides before, between, and after them, wherever the content calls for it.
- Do not repeat on a new slide what a kept slide says already.
- Example: "slides": [{ "template_slide": 1 }, { "layout": "L3", "title": "..." }, { "template_slide": 3, "title": "...", "texts": [["..."]], "remove": ["element 1"] }]
""";
}
/// <summary>
/// The rule for the field listing what an edited template slide removes; empty when the user
/// edits no template slide, so that the model does not hear of the field at all.
/// </summary>
private string PromptRemoveRule() => this.template is not null && this.templateSlideUses.Any(use => use is TemplateSlideUse.EDIT)
? """
- "remove" lists the slots and elements a template slide to edit no longer shows, by the names TEMPLATE_SLIDES uses. Leave it out on every other slide.
"""
: string.Empty;
/// <summary>
/// The layouts the model plans with, for the user's request.
/// </summary>
/// <remarks>
/// The catalog names the layouts as the author of the template named them. These names come from
/// a file, which may carry instructions of somebody else, so the catalog goes with the request
/// rather than into the system prompt, and the layouts of a template pass the prompt injection
/// filter like its slides. The layouts of a plain presentation come from the code.
/// </remarks>
/// <param name="guardService">The prompt injection filter.</param>
/// <param name="promptInjectionScope">The action of the request, which reports what the filter removed.</param>
/// <param name="catalog">The layouts taken at the start of the request.</param>
/// <param name="templatePath">The template the layouts were read from; empty for the layouts of a plain presentation.</param>
/// <returns>The section of the request.</returns>
private static async Task<string> PromptLayoutsAsync(PromptInjectionGuardService guardService, PromptInjectionReportingScope promptInjectionScope, PresentationTemplate catalog, string templatePath)
{
var layouts = catalog.ToCatalogPrompt();
if (templatePath.Length > 0)
layouts = await guardService.SanitizeAsync(layouts, PromptInjectionSource.FileContent(templatePath), promptInjectionScope);
return $"""
# LAYOUTS
{layouts}
""";
}
/// <summary>
/// The slides of the template the user chose, with what they hold, for the user's request.
/// </summary>
/// <remarks>
/// Their texts come from a file, which may carry instructions of somebody else, so they pass the
/// prompt injection filter like every attached document.
/// </remarks>
/// <param name="guardService">The prompt injection filter.</param>
/// <param name="promptInjectionScope">The action of the request, which reports what the filter removed.</param>
/// <param name="catalog">The layouts and slides taken at the start of the request.</param>
/// <param name="templatePath">The template they were read from; empty without a template.</param>
/// <param name="templateSlideUses">What the user chose for each slide of the template.</param>
/// <returns>The section of the request; empty when the user chose no slide.</returns>
private static async Task<string> PromptTemplateSlidesAsync(PromptInjectionGuardService guardService, PromptInjectionReportingScope promptInjectionScope, PresentationTemplate catalog, string templatePath, IReadOnlyList<TemplateSlideUse> templateSlideUses)
{
if (templatePath.Length == 0 || catalog.ToTemplateSlidesPrompt(templateSlideUses) is not { Length: > 0 } templateSlides)
return string.Empty;
var sanitizedTemplateSlides = await guardService.SanitizeAsync(templateSlides, PromptInjectionSource.FileContent(templatePath), promptInjectionScope);
return $"""
# TEMPLATE_SLIDES
{sanitizedTemplateSlides}
""";
}
private string PromptBulletPoints()
{
var count = this.numberOfBulletPoints > 0
? $" - A slide has at most {this.numberOfBulletPoints} main points, all its text slots together. Fewer are fine where the content needs fewer."
: " - Choose between 1 and 7 main points per text slot, as its space allows.";
return $"""
{count}
- Every point is short: at most 12 words, clear, and directly related to the slide title.
""";
}
/// <summary>
/// Reads the slide deck out of an answer and checks it against the layouts it was planned for.
/// </summary>
/// <returns>The deck, or none when the answer cannot be read, with the issues, written for the model.</returns>
private CheckedAnswer CheckAnswer(string answer, PresentationTemplate catalog, int pictureCount, LibraryPictures libraryPictures, IReadOnlyList<TemplateSlideUse> chosenTemplateSlideUses, ChosenLayouts chosenLayouts)
{
if (!SlideDeckSpec.TryParse(answer, out var deck, out var issue))
return new CheckedAnswer(null, [issue]);
return new CheckedAnswer(deck, SlideDeckSpecValidator.Validate(deck, catalog, pictureCount, this.ExpectedSlideCount, chosenTemplateSlideUses, chosenLayouts, this.numberOfBulletPoints, libraryPictures));
}
private static string RepairPromptOf(IReadOnlyList<string> issues)
{
var listedIssues = string.Join(Environment.NewLine, issues.Take(MAX_REPORTED_ISSUES).Select(issue => $"- {issue}"));
return $"""
Your JSON does not fit the layouts yet:
{listedIssues}
Answer with the complete corrected JSON object and nothing else.
""";
}
/// <summary>
/// Shows the slide deck as an outline, which is the visible result of the assistant.
/// </summary>
/// <remarks>
/// The outline becomes the last block of the chat, so copying the result, exporting it, and
/// sending it to a chat all work with the outline rather than with the JSON behind it.
/// </remarks>
private void ShowOutline(SlideDeckSpec deck, PresentationTemplate catalog, IReadOnlyDictionary<int, string> pictureNames)
{
var outline = new ContentBlock
{
Time = DateTimeOffset.Now,
ContentType = ContentType.TEXT,
Role = ChatRole.AI,
HideFromUser = false,
Content = new ContentText { Text = deck.ToMarkdownOutline(catalog, pictureNames) },
};
this.ChatThread?.Blocks.Add(outline);
this.ResultingContentBlock = outline;
}
private void RestoreSlideDeck() => this.slideDeck = this.slideDeckAnswer.Length > 0 && SlideDeckSpec.TryParse(this.slideDeckAnswer, out var deck, out _) ? deck : null;
private async Task CreatePresentationFileAsync()
{
if (!this.CanCreatePresentation || this.slideDeck is not { } deck)
return;
var fileName = FileExportFormatExtensions.ToFileNameFragment(deck.Title);
var response = await this.RustService.SaveFile(T("Save the presentation"), [FileTypes.POWER_POINT_PRESENTATION], $"{(fileName.Length == 0 ? "presentation" : fileName)}.pptx");
if (response.UserCancelled)
return;
var targetPath = response.SaveFilePath;
this.isWritingPresentation = true;
this.StateHasChanged();
// Pictures make this take seconds, since each of them is prepared anew:
await using var feedback = new FileExportFeedback();
feedback.ShowRunning(targetPath);
try
{
var options = new PresentationWriteOptions(this.variantLabel, this.footerText.Trim(), this.keepEmptyPicturePlaceholders);
var pictures = new RustPictureSource(this.RustService, this.slideDeckPicturePaths, this.slideDeckIconNumbers);
var result = await PresentationFile.CreateAsync(this.slideDeckTemplatePath, targetPath, deck, this.slideDeckBasis, options, pictures, this.Logger, CancellationToken.None);
if (!result.IsWritten)
{
this.Logger.LogWarning("The PowerPoint template '{TemplatePath}' changed since the slides were planned, and they no longer fit it.", this.slideDeckTemplatePath);
await this.MessageBus.SendError(new(Icons.Material.Filled.Slideshow, T("The template has changed since the slides were planned. Please create the slides again.")));
return;
}
var placements = deck.Slides.SelectMany(slide => slide.Pictures).ToList();
this.Logger.LogInformation("The slide assistant saved {SlideCount} slides to '{FilePath}', with {PlacedPictureCount} of {PictureCount} pictures placed and empty picture placeholders {EmptyPicturePlaceholders}.", deck.Slides.Count, targetPath, placements.Count(number => !result.SkippedPictures.Contains(number)), placements.Count, this.keepEmptyPicturePlaceholders ? "kept" : "left out");
if (result.SkippedPictures.Count > 0)
{
var names = result.SkippedPictures.Select(number => this.slideDeckPicturePaths.TryGetValue(number, out var picturePath) ? Path.GetFileName(picturePath) : number.ToString(CultureInfo.InvariantCulture));
await this.MessageBus.SendWarning(new(Icons.Material.Filled.HideImage, string.Format(T("These pictures could not be placed, because PowerPoint cannot show their format or they cannot be read: {0}"), string.Join(", ", names))));
}
await feedback.SucceededAsync(Icons.Material.Filled.Slideshow, T("The PowerPoint file was created."), targetPath, true);
}
catch (Exception e)
{
this.Logger.LogError(e, "Creating the PowerPoint file '{FilePath}' failed.", targetPath);
await this.MessageBus.SendError(new(Icons.Material.Filled.Slideshow, T("The PowerPoint file could not be created. Please check that the template can be read and that the target file is not open in another program.")));
}
finally
{
this.isWritingPresentation = false;
this.StateHasChanged();
}
}
/// <summary>
/// A slide deck read out of an answer of the model, with what is wrong with it.
/// </summary>
/// <param name="Deck">The deck; null when the answer cannot be read.</param>
/// <param name="Issues">The issues, written for the model; empty when the deck fits.</param>
private sealed record CheckedAnswer(SlideDeckSpec? Deck, IReadOnlyList<string> Issues);
}
@@ -0,0 +1,60 @@
using AIStudio.Chat;
using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.PowerPoint;
namespace AIStudio.Assistants.SlideBuilder;
/// <summary>
/// The part of the Slide Assistant which tells, below the progress bar, what the LLM is doing while
/// it plans.
/// </summary>
/// <remarks>
/// The answer is JSON, which the assistant does not show. Without these texts, the user would watch
/// a bar for minutes without knowing whether anything happens: the LLM first thinks about the
/// structure, which streams nothing at all, then writes the slides one by one, and may fit them to
/// the layouts in a second round.
/// </remarks>
public partial class SlideAssistant
{
private PlanningStep planningStep;
private static readonly AssistantSessionStateKey<PlanningStep> PLANNING_STEP_STATE_KEY = new(nameof(planningStep));
private ContentText? countedAnswer;
private StreamedSlideCounter slideCounter = new();
protected override string ProgressText => this.planningStep switch
{
PlanningStep.PLANNING when this.SlidesSoFar() is var slides and > 0 => string.Format(T("The LLM is writing slide {0}..."), slides),
PlanningStep.PLANNING => T("The LLM is planning the structure of the presentation..."),
PlanningStep.REPAIRING => T("The LLM is fitting the slides to the layouts..."),
_ => string.Empty,
};
/// <summary>
/// Counts the slides which the answer streaming in has started so far.
/// </summary>
private int SlidesSoFar()
{
if (this.ResultingContentBlock?.Content is not ContentText answer)
return 0;
if (!ReferenceEquals(answer, this.countedAnswer))
{
this.countedAnswer = answer;
this.slideCounter = new();
}
return this.slideCounter.Count(answer.Text);
}
/// <summary>
/// What the LLM is asked to do right now.
/// </summary>
private enum PlanningStep
{
NONE,
PLANNING,
REPAIRING,
}
}
@@ -1,7 +1,9 @@
using System.Text; using System.Text;
using AIStudio.Chat; using AIStudio.Chat;
using AIStudio.Dialogs.Settings; using AIStudio.Dialogs.Settings;
using AIStudio.Settings;
using AIStudio.Tools.AssistantSessions; using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.PowerPoint;
using AIStudio.Tools.Security; using AIStudio.Tools.Security;
namespace AIStudio.Assistants.SlideBuilder; namespace AIStudio.Assistants.SlideBuilder;
@@ -10,52 +12,77 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
{ {
protected override Tools.Components Component => Tools.Components.SLIDE_BUILDER_ASSISTANT; protected override Tools.Components Component => Tools.Components.SLIDE_BUILDER_ASSISTANT;
protected override string Title => T("Slide Planner Assistant"); protected override string Title => T("Slide Assistant");
protected override string Description => T("This assistant helps you create clear, structured slides from long texts or documents. Enter a presentation title and provide the content either as text or with one or more documents. Important aspects allow you to add instructions to the LLM regarding output or formatting. Set the number of slides either directly or based on your desired presentation duration. You can also specify the number of bullet points. If the default value of 0 is not changed, the LLM will independently determine how many slides or bullet points to generate. The output can be flexibly generated in various languages and tailored to a specific audience."); protected override string Description => T("This assistant helps you create clear, structured slides from long texts or documents. Enter a presentation title and provide the content either as text or with one or more documents. Important aspects allow you to add instructions to the LLM regarding output or formatting. Set the number of slides either directly or based on your desired presentation duration. You can also limit the number of bullet points per slide. If the default value of 0 is not changed, the LLM will independently determine how many slides or bullet points to generate. The output can be flexibly generated in various languages and tailored to a specific audience. When you choose a PowerPoint template, the slides use its layouts, and you can create a PowerPoint file in its design from the result.");
protected override string SystemPrompt => protected override string SystemPrompt =>
$$$""" $$$"""
You are a professional presentation editor and writer. You are a professional presentation editor and writer.
Create a clear, single-slide outline from the user's inputs. Plan a presentation from the user's inputs and describe it slide by slide as JSON, using the slide layouts LAYOUTS lists.
# Presentation title: # Presentation title:
- IGNORE the language of the PRESENTATION_TITLE. - IGNORE the language of the PRESENTATION_TITLE.
- Translate PRESENTATION_TITLE in: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}} - Translate PRESENTATION_TITLE in: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
# Content # Content
- You get the following inputs: PRESENTATION_TITLE, PRESENTATION_CONTENT, and any attached documents that may provide additional context or source material (DOCUMENTS). - You get the following inputs: PRESENTATION_TITLE, PRESENTATION_CONTENT, the layouts the slides use (LAYOUTS), any attached documents (DOCUMENTS) or images that provide source material, and the pictures for the slides (PICTURES).
- Base the slides on the provided source material: PRESENTATION_CONTENT, DOCUMENTS, and attached images.
- If both PRESENTATION_CONTENT and attached source material are provided, use all of them, while prioritizing direct user instructions from PRESENTATION_CONTENT when resolving ambiguity.
- If PRESENTATION_CONTENT is empty but attached source material is available, create the slides from the attached source material.
- If neither PRESENTATION_CONTENT nor any attached source material is available, create a single slide with the title and the text "No content provided."
{{{this.GetDocumentTaskDescription()}}} {{{this.GetDocumentTaskDescription()}}}
{{{this.PromptImportantAspects()}}} {{{this.PromptImportantAspects()}}}
# Subheadings # Layouts
- Rule for creating the individual subheadings: Every slide uses one of the layouts LAYOUTS lists. A layout offers slots, listed in reading order with their place on the slide and how much text fits.
- If {{{this.numberOfSheets}}} is NOT 0 - Choose for each slide the layout whose slots suit its content, and vary the layouts where the content allows.
- Generate exactly {{{this.numberOfSheets}}} precise subheadings, each heading represents one slide in a presentation. - Fill only slots the layout offers, and stay within the stated lines and characters. Spread content over more slides rather than overfilling one.
- If {{{this.timeSpecification}}} is NOT 0 - A label is a short text, e.g., a heading above a text or the source below a picture.
- Generate exactly {{{this.calculatedNumberOfSlides}}} precise subheadings, each heading represents one slide in a presentation. - Set "starts_chapter" to true on each slide which opens a new part of the presentation. Where a layout is meant for chapters or sections, use it for these slides.
- If either parameter is 0, ignore that rules. {{{this.PromptChosenLayouts()}}}
- Each subheadings must have: {{{this.PromptTemplateSlides()}}}
- A clear, concise, and thematically meaningful heading.
- Place *** on its own line immediately before each heading.
# Bullet points (per subheading) # Pictures
- You MUST generate exactly this {{{this.numberOfBulletPoints}}} many bullet points per subheading: {{{this.PromptPictures()}}}
- If {{{this.numberOfBulletPoints}}} == 0 → choose a number between 1 and 7 (your choice, but max 7).
- Each bullet point must have:
- Each bullet point must be max 12 words.
- Clear and directly related to the subheading and summarizing the slide’s content.
# Output requirements: # Slides
- Output only Markdown. {{{this.PromptSlideCount()}}}
- Start with a single H1 title that contains the user's PRESENTATION_TITLE. - Every slide has a clear, concise, and thematically meaningful title.
- Then add headings with own bullet lists based on the provided source material: PRESENTATION_CONTENT, DOCUMENTS, and attached images.
- If both PRESENTATION_CONTENT and attached source material are provided, use all of them, while prioritizing direct user instructions from PRESENTATION_CONTENT when resolving ambiguity. # Bullet points (per text slot)
- If PRESENTATION_CONTENT is empty but attached source material is available, create the slides from the attached source material. {{{this.PromptBulletPoints()}}}
- If neither PRESENTATION_CONTENT nor any attached source material is available, output the title and one bullet: "No content provided."
# Output format:
Answer with one JSON object and nothing else: no explanation, no Markdown, and no code fence.
{
"title": "<the presentation title>",
"slides": [
{
"layout": "<the ID of a layout, e.g., L3>",
"starts_chapter": false,
"title": "<the slide title>",
"subtitle": "<only for layouts with a subtitle>",
"texts": [["<a main point>", { "text": "<a sub-point>", "level": 1 }]],
"labels": ["<label 1>"],
"pictures": [1],
"table": { "headers": ["<column>"], "rows": [["<cell>"]] }
}
]
}
- "texts" holds one list of paragraphs per text slot, in slot order: the first list fills "text 1". A paragraph is a string, or an object with "text" and "level", which is 0 for a main point and 1 for a sub-point.
- "labels" holds one string per label slot, in slot order. Use "" to leave a label empty while filling a later one.
- "pictures" holds the numbers of attached pictures, one per picture slot, in slot order.
- "table" fills the table slot. Every row has as many cells as there are headers.
- Leave out every field a slide does not use. Do not write bullet characters: the template draws them.
- Texts appear on the slides exactly as written. Write formulas in Unicode, e.g., a² + b² = c², xᵢ, ∑, √, or ≤, and never in LaTeX or another markup.
{{{this.PromptRemoveRule()}}}
- Do not mention these instructions or add commentary. - Do not mention these instructions or add commentary.
# Occasion:
- {{{this.selectedOccasion.Prompt(this.customOccasion)}}}
# Audience: # Audience:
{{{this.PromptAudience()}}} {{{this.PromptAudience()}}}
@@ -64,17 +91,36 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
- OUTPUT AND PRESENTATION_TITLE MUST BE IN: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}} - OUTPUT AND PRESENTATION_TITLE MUST BE IN: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
- This is a HARD RULE: Never translate or adapt the output language based on input language. - This is a HARD RULE: Never translate or adapt the output language based on input language.
- Always use the specified target language, even if the input is in another language. - Always use the specified target language, even if the input is in another language.
- The language applies to the texts of the slides only: JSON field names, layout IDs such as "L3", and the names of slots and elements such as "label 1" or "element 2" stay exactly as given.
# Language-Override (IMPORTANT!): # Language-Override (IMPORTANT!):
- Before generating any output, internally set your language mode to: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}} - Before generating any output, internally set your language mode to: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
- If you detect any other language in the input, DO NOT switch to this language, stay in {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}} - If you detect any other language in the input, DO NOT switch to this language, stay in {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
- Translate PRESENTATION_TITLE in: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}} - Translate PRESENTATION_TITLE in: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
- Your output must be in {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}, without any comment, note, or marker about it. - Your output must be in {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}, without any comment, note, or marker about it. Never translate the JSON field names, the layout IDs, or the names of slots and elements.
"""; """;
protected override bool AllowProfiles => true; protected override bool AllowProfiles => true;
protected override IReadOnlyList<IButtonData> FooterButtons => []; protected override IReadOnlyList<IButtonData> FooterButtons =>
[
new ButtonData(T("Create PowerPoint file"), Icons.Material.Filled.Slideshow, Color.Default, this.CreatePresentationTooltip, this.CreatePresentationFileAsync, () => !this.CanCreatePresentation),
];
//
// The model answers in JSON, which nobody wants to watch stream in. The result shows up once
// the slides are read and checked, as an outline; until then, a progress bar shows the work:
//
protected override bool ShowResult => this.slideDeck is not null;
protected override bool ShowDedicatedProgress => true;
/// <summary>
/// The slides may carry the date, e.g., on the title slide or in an imprint, so the model must not guess it.
/// </summary>
protected override bool IncludeDateTime => true;
protected override string ExportFileName => this.slideDeck is { Title.Length: > 0 } deck ? deck.Title : this.Title;
protected override string SubmitText => T("Create Slides"); protected override string SubmitText => T("Create Slides");
@@ -95,7 +141,7 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
return new ChatThread return new ChatThread
{ {
ChatId = Guid.NewGuid(), ChatId = Guid.NewGuid(),
Name = string.Format(T("{0} - Slide Builder Session"), this.inputTitle), Name = string.Format(T("{0} - Slide Assistant Session"), this.inputTitle),
SystemPrompt = SystemPrompts.DEFAULT, SystemPrompt = SystemPrompts.DEFAULT,
Blocks = Blocks =
[ [
@@ -108,8 +154,8 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
ContentType = ContentType.TEXT, ContentType = ContentType.TEXT,
Content = new ContentText Content = new ContentText
{ {
Text = this.T("The result of your previous slide builder session."), Text = this.T("The result of your previous Slide Assistant session."),
FileAttachments = this.loadedDocumentPaths.ToList(), FileAttachments = [..this.loadedDocumentPaths, ..this.slidePictures],
} }
}, },
@@ -157,14 +203,32 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
this.inputTitle = string.Empty; this.inputTitle = string.Empty;
this.inputContent = string.Empty; this.inputContent = string.Empty;
this.loadedDocumentPaths.Clear(); this.loadedDocumentPaths.Clear();
this.slidePictures.Clear();
this.selectedAudienceProfile = AudienceProfile.UNSPECIFIED; this.selectedAudienceProfile = AudienceProfile.UNSPECIFIED;
this.selectedAudienceAgeGroup = AudienceAgeGroup.UNSPECIFIED; this.selectedAudienceAgeGroup = AudienceAgeGroup.UNSPECIFIED;
this.selectedAudienceOrganizationalLevel = AudienceOrganizationalLevel.UNSPECIFIED; this.selectedAudienceOrganizationalLevel = AudienceOrganizationalLevel.UNSPECIFIED;
this.selectedAudienceExpertise = AudienceExpertise.UNSPECIFIED; this.selectedAudienceExpertise = AudienceExpertise.UNSPECIFIED;
this.selectedOccasion = PresentationOccasion.UNSPECIFIED;
this.customOccasion = string.Empty;
this.slideDeck = null;
this.slideDeckAnswer = string.Empty;
this.slideDeckTemplatePath = string.Empty;
this.slideDeckPicturePaths.Clear();
this.slideDeckIconNumbers.Clear();
this.slideDeckBasis.Clear();
this.templateSlideUses = [];
this.layoutNames.Clear();
if (!this.MightPreselectValues()) if (!this.MightPreselectValues())
{ {
this.selectedTargetLanguage = CommonLanguages.AS_IS; this.selectedTargetLanguage = CommonLanguages.AS_IS;
this.customTargetLanguage = string.Empty; this.customTargetLanguage = string.Empty;
this.footerText = string.Empty;
this.keepEmptyPicturePlaceholders = false;
this.numberOfBulletPoints = 0;
this.pictureLibraryPath = string.Empty;
this.StartCountingPictureLibrary();
this.templatePath = string.Empty;
this.LoadTemplate();
} }
} }
@@ -172,12 +236,26 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
{ {
if (this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions) if (this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)
{ {
this.templatePath = this.SettingsManager.ConfigurationData.SlideBuilder.TemplatePath;
this.LoadTemplate();
this.ApplyPreselectedTemplateSlideUses();
this.footerText = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedFooterText;
this.keepEmptyPicturePlaceholders = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedKeepEmptyPicturePlaceholders;
this.numberOfBulletPoints = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedMaxBulletPointsPerSlide;
this.pictureLibraryPath = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedPictureLibraryPath;
this.StartCountingPictureLibrary();
this.layoutNames.Clear();
foreach (var (purpose, layoutName) in this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedLayouts)
this.layoutNames[purpose] = layoutName;
this.selectedTargetLanguage = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTargetLanguage; this.selectedTargetLanguage = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTargetLanguage;
this.customTargetLanguage = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOtherLanguage; this.customTargetLanguage = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOtherLanguage;
this.selectedAudienceProfile = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceProfile; this.selectedAudienceProfile = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceProfile;
this.selectedAudienceAgeGroup = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceAgeGroup; this.selectedAudienceAgeGroup = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceAgeGroup;
this.selectedAudienceOrganizationalLevel = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceOrganizationalLevel; this.selectedAudienceOrganizationalLevel = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceOrganizationalLevel;
this.selectedAudienceExpertise = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceExpertise; this.selectedAudienceExpertise = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceExpertise;
this.selectedOccasion = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOccasion;
this.customOccasion = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOtherOccasion;
this.importantAspects = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedImportantAspects; this.importantAspects = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedImportantAspects;
return true; return true;
} }
@@ -193,12 +271,26 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
private AudienceOrganizationalLevel selectedAudienceOrganizationalLevel; private AudienceOrganizationalLevel selectedAudienceOrganizationalLevel;
private AudienceExpertise selectedAudienceExpertise; private AudienceExpertise selectedAudienceExpertise;
private CommonLanguages selectedTargetLanguage; private CommonLanguages selectedTargetLanguage;
private PresentationOccasion selectedOccasion;
private string customOccasion = string.Empty;
private int numberOfSheets; private int numberOfSheets;
private int numberOfBulletPoints; private int numberOfBulletPoints;
/// <summary>
/// Whether the organization dictates the maximum number of bullet points per slide, e.g., to fit its template.
/// </summary>
/// <remarks>
/// Like the template, the maximum is a preselection, so a lock binds only while preselection is enabled.
/// </remarks>
private bool IsBulletPointMaximumLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedMaxBulletPointsPerSlide, out var meta) && meta.IsLocked;
private int timeSpecification; private int timeSpecification;
private int calculatedNumberOfSlides;
private string importantAspects = string.Empty; private string importantAspects = string.Empty;
private HashSet<FileAttachment> loadedDocumentPaths = []; private HashSet<FileAttachment> loadedDocumentPaths = [];
/// <summary>
/// The pictures the presentation must show, each on exactly one slide; source material lives in loadedDocumentPaths.
/// </summary>
private HashSet<FileAttachment> slidePictures = [];
private static readonly AssistantSessionStateKey<string> INPUT_TITLE_STATE_KEY = new(nameof(inputTitle)); private static readonly AssistantSessionStateKey<string> INPUT_TITLE_STATE_KEY = new(nameof(inputTitle));
private static readonly AssistantSessionStateKey<string> INPUT_CONTENT_STATE_KEY = new(nameof(inputContent)); private static readonly AssistantSessionStateKey<string> INPUT_CONTENT_STATE_KEY = new(nameof(inputContent));
private static readonly AssistantSessionStateKey<string> CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); private static readonly AssistantSessionStateKey<string> CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage));
@@ -207,12 +299,14 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
private static readonly AssistantSessionStateKey<AudienceOrganizationalLevel> SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY = new(nameof(selectedAudienceOrganizationalLevel)); private static readonly AssistantSessionStateKey<AudienceOrganizationalLevel> SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY = new(nameof(selectedAudienceOrganizationalLevel));
private static readonly AssistantSessionStateKey<AudienceExpertise> SELECTED_AUDIENCE_EXPERTISE_STATE_KEY = new(nameof(selectedAudienceExpertise)); private static readonly AssistantSessionStateKey<AudienceExpertise> SELECTED_AUDIENCE_EXPERTISE_STATE_KEY = new(nameof(selectedAudienceExpertise));
private static readonly AssistantSessionStateKey<CommonLanguages> SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); private static readonly AssistantSessionStateKey<CommonLanguages> SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage));
private static readonly AssistantSessionStateKey<PresentationOccasion> SELECTED_OCCASION_STATE_KEY = new(nameof(selectedOccasion));
private static readonly AssistantSessionStateKey<string> CUSTOM_OCCASION_STATE_KEY = new(nameof(customOccasion));
private static readonly AssistantSessionStateKey<int> NUMBER_OF_SHEETS_STATE_KEY = new(nameof(numberOfSheets)); private static readonly AssistantSessionStateKey<int> NUMBER_OF_SHEETS_STATE_KEY = new(nameof(numberOfSheets));
private static readonly AssistantSessionStateKey<int> NUMBER_OF_BULLET_POINTS_STATE_KEY = new(nameof(numberOfBulletPoints)); private static readonly AssistantSessionStateKey<int> NUMBER_OF_BULLET_POINTS_STATE_KEY = new(nameof(numberOfBulletPoints));
private static readonly AssistantSessionStateKey<int> TIME_SPECIFICATION_STATE_KEY = new(nameof(timeSpecification)); private static readonly AssistantSessionStateKey<int> TIME_SPECIFICATION_STATE_KEY = new(nameof(timeSpecification));
private static readonly AssistantSessionStateKey<int> CALCULATED_NUMBER_OF_SLIDES_STATE_KEY = new(nameof(calculatedNumberOfSlides));
private static readonly AssistantSessionStateKey<string> IMPORTANT_ASPECTS_STATE_KEY = new(nameof(importantAspects)); private static readonly AssistantSessionStateKey<string> IMPORTANT_ASPECTS_STATE_KEY = new(nameof(importantAspects));
private static readonly AssistantSessionStateKey<HashSet<FileAttachment>> LOADED_DOCUMENT_PATHS_STATE_KEY = new(nameof(loadedDocumentPaths)); private static readonly AssistantSessionStateKey<HashSet<FileAttachment>> LOADED_DOCUMENT_PATHS_STATE_KEY = new(nameof(loadedDocumentPaths));
private static readonly AssistantSessionStateKey<HashSet<FileAttachment>> SLIDE_PICTURES_STATE_KEY = new(nameof(slidePictures));
/// <inheritdoc /> /// <inheritdoc />
protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state)
@@ -225,17 +319,37 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
state.Set(SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY, this.selectedAudienceOrganizationalLevel); state.Set(SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY, this.selectedAudienceOrganizationalLevel);
state.Set(SELECTED_AUDIENCE_EXPERTISE_STATE_KEY, this.selectedAudienceExpertise); state.Set(SELECTED_AUDIENCE_EXPERTISE_STATE_KEY, this.selectedAudienceExpertise);
state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage);
state.Set(SELECTED_OCCASION_STATE_KEY, this.selectedOccasion);
state.Set(CUSTOM_OCCASION_STATE_KEY, this.customOccasion);
state.Set(NUMBER_OF_SHEETS_STATE_KEY, this.numberOfSheets); state.Set(NUMBER_OF_SHEETS_STATE_KEY, this.numberOfSheets);
state.Set(NUMBER_OF_BULLET_POINTS_STATE_KEY, this.numberOfBulletPoints); state.Set(NUMBER_OF_BULLET_POINTS_STATE_KEY, this.numberOfBulletPoints);
state.Set(TIME_SPECIFICATION_STATE_KEY, this.timeSpecification); state.Set(TIME_SPECIFICATION_STATE_KEY, this.timeSpecification);
state.Set(CALCULATED_NUMBER_OF_SLIDES_STATE_KEY, this.calculatedNumberOfSlides);
state.Set(IMPORTANT_ASPECTS_STATE_KEY, this.importantAspects); state.Set(IMPORTANT_ASPECTS_STATE_KEY, this.importantAspects);
state.SetHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths); state.SetHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths);
state.SetHashSet(SLIDE_PICTURES_STATE_KEY, this.slidePictures);
state.Set(TEMPLATE_PATH_STATE_KEY, this.templatePath);
state.Set(FOOTER_TEXT_STATE_KEY, this.footerText);
state.Set(KEEP_EMPTY_PICTURE_PLACEHOLDERS_STATE_KEY, this.keepEmptyPicturePlaceholders);
state.Set(VARIANT_LABEL_STATE_KEY, this.variantLabel ?? string.Empty);
state.Set(ADVANCED_OPTIONS_EXPANDED_STATE_KEY, this.advancedOptionsExpanded);
state.SetList(TEMPLATE_SLIDE_USES_STATE_KEY, this.templateSlideUses);
state.SetDictionary(LAYOUT_NAMES_STATE_KEY, this.layoutNames);
state.Set(SLIDE_DECK_ANSWER_STATE_KEY, this.slideDeckAnswer);
state.Set(SLIDE_DECK_TEMPLATE_PATH_STATE_KEY, this.slideDeckTemplatePath);
state.SetDictionary(SLIDE_DECK_PICTURE_PATHS_STATE_KEY, this.slideDeckPicturePaths);
state.SetHashSet(SLIDE_DECK_ICON_NUMBERS_STATE_KEY, this.slideDeckIconNumbers);
state.Set(PICTURE_LIBRARY_PATH_STATE_KEY, this.pictureLibraryPath);
state.SetDictionary(SLIDE_DECK_BASIS_STATE_KEY, this.slideDeckBasis);
state.Set(PLANNING_STEP_STATE_KEY, this.planningStep);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state)
{ {
var previousTemplatePath = this.templatePath;
var previousSlideDeckAnswer = this.slideDeckAnswer;
var previousPictureLibraryPath = this.pictureLibraryPath;
state.Restore(INPUT_TITLE_STATE_KEY, value => this.inputTitle = value); state.Restore(INPUT_TITLE_STATE_KEY, value => this.inputTitle = value);
state.Restore(INPUT_CONTENT_STATE_KEY, value => this.inputContent = value); state.Restore(INPUT_CONTENT_STATE_KEY, value => this.inputContent = value);
state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value);
@@ -244,12 +358,45 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
state.Restore(SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY, value => this.selectedAudienceOrganizationalLevel = value); state.Restore(SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY, value => this.selectedAudienceOrganizationalLevel = value);
state.Restore(SELECTED_AUDIENCE_EXPERTISE_STATE_KEY, value => this.selectedAudienceExpertise = value); state.Restore(SELECTED_AUDIENCE_EXPERTISE_STATE_KEY, value => this.selectedAudienceExpertise = value);
state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value);
state.Restore(SELECTED_OCCASION_STATE_KEY, value => this.selectedOccasion = value);
state.Restore(CUSTOM_OCCASION_STATE_KEY, value => this.customOccasion = value);
state.Restore(NUMBER_OF_SHEETS_STATE_KEY, value => this.numberOfSheets = value); state.Restore(NUMBER_OF_SHEETS_STATE_KEY, value => this.numberOfSheets = value);
state.Restore(NUMBER_OF_BULLET_POINTS_STATE_KEY, value => this.numberOfBulletPoints = value); state.Restore(NUMBER_OF_BULLET_POINTS_STATE_KEY, value => this.numberOfBulletPoints = value);
state.Restore(TIME_SPECIFICATION_STATE_KEY, value => this.timeSpecification = value); state.Restore(TIME_SPECIFICATION_STATE_KEY, value => this.timeSpecification = value);
state.Restore(CALCULATED_NUMBER_OF_SLIDES_STATE_KEY, value => this.calculatedNumberOfSlides = value);
state.Restore(IMPORTANT_ASPECTS_STATE_KEY, value => this.importantAspects = value); state.Restore(IMPORTANT_ASPECTS_STATE_KEY, value => this.importantAspects = value);
state.RestoreHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths); state.RestoreHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths);
state.RestoreHashSet(SLIDE_PICTURES_STATE_KEY, this.slidePictures);
state.Restore(TEMPLATE_PATH_STATE_KEY, value => this.templatePath = value);
state.Restore(FOOTER_TEXT_STATE_KEY, value => this.footerText = value);
state.Restore(KEEP_EMPTY_PICTURE_PLACEHOLDERS_STATE_KEY, value => this.keepEmptyPicturePlaceholders = value);
state.Restore(VARIANT_LABEL_STATE_KEY, value => this.variantLabel = value.Length > 0 ? value : null);
state.Restore(ADVANCED_OPTIONS_EXPANDED_STATE_KEY, value => this.advancedOptionsExpanded = value);
state.Restore(TEMPLATE_SLIDE_USES_STATE_KEY, value => this.templateSlideUses = value.ToList());
state.RestoreDictionary(LAYOUT_NAMES_STATE_KEY, this.layoutNames);
state.Restore(SLIDE_DECK_ANSWER_STATE_KEY, value => this.slideDeckAnswer = value);
state.Restore(SLIDE_DECK_TEMPLATE_PATH_STATE_KEY, value => this.slideDeckTemplatePath = value);
state.RestoreDictionary(SLIDE_DECK_PICTURE_PATHS_STATE_KEY, this.slideDeckPicturePaths);
state.RestoreHashSet(SLIDE_DECK_ICON_NUMBERS_STATE_KEY, this.slideDeckIconNumbers);
state.Restore(PICTURE_LIBRARY_PATH_STATE_KEY, value => this.pictureLibraryPath = value);
state.RestoreDictionary(SLIDE_DECK_BASIS_STATE_KEY, this.slideDeckBasis);
state.Restore(PLANNING_STEP_STATE_KEY, value => this.planningStep = value);
//
// Neither the template nor the slides nor the count of the library are kept themselves, only
// what they were read from. A running session stores its state every few seconds, and the
// template is read on the thread of the renderer, maybe from a network drive. So each one is
// read again only when what it was read from has changed:
//
if (string.Equals(previousTemplatePath, this.templatePath, StringComparison.Ordinal))
this.FitChoicesToTemplate();
else
this.LoadTemplate();
if (!string.Equals(previousSlideDeckAnswer, this.slideDeckAnswer, StringComparison.Ordinal))
this.RestoreSlideDeck();
if (!string.Equals(previousPictureLibraryPath, this.pictureLibraryPath, StringComparison.Ordinal))
this.StartCountingPictureLibrary();
} }
#region Overrides of ComponentBase #region Overrides of ComponentBase
@@ -280,14 +427,42 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
return null; return null;
} }
private bool HasValidInputDocuments() => this.loadedDocumentPaths.Any(n => n is { Exists: true }); private bool HasValidInputDocuments() => this.loadedDocumentPaths.Any(n => n is { Exists: true }) || this.slidePictures.Any(n => n is { Exists: true });
private async Task OnDocumentsChanged(HashSet<FileAttachment> _) private async Task OnDocumentsChanged(HashSet<FileAttachment> _)
{ {
await this.KeepSlidePicturesOutOfDocumentsAsync();
if(this.Form is not null) if(this.Form is not null)
await this.Form.Validate(); await this.Form.Validate();
} }
private async Task OnSlidePicturesChanged(HashSet<FileAttachment> _)
{
await this.KeepSlidePicturesOutOfDocumentsAsync();
if(this.Form is not null)
await this.Form.Validate();
}
/// <summary>
/// Keeps the pictures for the slides out of the source material.
/// </summary>
/// <remarks>
/// A picture is either source material, which the LLM only reads, or a picture for the slides,
/// which the presentation must show; it cannot be both. The pictures for the slides win, since
/// the documents take every file, while the pictures take only what a slide can show. Without
/// the warning, the file would vanish from the documents without a word.
/// </remarks>
private async Task KeepSlidePicturesOutOfDocumentsAsync()
{
var picturePaths = this.slidePictures.Select(picture => picture.FilePath).ToHashSet(PathTools.COMPARER);
var displaced = this.loadedDocumentPaths.Where(document => picturePaths.Contains(document.FilePath)).ToArray();
if (displaced.Length == 0)
return;
this.loadedDocumentPaths.ExceptWith(displaced);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, string.Format(T("These files are already attached as pictures for the slides and were removed from the documents: {0}"), string.Join(", ", displaced.Select(document => document.FileName)))));
}
private string? ValidateCustomLanguage(string language) private string? ValidateCustomLanguage(string language)
{ {
if(this.selectedTargetLanguage == CommonLanguages.OTHER && string.IsNullOrWhiteSpace(language)) if(this.selectedTargetLanguage == CommonLanguages.OTHER && string.IsNullOrWhiteSpace(language))
@@ -296,9 +471,12 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
return null; return null;
} }
private int CalculateNumberOfSlides() private string? ValidateCustomOccasion(string occasion)
{ {
return this.calculatedNumberOfSlides = (int)Math.Round(this.timeSpecification / 1.5); if(this.selectedOccasion == PresentationOccasion.OTHER && string.IsNullOrWhiteSpace(occasion))
return T("Please describe the occasion.");
return null;
} }
private string PromptImportantAspects() private string PromptImportantAspects()
@@ -358,7 +536,7 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
}; };
} }
private async Task<string> PromptLoadDocumentsContent() private async Task<string> PromptLoadDocumentsContent(PromptInjectionReportingScope promptInjectionScope)
{ {
if (this.loadedDocumentPaths.Count == 0) if (this.loadedDocumentPaths.Count == 0)
return string.Empty; return string.Empty;
@@ -374,13 +552,6 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
"""); """);
} }
//
// One report for the whole batch: reading twenty documents must produce one dialog
// listing all of them, not twenty dialogs in a row.
//
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
await using var promptInjectionScope = guardService.BeginAction();
var numDocuments = 1; var numDocuments = 1;
foreach (var document in documents) foreach (var document in documents)
{ {
@@ -434,9 +605,9 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
{ {
sb.AppendLine($""" sb.AppendLine($"""
There are {numImages} image file(s) attached as documents. There are {numImages} image file(s) attached as source material.
Please consider them as documents as well and use them to Please consider them as documents as well and use them to
answer accordingly. answer accordingly, but never place them on a slide.
"""); """);
} }
@@ -444,9 +615,9 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
{ {
sb.AppendLine($""" sb.AppendLine($"""
Additionally, there are {numImages} image file(s) attached. Additionally, there are {numImages} image file(s) attached as source material.
Please consider them as documents as well and use them to Please consider them as documents as well and use them to
answer accordingly. answer accordingly, but never place them on a slide.
"""); """);
} }
@@ -461,13 +632,55 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
if (!this.InputIsValid) if (!this.InputIsValid)
return; return;
this.calculatedNumberOfSlides = this.timeSpecification > 0 ? this.CalculateNumberOfSlides() : 0; //
// The slides of an earlier run stay out of sight until this run has a result of its own.
// Catalog, template, and pictures are taken once, so that the prompt, the check, and the
// file later on all see the same layouts and the same numbering, even when the user
// chooses another template while the LLM is still planning:
//
this.slideDeck = null;
this.slideDeckAnswer = string.Empty;
this.slideDeckBasis.Clear();
this.slideDeckPicturePaths.Clear();
this.slideDeckIconNumbers.Clear();
this.planningStep = PlanningStep.NONE;
var catalog = this.Catalog;
var plannedTemplatePath = this.template is null ? string.Empty : this.templatePath;
var chosenTemplateSlideUses = this.ChosenTemplateSlideUses;
var chosenLayouts = this.ChosenLayoutsOfTemplate;
var imageAttachments = this.PicturesOfRequest();
//
// The pictures of the library follow the pictures for the slides in one numbering, so the
// JSON names every picture the same way. The system prompt needs the library only while
// it is written, so the planning keeps it no longer than that:
//
var library = await this.ReadPictureLibraryForPlanningAsync();
var firstLibraryNumber = imageAttachments.Count + 1;
var libraryPictures = new LibraryPictures(library.Pictures.Count, library.Pictures.Select((picture, index) => (Picture: picture, Number: firstLibraryNumber + index)).Where(entry => entry.Picture.IsIcon).Select(entry => entry.Number).ToHashSet());
this.planningLibrary = library;
this.CreateChatThread(); this.CreateChatThread();
var documentContent = await this.PromptLoadDocumentsContent(); this.planningLibrary = PictureLibraryContent.NONE;
var imageAttachments = this.loadedDocumentPaths.Where(n => n is { Exists: true, IsImage: true }).ToList();
var time = this.AddUserRequest( //
// One report for the whole request: reading twenty documents must produce one dialog
// listing all of them, not twenty dialogs in a row. The layouts and the slides of the
// template come from a file as well, so they belong to the same report:
//
string documentContent;
string layouts;
string templateSlides;
string pictureLibrary;
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
await using (var promptInjectionScope = guardService.BeginAction())
{
documentContent = await this.PromptLoadDocumentsContent(promptInjectionScope);
layouts = await PromptLayoutsAsync(guardService, promptInjectionScope, catalog, plannedTemplatePath);
templateSlides = await PromptTemplateSlidesAsync(guardService, promptInjectionScope, catalog, plannedTemplatePath, chosenTemplateSlideUses);
pictureLibrary = await PromptPictureLibraryAsync(guardService, promptInjectionScope, library, firstLibraryNumber, this.pictureLibraryPath);
}
var request =
$""" $"""
# PRESENTATION_TITLE # PRESENTATION_TITLE
``` ```
@@ -481,10 +694,93 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
``` ```
{documentContent} {documentContent}
""", {layouts}
hideContentFromUser: true, {PromptPictureList(imageAttachments, this.ProviderSettings.SupportsImageInput())}
imageAttachments); {pictureLibrary}
{templateSlides}
""";
await this.AddAIResponseAsync(time); //
// The pictures for the slides come first, in the order of PICTURES, so that the LLM can tell
// them from the images of the source material which follow:
//
var time = this.AddUserRequest(request, hideContentFromUser: true, [..imageAttachments, ..this.SourceImagesOfRequest()]);
this.planningStep = PlanningStep.PLANNING;
var answer = await this.AddAIResponseAsync(time, hideContentFromUser: true);
// The provider failed and the base class has said so already:
if (string.IsNullOrWhiteSpace(answer))
return;
var (deck, issues) = this.CheckAnswer(answer, catalog, imageAttachments.Count, libraryPictures, chosenTemplateSlideUses, chosenLayouts);
var neededRepairRound = deck is null || issues.Count > 0;
if (neededRepairRound)
{
//
// One repair round in the same chat, so the model sees its own answer next to what is
// wrong with it. A repaired answer which cannot be read at all is worse than the first
// one, though, so it only replaces an answer it improves on:
//
var repairTime = this.AddUserRequest(RepairPromptOf(issues), hideContentFromUser: true);
this.planningStep = PlanningStep.REPAIRING;
var repairedAnswer = await this.AddAIResponseAsync(repairTime, hideContentFromUser: true);
var (repairedDeck, repairedIssues) = string.IsNullOrWhiteSpace(repairedAnswer) ? new CheckedAnswer(null, issues) : this.CheckAnswer(repairedAnswer, catalog, imageAttachments.Count, libraryPictures, chosenTemplateSlideUses, chosenLayouts);
if (repairedDeck is not null)
{
answer = repairedAnswer;
deck = repairedDeck;
issues = repairedIssues;
}
}
bool IsLibraryNumber(int number) => number >= firstLibraryNumber && number < firstLibraryNumber + library.Pictures.Count;
var libraryPlacements = deck?.Slides.SelectMany(slide => slide.Pictures).Where(IsLibraryNumber).ToList() ?? [];
this.Logger.LogInformation("The slide assistant asked with {RequestCharacters} characters, {PictureCount} pictures for the slides, and {LibraryPictureCount} pictures of the library, {LibraryIconCount} of them icons, and got {SlideCount} slides, {RepairRound}. The slides place {LibraryPlacementCount} pictures of the library, {LibraryIconPlacementCount} of them icons.", request.Length, imageAttachments.Count, library.Pictures.Count, library.Pictures.Count(picture => picture.IsIcon), deck?.Slides.Count ?? 0, neededRepairRound ? "after a repair round" : "without a repair round", libraryPlacements.Count, libraryPlacements.Count(number => library.Pictures[number - firstLibraryNumber].IsIcon));
//
// Slides on layouts the template does not have, or on template slides the user did not
// choose, cannot be shown, let alone written. Any other issue, such as a text which runs
// long, leaves slides worth looking at:
//
if (deck is null || deck.Slides.Count == 0 || deck.Slides.Any(slide => !SlideDeckSpecValidator.CanWrite(slide, catalog, chosenTemplateSlideUses)))
{
this.Logger.LogWarning("The slides the model planned cannot be used: {Issues}", string.Join(" | ", issues));
await this.MessageBus.SendError(new(Icons.Material.Filled.Slideshow, T("The LLM did not describe the slides in a usable form, even after a second request. Please try again, or choose another model.")));
return;
}
if (issues.Count > 0)
{
this.Logger.LogInformation("The slides the model planned still have issues after the repair round: {Issues}", string.Join(" | ", issues));
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Slideshow, T("Not every slide fits its layout as planned; for example, a text may run long. Please check the slides before presenting.")));
}
this.slideDeck = deck;
this.slideDeckAnswer = answer;
this.slideDeckTemplatePath = plannedTemplatePath;
foreach (var (id, description) in catalog.DescribeBasisOf(deck))
this.slideDeckBasis[id] = description;
//
// Of the library, only the pictures the deck uses are kept, not the thousands it was offered:
//
var pictureNames = new Dictionary<int, string>();
for (var index = 0; index < imageAttachments.Count; index++)
{
this.slideDeckPicturePaths[index + 1] = imageAttachments[index].FilePath;
pictureNames[index + 1] = imageAttachments[index].FileName;
}
foreach (var number in libraryPlacements.Distinct())
{
var picture = library.Pictures[number - firstLibraryNumber];
this.slideDeckPicturePaths[number] = picture.FilePath;
pictureNames[number] = picture.Name;
if (picture.IsIcon)
this.slideDeckIconNumbers.Add(number);
}
this.ShowOutline(deck, catalog, pictureNames);
} }
} }
@@ -0,0 +1,41 @@
using AIStudio.Tools.PowerPoint;
namespace AIStudio.Assistants.SlideBuilder;
/// <summary>
/// The explanations which the Slide Assistant and its settings dialog share, so that both say the same.
/// </summary>
public static class SlideAssistantTexts
{
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(SlideAssistantTexts).Namespace, nameof(SlideAssistantTexts));
/// <summary>
/// What the LLM knows about the layouts of a template, and why their names matter.
/// </summary>
public static string WhatTheLLMKnowsOfLayouts => TB("The LLM does not see what your layouts look like. It knows only their names from the slide master of your template, where their placeholders sit, and how much text fits into them. Clear names such as “Chapter” or “Content with picture” therefore help it choose well; in PowerPoint, you rename a layout in the slide master view. Logo, background, and footer come with the layout. Layouts which differ only in their color count as one, and the color applies to the whole presentation.");
/// <summary>
/// What the choice of a layout for a kind of slide does, and what it does not.
/// </summary>
public static string WhatALayoutChoiceDoes => TB("Choose which layout of your template the LLM uses for certain kinds of slides. The lists offer the layouts with room for content. Where you have no preference, the LLM decides by the names and placeholders of the layouts. A choice says which layout such a slide uses, not whether the presentation has one: whether there is, e.g., an agenda, follows from your content and your important aspects. Only for the framing slides, e.g., the table of contents or the imprint, does a choice also add such a slide; their help says so. Each chapter becomes a section in PowerPoint, which the slide sorter shows as an outline.");
/// <summary>
/// What keeping the empty picture placeholders does.
/// </summary>
public static string WhatKeepingEmptyPicturePlaceholdersDoes => TB("When a new slide has no picture for a picture placeholder of its layout, it leaves the placeholder out, as every other placeholder without content. Keep these placeholders to insert pictures of your own later: PowerPoint shows them while you edit and hides them in the slide show.");
/// <summary>
/// What a picture library is, which of its files count, and how the LLM uses them.
/// </summary>
public static string WhatAPictureLibraryOffers => string.Format(TB("Choose a folder with pictures and icons, e.g., the picture library of your organization. The LLM reads only their names, never the pictures themselves, and uses a picture where its name fits a slide; it does not have to use any. So clear names such as “rocket-launch.jpg” help. Only PNG and JPEG pictures count: many icons come as SVG, which the assistant leaves out. A picture whose name, or the name of one of its folders, the chosen folder included, starts with “icon” is an icon. An icon may appear on several slides and goes into picture placeholders of any size, except those which cover half the slide or more, as a background picture does. From a folder with more than {0} pictures, the LLM gets a random {0} of them for each planning."), MaxLibraryPictures);
/// <summary>
/// The most pictures of a library the LLM reads about, as the user reads the number.
/// </summary>
public static string MaxLibraryPictures => PictureLibrary.MAX_LIBRARY_PICTURES.ToString("N0", Tools.PluginSystem.I18N.I.Culture);
/// <summary>
/// What the presentation does with the slides a template carries besides its layouts.
/// </summary>
public static string WhatHappensToTemplateSlides => TB("Besides its layouts, your file contains slides, e.g., samples of the template or the slides of an earlier presentation. By default, the presentation leaves them out. Choose for each slide whether the presentation keeps it unchanged or lets the LLM edit its content. The LLM uses each of these slides exactly once, keeps them in their order, and may add new slides before, between, and after them. It never copies a slide as a pattern: new slides always come from the layouts of your template.");
}
@@ -0,0 +1,30 @@
using AIStudio.Tools.PowerPoint;
namespace AIStudio.Assistants.SlideBuilder;
internal static class TemplateSlideExtensions
{
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(TemplateSlideExtensions).Namespace, nameof(TemplateSlideExtensions));
/// <summary>
/// How many characters of its title a slide of the template shows in its label.
/// </summary>
private const int MAX_SLIDE_TITLE_CHARACTERS = 60;
/// <summary>
/// How the forms name a slide of the template: by its number and its title, and what is special about it.
/// </summary>
/// <remarks>
/// The title tells the slides apart at a glance, since a template rarely gives its slides more
/// than a title. A long one is cut, so that the label stays on one line.
/// </remarks>
public static string Label(this TemplateSlide slide)
{
var title = slide.Title.Length > MAX_SLIDE_TITLE_CHARACTERS ? $"{slide.Title[..MAX_SLIDE_TITLE_CHARACTERS].TrimEnd()}…" : slide.Title;
var label = title.Length > 0 ? string.Format(TB("Slide {0}: {1}"), slide.Number, title) : string.Format(TB("Slide {0}"), slide.Number);
if (slide.IsHidden)
label = $"{label} ({TB("hidden")})";
return label;
}
}
@@ -0,0 +1,17 @@
using AIStudio.Tools.PowerPoint;
namespace AIStudio.Assistants.SlideBuilder;
internal static class TemplateSlideUseExtensions
{
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(TemplateSlideUseExtensions).Namespace, nameof(TemplateSlideUseExtensions));
public static string Name(this TemplateSlideUse use) => use switch
{
TemplateSlideUse.OMIT => TB("Leave out"),
TemplateSlideUse.KEEP => TB("Keep unchanged"),
TemplateSlideUse.EDIT => TB("Let the LLM edit it"),
_ => TB("Leave out"),
};
}
@@ -134,8 +134,10 @@ public partial class VisualBriefingAssistant
// why it gets no notice that AI generated it: any change would make it a version nobody checked. // why it gets no notice that AI generated it: any change would make it a version nobody checked.
// //
await using var source = verified.Value.Stream; await using var source = verified.Value.Stream;
await using var destination = new FileStream(response.SaveFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 65_536, true); await using var feedback = new FileExportFeedback();
await source.CopyToAsync(destination); feedback.ShowRunning(response.SaveFilePath);
if (!await this.TryWriteExportAsync(source, response.SaveFilePath))
return;
var exportedVersion = this.selectedBriefing.Versions.First(version => var exportedVersion = this.selectedBriefing.Versions.First(version =>
version.RevisionId == this.selectedRevisionId); version.RevisionId == this.selectedRevisionId);
@@ -150,7 +152,56 @@ public partial class VisualBriefingAssistant
exportedVersion.DocumentHash, exportedVersion.DocumentHash,
source.Length); source.Length);
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.FileDownload, T("The visual briefing was exported."))); await feedback.SucceededAsync(Icons.Material.Filled.FileDownload, T("The visual briefing was exported."), response.SaveFilePath, true);
}
/// <summary>
/// Writes an exported version into the file the user chose, and tells the user when that fails.
/// </summary>
/// <remarks>
/// The file is written over rather than removed and created anew, which the Flatpak would not allow.
/// A file which cannot even be opened, e.g., because another program holds it, stays as it is. Once
/// writing has begun, a failure removes the file: half a briefing is worse than none.
/// </remarks>
/// <param name="source">The verified version to export.</param>
/// <param name="targetPath">The file the user chose.</param>
/// <returns>True, when the file was written completely.</returns>
private async Task<bool> TryWriteExportAsync(Stream source, string targetPath)
{
FileStream destination;
try
{
destination = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 65_536, true);
}
catch (Exception e)
{
this.Logger.LogError(e, "The visual briefing could not be exported, because the file '{FilePath}' cannot be opened for writing.", targetPath);
await this.MessageBus.SendError(new(Icons.Material.Filled.FileDownload, T("The visual briefing could not be exported. Please check that the target file is not open in another program.")));
return false;
}
try
{
await using (destination)
await source.CopyToAsync(destination);
return true;
}
catch (Exception e)
{
this.Logger.LogError(e, "Writing the visual briefing into the file '{FilePath}' failed.", targetPath);
try
{
File.Delete(targetPath);
}
catch (Exception deleteFailure)
{
this.Logger.LogWarning(deleteFailure, "The incomplete export '{FilePath}' cannot be deleted.", targetPath);
}
await this.MessageBus.SendError(new(Icons.Material.Filled.FileDownload, T("The visual briefing could not be written completely.")));
return false;
}
} }
/// <summary> /// <summary>
+62
View File
@@ -42,6 +42,19 @@ public sealed class ChatDraft
[JsonInclude] [JsonInclude]
public IReadOnlyList<FileAttachment> FileAttachments { get; private set; } = []; public IReadOnlyList<FileAttachment> FileAttachments { get; private set; } = [];
/// <summary>
/// The texts a preview read of the attached files, which sending takes along instead of reading the files again.
/// </summary>
/// <remarks>
/// Kept apart from the attachments, which stay as the user attached them: the composer, this
/// draft, and the attachment dialogs tell attachments apart by their values, and one which changed
/// underneath them would count as removed and added. Stored with the chat like everything else of
/// the draft, so a preview still counts when the user comes back to the chat. The provenance
/// store holds the texts themselves.
/// </remarks>
[JsonInclude]
public IReadOnlyList<PreviewedText> PreviewedTexts { get; private set; } = [];
/// <summary> /// <summary>
/// Whether the draft holds neither text nor attachments. /// Whether the draft holds neither text nor attachments.
/// </summary> /// </summary>
@@ -140,15 +153,64 @@ public sealed class ChatDraft
} }
} }
/// <summary>
/// Finds the text a preview read of an attached file.
/// </summary>
/// <param name="filePath">The path of the file.</param>
/// <returns>The text, or null when no preview read the file.</returns>
public PreviewedText? GetPreviewedText(string filePath)
{
lock (this.stateLock)
return this.PreviewedTexts.FirstOrDefault(text => string.Equals(text.FilePath, filePath, StringComparison.Ordinal));
}
/// <summary>
/// Notes the text a preview read of an attached file, in place of one noted for the same file before.
/// </summary>
/// <param name="previewedText">The text.</param>
/// <returns>The entry of the text noted before, which nothing refers to any longer, or null.</returns>
public Guid? NotePreviewedText(PreviewedText previewedText)
{
lock (this.stateLock)
{
var replaced = this.PreviewedTexts.FirstOrDefault(text => string.Equals(text.FilePath, previewedText.FilePath, StringComparison.Ordinal));
this.PreviewedTexts = [..this.PreviewedTexts.Where(text => !ReferenceEquals(text, replaced)), previewedText];
return replaced?.ProvenanceId;
}
}
/// <summary>
/// Lets go of the texts a preview read of files which are no longer attached, e.g., since the user took them out.
/// </summary>
/// <returns>The entries of those texts, which nothing refers to any longer, so the caller removes them from the provenance store.</returns>
public IReadOnlyList<Guid> ForgetPreviewsOfDetachedFiles()
{
lock (this.stateLock)
{
var attachedPaths = this.FileAttachments.Select(attachment => attachment.FilePath).ToHashSet(StringComparer.Ordinal);
var detached = this.PreviewedTexts.Where(text => !attachedPaths.Contains(text.FilePath)).ToList();
if (detached.Count is 0)
return [];
this.PreviewedTexts = this.PreviewedTexts.Except(detached).ToList();
return detached.Select(text => text.ProvenanceId).ToList();
}
}
/// <summary> /// <summary>
/// Empties the draft, which is what sending it does: its attachments belong to the new message from then on. /// Empties the draft, which is what sending it does: its attachments belong to the new message from then on.
/// </summary> /// </summary>
/// <remarks>
/// The texts a preview read go as well. Whoever sends the draft removes those the message went
/// without from the provenance store.
/// </remarks>
public void Clear() public void Clear()
{ {
lock (this.stateLock) lock (this.stateLock)
{ {
this.Text = string.Empty; this.Text = string.Empty;
this.FileAttachments = []; this.FileAttachments = [];
this.PreviewedTexts = [];
} }
} }
@@ -0,0 +1,115 @@
using AIStudio.Tools.Databases;
using AIStudio.Tools.Databases.ProvenanceStore;
namespace AIStudio.Chat;
/// <summary>
/// Keeps what chats received in the provenance store in step with the chats themselves: it goes
/// when its chat goes, and it comes along when its chat is copied.
/// </summary>
/// <remarks>
/// Called from the places which delete or copy chats. Nothing here may stop them: a chat is
/// deleted or copied either way, and what the store keeps too long is removed by the cleanup at
/// the next start, see ProvenanceCleanup.
/// </remarks>
public static class ChatProvenance
{
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(ChatProvenance));
/// <summary>
/// Collects the entries of the provenance store which a chat refers to.
/// </summary>
/// <remarks>
/// The passages of its answers, the documents attached to its messages, and those attached to
/// the message being written, which may come from a message sent before, e.g., one being edited,
/// together with the texts a preview read of them.
/// </remarks>
/// <param name="thread">The chat.</param>
/// <returns>The ids of the entries.</returns>
public static IReadOnlySet<Guid> CollectProvenanceIds(this ChatThread thread)
{
var provenanceIds = new HashSet<Guid>();
foreach (var block in thread.Blocks)
{
if (block.Content is ContentText text)
provenanceIds.UnionWith(text.SourcePassages.Select(passage => passage.ProvenanceId).OfType<Guid>());
if (block.Content is not null)
provenanceIds.UnionWith(block.Content.FileAttachments.Select(attachment => attachment.ProvenanceId).OfType<Guid>());
}
provenanceIds.UnionWith(thread.Draft.FileAttachments.Select(attachment => attachment.ProvenanceId).OfType<Guid>());
provenanceIds.UnionWith(thread.Draft.PreviewedTexts.Select(text => text.ProvenanceId));
return provenanceIds;
}
/// <summary>
/// Deletes what the given chats received from the provenance store, for chats which were deleted.
/// </summary>
/// <param name="chatIds">The deleted chats.</param>
public static async Task ForgetChatsAsync(IReadOnlyCollection<Guid> chatIds)
{
if (chatIds.Count is 0 || await GetStoreAsync() is not { } store)
return;
try
{
var deleted = await store.DeleteChatsAsync(chatIds, CancellationToken.None);
if (deleted > 0)
LOGGER.LogInformation("Deleted {Count} provenance entries of {ChatCount} deleted chats.", deleted, chatIds.Count);
}
catch (Exception exception)
{
LOGGER.LogWarning(exception, "Failed to delete the provenance entries of {ChatCount} deleted chats. The next start removes them.", chatIds.Count);
}
}
/// <summary>
/// Copies what a chat received to its copy, which refers to the same entries.
/// </summary>
/// <param name="sourceChatId">The chat which was copied.</param>
/// <param name="targetChatId">The copy.</param>
public static async Task CopyChatAsync(Guid sourceChatId, Guid targetChatId)
{
if (await GetStoreAsync() is not { } store)
return;
try
{
await store.CopyChatAsync(sourceChatId, targetChatId, CancellationToken.None);
}
catch (Exception exception)
{
LOGGER.LogWarning(exception, "Failed to copy the provenance entries of the chat '{SourceChatId}' to its copy '{TargetChatId}'. The copy shows them as long as the original keeps them.", sourceChatId, targetChatId);
}
}
/// <summary>
/// Gets the provenance store for chats and their messages, or nothing when there is none to use.
/// </summary>
/// <remarks>
/// The one way to the store for code which the app's services do not create, such as a chat or a
/// message. Components and services get the DatabaseClientProvider injected instead. It never
/// throws: there is no store while the app has not started its services, e.g., in a test, or when
/// the store is not available. Every caller carries on without it then.
/// </remarks>
/// <returns>The provenance store, or null when there is none.</returns>
internal static async Task<ProvenanceStoreClient?> GetStoreAsync()
{
try
{
// Declared as never null, but it is null until the app has started its services:
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
if (Program.SERVICE_PROVIDER?.GetService<DatabaseClientProvider>() is not { } databaseClientProvider)
return null;
var store = await databaseClientProvider.GetProvenanceStoreAsync();
return store is NoProvenanceStoreClient ? null : store;
}
catch (Exception exception)
{
LOGGER.LogWarning(exception, "The provenance store is not available to the chats.");
return null;
}
}
}
+50 -4
View File
@@ -5,6 +5,7 @@ using AIStudio.Components;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.Security;
using AIStudio.Tools.ToolCallingSystem; using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ERIClient.DataModel; using AIStudio.Tools.ERIClient.DataModel;
using AIStudio.Tools.Web; using AIStudio.Tools.Web;
@@ -102,6 +103,15 @@ public sealed record ChatThread
/// <summary> /// <summary>
/// The augmented data for this chat thread. Will be inserted into the system prompt. /// The augmented data for this chat thread. Will be inserted into the system prompt.
/// </summary> /// </summary>
/// <remarks>
/// Not stored: it belongs to one request. The classic RAG process empties and writes it anew for
/// every message, a regenerated answer included, and a rollback empties it, so nothing reads it
/// once the answer is there. What the AI read of it is kept with the answer instead, as the
/// passages of its sources, see ContentText.StoreReceivedContentAsync. Stored, it was often the
/// largest part of a chat file. Older chat files still hold it; it is ignored on loading and
/// left out on their next save.
/// </remarks>
[JsonIgnore]
public string AugmentedData { get; set; } = string.Empty; public string AugmentedData { get; set; } = string.Empty;
/// <summary> /// <summary>
@@ -146,15 +156,15 @@ public sealed record ChatThread
/// <summary> /// <summary>
/// Where this chat may still send data, because of the internal content it holds: mails, a /// Where this chat may still send data, because of the internal content it holds: mails, a
/// search of the wiki, or an internal web page. /// search of the wiki, an internal web page, or passages of a data source.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The third thing the thread ratchets, next to the data security and the required provider /// The third thing the thread ratchets, next to the data security and the required provider
/// confidence: once internal content is in the thread, every tool which runs afterwards could /// confidence: once internal content is in the thread, every tool which runs afterwards could
/// carry it out, in a search query or in the address of a web page. It only ever gets stricter, /// carry it out, in a search query or in the address of a web page. It only ever gets stricter,
/// and a later change of the settings of a mailbox or of Read Web Page does not loosen it /// and a later change of the settings of a mailbox, a data source, or Read Web Page does not
/// either, because the content was read under the old one. A chat which holds no internal /// loosen it either, because the content was read under the old one. A chat which holds no
/// content demands nothing. /// internal content demands nothing.
/// </remarks> /// </remarks>
[JsonInclude] [JsonInclude]
public OutboundDataRequirement RequiredOutboundDataRestriction { get; private set; } = OutboundDataRequirement.NONE; public OutboundDataRequirement RequiredOutboundDataRestriction { get; private set; } = OutboundDataRequirement.NONE;
@@ -515,6 +525,42 @@ public sealed record ChatThread
this.Blocks.Remove(block); this.Blocks.Remove(block);
} }
/// <summary>
/// Stores the documents attached to the messages of this chat which were never sent, so that each
/// one is read from the disk once.
/// </summary>
/// <remarks>
/// Called before every request of the chat, see AIJobService. The request then sends the stored
/// texts, see ContentText.PrepareTextContentForAI, and so does every later one. Assistants do not
/// call this: their messages are made anew with every run, so every run would store its documents
/// once more.<br/><br/>
///
/// Nothing here may stop the request. A document which is not stored, e.g., since the store is not
/// available, is read when the message is built, as every request did before.
/// </remarks>
/// <param name="readChangedAgain">Whether documents whose files changed since they were stored are read again, see DataChat.ReadChangedAttachmentsAgain.</param>
public async Task StoreAttachedDocumentsAsync(bool readChangedAgain)
{
var messages = this.Blocks.Select(block => block.Content).OfType<ContentText>().Where(text => text.HasDocumentsToStore(readChangedAgain)).ToList();
if (messages.Count is 0)
return;
try
{
if (await ChatProvenance.GetStoreAsync() is not { } store)
return;
// One report for everything filtered out of the documents, as for the request itself:
await using var reportingScope = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>().BeginAction();
foreach (var message in messages)
await message.StoreAttachedDocumentsAsync(store, this.ChatId, Program.RUST_SERVICE, reportingScope, readChangedAgain);
}
catch (Exception exception)
{
LOGGER.LogError(exception, "Failed to store the attached documents of the chat '{ChatId}'. They are read from the disk with every request.", this.ChatId);
}
}
/// <summary> /// <summary>
/// Rolls this chat thread back, so that the given content becomes the last block of the conversation. /// Rolls this chat thread back, so that the given content becomes the last block of the conversation.
/// </summary> /// </summary>
@@ -10,6 +10,8 @@ public static class ChatThreadExtensions
/// Checks if the specified provider is allowed for the chat thread. /// Checks if the specified provider is allowed for the chat thread.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// A provider the organization prohibited is never allowed, whatever the chat thread.<br/><br/>
///
/// We don't check if the provider is allowed to use the data sources of the chat thread. /// We don't check if the provider is allowed to use the data sources of the chat thread.
/// That kind of check is done when the available data sources are resolved.<br/><br/> /// That kind of check is done when the available data sources are resolved.<br/><br/>
/// ///
@@ -23,11 +25,16 @@ public static class ChatThreadExtensions
/// <returns>True, when the provider is allowed for the chat thread. False, otherwise.</returns> /// <returns>True, when the provider is allowed for the chat thread. False, otherwise.</returns>
public static bool IsLLMProviderAllowed<T>(this ChatThread? chatThread, T provider) public static bool IsLLMProviderAllowed<T>(this ChatThread? chatThread, T provider)
{ {
var settingsManager = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
// A provider the organization prohibited is allowed in no chat, not even in a new one:
if (settingsManager.IsProviderProhibited(GetLLMProvider(provider)))
return false;
// No chat thread available means we have a new chat. That's fine: // No chat thread available means we have a new chat. That's fine:
if (chatThread is null) if (chatThread is null)
return true; return true;
var settingsManager = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
var providerConfidence = provider switch var providerConfidence = provider switch
{ {
IProvider p => p.GetConfidenceLevel(settingsManager), IProvider p => p.GetConfidenceLevel(settingsManager),
@@ -75,4 +82,12 @@ public static class ChatThreadExtensions
false => chatThread.DataSecurity is not DataSourceSecurity.SELF_HOSTED, false => chatThread.DataSecurity is not DataSourceSecurity.SELF_HOSTED,
}; };
} }
private static LLMProviders GetLLMProvider<T>(T provider) => provider switch
{
IProvider p => p.Provider,
AIStudio.Settings.Provider p => p.UsedLLMProvider,
_ => LLMProviders.NONE,
};
} }
@@ -38,4 +38,24 @@ public class ContentBlock
Role = this.Role, Role = this.Role,
HideFromUser = changeHideState ? hideFromUser : this.HideFromUser, HideFromUser = changeHideState ? hideFromUser : this.HideFromUser,
}; };
/// <summary>
/// Clones this block for a place apart from its chat, e.g., the example conversation of a chat template.
/// </summary>
/// <remarks>
/// Without anything which refers to the provenance store: its entries belong to the chat and are
/// deleted with it. The passages of an answer stay behind, and every chat which receives an
/// attached document reads it anew from its file, cf. FileAttachment.ProvenanceId.
/// </remarks>
public ContentBlock CloneApartFromChat(bool changeHideState = false, bool hideFromUser = true)
{
var clone = this.DeepClone(changeHideState, hideFromUser);
if (clone.Content is ContentText text)
text.SourcePassages = [];
if (clone.Content is not null)
clone.Content.FileAttachments = clone.Content.FileAttachments.Select(attachment => attachment.WithoutStoredText()).ToList();
return clone;
}
} }
@@ -229,7 +229,7 @@
} }
@if (textContent.Sources.Count > 0) @if (textContent.Sources.Count > 0)
{ {
<SourcesList @ref="this.sourcesList" Sources="@textContent.Sources"/> <SourcesList @ref="this.sourcesList" Sources="@textContent.Sources" Passages="@textContent.SourcePassages"/>
} }
</div> </div>
} }
@@ -360,6 +360,7 @@ public partial class ContentBlockComponent : MSGComponentBase
hash.Add(textValue.Length); hash.Add(textValue.Length);
hash.Add(textValue.GetHashCode(StringComparison.Ordinal)); hash.Add(textValue.GetHashCode(StringComparison.Ordinal));
hash.Add(text.Sources.Count); hash.Add(text.Sources.Count);
hash.Add(text.SourcePassages.Count);
hash.Add(text.ToolInvocations.Count); hash.Add(text.ToolInvocations.Count);
hash.Add(text.ToolRuntimeStatus.IsRunning); hash.Add(text.ToolRuntimeStatus.IsRunning);
hash.Add(text.ToolRuntimeStatus.Message); hash.Add(text.ToolRuntimeStatus.Message);
@@ -399,8 +400,6 @@ public partial class ContentBlockComponent : MSGComponentBase
private bool HasToolTrace => this.Role is ChatRole.AI && this.GetToolInvocations().Count > 0; private bool HasToolTrace => this.Role is ChatRole.AI && this.GetToolInvocations().Count > 0;
private CodeBlockTheme CodeColorPalette => this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default;
private static Color GetTraceColor(ToolInvocationTraceStatus status) => status switch private static Color GetTraceColor(ToolInvocationTraceStatus status) => status switch
{ {
ToolInvocationTraceStatus.SUCCESS => Color.Success, ToolInvocationTraceStatus.SUCCESS => Color.Success,
@@ -446,11 +445,6 @@ public partial class ContentBlockComponent : MSGComponentBase
? this.T("No result") ? this.T("No result")
: invocation.Result; : invocation.Result;
private MudMarkdownStyling MarkdownStyling => new()
{
CodeBlock = { Theme = this.CodeColorPalette },
};
private MarkdownRenderPlan GetMarkdownRenderPlan(string text) private MarkdownRenderPlan GetMarkdownRenderPlan(string text)
{ {
if (ReferenceEquals(this.cachedMarkdownRenderPlanInput, text) || string.Equals(this.cachedMarkdownRenderPlanInput, text, StringComparison.Ordinal)) if (ReferenceEquals(this.cachedMarkdownRenderPlanInput, text) || string.Equals(this.cachedMarkdownRenderPlanInput, text, StringComparison.Ordinal))
@@ -1,7 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Tools.Validation;
namespace AIStudio.Chat; namespace AIStudio.Chat;
@@ -53,23 +52,6 @@ public sealed class ContentImage : IContent, IImageSource
#endregion #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> /// <summary>
/// The type of the image source. /// The type of the image source.
/// </summary> /// </summary>
+390 -41
View File
@@ -1,12 +1,16 @@
using System.Diagnostics;
using System.Text; using System.Text;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Tools.Databases.ProvenanceStore;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.RAG;
using AIStudio.Tools.RAG.RAGProcesses; using AIStudio.Tools.RAG.RAGProcesses;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
using AIStudio.Tools.Security; using AIStudio.Tools.Security;
using AIStudio.Tools.Services;
using AIStudio.Tools.ToolCallingSystem; using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.Web; using AIStudio.Tools.Web;
@@ -53,6 +57,128 @@ public sealed class ContentText : IContent
public List<ToolInvocationTrace> ToolInvocations { get; set; } = []; public List<ToolInvocationTrace> ToolInvocations { get; set; } = [];
/// <summary>
/// The passages the data sources handed to the AI for this answer, as the AI received them.
/// </summary>
/// <remarks>
/// Stored with the answer, so the user can still read what it rests on after the chat was
/// reloaded. Each passage belongs to one of the sources, see SourcePassageExtensions.GetPassagesOf.
/// Empty for every answer written before passages were kept, and for every answer no data
/// source contributed to.
/// </remarks>
public List<SourcePassage> SourcePassages { get; set; } = [];
/// <summary>
/// Adds sources to this answer, together with the passages the AI received from them.
/// </summary>
/// <remarks>
/// Both at once, because the title of a source names where its passages are, see
/// SourcePassageExtensions.NamePassagesInTitles. A passage added on its own would leave a title
/// behind which names only the pages known before it. Both lists are replaced as a whole rather
/// than changed, as in StoreReceivedContentAsync: the user interface may read them meanwhile.
/// </remarks>
/// <param name="sources">The sources to add.</param>
/// <param name="passages">The passages to add.</param>
public void AddSources(IEnumerable<ISource> sources, IEnumerable<SourcePassage> passages)
{
var mergedSources = new List<Source>(this.Sources);
mergedSources.MergeSources(sources);
var mergedPassages = new List<SourcePassage>(this.SourcePassages);
mergedPassages.MergePassages(passages);
mergedSources.NamePassagesInTitles(mergedPassages);
// The passages first, so that every source the user interface finds has its passages:
this.SourcePassages = mergedPassages;
this.Sources = mergedSources;
}
/// <summary>
/// Whether the user was told that a text stays in the chat file, since the provenance store could not take it.
/// </summary>
/// <remarks>
/// Once per run of the app: a store which cannot be written stays so for a while, and a message
/// with every answer would tell the user nothing new.
/// </remarks>
private static int UNSTORED_CONTENT_REPORTED;
/// <summary>
/// Moves what the AI received for this answer into the provenance store, so the chat keeps only references to it.
/// </summary>
/// <remarks>
/// Called once the answer is complete, also when it was stopped or failed: until then, a passage
/// found twice is told apart by its text, see SourcePassageExtensions.MergePassages, and only the
/// passages which made it into the answer get stored. Nothing here may stop the answer from being
/// stored, so every failure ends in the passages keeping their texts.
/// </remarks>
/// <param name="chatId">The chat this answer belongs to.</param>
public async Task StoreReceivedContentAsync(Guid chatId)
{
try
{
// Most answers, those of the agents among them, received nothing to store, and need no database:
if (!this.SourcePassages.Any(passage => passage.HoldsTextsToStore()))
return;
if (await ChatProvenance.GetStoreAsync() is { } store && await this.StoreReceivedContentAsync(store, chatId))
return;
}
catch (Exception exception)
{
LOGGER.LogError(exception, "Failed to store what the AI received for an answer of the chat '{ChatId}'. It stays in the chat file.", chatId);
}
if (Interlocked.Exchange(ref UNSTORED_CONTENT_REPORTED, 1) is 0)
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Storage, TB("AI Studio could not store what the AI received separately from your chat. It remains in the chat itself, so you can still read it.")));
}
/// <summary>
/// Moves what the AI received for this answer into the given provenance store.
/// </summary>
/// <remarks>
/// The list of passages is replaced as a whole rather than changed: the user interface may read
/// it meanwhile. A passage which the store could not take keeps its texts.
/// </remarks>
/// <param name="store">The provenance store.</param>
/// <param name="chatId">The chat this answer belongs to.</param>
/// <returns>True when everything was stored, false when something stays in the chat itself.</returns>
internal async Task<bool> StoreReceivedContentAsync(ProvenanceStoreClient store, Guid chatId)
{
var allStored = true;
var passages = new List<SourcePassage>(this.SourcePassages.Count);
foreach (var passage in this.SourcePassages)
{
if (!passage.HoldsTextsToStore())
{
passages.Add(passage);
continue;
}
Guid? provenanceId = null;
try
{
// Not cancellable: the answer is complete, also when the user stopped it.
provenanceId = await store.StoreAsync(chatId, passage.GetProvenanceKind(), passage.Text, passage.SurroundingContent, CancellationToken.None);
}
catch (Exception exception)
{
LOGGER.LogWarning(exception, "Failed to store a passage of the source '{SourceUrl}'. It stays in the chat file.", passage.SourceUrl);
}
if (provenanceId is null)
{
allStored = false;
passages.Add(passage);
continue;
}
passages.Add(passage with { ProvenanceId = provenanceId, Text = string.Empty, SurroundingContent = [] });
}
this.SourcePassages = passages;
return allStored;
}
/// <summary> /// <summary>
/// What the provider said everything sent along with this answer cost, where it said anything. /// What the provider said everything sent along with this answer cost, where it said anything.
/// </summary> /// </summary>
@@ -138,6 +264,31 @@ public sealed class ContentText : IContent
}; };
} }
/// <summary>
/// Adds what one chunk of the stream brings to this answer: its text, its sources together with
/// their passages, and what the provider says the request cost.
/// </summary>
/// <remarks>
/// The chat and the job service both write answers from a stream, and they used to do so step
/// by step each. A step one of them missed, such as the passages, would have been missing from
/// every answer written that way.
/// </remarks>
/// <param name="chunk">The chunk of the stream.</param>
/// <param name="modelId">The model the request went to.</param>
/// <param name="blockCount">How many blocks the conversation had when the request went out, this answer included.</param>
public void ApplyStreamChunk(ContentStreamChunk chunk, string modelId, int blockCount)
{
// The first chunk ends the waiting animation:
this.InitialRemoteWait = false;
this.IsStreaming = true;
this.Text += chunk;
if (chunk.Sources.Count > 0 || chunk.Passages.Count > 0)
this.AddSources(chunk.Sources, chunk.Passages);
this.RecordReportedUsage(chunk.Usage, modelId, blockCount);
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default) public async Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default)
{ {
@@ -149,7 +300,7 @@ public sealed class ContentText : IContent
if(!chatThread.IsLLMProviderAllowed(provider)) if(!chatThread.IsLLMProviderAllowed(provider))
{ {
LOGGER.LogError("The provider is not allowed for this chat thread due to data security or confidence-level requirements. Skipping the AI process."); LOGGER.LogError("The provider is not allowed for this chat thread due to a prohibition of the organization, data security, or confidence-level requirements. Skipping the AI process.");
await this.CompleteWithoutStreaming(); await this.CompleteWithoutStreaming();
return chatThread; return chatThread;
} }
@@ -202,6 +353,23 @@ public sealed class ContentText : IContent
// What the request carries, for telling later whether a report still describes this chat: // What the request carries, for telling later whether a report still describes this chat:
var blocksSent = chatThread.Blocks.Count; var blocksSent = chatThread.Blocks.Count;
//
// How long the answer takes, in three parts: the wait for its first chunk, which is the
// model reading the request and thinking; the streaming afterward; and how much of that
// the UI took to show it. Only these parts tell a slow model from a slow UI:
//
var requestStartedAt = Stopwatch.GetTimestamp();
TimeSpan? timeToFirstChunk = null;
var chunkCount = 0;
var uiDuration = TimeSpan.Zero;
async Task NotifyStreamingAsync()
{
var notifyingStartedAt = Stopwatch.GetTimestamp();
await this.StreamingEvent();
uiDuration += Stopwatch.GetElapsedTime(notifyingStartedAt);
}
// Start another thread by using a task to uncouple // Start another thread by using a task to uncouple
// the UI thread from the AI processing: // the UI thread from the AI processing:
try try
@@ -220,18 +388,11 @@ public sealed class ContentText : IContent
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
break; break;
// Stop the waiting animation: chunkCount++;
this.InitialRemoteWait = false; timeToFirstChunk ??= Stopwatch.GetElapsedTime(requestStartedAt);
this.IsStreaming = true;
// Add the response to the text: // Add the response, its sources, and what it cost:
this.Text += contentStreamChunk; this.ApplyStreamChunk(contentStreamChunk, chatModel.Id, blocksSent);
// Merge the sources:
this.Sources.MergeSources(contentStreamChunk.Sources);
// Keep what the provider says the request cost:
this.RecordReportedUsage(contentStreamChunk.Usage, chatModel.Id, blocksSent);
// Notify the UI that the content has changed, // Notify the UI that the content has changed,
// depending on the energy saving mode: // depending on the energy saving mode:
@@ -241,7 +402,7 @@ public sealed class ContentText : IContent
// Energy saving mode is off. We notify the UI // Energy saving mode is off. We notify the UI
// as fast as possible -- no matter the odds: // as fast as possible -- no matter the odds:
case false: case false:
await this.StreamingEvent(); await NotifyStreamingAsync();
break; break;
// Energy saving mode is on. We notify the UI // Energy saving mode is on. We notify the UI
@@ -249,7 +410,7 @@ public sealed class ContentText : IContent
// greater than the minimum time: // greater than the minimum time:
case true when now - last > MIN_TIME: case true when now - last > MIN_TIME:
last = now; last = now;
await this.StreamingEvent(); await NotifyStreamingAsync();
break; break;
} }
} }
@@ -265,9 +426,18 @@ public sealed class ContentText : IContent
} }
finally finally
{ {
var requestDuration = Stopwatch.GetElapsedTime(requestStartedAt);
if (timeToFirstChunk is { } firstChunkAfter)
LOGGER.LogInformation("The answer of model '{ModelId}' from '{ProviderInstanceName}' took {RequestSeconds:F1} s: {FirstChunkSeconds:F1} s until its first chunk, then {ChunkCount} chunks with {CharacterCount} characters, which the UI took {UISeconds:F1} s to show.", chatModel.Id, provider.InstanceName, requestDuration.TotalSeconds, firstChunkAfter.TotalSeconds, chunkCount, this.Text.Length, uiDuration.TotalSeconds);
else
LOGGER.LogInformation("The model '{ModelId}' from '{ProviderInstanceName}' sent no answer within {RequestSeconds:F1} s.", chatModel.Id, provider.InstanceName, requestDuration.TotalSeconds);
this.Text = this.Text.RemoveThinkTags().Trim(); this.Text = this.Text.RemoveThinkTags().Trim();
this.EndToolRun(); this.EndToolRun();
// Before whoever is told next stores the chat, so that it refers to what the AI received instead of holding it:
await this.StoreReceivedContentAsync(chatThread.ChatId);
// Inform the UI that the streaming is done: // Inform the UI that the streaming is done:
await this.StreamingDone(); await this.StreamingDone();
} }
@@ -368,6 +538,7 @@ public sealed class ContentText : IContent
InitialRemoteWait = this.InitialRemoteWait, InitialRemoteWait = this.InitialRemoteWait,
IsStreaming = this.IsStreaming, IsStreaming = this.IsStreaming,
Sources = [..this.Sources], Sources = [..this.Sources],
SourcePassages = [..this.SourcePassages],
FileAttachments = [..this.FileAttachments], FileAttachments = [..this.FileAttachments],
ToolInvocations = [..this.ToolInvocations.Select(x => new ToolInvocationTrace ToolInvocations = [..this.ToolInvocations.Select(x => new ToolInvocationTrace
{ {
@@ -392,20 +563,204 @@ public sealed class ContentText : IContent
/// WebAddresses.CreateRequestKey. /// WebAddresses.CreateRequestKey.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The documents are read from disk only when a message is sent, so their addresses are /// The texts of the documents are known only when a message is sent, from the provenance store
/// collected right then, see PrepareTextContentForAI, and count as given to the model, see /// or from the disk, so their addresses are collected right then, see PrepareTextContentForAI,
/// ChatThread.IsWebAddressGivenToTheModel. Not stored: every request reads the documents anew, /// and count as given to the model, see ChatThread.IsWebAddressGivenToTheModel. Not stored:
/// and a document changed or gone since no longer gives an address. /// every request collects them anew from what it sends.
/// </remarks> /// </remarks>
[JsonIgnore] [JsonIgnore]
public IReadOnlySet<string> RuntimeAttachmentWebAddresses { get; internal set; } = new HashSet<string>(StringComparer.Ordinal); public IReadOnlySet<string> RuntimeAttachmentWebAddresses { get; internal set; } = new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// Whether this message holds attached documents which were never stored, see StoreAttachedDocumentsAsync.
/// </summary>
/// <param name="readChangedAgain">Whether documents whose files changed since they were stored count as well, see DataChat.ReadChangedAttachmentsAgain.</param>
internal bool HasDocumentsToStore(bool readChangedAgain) => this.FileAttachments.Any(attachment => IsToStore(attachment, readChangedAgain));
/// <summary>
/// Whether an attachment is a document to read and store: one which was never stored, or, when
/// the user chose so, one whose file changed since.
/// </summary>
private static bool IsToStore(FileAttachment attachment, bool readChangedAgain) => attachment.Type is FileAttachmentType.DOCUMENT && (attachment.ProvenanceId is null || readChangedAgain && attachment.ChangedSinceStored());
/// <summary>
/// Stores the attached documents of this message which were never sent, reading each one from the disk once.
/// </summary>
/// <remarks>
/// A document which cannot be read or stored right now keeps no entry: the message reads it when
/// it is built, as every message did before, and tells the user then what went wrong. The list of
/// attachments is replaced as a whole rather than changed, since the user interface may read it
/// meanwhile.
/// </remarks>
/// <param name="store">The provenance store.</param>
/// <param name="chatId">The chat of this message.</param>
/// <param name="rustService">The runtime, which reads the documents.</param>
/// <param name="reportingScope">The request the documents are read for, which tells the user once about everything filtered out of them.</param>
/// <param name="readChangedAgain">Whether documents whose files changed since they were stored are read again, see DataChat.ReadChangedAttachmentsAgain.</param>
internal async Task StoreAttachedDocumentsAsync(ProvenanceStoreClient store, Guid chatId, RustService rustService, PromptInjectionReportingScope? reportingScope, bool readChangedAgain)
{
var attachments = new List<FileAttachment>(this.FileAttachments.Count);
var changed = false;
foreach (var attachment in this.FileAttachments)
{
var storedAttachment = await StoreAttachedDocumentAsync(attachment, store, chatId, rustService, reportingScope, readChangedAgain);
changed |= !ReferenceEquals(storedAttachment, attachment);
attachments.Add(storedAttachment);
}
if (changed)
this.FileAttachments = attachments;
}
/// <summary>
/// Stores one attached document which was never sent, or whose file changed since, when the user chose so.
/// </summary>
/// <remarks>
/// A document read again gets an entry of its own: the one before belongs to what the AI
/// received then, and the cleanup at the next start removes it once nothing refers to it.
/// </remarks>
/// <returns>The attachment with the entry of its text, or the attachment as it was when there is nothing to store, or nothing could be.</returns>
private static async Task<FileAttachment> StoreAttachedDocumentAsync(FileAttachment attachment, ProvenanceStoreClient store, Guid chatId, RustService rustService, PromptInjectionReportingScope? reportingScope, bool readChangedAgain)
{
if (!IsToStore(attachment, readChangedAgain))
return attachment;
var document = attachment.Normalize();
if (document.Type is not FileAttachmentType.DOCUMENT || !document.Exists)
return attachment;
try
{
// Quietly: building the message tells the user about a missing Pandoc, when it reads the document after all.
if (FileTypes.RequiresPandoc(document.FilePath))
{
var pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showMessages: false, showSuccessMessage: false);
if (pandocState is not { IsAvailable: true, CheckWasSuccessful: true })
return attachment;
}
// Taken before reading, so that a change while reading counts as a change:
var version = FileVersion.Of(document.FilePath);
var extraction = await rustService.ReadArbitraryFileData(document.FilePath, int.MaxValue, reportingScope: reportingScope);
if (!extraction.HasUsableContent)
return attachment;
// Told here, since no later request reads the document again:
await ReportIncompleteReadingAsync(document, extraction);
// Not cancellable: what was read is what the request sends.
var provenanceId = await store.StoreAsync(chatId, ProvenanceKind.ATTACHMENT, extraction.Content, null, CancellationToken.None);
return provenanceId is null ? attachment : attachment with { ProvenanceId = provenanceId, Version = version };
}
catch (Exception exception)
{
LOGGER.LogWarning(exception, "Failed to store the file attachment '{FilePath}'. It is read from the disk with every request.", document.FilePath);
return attachment;
}
}
/// <summary>
/// Tells the user when a document could be read, but not as it should have been.
/// </summary>
/// <param name="document">The document.</param>
/// <param name="extraction">What reading it gave.</param>
private static async Task ReportIncompleteReadingAsync(FileAttachment document, FileExtractionResult extraction)
{
//
// The file is usable, but we lost parts of it. The user has to know which
// parts are missing, because the answer will be based on the rest.
//
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
{
LOGGER.LogWarning("Parts of the file attachment '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages));
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
}
// The file was read correctly, but its extension lies about what it contains:
if (extraction.HasExtensionMismatch)
{
LOGGER.LogWarning("The file attachment '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
}
}
/// <summary>
/// Reads the stored texts of the attached documents.
/// </summary>
/// <param name="attachments">The attachments.</param>
/// <param name="getProvenanceStore">Gets the provenance store, or null when there is none. It is asked only when a document has an entry.</param>
/// <returns>The texts by the ids of their entries. An entry the store lacks, or cannot read, is missing.</returns>
private static async Task<IReadOnlyDictionary<Guid, string>> ReadStoredDocumentTextsAsync(IEnumerable<FileAttachment> attachments, Func<Task<ProvenanceStoreClient?>> getProvenanceStore)
{
var provenanceIds = attachments.Select(attachment => attachment.ProvenanceId).OfType<Guid>().Distinct().ToList();
if (provenanceIds.Count is 0)
return new Dictionary<Guid, string>();
try
{
if (await getProvenanceStore() is not { } store)
return new Dictionary<Guid, string>();
var entries = await store.GetEntriesAsync(provenanceIds, CancellationToken.None);
return entries.ToDictionary(entry => entry.Key, entry => entry.Value.Content);
}
catch (Exception exception)
{
LOGGER.LogWarning(exception, "Failed to read the stored texts of the file attachments. Their files are read instead.");
return new Dictionary<Guid, string>();
}
}
/// <summary>
/// Filters the stored texts of the attached documents for prompt injections once more.
/// </summary>
/// <remarks>
/// The texts were filtered when they were read from their files. Filtering them again with every
/// request applies the rules of today, as when every request read the files anew. All documents of
/// the message go in one request to the runtime, and the user hears about them with the request.
/// </remarks>
/// <param name="storedTexts">The stored texts by the ids of their entries.</param>
/// <param name="attachments">The attachments, which name the files of the texts.</param>
/// <param name="getPromptInjectionGuard">Gets the prompt injection filter.</param>
/// <param name="reportingScope">The request the texts are sent with.</param>
/// <returns>The filtered texts by the ids of their entries.</returns>
private static async Task<IReadOnlyDictionary<Guid, string>> FilterStoredTextsAsync(IReadOnlyDictionary<Guid, string> storedTexts, IReadOnlyList<FileAttachment> attachments, Func<PromptInjectionGuardService> getPromptInjectionGuard, PromptInjectionReportingScope? reportingScope)
{
if (storedTexts.Count is 0)
return storedTexts;
var filePaths = attachments
.Where(attachment => attachment.ProvenanceId is not null)
.GroupBy(attachment => attachment.ProvenanceId!.Value)
.ToDictionary(group => group.Key, group => group.First().FilePath);
var ids = storedTexts.Keys.ToList();
var filteredTexts = await getPromptInjectionGuard().SanitizeAsync(ids.Select(id => new PromptInjectionText(storedTexts[id], PromptInjectionSource.ChatAttachment(filePaths[id]))).ToList(), reportingScope);
return ids.Zip(filteredTexts).ToDictionary(pair => pair.First, pair => pair.Second);
}
/// <summary> /// <summary>
/// Builds the text the AI gets for this message, with the content of its attached documents. /// Builds the text the AI gets for this message, with the content of its attached documents.
/// </summary> /// </summary>
/// <param name="reportingScope">The request this message is part of, which tells the user once about everything filtered out of its attachments, or null when the request reads no documents.</param> /// <param name="reportingScope">The request this message is part of, which tells the user once about everything filtered out of its attachments, or null when the request reads no documents.</param>
/// <returns>The text for the AI.</returns> /// <returns>The text for the AI.</returns>
public async Task<string> PrepareTextContentForAI(PromptInjectionReportingScope? reportingScope) public Task<string> PrepareTextContentForAI(PromptInjectionReportingScope? reportingScope) => this.PrepareTextContentForAI(reportingScope, Program.RUST_SERVICE, ChatProvenance.GetStoreAsync, () => Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>());
/// <summary>
/// Builds the text the AI gets for this message, with the content of its attached documents.
/// </summary>
/// <remarks>
/// A document stored at its first send comes from the provenance store, also when its file
/// changed or is gone by now, see FileAttachment.ProvenanceId: the AI gets the same text with
/// every request. Only a document without a stored text is read from the disk, as before. A stored
/// text passes the prompt injection filter once more, see FilterStoredTextsAsync.
/// </remarks>
/// <param name="reportingScope">The request this message is part of, which tells the user once about everything filtered out of its attachments, or null when the request reads no documents.</param>
/// <param name="rustService">The runtime, which reads the documents without a stored text.</param>
/// <param name="getProvenanceStore">Gets the provenance store, or null when there is none. It is asked only when a document has an entry.</param>
/// <param name="getPromptInjectionGuard">Gets the prompt injection filter, which is asked only when a document has a stored text.</param>
/// <returns>The text for the AI.</returns>
internal async Task<string> PrepareTextContentForAI(PromptInjectionReportingScope? reportingScope, RustService rustService, Func<Task<ProvenanceStoreClient?>> getProvenanceStore, Func<PromptInjectionGuardService> getPromptInjectionGuard)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
var attachmentWebAddresses = new HashSet<string>(StringComparer.Ordinal); var attachmentWebAddresses = new HashSet<string>(StringComparer.Ordinal);
@@ -417,8 +772,10 @@ public sealed class ContentText : IContent
.Select(attachment => attachment.Normalize()) .Select(attachment => attachment.Normalize())
.ToList(); .ToList();
// Get the list of existing documents: var storedTexts = await FilterStoredTextsAsync(await ReadStoredDocumentTextsAsync(normalizedAttachments, getProvenanceStore), normalizedAttachments, getPromptInjectionGuard, reportingScope);
var existingDocuments = normalizedAttachments.Where(x => x.Type is FileAttachmentType.DOCUMENT && x.Exists).ToList();
// Get the list of existing documents, those with a stored text included, whether their file is still there or not:
var existingDocuments = normalizedAttachments.Where(x => x.Type is FileAttachmentType.DOCUMENT && (StoredTextOf(x) is not null || x.Exists)).ToList();
// //
// Report missing files. We tell the user about them instead of only logging: on a // Report missing files. We tell the user about them instead of only logging: on a
@@ -441,9 +798,9 @@ public sealed class ContentText : IContent
// Pandoc installation must not stop them. // Pandoc installation must not stop them.
// //
var pandocIsUsable = true; var pandocIsUsable = true;
if (existingDocuments.Any(document => FileTypes.RequiresPandoc(document.FilePath))) if (existingDocuments.Any(document => StoredTextOf(document) is null && FileTypes.RequiresPandoc(document.FilePath)))
{ {
var pandocState = await Pandoc.CheckAvailabilityAsync(Program.RUST_SERVICE, showMessages: true, showSuccessMessage: false); var pandocState = await Pandoc.CheckAvailabilityAsync(rustService, showMessages: true, showSuccessMessage: false);
pandocIsUsable = pandocState is { IsAvailable: true, CheckWasSuccessful: true }; pandocIsUsable = pandocState is { IsAvailable: true, CheckWasSuccessful: true };
if (!pandocState.IsAvailable) if (!pandocState.IsAvailable)
@@ -459,6 +816,9 @@ public sealed class ContentText : IContent
// //
var documentBlocks = new StringBuilder(); var documentBlocks = new StringBuilder();
foreach(var document in existingDocuments) foreach(var document in existingDocuments)
{
var content = StoredTextOf(document);
if (content is null)
{ {
if (document.IsForbidden) if (document.IsForbidden)
{ {
@@ -473,7 +833,7 @@ public sealed class ContentText : IContent
continue; continue;
} }
var extraction = await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue, reportingScope: reportingScope); var extraction = await rustService.ReadArbitraryFileData(document.FilePath, int.MaxValue, reportingScope: reportingScope);
if (!extraction.HasUsableContent) if (!extraction.HasUsableContent)
{ {
LOGGER.LogError("Reading the file attachment '{FilePath}' failed and it will not be sent: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage); LOGGER.LogError("Reading the file attachment '{FilePath}' failed and it will not be sent: code={ErrorCode}, message='{ErrorMessage}'.", document.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
@@ -481,21 +841,8 @@ public sealed class ContentText : IContent
continue; continue;
} }
// await ReportIncompleteReadingAsync(document, extraction);
// The file is usable, but we lost parts of it. The user has to know which content = extraction.Content;
// parts are missing, because the answer will be based on the rest.
//
if (extraction.Outcome is FileExtractionOutcome.PARTIAL)
{
LOGGER.LogWarning("Parts of the file attachment '{FilePath}' could not be read: pages={FailedPages}.", document.FilePath, string.Join(", ", extraction.FailedPages));
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Description, extraction.ToPartialUserMessage(document.FileName)));
}
// The file was read correctly, but its extension lies about what it contains:
if (extraction.HasExtensionMismatch)
{
LOGGER.LogWarning("The file attachment '{FilePath}' is actually a '{DetectedFormat}'.", document.FilePath, extraction.DetectedFormat);
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.RuleFolder, extraction.ToExtensionMismatchUserMessage(document.FileName)));
} }
documentBlocks.AppendLine(); documentBlocks.AppendLine();
@@ -503,10 +850,10 @@ public sealed class ContentText : IContent
documentBlocks.AppendLine($"File path: {document.FilePath}"); documentBlocks.AppendLine($"File path: {document.FilePath}");
documentBlocks.AppendLine("File content:"); documentBlocks.AppendLine("File content:");
documentBlocks.AppendLine("````"); documentBlocks.AppendLine("````");
documentBlocks.AppendLine(extraction.Content); documentBlocks.AppendLine(content);
documentBlocks.AppendLine("````"); documentBlocks.AppendLine("````");
foreach (var address in WebAddresses.Find(extraction.Content)) foreach (var address in WebAddresses.Find(content))
if (WebAddresses.TryCreateRequestKey(address, out var requestKey)) if (WebAddresses.TryCreateRequestKey(address, out var requestKey))
attachmentWebAddresses.Add(requestKey); attachmentWebAddresses.Add(requestKey);
} }
@@ -526,6 +873,8 @@ public sealed class ContentText : IContent
sb.AppendLine("Please consider them as part of the message content and use them to answer accordingly."); sb.AppendLine("Please consider them as part of the message content and use them to answer accordingly.");
} }
} }
string? StoredTextOf(FileAttachment document) => document.ProvenanceId is { } provenanceId ? storedTexts.GetValueOrDefault(provenanceId) : null;
} }
this.RuntimeAttachmentWebAddresses = attachmentWebAddresses; this.RuntimeAttachmentWebAddresses = attachmentWebAddresses;
+43 -2
View File
@@ -58,10 +58,51 @@ public record FileAttachment(FileAttachmentType Type, string FileName, [property
[JsonIgnore] [JsonIgnore]
public bool Exists => File.Exists(this.FilePath); public bool Exists => File.Exists(this.FilePath);
/// <summary>
/// The entry of the provenance store which holds the text this document was first sent with, or
/// null while it was never sent, or could not be stored.
/// </summary>
/// <remarks>
/// A document is read from the disk once, see ChatThread.StoreAttachedDocumentsAsync. Every later
/// request of the chat sends the stored text, also when the file changed or is gone by now, so
/// what the AI received stays the same and can be shown as it was. Left out of the chat file, and
/// of chat templates, while null.
/// </remarks>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Guid? ProvenanceId { get; init; }
/// <summary>
/// The state of the file which the stored text was read from, see ProvenanceId, or null while
/// there is no stored text, or when it was stored before AI Studio noted the state.
/// </summary>
/// <remarks>
/// Tells whether the file changed since, for a chat which reads changed files again, see
/// DataChat.ReadChangedAttachmentsAgain. Left out of the chat file while null.
/// </remarks>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public FileVersion? Version { get; init; }
/// <summary>
/// Whether the file changed since its text was stored.
/// </summary>
/// <remarks>
/// Reads the state of the file. A file which is gone did not change: the stored text is all
/// there is of it. Without a noted state, nothing tells, and the stored text stays as well.
/// </remarks>
public bool ChangedSinceStored() => this.ProvenanceId is not null && this.Version is { } version && FileVersion.Of(this.FilePath) is { } current && current != version;
/// <summary>
/// The same attachment without its stored text, e.g., for a place apart from its chat, where the file is read anew.
/// </summary>
public FileAttachment WithoutStoredText() => this with { ProvenanceId = null, Version = null };
/// <summary> /// <summary>
/// Rebuilds the attachment from its current file path so file type detection uses the latest rules. /// Rebuilds the attachment from its current file path so file type detection uses the latest rules.
/// </summary> /// </summary>
public virtual FileAttachment Normalize() => FromPath(this.FilePath); /// <remarks>
/// The entry of its text stays: it belongs to the attachment, not to the rules.
/// </remarks>
public virtual FileAttachment Normalize() => FromPath(this.FilePath) with { ProvenanceId = this.ProvenanceId, Version = this.Version };
/// <summary> /// <summary>
/// Makes the same attachment for its file at another place, e.g. after the chat holding the file moved. /// Makes the same attachment for its file at another place, e.g. after the chat holding the file moved.
@@ -72,7 +113,7 @@ public record FileAttachment(FileAttachmentType Type, string FileName, [property
/// </remarks> /// </remarks>
/// <param name="filePath">The path of the file now.</param> /// <param name="filePath">The path of the file now.</param>
/// <returns>An attachment of the same kind, with the new path.</returns> /// <returns>An attachment of the same kind, with the new path.</returns>
public virtual FileAttachment WithFilePath(string filePath) => new(this.Type, this.FileName, filePath, this.FileSizeBytes); public virtual FileAttachment WithFilePath(string filePath) => new(this.Type, this.FileName, filePath, this.FileSizeBytes) { ProvenanceId = this.ProvenanceId, Version = this.Version };
/// <summary> /// <summary>
/// Creates a FileAttachment from a file path by automatically determining the type, /// Creates a FileAttachment from a file path by automatically determining the type,
@@ -28,5 +28,7 @@ public record FileAttachmentImage(string FileName, string FilePath, long FileSiz
{ {
SourceType = this.SourceType, SourceType = this.SourceType,
Source = this.SourceType is ContentImageSource.LOCAL_PATH ? filePath : this.Source, Source = this.SourceType is ContentImageSource.LOCAL_PATH ? filePath : this.Source,
ProvenanceId = this.ProvenanceId,
Version = this.Version,
}; };
} }
@@ -0,0 +1,24 @@
namespace AIStudio.Chat;
/// <summary>
/// The state of a file which a text was read from, to tell whether the file changed since.
/// </summary>
/// <remarks>
/// The size counts as well as the time: some file systems keep the time coarsely, e.g., FAT to two
/// seconds, and a network drive may report the time of another clock.
/// </remarks>
/// <param name="LastWriteUtc">When the file was last written.</param>
/// <param name="Length">The size of the file in bytes.</param>
public sealed record FileVersion(DateTime LastWriteUtc, long Length)
{
/// <summary>
/// Reads the state of a file as it is now.
/// </summary>
/// <param name="filePath">The path of the file.</param>
/// <returns>The state, or null when the file is gone.</returns>
public static FileVersion? Of(string filePath)
{
var file = new FileInfo(filePath);
return file.Exists ? new(file.LastWriteTimeUtc, file.Length) : null;
}
}
@@ -1,4 +1,5 @@
using AIStudio.Tools.MIME; using AIStudio.Provider;
using AIStudio.Provider.SelfHosted;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
namespace AIStudio.Chat; namespace AIStudio.Chat;
@@ -7,72 +8,40 @@ public static class IImageSourceExtensions
{ {
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(IImageSourceExtensions).Namespace, nameof(IImageSourceExtensions)); private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(IImageSourceExtensions).Namespace, nameof(IImageSourceExtensions));
public static MIMEType DetermineMimeType(this IImageSource image) /// <summary>
{ /// The largest image, in bytes, we send anywhere: no image prepared for a model is larger, and
switch (image.SourceType) /// TryAsBase64 skips every larger file.
{ /// </summary>
case ContentImageSource.BASE64: public const long MAX_IMAGE_SIZE_BYTES = 10_000_000;
{
// 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: /// <summary>
return Builder.Create().UseApplication().UseSubtype(ApplicationSubtype.OCTET_STREAM).Build(); /// The largest image file, in bytes, which we send through the given provider.
} /// </summary>
/// <remarks>
case ContentImageSource.URL: /// The smallest of the limits we know: the one of a self-hosted engine, the one of the provider,
/// and MAX_IMAGE_SIZE_BYTES, our own. A provider or engine which states no limit gets ours.
/// </remarks>
/// <param name="provider">The configured provider the image is sent through.</param>
/// <returns>The size limit of one image file.</returns>
public static long MaxImageSizeBytesFor(AIStudio.Settings.Provider provider)
{ {
// Try to detect the mime type from the URL extension: var engineLimit = provider.UsedLLMProvider is LLMProviders.SELF_HOSTED ? provider.Host.GetMaxImageSizeBytes() : null;
var uri = new Uri(image.Source); var providerLimit = provider.UsedLLMProvider.GetMaxImageSizeBytes();
var extension = Path.GetExtension(uri.AbsolutePath).ToLowerInvariant(); return Math.Min(Math.Min(engineLimit ?? MAX_IMAGE_SIZE_BYTES, providerLimit ?? MAX_IMAGE_SIZE_BYTES), MAX_IMAGE_SIZE_BYTES);
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> /// <summary>
/// Read the image content as a base64 string. /// Read the image content as a base64 string.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This is for the images inside the content of a chat, which go to external data sources and
/// agents as they are. Attached images, which go to a model, take ModelImageService instead: it
/// scales them down and encodes them anew within the limit of the provider.<br/>
/// <br/>
/// The images are directly converted to base64 strings. The maximum /// 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 /// size of the image is around 10 MB. If the image is larger, the method
/// returns an empty string.<br/> /// returns an empty string.<br/>
/// <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.<br/>
/// <br/>
/// Note as well that this method returns just the base64 string without /// Note as well that this method returns just the base64 string without
/// any data URI prefix (like "data:image/png;base64,"). The caller has /// any data URI prefix (like "data:image/png;base64,"). The caller has
/// to take care of that if needed. /// to take care of that if needed.
@@ -97,7 +66,7 @@ public static class IImageSourceExtensions
{ {
// Read the length of the content: // Read the length of the content:
var lengthBytes = response.Content.Headers.ContentLength; var lengthBytes = response.Content.Headers.ContentLength;
if(lengthBytes > 10_000_000) if(lengthBytes > MAX_IMAGE_SIZE_BYTES)
{ {
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.ImageNotSupported, TB("The image at the URL is too large (>10 MB). Skipping the image."))); 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); return (success: false, string.Empty);
@@ -116,7 +85,7 @@ public static class IImageSourceExtensions
{ {
// Read the content length: // Read the content length:
var length = new FileInfo(image.Source).Length; var length = new FileInfo(image.Source).Length;
if(length > 10_000_000) if(length > MAX_IMAGE_SIZE_BYTES)
{ {
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.ImageNotSupported, TB("The local image file is too large (>10 MB). Skipping the image."))); 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); return (success: false, string.Empty);
@@ -1,7 +1,9 @@
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Provider.OpenAI; using AIStudio.Provider.OpenAI;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Security; using AIStudio.Tools.Security;
using AIStudio.Tools.Services;
namespace AIStudio.Chat; namespace AIStudio.Chat;
@@ -14,14 +16,16 @@ public static class ListContentBlockExtensions
/// <param name="roleTransformer">A function that transforms each content block into a message result asynchronously.</param> /// <param name="roleTransformer">A function that transforms each content block into a message result asynchronously.</param>
/// <param name="provider">The configured provider, whose model is being written to.</param> /// <param name="provider">The configured provider, whose model is being written to.</param>
/// <param name="textSubContentFactory">A factory function to create text sub-content.</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> /// <param name="imageSubContentFactory">A factory function to create image sub-content from an image prepared for the model.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>An asynchronous task that resolves to a list of transformed results.</returns> /// <returns>An asynchronous task that resolves to a list of transformed results.</returns>
public static async Task<IList<IMessageBase>> BuildMessagesAsync( public static async Task<IList<IMessageBase>> BuildMessagesAsync(
this List<ContentBlock> blocks, this List<ContentBlock> blocks,
AIStudio.Settings.Provider provider, AIStudio.Settings.Provider provider,
Func<ChatRole, string> roleTransformer, Func<ChatRole, string> roleTransformer,
Func<string, ISubContent> textSubContentFactory, Func<string, ISubContent> textSubContentFactory,
Func<FileAttachmentImage, Task<ISubContent>> imageSubContentFactory) Func<PreparedModelImage, ISubContent> imageSubContentFactory,
CancellationToken token = default)
{ {
// //
// Asked through the configured provider, so that what a person set in their expert settings // Asked through the configured provider, so that what a person set in their expert settings
@@ -59,7 +63,7 @@ public static class ListContentBlockExtensions
// Text prompt with images as attachments, and the provider/model can process images: // 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(): case ContentText text when block.ContentType is ContentType.TEXT && !string.IsNullOrWhiteSpace(text.Text) && text.FileAttachments.ContainsImages():
messageTaskList.Add(CreateMultimodalMessageAsync(block, text, textSubContentFactory, imageSubContentFactory)); messageTaskList.Add(CreateMultimodalMessageAsync(block, text));
break; break;
} }
} }
@@ -81,34 +85,41 @@ public static class ListContentBlockExtensions
} }
// Local function to create a multimodal message asynchronously. // Local function to create a multimodal message asynchronously.
Task<IMessageBase> CreateMultimodalMessageAsync( Task<IMessageBase> CreateMultimodalMessageAsync(ContentBlock block, ContentText text)
ContentBlock block,
ContentText text,
Func<string, ISubContent> innerTextSubContentFactory,
Func<FileAttachmentImage, Task<ISubContent>> innerImageSubContentFactory)
{ {
return Task.Run(async () => return Task.Run(async () =>
{ {
var imagesTasks = text.FileAttachments var images = await Task.WhenAll(text.FileAttachments
.Where(x => x is { IsImage: true, Exists: true }) .Where(x => x is { IsImage: true, Exists: true })
.Cast<FileAttachmentImage>() .Cast<FileAttachmentImage>()
.Select(innerImageSubContentFactory) .Select(CreateImageSubContentAsync));
.ToList();
Task.WaitAll(imagesTasks);
var images = imagesTasks.Select(t => t.Result).ToList();
return new MultimodalMessage return new MultimodalMessage
{ {
Role = roleTransformer(block.Role), Role = roleTransformer(block.Role),
Content = Content =
[ [
innerTextSubContentFactory(await text.PrepareTextContentForAI(reportingScope)), textSubContentFactory(await text.PrepareTextContentForAI(reportingScope)),
..images, ..images.OfType<ISubContent>(),
] ]
} as IMessageBase; } as IMessageBase;
}); });
} }
//
// Local function to prepare one image for the model. An image which cannot be prepared
// stays out of the message, and the user learns why. It used to go along as an image
// without data, which the providers refuse together with the whole request.
//
async Task<ISubContent?> CreateImageSubContentAsync(FileAttachmentImage image)
{
var prepared = await Program.SERVICE_PROVIDER.GetRequiredService<ModelImageService>().PrepareAsync(image, provider, token);
if (prepared.IsPrepared)
return imageSubContentFactory(prepared);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.ImageNotSupported, prepared.ErrorCode.ToUserMessage(image.FileName)));
return null;
}
} }
/// <summary> /// <summary>
@@ -158,20 +169,16 @@ public static class ListContentBlockExtensions
Text = text, Text = text,
}; };
private static async Task<ISubContent> DirectImageSubContentFactory(FileAttachmentImage attachment) => new SubContentImageUrl private static ISubContent DirectImageSubContentFactory(PreparedModelImage image) => new SubContentImageUrl
{ {
ImageUrl = await attachment.TryAsBase64() is (true, var base64Content) ImageUrl = image.ToDataUrl(),
? $"data:{attachment.DetermineMimeType()};base64,{base64Content}"
: string.Empty,
}; };
private static async Task<ISubContent> NestedImageSubContentFactory(FileAttachmentImage attachment) => new SubContentImageUrlNested private static ISubContent NestedImageSubContentFactory(PreparedModelImage image) => new SubContentImageUrlNested
{ {
ImageUrl = new SubContentImageUrlData ImageUrl = new SubContentImageUrlData
{ {
Url = await attachment.TryAsBase64() is (true, var base64Content) Url = image.ToDataUrl(),
? $"data:{attachment.DetermineMimeType()};base64,{base64Content}"
: string.Empty,
}, },
}; };
@@ -34,7 +34,7 @@ public sealed record ManagedTranscriptAttachment(string FileName, string FilePat
} }
/// <inheritdoc /> /// <inheritdoc />
public override FileAttachment WithFilePath(string filePath) => new ManagedTranscriptAttachment(this.FileName, filePath, this.FileSizeBytes, this.OriginalFileName, this.IsStaged); public override FileAttachment WithFilePath(string filePath) => new ManagedTranscriptAttachment(this.FileName, filePath, this.FileSizeBytes, this.OriginalFileName, this.IsStaged) { ProvenanceId = this.ProvenanceId, Version = this.Version };
/// <summary>Creates a transcript in an operation-specific staging directory.</summary> /// <summary>Creates a transcript in an operation-specific staging directory.</summary>
/// <param name="originalPath">Original media path.</param> /// <param name="originalPath">Original media path.</param>
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace AIStudio.Chat;
/// <summary>
/// The text a preview read of a file attached to the draft of a chat, which sending takes along instead of reading the file again.
/// </summary>
/// <param name="FilePath">The path of the file, as the attachment names it.</param>
/// <param name="ProvenanceId">The entry of the provenance store which holds the text.</param>
/// <param name="Version">The state of the file before the preview read it. A file which changed since is read again.</param>
public sealed record PreviewedText([property: JsonConverter(typeof(DataDirectoryRelativePathConverter))] string FilePath, Guid ProvenanceId, FileVersion Version);
@@ -0,0 +1,91 @@
using AIStudio.Tools.Databases.ProvenanceStore;
namespace AIStudio.Chat;
/// <summary>
/// Removes from the provenance store what no stored chat refers to anymore.
/// </summary>
/// <remarks>
/// Deleting a chat takes its entries along right away, see ChatProvenance, and so does the
/// maintenance of the temporary chats. What is left over comes from changes inside a chat, or from
/// runs which ended before they could clean up:
/// <list type="bullet">
/// <item>a message which was removed, edited, or answered anew, together with its passages and attached documents;</item>
/// <item>a text which a preview stored, while its chat was never stored with it, or sending could not remove it;</item>
/// <item>an assistant whose conversation was never sent to a chat;</item>
/// <item>a chat which was never stored, or deleted while the store was not available.</item>
/// </list>
/// Each chat file is read for its references, which only a cleanup at the start can do safely:
/// while the app runs, a chat in memory may refer to an entry its file does not know yet. For the
/// same reason, only entries stored before the start are removed.<br/><br/>
///
/// Reading every chat at every start would cost much on a small computer, so a chat is read only
/// when something changed since the last complete cleanup: its file was written since, or it
/// received an entry since. Otherwise, the last cleanup removed all it could, and nothing it kept
/// has become unreferenced. A chat which is not read keeps its entries; skipping never removes
/// anything. Chats which no longer exist cost no reading and are always taken care of.
/// </remarks>
public static class ProvenanceCleanup
{
/// <summary>
/// How long before the last cleanup a chat file may have been written and still counts as changed.
/// </summary>
/// <remarks>
/// The time of a file may be coarse, e.g., two seconds on FAT, or come from the clock of another
/// computer, for a data folder on a network drive. Reading a few chats too many costs little,
/// while missing one leaves what it no longer refers to until it changes again.
/// </remarks>
private static readonly TimeSpan WRITE_TIME_TOLERANCE = TimeSpan.FromMinutes(5);
/// <summary>
/// Removes the entries of chats which no longer exist, and those their chat no longer refers to.
/// </summary>
/// <param name="store">The provenance store.</param>
/// <param name="chatLocations">The workspace of every chat on the disk by its ID, Guid.Empty for a temporary chat.</param>
/// <param name="loadChat">Reads a chat by its workspace and ID, or answers null when it cannot be read.</param>
/// <param name="getChatWrittenAt">Tells when a chat file was last written, by its workspace and ID, or answers null when that is not known.</param>
/// <param name="createdBefore">The start of this run: entries stored since may belong to a chat which is not on the disk yet.</param>
/// <param name="lastCleanup">When the last complete cleanup began, or null to read every chat.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>What the cleanup did.</returns>
public static async Task<ProvenanceCleanupResult> CleanUpAsync(ProvenanceStoreClient store, IReadOnlyDictionary<Guid, Guid> chatLocations, Func<Guid, Guid, Task<ChatThread?>> loadChat, Func<Guid, Guid, DateTimeOffset?> getChatWrittenAt, DateTimeOffset createdBefore, DateTimeOffset? lastCleanup, CancellationToken token)
{
// The chats which received an entry since the last cleanup, in one query, which the last cleanup had to leave alone:
var chatsWithNewEntries = lastCleanup is { } since ? (await store.GetChatIdsAsync(token, since)).ToHashSet() : [];
var unreferenced = 0;
var skipped = 0;
var unreadable = 0;
var orphanedChats = new List<Guid>();
foreach (var chatId in await store.GetChatIdsAsync(token))
{
token.ThrowIfCancellationRequested();
if (!chatLocations.TryGetValue(chatId, out var workspaceId))
{
orphanedChats.Add(chatId);
continue;
}
// A chat whose time of writing is not known counts as changed:
if (lastCleanup is { } previousStart && !chatsWithNewEntries.Contains(chatId) && getChatWrittenAt(workspaceId, chatId) is { } writtenAt && writtenAt < previousStart - WRITE_TIME_TOLERANCE)
{
skipped++;
continue;
}
// A chat which cannot be read keeps everything: nothing tells what it refers to.
var chat = await loadChat(workspaceId, chatId);
if (chat is null)
{
unreadable++;
continue;
}
unreferenced += await store.DeleteChatEntriesExceptAsync(chatId, chat.CollectProvenanceIds(), token, createdBefore);
}
// The chats which no longer exist all at once, since there may be many of them after a long time without a start:
var orphaned = await store.DeleteChatsAsync(orphanedChats, token, createdBefore);
return new(orphaned, unreferenced, skipped, unreadable);
}
}
@@ -0,0 +1,10 @@
namespace AIStudio.Chat;
/// <summary>
/// What a cleanup of the provenance store did, see ProvenanceCleanup.
/// </summary>
/// <param name="Orphaned">How many entries of chats which no longer exist were removed.</param>
/// <param name="Unreferenced">How many entries their chats no longer refer to were removed.</param>
/// <param name="Skipped">How many chats were left unread, since nothing changed in them since the last cleanup.</param>
/// <param name="Unreadable">How many chats could not be read, and thus kept all their entries.</param>
public sealed record ProvenanceCleanupResult(int Orphaned, int Unreferenced, int Skipped, int Unreadable);
@@ -0,0 +1,21 @@
namespace AIStudio.Chat;
public static class SetFileAttachmentExtensions
{
/// <summary>
/// Attaches a file in place of the attachment of the same file the set held before.
/// </summary>
/// <remarks>
/// The attachments differ by more than their files: one of a message being edited holds the
/// entry of the text it was sent with, and the size of the file may have changed since. Both
/// would stay in the set, and the message would go with the old and the new text of the file.
/// Attaching a file again means the file as it is now.
/// </remarks>
/// <param name="attachments">The attachments of a message.</param>
/// <param name="attachment">The attachment of the file just attached.</param>
public static void AttachInPlaceOfSameFile(this HashSet<FileAttachment> attachments, FileAttachment attachment)
{
attachments.RemoveWhere(existing => string.Equals(existing.FilePath, attachment.FilePath, StringComparison.Ordinal));
attachments.Add(attachment);
}
}
@@ -81,6 +81,16 @@ public partial class AttachDocuments : MSGComponentBase
[Parameter] [Parameter]
public Func<string, Task<ChatThread?>> EnsureOwnerChatAsync { get; set; } = _ => Task.FromResult<ChatThread?>(null); public Func<string, Task<ChatThread?>> EnsureOwnerChatAsync { get; set; } = _ => Task.FromResult<ChatThread?>(null);
/// <summary>
/// Keeps what the preview read of an attached document, so that sending it does not read the file again.
/// </summary>
/// <remarks>
/// Only the chat hands one over, cf. DocumentCheckDialog.KeepReadText. Elsewhere, e.g., in the
/// assistants, the documents are read for every run anyway.
/// </remarks>
[Parameter]
public Func<FileAttachment, string, FileVersion, Task>? KeepPreviewedText { get; set; }
[Inject] [Inject]
private ILogger<AttachDocuments> Logger { get; set; } = null!; private ILogger<AttachDocuments> Logger { get; set; } = null!;
@@ -263,7 +273,7 @@ public partial class AttachDocuments : MSGComponentBase
return; return;
var previousAttachments = this.DocumentPaths.ToHashSet(); var previousAttachments = this.DocumentPaths.ToHashSet();
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths, this.AttachDroppedPathsAsync, () => this.IsUnavailable); this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths, this.AttachDroppedPathsAsync, () => this.IsUnavailable, this.KeepPreviewedText);
var removedAttachments = previousAttachments.Except(this.DocumentPaths).ToList(); var removedAttachments = previousAttachments.Except(this.DocumentPaths).ToList();
foreach (var removedAttachment in removedAttachments) foreach (var removedAttachment in removedAttachments)
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment); ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
@@ -383,7 +393,7 @@ public partial class AttachDocuments : MSGComponentBase
// about whether it happened to be new to us. // about whether it happened to be new to us.
// //
var attachment = FileAttachment.FromPath(path); var attachment = FileAttachment.FromPath(path);
this.DocumentPaths.Add(attachment); this.DocumentPaths.AttachInPlaceOfSameFile(attachment);
attached.Add(attachment); attached.Add(attachment);
} }
@@ -452,6 +462,7 @@ public partial class AttachDocuments : MSGComponentBase
{ x => x.Document, fileAttachment }, { x => x.Document, fileAttachment },
{ x => x.AttachPaths, this.AttachDroppedPathsAsync }, { x => x.AttachPaths, this.AttachDroppedPathsAsync },
{ x => x.IsAttachingUnavailable, () => this.IsUnavailable }, { x => x.IsAttachingUnavailable, () => this.IsUnavailable },
{ x => x.KeepReadText, this.KeepPreviewedText },
}; };
await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN); await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);
@@ -109,7 +109,7 @@
</MudTooltip> </MudTooltip>
} }
<AttachDocuments Name="File Attachments" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" ShowMediaStatus="false" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" Disabled="@(!this.CanAttachFiles)"/> <AttachDocuments Name="File Attachments" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" ShowMediaStatus="false" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" KeepPreviewedText="@this.KeepPreviewedTextAsync" Disabled="@(!this.CanAttachFiles)"/>
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/> <MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
@@ -157,7 +157,7 @@
@if (!this.ChatThread.IsLLMProviderAllowed(this.Provider)) @if (!this.ChatThread.IsLLMProviderAllowed(this.Provider))
{ {
<MudTooltip Text="@T("The selected provider is not allowed in this chat due to data security or confidence-level requirements.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@(this.SettingsManager.IsProviderProhibited(this.Provider.UsedLLMProvider) ? T("The use of this provider has been prohibited by your organization. Please select a different provider.") : T("The selected provider is not allowed in this chat due to data security or confidence-level requirements."))" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Error" Color="Color.Error"/> <MudIconButton Icon="@Icons.Material.Filled.Error" Color="Color.Error"/>
</MudTooltip> </MudTooltip>
} }
@@ -5,6 +5,8 @@ using AIStudio.Dialogs;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases;
using AIStudio.Tools.Databases.ProvenanceStore;
using AIStudio.Tools.ToolCallingSystem; using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.AIJobs; using AIStudio.Tools.AIJobs;
using AIStudio.Tools.Media; using AIStudio.Tools.Media;
@@ -84,6 +86,9 @@ public partial class ChatComponent : MSGComponentBase
[Inject] [Inject]
private NavigationManager NavigationManager { get; init; } = null!; private NavigationManager NavigationManager { get; init; } = null!;
[Inject]
private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!;
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top; private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new(); private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
@@ -901,6 +906,84 @@ public partial class ChatComponent : MSGComponentBase
this.selectedToolIds = await this.ToolRegistry.FilterSelectableToolIdsAsync(Tools.Components.CHAT, templateToolIds); this.selectedToolIds = await this.ToolRegistry.FilterSelectableToolIdsAsync(Tools.Components.CHAT, templateToolIds);
} }
/// <summary>
/// Takes over the profile and provider which the chat template preselects, wherever it preselects one.
/// </summary>
/// <remarks>
/// A preselection, not a limit — the user changes both in the chat as usual. A template which
/// says nothing, or names a provider which is gone or may not be used for chats, leaves the
/// current choice in place, so the chat starts as it would without the template. A template
/// whose profile is gone starts with the default profile of the chat settings instead. What the
/// previous template preselected is not carried over, see SettingsManager.ChooseChatTemplateProfile.
/// The caller tells the parent about a new provider.
/// </remarks>
/// <param name="previousChatTemplate">The chat template the current profile and provider were chosen with; the current one when the template does not change.</param>
/// <returns>True when the provider changed.</returns>
private bool ApplyChatTemplateProfileAndProvider(ChatTemplate previousChatTemplate)
{
this.currentProfile = this.SettingsManager.ChooseChatTemplateProfile(this.currentChatTemplate, previousChatTemplate, this.currentProfile);
var provider = this.SettingsManager.ChooseChatTemplateProvider(this.currentChatTemplate, previousChatTemplate, this.Provider);
if (provider == this.Provider)
return false;
this.Provider = provider;
return true;
}
/// <summary>
/// The workspace a chat which has no thread yet gets with its first content.
/// </summary>
/// <remarks>
/// The first content is the first message, a media import, or a draft kept when the user leaves
/// the page. A chat without a thread belongs to no workspace, so the workspace of its chat
/// template fills in, opened or created by its name. That happens only here, when the chat gets
/// something to keep: merely opening the chat page or trying out a template creates no
/// workspace, and a workspace the user deleted stays gone until a chat with that template
/// actually starts. Without a usable workspace of the template, the chat stays without one.
/// </remarks>
/// <returns>The ID of the workspace, or Guid.Empty for none.</returns>
private async Task<Guid> ResolveWorkspaceOfFirstThreadAsync()
{
if (this.currentWorkspaceId != Guid.Empty)
return this.currentWorkspaceId;
if (this.SettingsManager.GetChatTemplateWorkspaceName(this.currentChatTemplate) is not { } workspaceName)
return Guid.Empty;
return await WorkspaceBehaviour.ResolveOrCreateChatTemplateWorkspaceAsync(workspaceName, this.currentChatTemplate.GetSafeName());
}
/// <summary>
/// Hands a thread which this component just created for a chat without one to the parent.
/// </summary>
/// <remarks>
/// The thread is a parameter of this component, and the parent hands over its own whenever it
/// renders. So the parent hears about the new thread before anything else may render it, see
/// StartNewChat. Only afterward, the header shows the workspace of the thread, since showing a
/// workspace name renders the parent as well.
/// </remarks>
private async Task PublishNewChatThreadAsync()
{
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
// The header shows the workspace of the new thread from now on, e.g., the one of its chat template:
if (this.ChatThread is not null && this.ChatThread.WorkspaceId != Guid.Empty)
await this.SyncWorkspaceHeaderWithChatThreadAsync();
}
/// <summary>
/// Whether the open chat is in the workspace which the given chat template names.
/// </summary>
/// <remarks>
/// Such a workspace was the choice of the template rather than of the user, so a chat started
/// with another template does not have to stay there.
/// </remarks>
private bool IsInWorkspaceOfChatTemplate(ChatTemplate chatTemplate) => this.currentWorkspaceId != Guid.Empty
&& this.SettingsManager.GetChatTemplateWorkspaceName(chatTemplate) is { } workspaceName
&& string.Equals(WorkspaceBehaviour.NormalizeWorkspaceName(this.currentWorkspaceName), workspaceName, StringComparison.OrdinalIgnoreCase);
/// <summary> /// <summary>
/// The data source options a chat starts with: those of its chat template, or the chat defaults. /// The data source options a chat starts with: those of its chat template, or the chat defaults.
/// </summary> /// </summary>
@@ -1006,24 +1089,36 @@ public partial class ChatComponent : MSGComponentBase
private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate) private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
{ {
this.currentChatTemplate = this.SettingsManager.GetChatTemplateById(chatTemplate.Id); var nextChatTemplate = this.SettingsManager.GetChatTemplateById(chatTemplate.Id);
if (this.ChatThread is not null)
{
//
// Starting the new chat is what hands the new template to it, with its text, attachments,
// and selections. Nothing changes here before, since the user may still decline to leave
// the open chat; it then stays as it was, with its template. The new chat stays in the
// workspace of the open one, unless that workspace is merely the one of the current
// template: then the new template decides where its chat goes.
//
await this.StartNewChat(useSameWorkspace: !this.IsInWorkspaceOfChatTemplate(this.currentChatTemplate), chatTemplate: nextChatTemplate);
return;
}
var previousChatTemplate = this.currentChatTemplate;
this.currentChatTemplate = nextChatTemplate;
if(!string.IsNullOrWhiteSpace(this.currentChatTemplate.PredefinedUserPrompt)) if(!string.IsNullOrWhiteSpace(this.currentChatTemplate.PredefinedUserPrompt))
this.ComposerState.SetSystemInput(this.currentChatTemplate.PredefinedUserPrompt); this.ComposerState.SetSystemInput(this.currentChatTemplate.PredefinedUserPrompt);
// Apply template's file attachments (replaces existing): // Apply template's file attachments (replaces existing):
this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments); this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments);
if (this.ChatThread is not null)
{
// Starting the new chat is what hands the selection of the new template to it:
await this.StartNewChat(true);
return;
}
// //
// Without a thread there is nothing to start anew, so the selection of the new template is // Without a thread there is nothing to start anew, so the selection of the new template is
// applied right here. It travels into the thread which the first message creates. // applied right here. It travels into the thread which the first message creates, and that
// message also decides the workspace, see ResolveWorkspaceOfFirstThreadAsync.
// //
if (this.ApplyChatTemplateProfileAndProvider(previousChatTemplate))
await this.ProviderChanged.InvokeAsync(this.Provider);
await this.ApplyChatTemplateToolSelectionAsync(); await this.ApplyChatTemplateToolSelectionAsync();
this.ApplyAutomaticDataSourceOptions(); this.ApplyAutomaticDataSourceOptions();
} }
@@ -1047,6 +1142,19 @@ public partial class ChatComponent : MSGComponentBase
{ {
this.currentProfile = this.SettingsManager.GetPreselectedProfile(Tools.Components.CHAT); this.currentProfile = this.SettingsManager.GetPreselectedProfile(Tools.Components.CHAT);
this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT); this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT);
//
// A chat which has not started yet keeps up with its chat template. The provider follows
// only when the template now preselects another one, so that a provider the user picked
// by hand survives a change of some unrelated setting:
//
if (previousChatTemplate.Id != this.currentChatTemplate.Id || previousChatTemplate.ProviderId != this.currentChatTemplate.ProviderId)
{
if (this.ApplyChatTemplateProfileAndProvider(previousChatTemplate))
await this.ProviderChanged.InvokeAsync(this.Provider);
}
else if (this.SettingsManager.GetChatTemplateStartProfile(this.currentChatTemplate) is { } templateProfile)
this.currentProfile = templateProfile;
} }
else else
{ {
@@ -1170,7 +1278,7 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
} }
private void ComposerAttachmentsChanged(HashSet<FileAttachment> attachments) private async Task ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
{ {
if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments)) if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments))
this.ComposerState.ReplaceFileAttachments(attachments); this.ComposerState.ReplaceFileAttachments(attachments);
@@ -1178,6 +1286,10 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.MarkUserDraft(); this.ComposerState.MarkUserDraft();
this.KeepComposerInDraft(); this.KeepComposerInDraft();
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
// A file the user took out goes without the text its preview read:
if (this.ChatThread is { } chat)
await this.ForgetPreviewedTextsAsync(chat.ChatId, chat.Draft.ForgetPreviewsOfDetachedFiles());
} }
/// <summary> /// <summary>
@@ -1256,10 +1368,16 @@ public partial class ChatComponent : MSGComponentBase
if (!this.ComposerState.HasVisibleUserDraft) if (!this.ComposerState.HasVisibleUserDraft)
return; return;
//
// The message moves out of the composer before anything below waits, so that leaving a
// second time meanwhile finds nothing left to keep. Only the workspace follows afterward,
// since finding or creating the one of the chat template may take a moment:
//
this.RefreshCurrentProfileAndChatTemplate(); this.RefreshCurrentProfileAndChatTemplate();
chat = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput)); chat = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput), Guid.Empty);
this.MirrorComposerIntoDraft(chat); this.MirrorComposerIntoDraft(chat);
this.ComposerState.Clear(); this.ComposerState.Clear();
chat.WorkspaceId = await this.ResolveWorkspaceOfFirstThreadAsync();
} }
else if (unstoredChatBeingLeft is not null && (!unstoredChatBeingLeft.Draft.IsEmpty || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(unstoredChatBeingLeft.ChatId)))) else if (unstoredChatBeingLeft is not null && (!unstoredChatBeingLeft.Draft.IsEmpty || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(unstoredChatBeingLeft.ChatId))))
{ {
@@ -1319,7 +1437,117 @@ public partial class ChatComponent : MSGComponentBase
} }
/// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary> /// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary>
private async Task<ChatThread?> EnsureMediaImportChatAsync(string firstMediaPath) private Task<ChatThread?> EnsureMediaImportChatAsync(string firstMediaPath) => this.EnsureChatAsync(string.Format(T("Transcription: {0}"), Path.GetFileName(firstMediaPath)));
/// <summary>
/// Keeps the text the preview read of an attached document, so that sending the message does not read the file again.
/// </summary>
/// <remarks>
/// The text belongs to a chat, and its draft notes it, see ChatDraft.PreviewedTexts. Only a chat
/// which exists keeps it: looking at a file must not create a chat, which the user may never have
/// meant to keep. In a new chat, sending reads the file, as it did before. Another preview of a
/// file which did not change keeps nothing new; one of a file which changed replaces the text kept
/// before. Nothing here may stop the preview: should the text not be kept, sending reads the file
/// as before.
/// </remarks>
/// <param name="attachment">The attachment, as the composer holds it.</param>
/// <param name="text">The text the preview read, after the prompt injection filter.</param>
/// <param name="version">The state of the file before the preview read it.</param>
private async Task KeepPreviewedTextAsync(FileAttachment attachment, string text, FileVersion version)
{
try
{
if (this.ChatThread is not { } chat)
return;
if (chat.Draft.GetPreviewedText(attachment.FilePath)?.Version == version)
return;
var store = await this.DatabaseClientProvider.GetProvenanceStoreAsync();
var provenanceId = await store.StoreAsync(chat.ChatId, ProvenanceKind.ATTACHMENT, text, null, CancellationToken.None);
if (provenanceId is null)
return;
// Stored with the chat like a change in the composer, so the text still counts after leaving the chat:
var replacedId = chat.Draft.NotePreviewedText(new(attachment.FilePath, provenanceId.Value, version));
this.KeepComposerInDraft();
if (replacedId is { } id)
await this.ForgetPreviewedTextsAsync(chat.ChatId, [id]);
}
catch (Exception exception)
{
this.Logger.LogWarning(exception, "Failed to keep the previewed text of the attachment '{FilePath}'. Sending reads the file instead.", attachment.FilePath);
}
}
/// <summary>
/// Gives an attachment of the message being sent the text its preview kept, as long as its file did not change since.
/// </summary>
/// <remarks>
/// A file which changed, or which is gone, is read when the message is sent, as it would be
/// without a preview. An attachment of a message sent before has its text already.
/// </remarks>
/// <param name="attachment">The attachment, normalized.</param>
/// <param name="draft">The draft being sent, which notes the texts its previews read.</param>
/// <returns>The attachment with the entry of its text, or the attachment as it was.</returns>
private static FileAttachment WithPreviewedText(FileAttachment attachment, ChatDraft draft)
{
if (attachment.ProvenanceId is not null || draft.GetPreviewedText(attachment.FilePath) is not { } previewed)
return attachment;
return FileVersion.Of(attachment.FilePath) == previewed.Version
? attachment with { ProvenanceId = previewed.ProvenanceId, Version = previewed.Version }
: attachment;
}
/// <summary>
/// Removes the texts the previews read for a message which was just sent from the provenance store, when the message went without them.
/// </summary>
/// <remarks>
/// The message went without the text of an attachment which the user removed, and without that
/// of a file which changed since its preview or is gone.
/// </remarks>
/// <param name="chatId">The chat the message went to.</param>
/// <param name="previewedTexts">The texts the draft of the message noted.</param>
/// <param name="sentAttachments">The attachments of the message.</param>
private Task ForgetUnsentPreviewsAsync(Guid chatId, IEnumerable<PreviewedText> previewedTexts, IEnumerable<FileAttachment> sentAttachments)
{
var sentIds = sentAttachments.Select(attachment => attachment.ProvenanceId).OfType<Guid>().ToHashSet();
return this.ForgetPreviewedTextsAsync(chatId, previewedTexts.Select(text => text.ProvenanceId).Where(id => !sentIds.Contains(id)).ToList());
}
/// <summary>
/// Removes texts the preview kept from the provenance store, since no message will be sent with them.
/// </summary>
/// <remarks>
/// Should that fail, the next start removes them, since the chat does not refer to them.
/// </remarks>
/// <param name="chatId">The chat the texts were kept for.</param>
/// <param name="provenanceIds">The entries of the texts.</param>
private async Task ForgetPreviewedTextsAsync(Guid chatId, IReadOnlyCollection<Guid> provenanceIds)
{
if (provenanceIds.Count is 0)
return;
try
{
var store = await this.DatabaseClientProvider.GetProvenanceStoreAsync();
await store.DeleteEntriesAsync(chatId, provenanceIds, CancellationToken.None);
}
catch (Exception exception)
{
this.Logger.LogWarning(exception, "Failed to remove {Count} previewed texts which no message of the chat '{ChatId}' was sent with. The next start removes them.", provenanceIds.Count, chatId);
}
}
/// <summary>
/// Creates and stores the chat the composer belongs to, when there is none yet, e.g., for what an
/// attachment brings along before the first message is sent.
/// </summary>
/// <param name="nameWithoutInput">The name of the chat while the user typed nothing yet.</param>
/// <returns>The chat of the composer.</returns>
private async Task<ChatThread?> EnsureChatAsync(string nameWithoutInput)
{ {
if (this.ChatThread is not null) if (this.ChatThread is not null)
return this.ChatThread; return this.ChatThread;
@@ -1327,25 +1555,31 @@ public partial class ChatComponent : MSGComponentBase
this.RefreshCurrentProfileAndChatTemplate(); this.RefreshCurrentProfileAndChatTemplate();
var promptName = this.ExtractThreadName(this.ComposerState.UserInput); var promptName = this.ExtractThreadName(this.ComposerState.UserInput);
var threadName = string.IsNullOrWhiteSpace(this.ComposerState.UserInput) var threadName = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
? string.Format(T("Transcription: {0}"), Path.GetFileName(firstMediaPath)) ? nameWithoutInput
: promptName; : promptName;
this.ChatThread = this.NewChatThread(threadName); var chat = this.NewChatThread(threadName, await this.ResolveWorkspaceOfFirstThreadAsync());
// A message the user started belongs to the new chat, so it is stored with it: // A message the user started belongs to the new chat, so it is stored with it:
if (this.ComposerState.HasUserDraft) if (this.ComposerState.HasUserDraft)
this.MirrorComposerIntoDraft(this.ChatThread); this.MirrorComposerIntoDraft(chat);
//
// The parent hears about the new thread before storing it waits, as with the first message.
// A render of the parent in between would otherwise hand back no thread at all, and the
// transcript would arrive in a chat which is nowhere:
//
this.ChatThread = chat;
await this.PublishNewChatThreadAsync();
// //
// Like saving, this goes through the workspace component whenever there is one, so its tree // Like saving, this goes through the workspace component whenever there is one, so its tree
// lists the new chat right away, together with the progress of the transcription, instead // lists the new chat right away, together with the progress of the transcription, instead
// of only after the first message was sent: // of only after the first message was sent:
// //
await this.StoreAndListChatAsync(this.ChatThread); await this.StoreAndListChatAsync(chat);
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.SyncForegroundChatAsync(); await this.SyncForegroundChatAsync();
return this.ChatThread; return chat;
} }
private async Task SendMessage(bool reuseLastUserPrompt = false) private async Task SendMessage(bool reuseLastUserPrompt = false)
@@ -1377,9 +1611,8 @@ public partial class ChatComponent : MSGComponentBase
// Create a new chat thread if necessary: // Create a new chat thread if necessary:
if (this.ChatThread is null) if (this.ChatThread is null)
{ {
this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput)); this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput), await this.ResolveWorkspaceOfFirstThreadAsync());
this.MarkCurrentChatAsLoadedParameter(); await this.PublishNewChatThreadAsync();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
} }
else else
{ {
@@ -1402,9 +1635,11 @@ public partial class ChatComponent : MSGComponentBase
IContent? lastUserPrompt; IContent? lastUserPrompt;
if (!reuseLastUserPrompt) if (!reuseLastUserPrompt)
{ {
var draft = this.ChatThread.Draft;
var normalizedAttachments = this.ComposerState.FileAttachments var normalizedAttachments = this.ComposerState.FileAttachments
.Select(attachment => attachment.Normalize()) .Select(attachment => attachment.Normalize())
.Where(attachment => attachment.IsValid) .Where(attachment => attachment.IsValid)
.Select(attachment => WithPreviewedText(attachment, draft))
.ToList(); .ToList();
lastUserPrompt = new ContentText lastUserPrompt = new ContentText
@@ -1412,7 +1647,10 @@ public partial class ChatComponent : MSGComponentBase
Text = this.ComposerState.UserInput, Text = this.ComposerState.UserInput,
FileAttachments = normalizedAttachments, FileAttachments = normalizedAttachments,
}; };
this.ChatThread.Draft.Clear();
var previewedTexts = draft.PreviewedTexts;
draft.Clear();
await this.ForgetUnsentPreviewsAsync(this.ChatThread.ChatId, previewedTexts, normalizedAttachments);
// //
// Add the user message to the thread: // Add the user message to the thread:
@@ -1576,12 +1814,17 @@ public partial class ChatComponent : MSGComponentBase
/// Every way to start a new chat leads here: the buttons of the toolbar, a change of the chat /// Every way to start a new chat leads here: the buttons of the toolbar, a change of the chat
/// template, and the workspace tree. So every new chat gets its chat template with the data /// template, and the workspace tree. So every new chat gets its chat template with the data
/// sources, tools, and example conversation, its profile, and its provider in the same way, and /// sources, tools, and example conversation, its profile, and its provider in the same way, and
/// the user is asked about unsaved changes in the same way. /// the user is asked about unsaved changes in the same way.<br/><br/>
///
/// A new chat template takes over only once the user agreed to leave the open chat and that
/// chat is stored or deleted. Declining leaves the open chat as it was, with its template, and
/// the template it runs with stays the one whose preselections the next chat must not carry over.
/// </remarks> /// </remarks>
/// <param name="useSameWorkspace">True starts the new chat with a thread right away, in the workspace of the open chat. False starts it outside any workspace, and its thread comes with the first message.</param> /// <param name="useSameWorkspace">True starts the new chat with a thread right away, in the workspace of the open chat. False starts it outside any workspace, and its thread comes with the first message. A chat which would belong to no workspace either way, while its chat template names one, also gets its thread only with the first message, in the workspace of the template.</param>
/// <param name="deletePreviousChat">Whether to delete the open chat first.</param> /// <param name="deletePreviousChat">Whether to delete the open chat first.</param>
/// <param name="targetWorkspaceId">The workspace for the new chat, when the workspace tree asks for one. When given, the new chat gets a thread in that workspace, whatever useSameWorkspace says.</param> /// <param name="targetWorkspaceId">The workspace for the new chat, when the workspace tree asks for one. When given, the new chat gets a thread in that workspace, whatever useSameWorkspace says.</param>
private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false, Guid? targetWorkspaceId = null) /// <param name="chatTemplate">The chat template of the new chat, when the user picked another one. Without one, the new chat keeps the template of the open chat.</param>
private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false, Guid? targetWorkspaceId = null, ChatTemplate? chatTemplate = null)
{ {
// //
// Where the new chat goes is decided right away, when it is asked for. Deleting the open chat // Where the new chat goes is decided right away, when it is asked for. Deleting the open chat
@@ -1589,6 +1832,9 @@ public partial class ChatComponent : MSGComponentBase
// //
var workspaceOfNewChat = targetWorkspaceId ?? this.currentWorkspaceId; var workspaceOfNewChat = targetWorkspaceId ?? this.currentWorkspaceId;
// The template the profile and the provider of the open chat were chosen with:
var previousChatTemplate = this.currentChatTemplate;
// //
// Want the user to manage the chat storage manually? In that case, we have to ask the user // Want the user to manage the chat storage manually? In that case, we have to ask the user
// about possible data loss: // about possible data loss:
@@ -1650,6 +1896,11 @@ public partial class ChatComponent : MSGComponentBase
// //
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
this.ComposerState.Clear(); this.ComposerState.Clear();
// The open chat is left now, so the new template takes over:
if (chatTemplate is not null)
this.currentChatTemplate = chatTemplate;
this.RefreshCurrentProfileAndChatTemplate(); this.RefreshCurrentProfileAndChatTemplate();
// //
@@ -1673,11 +1924,26 @@ public partial class ChatComponent : MSGComponentBase
break; break;
} }
// What the chat template preselects comes last, so that it wins over the behavior above:
if (this.ApplyChatTemplateProfileAndProvider(previousChatTemplate))
await this.ProviderChanged.InvokeAsync(this.Provider);
// //
// Reset the chat thread or create a new one. A target workspace always means a new chat in // Reset the chat thread or create a new one. A target workspace always means a new chat in
// that workspace, no matter what useSameWorkspace says: // that workspace, no matter what useSameWorkspace says:
// //
var startsOutsideWorkspaces = !useSameWorkspace && targetWorkspaceId is null; var startsOutsideWorkspaces = !useSameWorkspace && targetWorkspaceId is null;
//
// The workspace of the chat template fills in only where the new chat would belong to no
// workspace otherwise. A workspace asked for on purpose wins: the one of the workspace list,
// and the open one for the button that starts a new chat in it. Such a chat gets no thread
// here: its first message creates one in that workspace, so that the workspace is created
// only once there is something to keep, see ResolveWorkspaceOfFirstThreadAsync.
//
if (targetWorkspaceId is null && workspaceOfNewChat == Guid.Empty && this.SettingsManager.GetChatTemplateWorkspaceName(this.currentChatTemplate) is not null)
startsOutsideWorkspaces = true;
if (startsOutsideWorkspaces) if (startsOutsideWorkspaces)
{ {
// //
@@ -1917,6 +2183,10 @@ public partial class ChatComponent : MSGComponentBase
this.Provider = this.SettingsManager.GetChatProviderForLoadedChat(chatProvider); this.Provider = this.SettingsManager.GetChatProviderForLoadedChat(chatProvider);
// A chat without a thread has not started yet, so it starts with what its chat template preselects:
if (this.ChatThread is null)
this.ApplyChatTemplateProfileAndProvider(this.currentChatTemplate);
await this.ProviderChanged.InvokeAsync(this.Provider); await this.ProviderChanged.InvokeAsync(this.Provider);
// Try to select the profile: // Try to select the profile:
@@ -2054,7 +2324,7 @@ public partial class ChatComponent : MSGComponentBase
// that, and it may even bring an example conversation along -- reporting nothing for all // that, and it may even bring an example conversation along -- reporting nothing for all
// of it would tell a person their window is empty while their first message is not. // of it would tell a person their window is empty while their first message is not.
// //
var thread = this.ChatThread ?? this.NewChatThread(string.Empty); var thread = this.ChatThread ?? this.NewChatThread(string.Empty, this.currentWorkspaceId);
var toolDefinitions = this.GetRunnableToolDefinitions(offersSemanticSearch); var toolDefinitions = this.GetRunnableToolDefinitions(offersSemanticSearch);
provider = this.Provider; provider = this.Provider;
parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread, toolDefinitions), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput(), toolDefinitions); parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread, toolDefinitions), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput(), toolDefinitions);
@@ -2105,9 +2375,9 @@ public partial class ChatComponent : MSGComponentBase
/// its static definition: the one a request offers lists the data sources as well, which only /// its static definition: the one a request offers lists the data sources as well, which only
/// the request asks for. /// the request asks for.
/// ///
/// Read Web Page, Search Confluence, and Search Mails likewise count with their registered /// Read Web Page, Search Confluence, Search Mails, and Semantic Search likewise count with their
/// instructions, which assume the default settings and a chat which holds no internal content, /// registered instructions, which assume the default settings and a chat which holds no internal
/// and warn that internal content ends web search. A request words them after the actual settings, the chat, /// content, and warn that internal content ends web search. A request words them after the actual settings, the chat,
/// and the tools it offers, so the count comes out a few tokens off. One case it gets wrong as a /// and the tools it offers, so the count comes out a few tokens off. One case it gets wrong as a
/// whole: a chat restricted to the configured services only, with neither a wiki nor allowed /// whole: a chat restricted to the configured services only, with neither a wiki nor allowed
/// private hosts configured, leaves Read Web Page nothing to offer, and only the request finds /// private hosts configured, leaves Read Web Page nothing to offer, and only the request finds
@@ -2153,18 +2423,22 @@ public partial class ChatComponent : MSGComponentBase
/// The thread a new chat starts with, as the selections made so far decide it. /// The thread a new chat starts with, as the selections made so far decide it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// In one place because three code paths used to write it out, and because the token count has /// The only place which builds a new thread: every way to start a chat comes here, and so does
/// to measure the same thing they build. A count against a thread assembled differently from /// the token count, which has to measure the same thing they build. A count against a thread
/// the one which is then sent would be wrong in exactly the moment a person looks at it: before /// assembled differently from the one which is then sent would be wrong in exactly the moment a
/// they send their first message. /// person looks at it: before they send their first message.
///
/// The workspace is no default of its own but always named by the caller, since that is the one
/// part the ways to start a chat decide differently: StartNewChat decides it right away, and a
/// chat which gets its thread with its first content asks ResolveWorkspaceOfFirstThreadAsync.
/// ///
/// The data source options are those chosen before the chat had a thread. Starting a new chat in /// The data source options are those chosen before the chat had a thread. Starting a new chat in
/// a workspace replaces them right afterward with those its chat template asks for. /// a workspace replaces them right afterward with those its chat template asks for.
/// </remarks> /// </remarks>
/// <param name="name">The name of the thread.</param> /// <param name="name">The name of the thread.</param>
/// <param name="workspaceId">The workspace of the thread. Without one, the thread goes to the workspace of the open chat.</param> /// <param name="workspaceId">The workspace of the thread, or Guid.Empty for none.</param>
/// <returns>The new thread.</returns> /// <returns>The new thread.</returns>
private ChatThread NewChatThread(string name, Guid? workspaceId = null) => new() private ChatThread NewChatThread(string name, Guid workspaceId) => new()
{ {
DataSourceOptions = this.earlyDataSourceOptions, DataSourceOptions = this.earlyDataSourceOptions,
IncludeDateTime = true, IncludeDateTime = true,
@@ -2173,7 +2447,7 @@ public partial class ChatComponent : MSGComponentBase
SelectedChatTemplate = this.currentChatTemplate.Id, SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds], SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT, SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = workspaceId ?? this.currentWorkspaceId, WorkspaceId = workspaceId,
ChatId = Guid.NewGuid(), ChatId = Guid.NewGuid(),
Name = name, Name = name,
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(), Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
@@ -0,0 +1,15 @@
<MudExpansionPanels Dense="@true" Elevation="0" Class="@this.Class">
<MudExpansionPanel Dense="@true" Class="border-solid border rounded pt-n4" Style="border-color: #BDBDBD" Expanded="@this.Expanded" ExpandedChanged="@this.ExpandedChanged">
<TitleContent>
<div class="d-flex align-center">
<MudIcon Icon="@this.HeaderIcon" Color="@this.IconColor" Class="mr-3"/>
<MudText Typo="Typo.button">
@this.HeaderText
</MudText>
</div>
</TitleContent>
<ChildContent>
@this.ChildContent
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// A single, dense panel which hides content most users do not need, such as advanced options
/// or the source code of a generated plugin.
/// </summary>
/// <remarks>
/// Unlike the ExpansionPanel component, it brings its own panel list, so it stands anywhere in a
/// form. The header text comes from the caller, which keeps the translation with the caller as
/// well.
/// </remarks>
public partial class CompactExpansionPanel : ComponentBase
{
[Parameter]
public string HeaderText { get; set; } = string.Empty;
[Parameter]
public string HeaderIcon { get; set; } = Icons.Material.Filled.Tune;
[Parameter]
public Color IconColor { get; set; } = Color.Inherit;
/// <summary>
/// Class names for the surrounding panel list, separated by space, e.g., for its margins.
/// </summary>
[Parameter]
public string Class { get; set; } = string.Empty;
[Parameter]
public bool Expanded { get; set; }
[Parameter]
public EventCallback<bool> ExpandedChanged { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -1,14 +1,16 @@
@inherits ConfigurationBaseCore @inherits ConfigurationBaseCore
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <PathDropZone Frameless="@true" Disabled="@(() => this.IsDisabled)" OnPathsDropped="@this.PathsDropped" Context="isDropTarget">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Style="@(isDropTarget ? PathDropZone.FIELD_HIGHLIGHT_STYLE : string.Empty)">
<MudTextField <MudTextField
T="string" T="string"
Text="@this.Text()" Text="@this.Text()"
TextChanged="@this.InternalUpdate" TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled" Disabled="@this.IsDisabled"
Placeholder="@(this.IsDisabled ? string.Empty : T("You can also drag & drop the folder here."))"
Adornment="Adornment.Start" Adornment="Adornment.Start"
AdornmentIcon="@this.Icon" AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor" AdornmentColor="@(isDropTarget ? Color.Primary : this.IconColor)"
UserAttributes="@SPELLCHECK_ATTRIBUTES" UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true" Immediate="@true"
Underline="false" Underline="false"
@@ -25,3 +27,4 @@
@T("Choose Directory") @T("Choose Directory")
</MudButton> </MudButton>
</MudStack> </MudStack>
</PathDropZone>
@@ -41,6 +41,9 @@ public partial class ConfigurationDirectory : ConfigurationBaseCore
[Inject] [Inject]
private RustService RustService { get; init; } = null!; private RustService RustService { get; init; } = null!;
[Inject]
private ILogger<ConfigurationDirectory> Logger { get; init; } = null!;
private string internalText = string.Empty; private string internalText = string.Empty;
private bool isDirectoryDialogOpen; private bool isDirectoryDialogOpen;
@@ -105,6 +108,26 @@ public partial class ConfigurationDirectory : ConfigurationBaseCore
} }
} }
/// <summary>
/// Takes the first dropped path which leads to a folder.
/// </summary>
/// <param name="paths">The dropped paths.</param>
private async Task PathsDropped(List<string> paths)
{
var path = DroppedFolder.FirstUsable(paths);
if (path is null)
{
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a directory.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, DroppedFolder.WarningFor(paths)));
return;
}
this.Logger.LogInformation("The user dropped the directory '{DroppedDirectory}'.", path);
this.timer.Stop();
this.internalText = path;
await this.OptionChanged(path);
}
private async Task OptionChanged(string updatedText) private async Task OptionChanged(string updatedText)
{ {
this.TextUpdate(updatedText); this.TextUpdate(updatedText);
@@ -1,14 +1,16 @@
@inherits ConfigurationBaseCore @inherits ConfigurationBaseCore
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <PathDropZone Frameless="@true" Disabled="@(() => this.IsDisabled)" OnPathsDropped="@this.PathsDropped" Context="isDropTarget">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Style="@(isDropTarget ? PathDropZone.FIELD_HIGHLIGHT_STYLE : string.Empty)">
<MudTextField <MudTextField
T="string" T="string"
Text="@this.Text()" Text="@this.Text()"
TextChanged="@this.InternalUpdate" TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled" Disabled="@this.IsDisabled"
Placeholder="@(this.IsDisabled ? string.Empty : T("You can also drag & drop the file here."))"
Adornment="Adornment.Start" Adornment="Adornment.Start"
AdornmentIcon="@this.Icon" AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor" AdornmentColor="@(isDropTarget ? Color.Primary : this.IconColor)"
UserAttributes="@SPELLCHECK_ATTRIBUTES" UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true" Immediate="@true"
Underline="false" Underline="false"
@@ -25,3 +27,4 @@
@T("Choose File") @T("Choose File")
</MudButton> </MudButton>
</MudStack> </MudStack>
</PathDropZone>
@@ -48,6 +48,9 @@ public partial class ConfigurationFile : ConfigurationBaseCore
[Inject] [Inject]
private RustService RustService { get; init; } = null!; private RustService RustService { get; init; } = null!;
[Inject]
private ILogger<ConfigurationFile> Logger { get; init; } = null!;
private string internalText = string.Empty; private string internalText = string.Empty;
private bool isFileDialogOpen; private bool isFileDialogOpen;
private readonly Timer timer = new(TimeSpan.FromMilliseconds(500)) private readonly Timer timer = new(TimeSpan.FromMilliseconds(500))
@@ -111,6 +114,26 @@ public partial class ConfigurationFile : ConfigurationBaseCore
} }
} }
/// <summary>
/// Takes the first dropped path which leads to a usable file.
/// </summary>
/// <param name="paths">The dropped paths.</param>
private async Task PathsDropped(List<string> paths)
{
var path = DroppedFile.FirstUsable(paths, this.Filter);
if (path is null)
{
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a file.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, DroppedFile.WarningFor(paths)));
return;
}
this.Logger.LogInformation("The user dropped the file '{DroppedFilePath}'.", path);
this.timer.Stop();
this.internalText = path;
await this.OptionChanged(path);
}
private async Task OptionChanged(string updatedText) private async Task OptionChanged(string updatedText)
{ {
this.TextUpdate(updatedText); this.TextUpdate(updatedText);
@@ -1,3 +0,0 @@
@inherits ConfigurationText
<FileExclusionEditor Text="@this.EditingText" TextChanged="@this.InternalUpdate" Label="@this.OptionDescription" Disabled="@this.IsDisabled" ShowHelp="@this.ShowHelp" ShowReset="@(this.ResetValue is not null)" ResetValue="@(this.ResetValue?.Invoke() ?? FileExclusions.DEFAULT_PATTERNS)" ResetButtonText="@this.ResetButtonText" ResetRequested="@this.ApplyTextAsync"/>
@@ -1,17 +0,0 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Reuses ConfigurationText's persistence, validation, reset, and organization locks with the
/// shared exclusion editor. The editor supplies its own outlined fields and heading.
/// </summary>
public partial class ConfigurationFileExclusions : ConfigurationText
{
[Parameter]
public bool ShowHelp { get; set; } = true;
protected override Variant Variant => Variant.Text;
protected override string Label => string.Empty;
}
@@ -4,7 +4,7 @@
{ {
<MudTextField <MudTextField
T="string" T="string"
Text="@(this.ExpandToContent ? this.EditingText : this.Text())" Text="@this.Text()"
TextChanged="@this.InternalUpdate" TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled" Disabled="@this.IsDisabled"
Adornment="@this.IconAdornment" Adornment="@this.IconAdornment"
@@ -14,7 +14,6 @@
Lines="@this.NumLines" Lines="@this.NumLines"
AutoGrow="@this.AutoGrow" AutoGrow="@this.AutoGrow"
MaxLines="@this.GetMaxLines" MaxLines="@this.GetMaxLines"
Class="@this.InputClass"
Immediate="@true" Immediate="@true"
Validation="@this.Validation" Validation="@this.Validation"
Underline="false" Underline="false"
@@ -26,26 +25,20 @@ else
<div> <div>
<MudTextField <MudTextField
T="string" T="string"
Text="@(this.ExpandToContent ? this.EditingText : this.Text())" Text="@this.Text()"
TextChanged="@this.InternalUpdate" TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled" Disabled="@this.IsDisabled"
Adornment="@(this.ResetButtonIconOnly ? Adornment.End : this.IconAdornment)" Adornment="@this.IconAdornment"
AdornmentIcon="@(this.ResetButtonIconOnly ? Icons.Material.Filled.Restore : this.Icon)" AdornmentIcon="@this.Icon"
OnAdornmentClick="@this.ResetAdornmentClick"
AdornmentAriaLabel="@(this.ResetButtonIconOnly ? this.ResetButtonText : null)"
AdornmentColor="@this.IconColor" AdornmentColor="@this.IconColor"
UserAttributes="@SPELLCHECK_ATTRIBUTES" UserAttributes="@SPELLCHECK_ATTRIBUTES"
Lines="@this.NumLines" Lines="@this.NumLines"
AutoGrow="@this.AutoGrow" AutoGrow="@this.AutoGrow"
MaxLines="@this.GetMaxLines" MaxLines="@this.GetMaxLines"
Class="@this.InputClass"
Immediate="@true" Immediate="@true"
Validation="@this.Validation" Validation="@this.Validation"
Underline="false" Underline="false"
/> />
@if (!this.ResetButtonIconOnly)
{
<RestoreDefaultButton Text="@this.ResetButtonText" Disabled="@this.IsDisabled" OnClick="@this.ResetTextAsync"/> <RestoreDefaultButton Text="@this.ResetButtonText" Disabled="@this.IsDisabled" OnClick="@this.ResetTextAsync"/>
}
</div> </div>
} }
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Timer = System.Timers.Timer; using Timer = System.Timers.Timer;
@@ -25,18 +24,6 @@ public partial class ConfigurationText : ConfigurationBaseCore
[Parameter] [Parameter]
public string Icon { get; set; } = Icons.Material.Filled.Info; public string Icon { get; set; } = Icons.Material.Filled.Info;
/// <summary>
/// Removes the default bottom margin when the field is part of a compact group.
/// </summary>
[Parameter]
public bool Compact { get; set; }
/// <summary>
/// Wraps and grows to show the full text, including while the field is not focused.
/// </summary>
[Parameter]
public bool ExpandToContent { get; set; }
/// <summary> /// <summary>
/// The color of the icon to use. /// The color of the icon to use.
/// </summary> /// </summary>
@@ -67,12 +54,6 @@ public partial class ConfigurationText : ConfigurationBaseCore
[Parameter] [Parameter]
public string ResetButtonText { get; set; } = string.Empty; public string ResetButtonText { get; set; } = string.Empty;
/// <summary>
/// Uses an accessible reset icon inside the field.
/// </summary>
[Parameter]
public bool ResetButtonIconOnly { get; set; }
/// <summary> /// <summary>
/// Validates the configured text before it is stored. /// Validates the configured text before it is stored.
/// </summary> /// </summary>
@@ -80,15 +61,11 @@ public partial class ConfigurationText : ConfigurationBaseCore
public Func<string, string?>? Validation { get; set; } public Func<string, string?>? Validation { get; set; }
private string internalText = string.Empty; private string internalText = string.Empty;
private string? lastSourceText;
private readonly Timer timer = new(TimeSpan.FromMilliseconds(500)) private readonly Timer timer = new(TimeSpan.FromMilliseconds(500))
{ {
AutoReset = false AutoReset = false
}; };
protected string EditingText => this.internalText;
#region Overrides of ConfigurationBase #region Overrides of ConfigurationBase
/// <inheritdoc /> /// <inheritdoc />
@@ -96,8 +73,6 @@ public partial class ConfigurationText : ConfigurationBaseCore
protected override Variant Variant => Variant.Outlined; protected override Variant Variant => Variant.Outlined;
protected override string MarginClass => this.Compact ? "mb-0" : base.MarginClass;
protected override string Label => this.OptionDescription; protected override string Label => this.OptionDescription;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -108,24 +83,15 @@ public partial class ConfigurationText : ConfigurationBaseCore
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
var sourceText = this.Text(); this.internalText = this.Text();
// Re-rendering the parent must not replace a draft which is waiting for its debounce.
// A genuinely changed setting, e.g. from a configuration plugin, still replaces it.
if (this.IsDisabled || !string.Equals(sourceText, this.lastSourceText, StringComparison.Ordinal))
{
this.internalText = sourceText;
this.lastSourceText = sourceText;
}
await base.OnParametersSetAsync(); await base.OnParametersSetAsync();
} }
#endregion #endregion
private bool AutoGrow => this.ExpandToContent || this.NumLines > 1; private bool AutoGrow => this.NumLines > 1;
private int GetMaxLines => this.ExpandToContent ? 0 : this.AutoGrow ? this.MaxLines : 1; private int GetMaxLines => this.AutoGrow ? this.MaxLines : 1;
private string InputClass => this.ExpandToContent ? "pattern-text-field" : string.Empty;
// //
// MudBlazor centers the icon vertically, so in a field of several lines it floats in the middle // MudBlazor centers the icon vertically, so in a field of several lines it floats in the middle
@@ -133,50 +99,29 @@ public partial class ConfigurationText : ConfigurationBaseCore
// //
private Adornment IconAdornment => this.NumLines == 1 ? Adornment.Start : Adornment.None; private Adornment IconAdornment => this.NumLines == 1 ? Adornment.Start : Adornment.None;
protected void InternalUpdate(string text) private void InternalUpdate(string text)
{ {
if (this.IsDisabled)
return;
this.timer.Stop(); this.timer.Stop();
this.internalText = text; this.internalText = text;
this.timer.Start(); this.timer.Start();
} }
private EventCallback<MouseEventArgs> ResetAdornmentClick => this.ResetButtonIconOnly private async Task ResetTextAsync()
? EventCallback.Factory.Create<MouseEventArgs>(this, this.ResetTextAsync)
: default;
protected async Task ResetTextAsync()
{ {
if (this.ResetValue is null || this.IsDisabled) if (this.ResetValue is null || this.IsDisabled)
return; return;
await this.ApplyTextAsync(this.ResetValue());
}
/// <summary>
/// Applies an explicit value immediately, including a reset of part of a compound setting.
/// </summary>
protected async Task ApplyTextAsync(string text)
{
if (this.IsDisabled)
return;
this.timer.Stop(); this.timer.Stop();
this.internalText = text; this.internalText = this.ResetValue();
await this.OptionChanged(this.internalText); await this.OptionChanged(this.internalText);
} }
protected async Task OptionChanged(string updatedText) private async Task OptionChanged(string updatedText)
{ {
if (this.IsDisabled || this.Validation?.Invoke(updatedText) is not null) if (this.Validation?.Invoke(updatedText) is not null)
return; return;
this.TextUpdate(updatedText); this.TextUpdate(updatedText);
// A save may finish after another keystroke. Remember the value it applied before
// awaiting storage, so its configuration-change notification cannot erase that keystroke.
this.lastSourceText = this.Text();
await this.SettingsManager.StoreSettings(); await this.SettingsManager.StoreSettings();
await this.InformAboutChange(); await this.InformAboutChange();
} }
@@ -0,0 +1,55 @@
@inherits MSGComponentBase
@* One element around everything, so that a layout around the viewer can give it its place. *@
<div class="content-viewer">
@if (this.cutOffCharacters > 0 && this.CutOffNotice is not null)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="my-2">
@this.CutOffNotice(this.cutOffCharacters)
</MudAlert>
}
<MudPaper Outlined="@true" Class="content-viewer-frame">
<div class="content-viewer-toolbar">
<div class="content-viewer-toolbar-start">
@this.Toolbar
</div>
@if (this.views.Count > 1)
{
<MudToggleGroup T="ContentView" @bind-Value="@this.view" SelectionMode="SelectionMode.SingleSelection" Size="Size.Small" Color="Color.Primary">
@foreach (var offeredView in this.views)
{
<MudToggleItem Value="@offeredView" Text="@this.GetViewName(offeredView)"/>
}
</MudToggleGroup>
}
</div>
@* MathJax leaves this alone, even when something else on the page has it typeset everything:
it would replace elements Blazor renders here, and the next render of them would fail. *@
<div class="content-viewer-body mathjax_ignore" style="@($"max-height: {this.MaxHeight};")">
@switch (this.view)
{
case ContentView.MEDIA:
<MudImage ObjectFit="ObjectFit.ScaleDown" Style="width: 100%;" Src="@this.MediaUrl"/>
break;
case ContentView.FORMATTED:
<MudMarkdown Value="@this.shownText" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CONTENT_MARKDOWN_PIPELINE"/>
break;
default:
<pre class="content-viewer-plain">@this.shownText</pre>
break;
}
</div>
</MudPaper>
@if (!string.IsNullOrWhiteSpace(this.HelperText))
{
<MudText Typo="Typo.caption" Class="mt-1 px-1 mud-text-secondary">
@this.HelperText
</MudText>
}
</div>
@@ -0,0 +1,185 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Shows a piece of content for the user to check: text formatted and as plain text, and an image
/// as itself.
/// </summary>
/// <remarks>
/// Every place which lets the user see what AI Studio reads or hands to the AI shows it the same
/// way, which is why this lives here instead of in each of them. A slim bar above the content says
/// what it is, and switches between the ways to see it; the caller fills its start.<br/><br/>
/// The content scrolls inside its frame, so a dialog around it does not have to scroll as well. In a
/// dialog which fits the window, the frame gives way down to the room left over.<br/><br/>
/// The text is shown in full unless the caller sets a limit. Whether the beginning of a text is
/// enough depends on what the user wants to check: how a whole document was read, or exactly what
/// the AI received. Only the caller knows which, and how to explain a cut.
/// </remarks>
public partial class ContentViewer : MSGComponentBase
{
/// <summary>
/// The ways to see the content.
/// </summary>
/// <remarks>
/// Internal rather than private: Razor infers the type of the toggle items in a class of its own.
/// </remarks>
internal enum ContentView
{
MEDIA,
FORMATTED,
PLAIN,
}
/// <summary>
/// What kind of content this is.
/// </summary>
[Parameter]
public ContentViewerKind Kind { get; set; } = ContentViewerKind.TEXT;
/// <summary>
/// The text to show. For an image, the text which belongs to it, or nothing.
/// </summary>
[Parameter]
public string Text { get; set; } = string.Empty;
/// <summary>
/// The address an image is loaded from. Not used for text.
/// </summary>
[Parameter]
public string MediaUrl { get; set; } = string.Empty;
/// <summary>
/// What the bar above the content shows at its start, e.g., what the content is, where it comes
/// from, and a button to open it there.
/// </summary>
[Parameter]
public RenderFragment? Toolbar { get; set; }
/// <summary>
/// The hint below the text, or nothing.
/// </summary>
[Parameter]
public string HelperText { get; set; } = string.Empty;
/// <summary>
/// How many characters of the text we show at most, or null to show all of it.
/// </summary>
[Parameter]
public int? CharacterLimit { get; set; }
/// <summary>
/// Explains that the text was cut, given the number of characters left out. Null shows no explanation.
/// </summary>
[Parameter]
public Func<int, string>? CutOffNotice { get; set; }
/// <summary>
/// The height which does not limit the content: it may take all the room its place leaves over,
/// e.g., in a dialog which fits the window, see dialog-content-fit in app.css.
/// </summary>
public const string NO_HEIGHT_LIMIT = "none";
/// <summary>
/// How high the content may grow before it scrolls, unless its place says otherwise.
/// </summary>
public const string DEFAULT_MAX_HEIGHT = "40vh";
/// <summary>
/// How high the content may grow before it scrolls, as a CSS length.
/// </summary>
[Parameter]
public string MaxHeight { get; set; } = DEFAULT_MAX_HEIGHT;
/// <summary>
/// The ways to see this content, in the order they are offered; the first one is shown first.
/// </summary>
private readonly List<ContentView> views = [];
/// <summary>
/// The way the content is shown right now.
/// </summary>
private ContentView view;
/// <summary>
/// The text and the limit the current cut was made of, so that we cut once per text instead of once per render.
/// </summary>
private string? cutText;
private int? cutLimit;
/// <summary>
/// What we show of the text: all of it, or its beginning.
/// </summary>
private string shownText = string.Empty;
/// <summary>
/// How many characters we left out. Zero when we show the entire text.
/// </summary>
private int cutOffCharacters;
#region Overrides of ComponentBase
protected override void OnParametersSet()
{
if (!ReferenceEquals(this.cutText, this.Text) || this.cutLimit != this.CharacterLimit)
{
this.cutText = this.Text;
this.cutLimit = this.CharacterLimit;
this.shownText = CutAtLineBreak(this.Text, this.CharacterLimit ?? int.MaxValue, out this.cutOffCharacters);
}
//
// An image is seen as itself first. Text is seen formatted and as plain text, for text and
// for an image which brings some along. The way the user chose stays as long as it is offered:
//
this.views.Clear();
if (this.Kind is not ContentViewerKind.TEXT)
this.views.Add(ContentView.MEDIA);
if (this.Kind is ContentViewerKind.TEXT || !string.IsNullOrWhiteSpace(this.Text))
this.views.AddRange([ContentView.FORMATTED, ContentView.PLAIN]);
if (!this.views.Contains(this.view))
this.view = this.views[0];
base.OnParametersSet();
}
#endregion
private string GetViewName(ContentView contentView) => contentView switch
{
ContentView.MEDIA => T("Image"),
ContentView.FORMATTED => T("Formatted"),
_ => T("Plain text"),
};
/// <summary>
/// Takes the beginning of a text which is too long to show.
/// </summary>
/// <remarks>
/// We cut at the last line break before the limit. Otherwise, we might tear apart a Markdown
/// construct like a table row or a code fence in the middle of a line. A text without any line
/// break before the limit is cut at the limit itself.
/// </remarks>
/// <param name="text">The text.</param>
/// <param name="characterLimit">How many characters to keep at most.</param>
/// <param name="cutOffCharacters">How many characters were left out; zero when nothing was.</param>
/// <returns>The text itself when it fits, otherwise its beginning.</returns>
internal static string CutAtLineBreak(string text, int characterLimit, out int cutOffCharacters)
{
if (text.Length <= characterLimit)
{
cutOffCharacters = 0;
return text;
}
var cutIndex = text.LastIndexOf('\n', characterLimit - 1) + 1;
if (cutIndex < 1)
cutIndex = characterLimit;
cutOffCharacters = text.Length - cutIndex;
return text[..cutIndex];
}
}
@@ -0,0 +1,17 @@
namespace AIStudio.Components;
/// <summary>
/// What kind of content a ContentViewer shows.
/// </summary>
public enum ContentViewerKind
{
/// <summary>
/// Text, shown as rendered Markdown and as plain text.
/// </summary>
TEXT,
/// <summary>
/// An image, shown from its address.
/// </summary>
IMAGE,
}
@@ -32,9 +32,13 @@
@if (backupResult.Success) @if (backupResult.Success)
{ {
<MudAlert Severity="@(backupResult.IncludesDataSourceIndexes ? Severity.Success : Severity.Warning)" Dense="@true" Class="mt-3"> <MudAlert Severity="@(backupResult.IncludesDataSourceIndexes ? Severity.Success : Severity.Warning)" Dense="@true" Class="mt-3">
@* Opening a backup with the default program would only unpack it, so the file manager is the one way offered. *@
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2">
@string.Format(T("Backed up {0} files into: {1}"), backupResult.FileCount, backupResult.ArchivePath) @string.Format(T("Backed up {0} files into: {1}"), backupResult.FileCount, backupResult.ArchivePath)
</MudText> </MudText>
<FileActions Path="@backupResult.ArchivePath" ShowCopy="@false" ShowOpen="@false"/>
</MudStack>
@if (!backupResult.IncludesDataSourceIndexes) @if (!backupResult.IncludesDataSourceIndexes)
{ {
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2">
@@ -2,6 +2,7 @@ using System.Globalization;
using AIStudio.Dialogs; using AIStudio.Dialogs;
using AIStudio.Tools.ChatArchive; using AIStudio.Tools.ChatArchive;
using AIStudio.Tools.Databases;
using AIStudio.Tools.DataBackup; using AIStudio.Tools.DataBackup;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
@@ -43,6 +44,9 @@ public partial class DataBackupPanel : MSGComponentBase
[Inject] [Inject]
private DataRestoreService DataRestoreService { get; init; } = null!; private DataRestoreService DataRestoreService { get; init; } = null!;
[Inject]
private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!;
private ChatArchiveCollisionBehavior collisionBehavior = ChatArchiveCollisionBehavior.SKIP; private ChatArchiveCollisionBehavior collisionBehavior = ChatArchiveCollisionBehavior.SKIP;
private CancellationTokenSource? importCancellation; private CancellationTokenSource? importCancellation;
private ChatArchiveProgress importProgress; private ChatArchiveProgress importProgress;
@@ -253,7 +257,8 @@ public partial class DataBackupPanel : MSGComponentBase
var archivePath = this.selectedArchivePath; var archivePath = this.selectedArchivePath;
var token = this.importCancellation.Token; var token = this.importCancellation.Token;
var reporter = this.CreateImportProgressReporter(); var reporter = this.CreateImportProgressReporter();
this.importResult = await Task.Run(() => ChatArchiveImporter.ImportAsync(archivePath, this.collisionBehavior, reporter, token), token); var provenanceStore = await this.DatabaseClientProvider.GetProvenanceStoreAsync(token);
this.importResult = await Task.Run(() => ChatArchiveImporter.ImportAsync(archivePath, provenanceStore, this.collisionBehavior, reporter, token), token);
// //
// Drop the preview once every chat was added. Otherwise, another click on the button // Drop the preview once every chat was added. Otherwise, another click on the button
@@ -78,6 +78,7 @@ public partial class DataFolders : MSGComponentBase
DataBackupEntryKind.SETTINGS => T("Your settings."), DataBackupEntryKind.SETTINGS => T("Your settings."),
DataBackupEntryKind.WORKSPACES => T("Your chats in workspaces, along with their transcripts."), DataBackupEntryKind.WORKSPACES => T("Your chats in workspaces, along with their transcripts."),
DataBackupEntryKind.TEMPORARY_CHATS => T("Your temporary chats, along with their transcripts."), DataBackupEntryKind.TEMPORARY_CHATS => T("Your temporary chats, along with their transcripts."),
DataBackupEntryKind.CHAT_PROVENANCE => T("What the AI received in your chats besides your messages, such as the content of attached files, the results of tools, and passages of your data sources. It is part of your chats."),
DataBackupEntryKind.PLUGINS => T("Your plugins, such as the assistants you built, apart from the folders listed below."), DataBackupEntryKind.PLUGINS => T("Your plugins, such as the assistants you built, apart from the folders listed below."),
DataBackupEntryKind.TOKENIZERS => T("The tokenizers you chose for your providers."), DataBackupEntryKind.TOKENIZERS => T("The tokenizers you chose for your providers."),
DataBackupEntryKind.VISUAL_BRIEFINGS => T("Your projects in the Visual Briefing Assistant."), DataBackupEntryKind.VISUAL_BRIEFINGS => T("Your projects in the Visual Briefing Assistant."),
@@ -270,69 +270,7 @@ public partial class DataSourceManagement : MSGComponentBase
private async Task EditDataSource(IDataSource dataSource) private async Task EditDataSource(IDataSource dataSource)
{ {
if (dataSource.IsEnterpriseConfiguration) if (await DataSourceEditing.EditAsync(this.DialogService, this.SettingsManager, this.DataSourceEmbeddingService, dataSource.Id))
return;
IDataSource? editedDataSource = null;
var lockDataSourceOrigin = dataSource is IInternalDataSource
&& await this.DataSourceEmbeddingService.ShouldLockDataSourceOriginAsync(dataSource.Id);
switch (dataSource)
{
case DataSourceLocalFile localFile:
var localFileDialogParameters = new DialogParameters<DataSourceLocalFileDialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, localFile },
{ x => x.LockSource, lockDataSourceOrigin },
};
var localFileDialogReference = await this.DialogService.ShowAsync<DataSourceLocalFileDialog>(T("Edit Local File Data Source"), localFileDialogParameters, DialogOptions.FULLSCREEN);
var localFileDialogResult = await localFileDialogReference.Result;
if (localFileDialogResult is null || localFileDialogResult.Canceled)
return;
editedDataSource = (DataSourceLocalFile)localFileDialogResult.Data!;
break;
case DataSourceLocalDirectory localDirectory:
var localDirectoryDialogParameters = new DialogParameters<DataSourceLocalDirectoryDialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, localDirectory },
{ x => x.LockSource, lockDataSourceOrigin },
};
var localDirectoryDialogReference = await this.DialogService.ShowAsync<DataSourceLocalDirectoryDialog>(T("Edit Local Directory Data Source"), localDirectoryDialogParameters, DialogOptions.FULLSCREEN);
var localDirectoryDialogResult = await localDirectoryDialogReference.Result;
if (localDirectoryDialogResult is null || localDirectoryDialogResult.Canceled)
return;
editedDataSource = (DataSourceLocalDirectory)localDirectoryDialogResult.Data!;
break;
case DataSourceERI_V1 eriDataSource:
var eriDialogParameters = new DialogParameters<DataSourceERI_V1Dialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, eriDataSource },
};
var eriDialogReference = await this.DialogService.ShowAsync<DataSourceERI_V1Dialog>(T("Edit ERI v1 Data Source"), eriDialogParameters, DialogOptions.FULLSCREEN);
var eriDialogResult = await eriDialogReference.Result;
if (eriDialogResult is null || eriDialogResult.Canceled)
return;
editedDataSource = (DataSourceERI_V1)eriDialogResult.Data!;
break;
}
if(editedDataSource is null)
return;
this.SettingsManager.ConfigurationData.DataSources[this.SettingsManager.ConfigurationData.DataSources.IndexOf(dataSource)] = editedDataSource;
await this.SettingsManager.StoreSettings();
await this.DataSourceEmbeddingService.QueueDataSourceAsync(editedDataSource);
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED); await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
} }
@@ -1,16 +1,7 @@
@using AIStudio.Settings.DataModel
@inherits MSGComponentBase @inherits MSGComponentBase
@if (this.availableWorkspaces.Count > 0) <WorkspaceNameField WorkspaceName="@this.WorkspaceName" WorkspaceNameChanged="@this.SetWorkspaceName" SelectLabel="@T("Existing workspace (Optional)")" Label="@T("Workspace name (Optional)")" HelperText="@T("Choose an existing workspace or enter a name that should be created when the launcher is opened.")" AdornmentIcon="@(this.OpensTemporaryChat ? Icons.Material.Filled.Timer : Icons.Material.Filled.CreateNewFolder)" AdornmentIconSize="Size.Small" TextFieldClass="mb-1"/>
{
<MudSelect T="string" Value="@this.WorkspaceName" ValueChanged="@this.SelectExistingWorkspace" Strict="@false" Label="@T("Existing workspace (Optional)")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Folder">
@foreach (var workspace in this.availableWorkspaces)
{
<MudSelectItem T="string" Value="@workspace.Name">@workspace.Name</MudSelectItem>
}
</MudSelect>
}
<MudTextField T="string" Text="@this.WorkspaceName" TextChanged="@this.SetWorkspaceName" AdornmentIcon="@(this.OpensTemporaryChat ? Icons.Material.Filled.Timer : Icons.Material.Filled.CreateNewFolder)" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Workspace name (Optional)")" HelperText="@T("Choose an existing workspace or enter a name that should be created when the launcher is opened.")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-1" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
@* The tile behaves differently with and without a workspace, so the form says which of the two is @* The tile behaves differently with and without a workspace, so the form says which of the two is
chosen right now instead of only explaining that both are possible. *@ chosen right now instead of only explaining that both are possible. *@
@@ -19,6 +10,12 @@
? T("Without a workspace, the tile opens a disappearing chat: it belongs to no workspace and is deleted according to your workspace maintenance settings.") ? T("Without a workspace, the tile opens a disappearing chat: it belongs to no workspace and is deleted according to your workspace maintenance settings.")
: T("The tile opens its chat in this workspace and creates the workspace when it does not exist yet.")) : T("The tile opens its chat in this workspace and creates the workspace when it does not exist yet."))
</MudText> </MudText>
@if (this.OpensTemporaryChat && this.SettingsManager.GetChatTemplateWorkspaceName(this.SelectedChatTemplate) is { } templateWorkspaceName)
{
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
@string.Format(T("The chosen chat template keeps its chats in the workspace '{0}', so the tile opens its chat there instead. A workspace entered here wins over the one of the template."), templateWorkspaceName)
</MudText>
}
<MudSelect T="string" Value="@this.ProviderId" ValueChanged="@this.SetProviderId" Label="@T("Chat provider")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.SmartToy"> <MudSelect T="string" Value="@this.ProviderId" ValueChanged="@this.SetProviderId" Label="@T("Chat provider")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.SmartToy">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem> <MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
@foreach (var provider in this.SettingsManager.GetConfidentProviders(Components.CHAT)) @foreach (var provider in this.SettingsManager.GetConfidentProviders(Components.CHAT))
@@ -28,6 +25,12 @@
</MudSelectItem> </MudSelectItem>
} }
</MudSelect> </MudSelect>
@if (this.SelectedChatTemplate.ProviderId is not null)
{
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
@T("The chosen chat template preselects a provider of its own, and it wins over a selection made here as long as that provider can be used for chats.")
</MudText>
}
<MudSelect T="string" Value="@this.ProfileId" ValueChanged="@this.SetProfileId" Label="@T("Chat profile")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Person"> <MudSelect T="string" Value="@this.ProfileId" ValueChanged="@this.SetProfileId" Label="@T("Chat profile")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Person">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem> <MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
<MudSelectItem T="string" Value="@Guid.Empty.ToString()">@T("Use no profile")</MudSelectItem> <MudSelectItem T="string" Value="@Guid.Empty.ToString()">@T("Use no profile")</MudSelectItem>
@@ -36,6 +39,12 @@
<MudSelectItem T="string" Value="@profile.Id">@profile.GetSafeName()</MudSelectItem> <MudSelectItem T="string" Value="@profile.Id">@profile.GetSafeName()</MudSelectItem>
} }
</MudSelect> </MudSelect>
@if (this.SelectedChatTemplate is { AllowProfileUsage: true, ProfileId: not null })
{
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
@T("The chosen chat template preselects a profile of its own, and it wins over a selection made here as long as that profile exists.")
</MudText>
}
<MudSelect T="string" Value="@this.ChatTemplateId" ValueChanged="@this.SetChatTemplateId" Label="@T("Chat template")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Article"> <MudSelect T="string" Value="@this.ChatTemplateId" ValueChanged="@this.SetChatTemplateId" Label="@T("Chat template")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Article">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem> <MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
<MudSelectItem T="string" Value="@Guid.Empty.ToString()">@T("Use no chat template")</MudSelectItem> <MudSelectItem T="string" Value="@Guid.Empty.ToString()">@T("Use no chat template")</MudSelectItem>
@@ -44,6 +53,10 @@
<MudSelectItem T="string" Value="@chatTemplate.Id">@chatTemplate.GetSafeName()</MudSelectItem> <MudSelectItem T="string" Value="@chatTemplate.Id">@chatTemplate.GetSafeName()</MudSelectItem>
} }
</MudSelect> </MudSelect>
@* Without the RAG preview, the chat uses no data sources, so there is nothing to choose here. A
selection made before stays with the launcher and counts once RAG is switched on again: *@
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
<MudSelect T="string" Label="@T("Data sources (Optional)")" MultiSelection="@true" SelectedValues="@this.DataSourceIds" SelectedValuesChanged="@this.SetDataSourceIds" MultiSelectionTextFunc="@this.GetSelectedDataSourceText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@AppIcons.DATABASE"> <MudSelect T="string" Label="@T("Data sources (Optional)")" MultiSelection="@true" SelectedValues="@this.DataSourceIds" SelectedValuesChanged="@this.SetDataSourceIds" MultiSelectionTextFunc="@this.GetSelectedDataSourceText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@AppIcons.DATABASE">
@foreach (var dataSource in this.SettingsManager.ConfigurationData.DataSources) @foreach (var dataSource in this.SettingsManager.ConfigurationData.DataSources)
{ {
@@ -59,6 +72,7 @@
@T("The chosen chat template brings data sources of its own, and those win over a selection made here. Only a template can also leave the choice of sources to the AI, which is why it decides this on its own.") @T("The chosen chat template brings data sources of its own, and those win over a selection made here. Only a template can also leave the choice of sources to the AI, which is why it decides this on its own.")
</MudText> </MudText>
} }
}
<ToolSelectionField Component="Components.CHAT" SelectedToolIds="@this.ToolIds" SelectedToolIdsChanged="@this.SetToolIds" Label="@T("Tools (Optional)")" Help="@T("These tools are preselected when the chat opens. Users can change the selection in the chat, and every tool has to meet the confidence requirements of the provider in use.")"/> <ToolSelectionField Component="Components.CHAT" SelectedToolIds="@this.ToolIds" SelectedToolIdsChanged="@this.SetToolIds" Label="@T("Tools (Optional)")" Help="@T("These tools are preselected when the chat opens. Users can change the selection in the chat, and every tool has to meet the confidence requirements of the provider in use.")"/>
@@ -96,37 +96,6 @@ public partial class DirectChatLauncherForm : MSGComponentBase
? this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT) ? this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT)
: this.SettingsManager.GetChatTemplateById(this.ChatTemplateId); : this.SettingsManager.GetChatTemplateById(this.ChatTemplateId);
private IReadOnlyList<WorkspaceTreeWorkspace> availableWorkspaces = [];
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
// Configure the spellchecking for the workspace name input:
this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES);
await base.OnInitializedAsync();
var workspaceSnapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
this.availableWorkspaces = workspaceSnapshot.Workspaces;
}
#endregion
//
// Picking an existing workspace fills the name field. Clearing the select must not wipe a name
// the user typed, though, so an empty selection is ignored:
//
private async Task SelectExistingWorkspace(string workspaceName)
{
if (string.IsNullOrWhiteSpace(workspaceName))
return;
await this.SetWorkspaceName(workspaceName);
}
private async Task SetWorkspaceName(string workspaceName) private async Task SetWorkspaceName(string workspaceName)
{ {
this.WorkspaceName = workspaceName; this.WorkspaceName = workspaceName;
@@ -0,0 +1,8 @@
@inherits MSGComponentBase
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Timer" Size="Size.Small"/>
<MudText Typo="Typo.body2">
@Format(DateTimeOffset.Now - this.Start)
</MudText>
</MudStack>
@@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Shows how long something has been running, counting up every second.
/// </summary>
/// <remarks>
/// A model which thinks for minutes shows nothing for just as long, and the time is what tells the user
/// that it still works. The component owns its timer and renders only itself once a second, not the
/// page around it: an assistant would otherwise render its entire form every second.
/// </remarks>
public partial class ElapsedTime : MSGComponentBase
{
private static readonly TimeSpan TICK_INTERVAL = TimeSpan.FromSeconds(1);
/// <summary>
/// When it started.
/// </summary>
[Parameter]
public DateTimeOffset Start { get; set; }
/// <summary>Stops counting.</summary>
private readonly CancellationTokenSource tickCancellation = new();
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
this.TickAsync(this.tickCancellation.Token).Observe($"{nameof(ElapsedTime)}: counting the elapsed time");
}
#endregion
#region Overrides of MSGComponentBase
protected override void DisposeResources()
{
this.tickCancellation.Cancel();
this.tickCancellation.Dispose();
base.DisposeResources();
}
#endregion
/// <summary>
/// Writes a time span as minutes and seconds, and with the hours in front from the first hour on.
/// </summary>
/// <param name="elapsed">The time span; a negative one, e.g., after the clock was set back, counts as none.</param>
/// <returns>The time span, such as 4:07 or 1:02:09.</returns>
internal static string Format(TimeSpan elapsed)
{
if (elapsed < TimeSpan.Zero)
elapsed = TimeSpan.Zero;
var hours = (int)elapsed.TotalHours;
return hours > 0
? $"{hours}:{elapsed.Minutes:00}:{elapsed.Seconds:00}"
: $"{elapsed.Minutes}:{elapsed.Seconds:00}";
}
private async Task TickAsync(CancellationToken token)
{
using var timer = new PeriodicTimer(TICK_INTERVAL);
try
{
while (await timer.WaitForNextTickAsync(token))
await this.InvokeAsync(this.StateHasChanged);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
}
}
}
@@ -0,0 +1,62 @@
@inherits MSGComponentBase
@*
The tooltips of the buttons which open something show on hover only: the focus comes back to the
button from the program, the browser, or the file manager it opened, and a tooltip shown on focus
would then stay until the user clicks somewhere else.
*@
@switch (this.kind)
{
case FileActionsKind.WEB_PAGE:
@if (this.ShowCopy)
{
<MudTooltip Text="@string.Format(T("Copy the address: {0}"), this.Path)" Placement="@this.TooltipPlacement">
<MudIconButton Icon="@Icons.Material.Outlined.ContentCopy" Size="Size.Small" Class="icon-button-compact" OnClick="@this.CopyPath"/>
</MudTooltip>
}
@if (this.ShowOpen)
{
<MudTooltip Text="@T("Open this web page in your browser")" Placement="@this.TooltipPlacement" ShowOnFocus="@false">
<MudIconButton Icon="@Icons.Material.Outlined.OpenInNew" Size="Size.Small" Class="icon-button-compact" Href="@this.Path" Target="_blank"/>
</MudTooltip>
}
break;
case FileActionsKind.FILE:
@if (this.ShowCopy)
{
<MudTooltip Text="@string.Format(T("Copy the path: {0}"), this.Path)" Placement="@this.TooltipPlacement">
<MudIconButton Icon="@Icons.Material.Outlined.ContentCopy" Size="Size.Small" Class="icon-button-compact" OnClick="@this.CopyPath"/>
</MudTooltip>
}
@if (this.isMissing)
{
@* Instead of buttons which would only fail, and only where there would have been one: *@
@if (this.WarnIfMissing && (this.ShowFileManager || this.ShowOpen))
{
@* Not necessarily gone: the server of an ERI data source may name a file of its own, which was never on this computer. *@
<MudTooltip Text="@string.Format(T("AI Studio cannot find the file {0} on this computer. It may have been moved, renamed, or deleted, or it is on another computer, such as the server of a data source."), this.Path)" Placement="@this.TooltipPlacement">
<MudIcon Icon="@Icons.Material.Outlined.WarningAmber" Color="Color.Warning" Size="Size.Small" Class="file-action-warning"/>
</MudTooltip>
}
}
else
{
@if (this.ShowFileManager)
{
<MudTooltip Text="@T("Show this file in the file manager of your system")" Placement="@this.TooltipPlacement" ShowOnFocus="@false">
<MudIconButton Icon="@Icons.Material.Outlined.FolderOpen" Size="Size.Small" Class="icon-button-compact" OnClick="@this.ShowInFileManager"/>
</MudTooltip>
}
@if (this.ShowOpen)
{
<MudTooltip Text="@GetOpenTooltip(this.PageNumber)" Placement="@this.TooltipPlacement" ShowOnFocus="@false">
<MudIconButton Icon="@Icons.Material.Outlined.OpenInNew" Size="Size.Small" Class="icon-button-compact" OnClick="@this.Open"/>
</MudTooltip>
}
}
break;
}
@@ -0,0 +1,155 @@
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// What the user can do with a file or a web page which a source, a passage, or an attachment
/// names: copy its path, find it in the file manager, open it, at a page where there is one.
/// </summary>
/// <remarks>
/// Every place which names a file offers the same buttons the same way, which is why they live
/// here. Each place decides which of them it shows, e.g., a title copies and finds, while the bar
/// above a text opens.<br/><br/>
///
/// A file which is no longer at its place gets a warning instead of the buttons which would only
/// fail; copying its path stays, since it tells where the file was. Whether it is there is asked
/// off the UI thread and once per path: on an unreachable network share, the answer takes until
/// the timeout of the share, and a list of sources renders anew with every streamed chunk.<br/><br/>
///
/// A web page is copied and opened in the browser. Anything else, such as a mail, which only
/// AI Studio can find by its address, or a relative path, gets no buttons at all.<br/><br/>
///
/// Every place which opens a file names it the same way, see GetOpenTooltip, also where a title
/// opens it instead of a button.
/// </remarks>
public partial class FileActions : MSGComponentBase
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(FileActions).Namespace, nameof(FileActions));
/// <summary>
/// What a path names.
/// </summary>
internal enum FileActionsKind
{
NONE,
FILE,
WEB_PAGE,
}
/// <summary>
/// The path of the file, or the address of the web page.
/// </summary>
[Parameter]
public string Path { get; set; } = string.Empty;
/// <summary>
/// The page to open a file at, or null to open it at its beginning.
/// </summary>
[Parameter]
public int? PageNumber { get; set; }
[Parameter]
public bool ShowCopy { get; set; } = true;
[Parameter]
public bool ShowFileManager { get; set; } = true;
[Parameter]
public bool ShowOpen { get; set; } = true;
/// <summary>
/// Whether a file which is no longer at its place gets a warning. False where the caller says so
/// itself, or where another place on the screen warns already.
/// </summary>
[Parameter]
public bool WarnIfMissing { get; set; } = true;
[Parameter]
public Placement TooltipPlacement { get; set; } = Placement.Top;
/// <summary>
/// Tells the caller whether the file is at its place, once that is known, e.g., so that a title
/// which opens it stops being a link.
/// </summary>
[Parameter]
public EventCallback<bool> OnExistenceChecked { get; set; }
[Inject]
private RustService RustService { get; init; } = null!;
private FileActionsKind kind;
/// <summary>
/// The path whose existence was asked last, so that a render with the same path does not ask again.
/// </summary>
private string? checkedPath;
/// <summary>
/// Whether the file is known to be gone. Until the answer arrives, the buttons show: most files are there.
/// </summary>
private bool isMissing;
/// <summary>
/// The tooltip of opening a file, at its page where there is one.
/// </summary>
/// <param name="pageNumber">The page the file opens at, or null when it opens at its beginning.</param>
/// <returns>The tooltip.</returns>
internal static string GetOpenTooltip(int? pageNumber) => pageNumber is { } page
? string.Format(TB("Open the file at page {0}"), page)
: TB("Open the file in the program your system uses for it");
#region Overrides of ComponentBase
protected override async Task OnParametersSetAsync()
{
this.kind = DetermineKind(this.Path);
if (this.kind is FileActionsKind.FILE && !string.Equals(this.checkedPath, this.Path, StringComparison.Ordinal))
{
var path = this.Path;
this.checkedPath = path;
this.isMissing = false;
var exists = await Task.Run(() => File.Exists(path));
// Another path took over meanwhile, which asks for itself:
if (!string.Equals(this.checkedPath, path, StringComparison.Ordinal))
return;
this.isMissing = !exists;
await this.OnExistenceChecked.InvokeAsync(exists);
}
await base.OnParametersSetAsync();
}
#endregion
/// <summary>
/// Tells a web page from a file on this computer by its path.
/// </summary>
/// <remarks>
/// Only an absolute path names a file: a relative one would name another file depending on
/// where it is opened from.
/// </remarks>
internal static FileActionsKind DetermineKind(string path)
{
if (string.IsNullOrWhiteSpace(path))
return FileActionsKind.NONE;
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
return FileActionsKind.WEB_PAGE;
return System.IO.Path.IsPathRooted(path) && (uri is null || uri.IsFile)
? FileActionsKind.FILE
: FileActionsKind.NONE;
}
private async Task CopyPath() => await this.RustService.CopyText2Clipboard(this.Path);
private async Task ShowInFileManager() => await new SourceDocumentLocation(this.Path, null).ShowInFileManagerAsync(this.RustService);
private async Task Open() => await new SourceDocumentLocation(this.Path, this.PageNumber).OpenAsync(this.RustService);
}
@@ -0,0 +1,11 @@
@inherits MSGComponentBase
@* The name takes the free width, so the buttons stand right. *@
<div class="file-dialog-title">
<MudText Typo="Typo.h6" Class="flex-grow-1">
@this.Title
</MudText>
@* Opening a file belongs to the bar above its content; a web page has no such bar, so it opens from here: *@
<FileActions Path="@this.Path" ShowOpen="@this.OpensWebPages" WarnIfMissing="@this.WarnIfMissing" TooltipPlacement="Placement.Bottom"/>
</div>
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// The title of a dialog about one file or web page: its name, and at the side, ways to copy its
/// path and to find it in the file manager, see FileActions.
/// </summary>
/// <remarks>
/// The path is what the copy button copies, so that is where it shows, in the tooltip of that
/// button, instead of taking a line of its own. Every dialog about a file names it the same way,
/// which is why this lives here instead of in each of them. A file which is no longer at its place
/// gets a warning instead of the button of the file manager.
/// </remarks>
public partial class FileDialogTitle : MSGComponentBase
{
/// <summary>
/// The title, usually the name of the file.
/// </summary>
[Parameter]
public string Title { get; set; } = string.Empty;
/// <summary>
/// The path of the file or the address of the web page, or nothing when there is neither to name.
/// </summary>
[Parameter]
public string Path { get; set; } = string.Empty;
/// <summary>
/// Whether a file which is no longer at its place gets a warning, see FileActions.WarnIfMissing.
/// </summary>
[Parameter]
public bool WarnIfMissing { get; set; } = true;
/// <summary>
/// Whether the path is the address of a web page, which this title then opens in the browser.
/// </summary>
private bool OpensWebPages => FileActions.DetermineKind(this.Path) is FileActions.FileActionsKind.WEB_PAGE;
}
@@ -1,46 +0,0 @@
@inherits MSGComponentBase
<div class="file-exclusion-editor mb-3">
@if (this.ShowHeader || this.ShowHelp)
{
<MudStack Row="@true" AlignItems="AlignItems.Center" Spacing="1" Class="mb-1">
@if (this.ShowHeader)
{
<MudText Typo="Typo.subtitle2" Class="flex-grow-1">@this.EditorLabel</MudText>
}
@if (this.ShowHelp)
{
<MudTooltip Text="@T("Exclusion help and examples")" RootStyle="display: inline-flex;">
<MudIconButton Icon="@Icons.Material.Outlined.HelpOutline" Color="@(this.showHelp ? Color.Info : Color.Default)" OnClick="@this.ToggleHelp" aria-label="@T("Exclusion help and examples")" aria-expanded="@(this.showHelp ? "true" : "false")"/>
</MudTooltip>
}
</MudStack>
}
<MudStack Spacing="1">
@if (this.ReadOnly)
{
<TextInfoLines Icon="@Icons.Material.Filled.FolderOff" Label="@T("Excluded folders")" Value="@this.state.FolderText" Lines="1" MaxLines="0" ClipboardTooltipSubject="@T("the excluded folders")"/>
<TextInfoLines Icon="@Icons.Material.Filled.FilterAltOff" Label="@T("Excluded files")" Value="@this.state.FileText" Lines="1" MaxLines="0" ClipboardTooltipSubject="@T("the excluded files")"/>
}
else
{
<MudTextField T="string" InputId="@this.folderInputId" Text="@this.state.FolderText" TextChanged="@this.ChangeFoldersAsync" Immediate="@true" Disabled="@this.Disabled" Label="@T("Folders")" Lines="1" AutoGrow="@true" MaxLines="0" Class="pattern-text-field" Variant="Variant.Outlined" Margin="Margin.Dense" Validation="@FileExclusions.ValidateFolders" UserAttributes="@INPUT_ATTRIBUTES" Placeholder="node_modules/;bin/;cmake-build-*/" Adornment="@(this.ShowReset ? Adornment.End : Adornment.None)" AdornmentIcon="@Icons.Material.Filled.Restore" OnAdornmentClick="@(() => this.RestoreDefaultsAsync(true))" AdornmentAriaLabel="@this.FolderRestoreLabel"/>
<MudTextField T="string" InputId="@this.fileInputId" Text="@this.state.FileText" TextChanged="@this.ChangeFilesAsync" Immediate="@true" Disabled="@this.Disabled" Label="@T("Files")" Lines="1" AutoGrow="@true" MaxLines="0" Class="pattern-text-field" Variant="Variant.Outlined" Margin="Margin.Dense" Validation="@FileExclusions.ValidateFiles" UserAttributes="@INPUT_ATTRIBUTES" Placeholder="*.min.js;*.webm;draft-*" Adornment="@(this.ShowReset ? Adornment.End : Adornment.None)" AdornmentIcon="@Icons.Material.Filled.Restore" OnAdornmentClick="@(() => this.RestoreDefaultsAsync(false))" AdornmentAriaLabel="@this.FileRestoreLabel"/>
}
</MudStack>
@if (!this.ReadOnly)
{
<MudText Typo="Typo.caption" Class="mud-text-secondary mt-1">@T("Separate patterns with semicolons. Folder patterns must end with /. Exclusions take priority.")</MudText>
}
<MudCollapse Expanded="@(this.ShowHelp && this.showHelp)">
<MudSimpleTable Dense="@true" Elevation="0" Class="mt-2" aria-label="@T("Exclusion examples")">
<tbody>
<tr><td><code>*.pdf</code></td><td>@T("PDF files, regardless of letter case")</td></tr>
<tr><td><code>draft-*</code></td><td>@T("File names starting with draft-")</td></tr>
<tr><td><code>*build/</code></td><td>@T("Folders ending in build, such as build or app-build")</td></tr>
</tbody>
</MudSimpleTable>
</MudCollapse>
</div>
@@ -1,118 +0,0 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// A compact, two-group editor for the exclusion string shared by folder-based features.
/// </summary>
public partial class FileExclusionEditor : MSGComponentBase
{
private static readonly Dictionary<string, object?> INPUT_ATTRIBUTES = new()
{
["spellcheck"] = false,
["autocorrect"] = "off",
["autocapitalize"] = "off",
};
[Parameter]
public string? Text { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> TextChanged { get; set; }
[Parameter]
public string Label { get; set; } = string.Empty;
[Parameter]
public bool Disabled { get; set; }
/// <summary>
/// Displays both groups with copy buttons instead of editing and reset controls.
/// </summary>
[Parameter]
public bool ReadOnly { get; set; }
[Parameter]
public bool ShowReset { get; set; } = true;
[Parameter]
public bool ShowHelp { get; set; } = true;
[Parameter]
public bool ShowHeader { get; set; } = true;
[Parameter]
public string ResetValue { get; set; } = FileExclusions.DEFAULT_PATTERNS;
[Parameter]
public string ResetButtonText { get; set; } = string.Empty;
/// <summary>
/// Immediately persists the combined value after restoring just one of its groups.
/// Ordinary edits continue through TextChanged.
/// </summary>
[Parameter]
public EventCallback<string> ResetRequested { get; set; }
private readonly FileExclusionEditorState state = new();
private readonly string folderInputId = $"exclusion-folders-{Guid.NewGuid():N}";
private readonly string fileInputId = $"exclusion-files-{Guid.NewGuid():N}";
private bool showHelp;
private string EditorLabel => string.IsNullOrWhiteSpace(this.Label) ? T("Excluded files and folders") : this.Label;
private string RestoreLabel => string.IsNullOrWhiteSpace(this.ResetButtonText) ? T("Restore default") : this.ResetButtonText;
private string FolderRestoreLabel => $"{this.RestoreLabel}: {T("Folders")}";
private string FileRestoreLabel => $"{this.RestoreLabel}: {T("Files")}";
protected override void OnParametersSet()
{
this.state.Receive(this.Text, discardDraft: this.Disabled || this.ReadOnly);
base.OnParametersSet();
}
protected Task ChangeFoldersAsync(string text) => this.ChangePatternsAsync(text, folders: true);
protected Task ChangeFilesAsync(string text) => this.ChangePatternsAsync(text, folders: false);
private async Task ChangePatternsAsync(string text, bool folders)
{
if (this.Disabled || this.ReadOnly)
return;
var previousText = this.state.Text;
var wasValid = this.state.IsValid;
if (folders)
this.state.EditFolders(text);
else
this.state.EditFiles(text);
// Moving a rule to the correct field can keep the combined text identical. It must still
// publish the now-valid draft, because the previous, invalid arrangement was not sent.
if (this.state.IsValid && (!wasValid || !string.Equals(previousText, this.state.Text, StringComparison.Ordinal)))
await this.TextChanged.InvokeAsync(this.state.Text);
}
protected async Task RestoreDefaultsAsync(bool folders)
{
if (this.Disabled || this.ReadOnly || !this.ShowReset)
return;
if (folders)
this.state.RestoreFolders(this.ResetValue);
else
this.state.RestoreFiles(this.ResetValue);
if (!this.state.IsValid)
return;
if (this.ResetRequested.HasDelegate)
await this.ResetRequested.InvokeAsync(this.state.Text);
else
await this.TextChanged.InvokeAsync(this.state.Text);
}
private void ToggleHelp() => this.showHelp = !this.showHelp;
}
@@ -1,89 +0,0 @@
namespace AIStudio.Components;
/// <summary>
/// Presents the existing exclusion string as two editable groups without losing the other group
/// or letting delayed parameter echoes overwrite newer edits.
/// </summary>
internal sealed class FileExclusionEditorState
{
private const string PATTERN_SEPARATOR = "; ";
private string? receivedText;
private readonly HashSet<string> pendingValues = new(StringComparer.Ordinal);
public string Text { get; private set; } = string.Empty;
public string FolderText { get; private set; } = string.Empty;
public string FileText { get; private set; } = string.Empty;
public bool IsValid => FileExclusions.IsValidFolders(this.FolderText) && FileExclusions.IsValidFiles(this.FileText);
public bool Receive(string? text, bool discardDraft = false)
{
text ??= string.Empty;
if (discardDraft)
{
var changed = !string.Equals(text, this.Text, StringComparison.Ordinal);
this.receivedText = text;
this.Reset(text);
return changed;
}
if (string.Equals(text, this.receivedText, StringComparison.Ordinal))
return false;
this.receivedText = text;
if (string.Equals(text, this.Text, StringComparison.Ordinal))
{
this.pendingValues.Clear();
return false;
}
if (this.pendingValues.Remove(text))
return false;
this.Reset(text);
return true;
}
public void Reset(string text)
{
this.pendingValues.Clear();
this.Text = text;
this.Regroup();
}
public void EditFolders(string? text)
{
this.FolderText = text ?? string.Empty;
this.UpdateText();
}
public void EditFiles(string? text)
{
this.FileText = text ?? string.Empty;
this.UpdateText();
}
public void RestoreFolders(string defaults) => this.EditFolders(string.Join(PATTERN_SEPARATOR, FileExclusions.Parse(defaults).Where(IsFolder)));
public void RestoreFiles(string defaults) => this.EditFiles(string.Join(PATTERN_SEPARATOR, FileExclusions.Parse(defaults).Where(pattern => !IsFolder(pattern))));
private void Regroup()
{
var patterns = FileExclusions.Parse(this.Text);
this.FolderText = string.Join(PATTERN_SEPARATOR, patterns.Where(IsFolder));
this.FileText = string.Join(PATTERN_SEPARATOR, patterns.Where(pattern => !IsFolder(pattern)));
}
private static bool IsFolder(string pattern) => pattern.EndsWith(FileExclusions.FOLDER_SUFFIX);
private void UpdateText()
{
this.Text = string.Join(PATTERN_SEPARATOR, FileExclusions.Parse(this.FolderText)
.Concat(FileExclusions.Parse(this.FileText))
.Distinct(StringComparer.OrdinalIgnoreCase));
this.pendingValues.Add(this.Text);
}
}
@@ -0,0 +1,8 @@
@inherits MSGComponentBase
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="3">
<MudProgressCircular Size="Size.Small" Indeterminate="@true" Color="Color.Primary"/>
<div>
@string.Format(T("Saving '{0}'..."), this.FileName)
</div>
</MudStack>
@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// The content of the notification that a file is being exported, see DataFileExportRunningMessage.
/// </summary>
public partial class FileExportRunningMessage : MSGComponentBase
{
/// <summary>
/// The name of the file which is being written.
/// </summary>
[Parameter]
public string FileName { get; set; } = string.Empty;
}
@@ -0,0 +1,8 @@
@inherits MSGComponentBase
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
<div>
@this.Message
</div>
<FileActions Path="@this.FilePath" ShowCopy="@false" ShowOpen="@this.OffersOpening"/>
</MudStack>
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// The content of the notification that a file was exported, see DataFileExportSuccessMessage.
/// </summary>
/// <remarks>
/// Taking one of the ways to the file leaves the notification open: whoever opened the file often
/// wants to see it in its folder afterward, or the other way round.
/// </remarks>
public partial class FileExportSuccessMessage : MSGComponentBase
{
/// <summary>
/// What was exported.
/// </summary>
[Parameter]
public string Message { get; set; } = string.Empty;
/// <summary>
/// The file which was written.
/// </summary>
[Parameter]
public string FilePath { get; set; } = string.Empty;
/// <summary>
/// Whether the file may be opened with the default program.
/// </summary>
[Parameter]
public bool OffersOpening { get; set; }
}
@@ -1,42 +0,0 @@
@inherits MSGComponentBase
<MudStack Spacing="1" Class="mb-3">
<div style="@PATTERN_ROW_STYLE">
<div style="min-width: 0;">
@this.AllowedContent
</div>
<MudTooltip Text="@T("Pattern help and examples")" RootStyle="display: inline-flex;">
<MudIconButton Icon="@Icons.Material.Outlined.HelpOutline" Color="@(this.showHelp ? Color.Info : Color.Default)" OnClick="@this.ToggleHelp" aria-label="@T("Pattern help and examples")" aria-expanded="@(this.showHelp ? "true" : "false")"/>
</MudTooltip>
</div>
@if (this.HasExclusions)
{
<div style="@PATTERN_ROW_STYLE">
<div style="grid-column: 1; min-width: 0;" class="mt-3">
@this.ExcludedContent
</div>
</div>
}
<MudCollapse Expanded="@this.showHelp">
<div style="@PATTERN_ROW_STYLE">
<MudPaper Outlined="@true" Class="pa-3" Elevation="0" Style="grid-column: 1; min-width: 0;">
<MudText Typo="Typo.subtitle2">@T("Pattern examples")</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-2">@T("Separate patterns with semicolons. Exclusions take priority over included files.")</MudText>
<MudSimpleTable Dense="@true" Elevation="0" aria-label="@T("Pattern examples")">
<tbody>
<tr><td><code>*.pdf;*.docx</code></td><td>@T("PDF and Word files")</td></tr>
<tr><td><code>:documents:</code></td><td>@T("Include all documents and readable text files")</td></tr>
<tr><td><code>*</code></td><td>@T("Include all files, including audio and video")</td></tr>
<tr><td><code>:text:</code></td><td>@T("Include readable text files, including files without an extension")</td></tr>
<tr><td><code>draft-*</code></td><td>@T("File names starting with draft-")</td></tr>
@if (this.HasExclusions)
{
<tr><td><code>*build/</code></td><td>@T("Exclude folders ending in build, such as build or app-build")</td></tr>
}
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Class="mud-text-secondary mt-2">@T("Patterns match file names regardless of letter case. Folder paths are not supported.")</MudText>
</MudPaper>
</div>
</MudCollapse>
</MudStack>
@@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// A file-pattern field with shared, collapsible help and optional, always-visible exclusions.
/// Callers supply their existing fields, preserving validation and managed-configuration locks.
/// </summary>
public partial class FilePatternSelection : MSGComponentBase
{
private const string PATTERN_ROW_STYLE = "display: grid; grid-template-columns: minmax(0, 1fr) 48px; column-gap: 8px; align-items: center;";
[Parameter, EditorRequired]
public RenderFragment AllowedContent { get; set; } = null!;
/// <summary>
/// When supplied, shows the existing exclusion field below the inclusion field.
/// Both groups stay visible and use the same help control.
/// </summary>
[Parameter]
public RenderFragment? ExcludedContent { get; set; }
internal bool HasExclusions => this.ExcludedContent is not null;
private bool showHelp;
private void ToggleHelp() => this.showHelp = !this.showHelp;
}
@@ -0,0 +1,13 @@
@using AIStudio.Tools.PowerPoint
@foreach (var area in Enum.GetValues<LayoutPurposeArea>())
{
<CompactExpansionPanel HeaderText="@this.HeaderTextOf(area)" HeaderIcon="@IconOf(area)" Class="mb-3 rounded" Expanded="@this.expandedAreas.Contains(area)" ExpandedChanged="@(expanded => this.SetExpanded(area, expanded))">
@if (this.expandedAreas.Contains(area))
{
@foreach (var purpose in Enum.GetValues<LayoutPurpose>().Where(purpose => purpose.Area() == area))
{
@this.ChoiceContent(purpose)
}
}
</CompactExpansionPanel>
}
@@ -0,0 +1,62 @@
using AIStudio.Tools.PowerPoint;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Groups the choices of a layout per kind of slide into one collapsed panel per area, e.g., teaching
/// or science, since more than a hundred choices in one list help nobody.
/// </summary>
/// <remarks>
/// The choice itself comes from the caller: the Slide Assistant uses a plain select, and its
/// settings dialog a configuration select. The header of an area tells how many of its kinds have a
/// layout, so that no choice hides in a collapsed panel.
///
/// The choices of an area render only while its panel is open. A collapsed panel still keeps its
/// content in the page, and all areas together hold more than a hundred selects, which the Slide
/// Assistant would otherwise render again with every update while the LLM plans.
/// </remarks>
public partial class LayoutChoicePanels : ComponentBase
{
/// <summary>
/// Renders the choice of a layout for one kind of slide.
/// </summary>
[Parameter]
public RenderFragment<LayoutPurpose> ChoiceContent { get; set; } = _ => _ => { };
/// <summary>
/// Whether a layout is chosen for a kind of slide.
/// </summary>
[Parameter]
public Func<LayoutPurpose, bool> IsChosen { get; set; } = _ => false;
private readonly HashSet<LayoutPurposeArea> expandedAreas = [];
private string HeaderTextOf(LayoutPurposeArea area)
{
var chosen = Enum.GetValues<LayoutPurpose>().Count(purpose => purpose.Area() == area && this.IsChosen(purpose));
return chosen == 0 ? area.Label() : $"{area.Label()} ({chosen})";
}
private void SetExpanded(LayoutPurposeArea area, bool expanded)
{
if (expanded)
this.expandedAreas.Add(area);
else
this.expandedAreas.Remove(area);
}
private static string IconOf(LayoutPurposeArea area) => area switch
{
LayoutPurposeArea.FRAMING => Icons.Material.Filled.FormatListNumbered,
LayoutPurposeArea.GENERAL => Icons.Material.Filled.ViewQuilt,
LayoutPurposeArea.TEACHING => Icons.Material.Filled.School,
LayoutPurposeArea.BUSINESS => Icons.Material.Filled.BusinessCenter,
LayoutPurposeArea.SCIENCE => Icons.Material.Filled.Science,
LayoutPurposeArea.MEDICINE => Icons.Material.Filled.MedicalServices,
LayoutPurposeArea.LAW => Icons.Material.Filled.Gavel,
_ => Icons.Material.Filled.ViewQuilt,
};
}
@@ -23,6 +23,19 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage; private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
/// <summary>
/// How Markdown rendered by this component looks: its code blocks follow the color theme of the app.
/// </summary>
/// <remarks>
/// Every component which renders Markdown wants the same, so it is written down once here instead
/// of in each of them. Read on every render: a change of the color theme renders every component
/// again, cf. ProcessMessage, and the code blocks change along with it.
/// </remarks>
protected MudMarkdownStyling MarkdownStyling => new()
{
CodeBlock = { Theme = this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default },
};
#region Overrides of ComponentBase #region Overrides of ComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -1,13 +1,10 @@
@inherits MSGComponentBase @inherits MSGComponentBase
@if (ShouldShowInstallationPrompt(this.pandocInstallation))
{
<MudStack Row="false" Class="mb-3 pa-3 border-dashed border rounded-lg"> <MudStack Row="false" Class="mb-3 pa-3 border-dashed border rounded-lg">
<MudJustifiedText Typo="Typo.body2"> <MudJustifiedText Typo="Typo.body1">
@this.DetermineIntroText() @this.DetermineIntroText()
</MudJustifiedText> </MudJustifiedText>
<MudButton Color="@this.DetermineButtonColor()" Variant="Variant.Filled" OnClick="() => this.ShowPandocDialogAsync()"> <MudButton Color="@this.DetermineButtonColor()" Variant="Variant.Filled" OnClick="() => this.ShowPandocDialogAsync()">
@this.DetermineButtonText() @this.DetermineButtonText()
</MudButton> </MudButton>
</MudStack> </MudStack>
}
@@ -18,10 +18,7 @@ public partial class ManagePandocDependency : MSGComponentBase
[Inject] [Inject]
private RustService RustService { get; init; } = null!; private RustService RustService { get; init; } = null!;
// Null means the silent availability check has not completed yet. private PandocInstallation pandocInstallation;
private PandocInstallation? pandocInstallation;
internal static bool ShouldShowInstallationPrompt(PandocInstallation? installation) => installation is { IsAvailable: false };
#region Overrides of MSGComponentBase #region Overrides of MSGComponentBase
@@ -35,10 +32,10 @@ public partial class ManagePandocDependency : MSGComponentBase
private Color DetermineButtonColor() private Color DetermineButtonColor()
{ {
if (this.pandocInstallation is null) if (this.pandocInstallation == default)
return Color.Default; return Color.Default;
switch (this.pandocInstallation.Value) switch (this.pandocInstallation)
{ {
case { CheckWasSuccessful: true, IsAvailable: true }: case { CheckWasSuccessful: true, IsAvailable: true }:
return Color.Default; return Color.Default;
@@ -53,10 +50,10 @@ public partial class ManagePandocDependency : MSGComponentBase
private string DetermineButtonText() private string DetermineButtonText()
{ {
if(this.pandocInstallation is null) if(this.pandocInstallation == default)
return T("Please wait while we check the availability of Pandoc."); return T("Please wait while we check the availability of Pandoc.");
switch (this.pandocInstallation.Value) switch (this.pandocInstallation)
{ {
case { CheckWasSuccessful: true, IsAvailable: true }: case { CheckWasSuccessful: true, IsAvailable: true }:
return T("Check your Pandoc installation"); return T("Check your Pandoc installation");
@@ -71,10 +68,10 @@ public partial class ManagePandocDependency : MSGComponentBase
private string DetermineIntroText() private string DetermineIntroText()
{ {
if (this.pandocInstallation is null) if (this.pandocInstallation == default)
return $"{this.IntroText} {T("Please wait while we check the availability of Pandoc.")}"; return $"{this.IntroText} {T("Please wait while we check the availability of Pandoc.")}";
switch (this.pandocInstallation.Value) switch (this.pandocInstallation)
{ {
case { CheckWasSuccessful: true, IsAvailable: true }: case { CheckWasSuccessful: true, IsAvailable: true }:
return $"{this.IntroText} {T("Your Pandoc installation meets the requirements.")}"; return $"{this.IntroText} {T("Your Pandoc installation meets the requirements.")}";
@@ -0,0 +1,24 @@
@using AIStudio.Settings
@using AIStudio.Settings.DataModel
@inherits MSGComponentBase
<MudSelect T="OutboundDataRestriction" Value="@this.Value" ValueChanged="@this.ValueChanged" Text="@this.Value.GetName()" Label="@this.Label" Class="mb-1" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Shield">
@foreach (var restriction in ConfigurationSelectDataFactory.GetOutboundDataRestrictionData(this.Minimum))
{
<MudSelectItem T="OutboundDataRestriction" Value="@restriction.Value">
@restriction.Name
</MudSelectItem>
}
</MudSelect>
<MudJustifiedText Typo="Typo.body2" Class="mb-1">
@this.Value.GetDescription()
</MudJustifiedText>
@if (this.LimitationText is { } limitationText)
{
<MudJustifiedText Typo="Typo.body2" Class="mb-1">
@limitationText
</MudJustifiedText>
}
<MudJustifiedText Typo="Typo.body2" Class="mb-6">
@this.Explanation
</MudJustifiedText>
@@ -0,0 +1,72 @@
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Lets the user choose where a chat may still send data once it has read from a mailbox or a data
/// source, and explains the choice.
/// </summary>
/// <remarks>
/// Only the levels allowed are offered: an organization may rule out the less strict ones, and so
/// does a data source which only self-hosted providers may use, see
/// DataSourceOutboundDataExtensions.GetMinimumOutboundDataRestriction. The dialog keeps the value it
/// passes within those, since it stores it.
/// </remarks>
public partial class OutboundDataRestrictionSelection : MSGComponentBase
{
/// <summary>
/// The level chosen.
/// </summary>
[Parameter]
public OutboundDataRestriction Value { get; set; }
[Parameter]
public EventCallback<OutboundDataRestriction> ValueChanged { get; set; }
/// <summary>
/// The label of the setting. The blocked message of a tool quotes it, so the user finds the
/// setting by the words they read there, see OutboundDataRequirementExtensions.GetToolBlockedMessage.
/// </summary>
[Parameter]
public string Label { get; set; } = string.Empty;
/// <summary>
/// Why the setting matters for this kind of source.
/// </summary>
[Parameter]
public string Explanation { get; set; } = string.Empty;
/// <summary>
/// The least strict level the organization allows for this kind of source.
/// </summary>
[Parameter]
public OutboundDataRestriction OrganizationMinimum { get; set; } = OutboundDataRestriction.UNRESTRICTED;
/// <summary>
/// The security policy of the data source, or NOT_SPECIFIED for a source which has none.
/// </summary>
[Parameter]
public DataSourceSecurity SecurityPolicy { get; set; } = DataSourceSecurity.NOT_SPECIFIED;
private OutboundDataRestriction Minimum => DataSourceOutboundDataExtensions.GetMinimumOutboundDataRestriction(this.SecurityPolicy, this.OrganizationMinimum);
/// <summary>
/// Why less strict levels are not offered, or null when every level is.
/// </summary>
private string? LimitationText
{
get
{
if (this.SecurityPolicy is DataSourceSecurity.SELF_HOSTED)
return T("Only self-hosted providers may use this data source. A chat which found something in it may therefore only use the services configured in AI Studio, such as the allowed private hosts of the tool 'Read Web Page'.");
if (this.OrganizationMinimum is not OutboundDataRestriction.UNRESTRICTED)
return T("Your organization does not allow less strict settings than the ones offered here.");
return null;
}
}
}
@@ -141,6 +141,15 @@ public partial class PathDropZone : MSGComponentBase
[Inject] [Inject]
private ILogger<PathDropZone> Logger { get; init; } = null!; private ILogger<PathDropZone> Logger { get; init; } = null!;
/// <summary>
/// Marks a field in a frameless zone while a path hovers over it, e.g., an option in the settings.
/// </summary>
/// <remarks>
/// Such a field sits in the frame of its option already, so it gets no drop frame of its own. An
/// outline takes no room, which keeps the layout still while the cursor moves over the field.
/// </remarks>
public const string FIELD_HIGHLIGHT_STYLE = "outline: 2px dashed var(--mud-palette-primary); outline-offset: 4px; border-radius: 4px;";
private const string FRAME_CLASSES = "relative rounded-lg border-2 border-dashed mud-width-full"; private const string FRAME_CLASSES = "relative rounded-lg border-2 border-dashed mud-width-full";
private const string DEFAULT_SPACING_CLASSES = "pa-3 mb-3"; private const string DEFAULT_SPACING_CLASSES = "pa-3 mb-3";
Loaded 100 of 563 files, more files were not shown because too many files have changed in this diff. Show more