mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-10 15:33:48 +00:00
Merge branch 'main' into chunk-data
Resolved 29 conflicting files. The notable decisions: Confidence: main's tool-calling gate (RequiredProviderConfidence) and this branch's local-RAG gate (DataConfidenceLevel) turned out to be the same rule on the same axis, so they are now one field. Both tool results and data sources raise it through RequireProviderConfidence(). The gate checks the level strictly and no longer exempts providers trusted by configuration: TrustedProviderIds is documented as applying to data-source security checks only, and organizations set confidence through DataConfidence .CustomConfidenceScheme instead. The security axis (DataSecurity, ERI, IsTrustedForDataSourceSecurityChecks) is unchanged. Provider creation: main's CreateProvider signature won (hfEndpointKind, capabilityOverrides, no model parameter); tokenizerPath was added to it and is set for every provider, including the new Hetzner, IONOS and LiteLLM. Provider and EmbeddingProvider combine the record parameters, Lua parsing and Lua serialization of both sides. File types: main's hierarchy (ODT leaf, WORD parent, PowerPoint without the legacy .ppt, TABULAR instead of DELIMITED_TABLE) plus this branch's SPREADSHEET parent with ODS and the xlsm/xlsb/xla/xlam extensions, which the runtime already reads. Both sides had added a conflicting HTML filter; the reading family keeps the name, and the export path uses a narrow HTML_DOCUMENT, following the existing LATEX/TEX split. Runtime: main's file_data.rs is the base, including the prompt-injection sanitizer and the extraction routes. Token counting and chunk segmentation moved into take_released, so they act on the text the filter has released rather than on text it is still holding. A failed count is logged and left out instead of ending the extraction, because the app counts such a segment itself. Data sources: the participating-provider checks of this branch are kept, and main's GetAllowedDataSources overload now builds on them. DirectChatService resolves the launched chat's data source options before the check, so filter and chat see the same options. .NET and Rust both build clean; I18N regenerated to 4060 keys.
This commit is contained in:
640 files changed
+45059
-4952
No files matched your search
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
public sealed record ChatStartRequest(ChatThread ChatThread, bool ApplySelectedChatTemplateToComposer = false, bool PreserveDataSourceOptions = false);
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
using AIStudio.Tools.ERIClient.DataModel;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
@@ -51,6 +53,18 @@ public sealed record ChatThread
|
||||
/// </summary>
|
||||
public string SelectedChatTemplate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the tools selected for the chat thread, as the user chose them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null means the thread never stored a selection, which is the case for every chat written
|
||||
/// before tools existed: those open with the defaults of their component. An empty set is the
|
||||
/// opposite statement — the user switched every tool off and wants it to stay that way.<br/><br/>
|
||||
/// This is the unfiltered selection. What a provider may actually run is decided per request,
|
||||
/// because a provider with too little confidence must not cost the user a tool permanently.
|
||||
/// </remarks>
|
||||
public HashSet<string>? SelectedToolIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether to include the current date and time in the system prompt.
|
||||
/// False by default for backward compatibility.
|
||||
@@ -78,9 +92,19 @@ public sealed record ChatThread
|
||||
public DataSourceSecurity DataSecurity { get; set; } = DataSourceSecurity.NOT_SPECIFIED;
|
||||
|
||||
/// <summary>
|
||||
/// The minimum provider confidence required by data sources used so far.
|
||||
/// The minimum confidence required for providers that continue this chat. It is raised whenever
|
||||
/// a tool returned sensitive data, and whenever a data source was used which demands a higher
|
||||
/// level. Both cases share one rule: once such data is in the thread, every provider which
|
||||
/// continues it must meet the level.
|
||||
/// </summary>
|
||||
public ConfidenceLevel DataConfidenceLevel { get; set; } = ConfidenceLevel.NONE;
|
||||
[JsonInclude]
|
||||
public ConfidenceLevel RequiredProviderConfidence { get; private set; } = ConfidenceLevel.NONE;
|
||||
|
||||
public void RequireProviderConfidence(ConfidenceLevel minimumProviderConfidence)
|
||||
{
|
||||
if (minimumProviderConfidence > this.RequiredProviderConfidence)
|
||||
this.RequiredProviderConfidence = minimumProviderConfidence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The name of the chat thread. Usually generated by an AI model or manually edited by the user.
|
||||
@@ -96,6 +120,31 @@ public sealed record ChatThread
|
||||
/// The content blocks of the chat thread.
|
||||
/// </summary>
|
||||
public List<ContentBlock> Blocks { get; init; } = [];
|
||||
|
||||
[JsonIgnore]
|
||||
public AIStudio.Tools.Components RuntimeComponent { get; set; } = AIStudio.Tools.Components.CHAT;
|
||||
|
||||
[JsonIgnore]
|
||||
public HashSet<string> RuntimeSelectedToolIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the tools of this run were named by the assistant's own rules instead of chosen by
|
||||
/// the user.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A user who cannot see a tool selection must not get tools they never picked, which is why
|
||||
/// running tools normally requires a visible selection. That rule misses the case where nobody
|
||||
/// asked the user in the first place: a document analysis policy or an assistant plugin names
|
||||
/// its tools, and hiding the selection is the point rather than an obstacle. This flag tells
|
||||
/// the providers which of the two they are looking at.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public bool RuntimeToolsAreAssistantManaged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this thread may run tools at all.
|
||||
/// </summary>
|
||||
public bool MayRunTools(SettingsManager settingsManager) => this.RuntimeToolsAreAssistantManaged || settingsManager.IsToolSelectionVisible(this.RuntimeComponent);
|
||||
|
||||
private bool allowProfile = true;
|
||||
|
||||
@@ -108,8 +157,9 @@ public sealed record ChatThread
|
||||
/// is extended with the profile chosen.
|
||||
/// </remarks>
|
||||
/// <param name="settingsManager">The settings manager instance to use.</param>
|
||||
/// <param name="runnableToolDefinitions">The tools which may run in this thread. Their instructions become part of the system prompt. Null when the thread runs without tools.</param>
|
||||
/// <returns>The prepared system prompt.</returns>
|
||||
public string PrepareSystemPrompt(SettingsManager settingsManager)
|
||||
public string PrepareSystemPrompt(SettingsManager settingsManager, IEnumerable<ToolDefinition>? runnableToolDefinitions = null)
|
||||
{
|
||||
this.allowProfile = true;
|
||||
|
||||
@@ -204,6 +254,17 @@ public sealed record ChatThread
|
||||
}
|
||||
|
||||
LOGGER.LogInformation(logMessage);
|
||||
|
||||
var toolPolicy = ToolSelectionRules.BuildToolPolicyPrompt(runnableToolDefinitions ?? []);
|
||||
if (!string.IsNullOrWhiteSpace(toolPolicy))
|
||||
{
|
||||
systemPromptText = $"""
|
||||
{systemPromptText}
|
||||
|
||||
{toolPolicy}
|
||||
""";
|
||||
}
|
||||
|
||||
if(!this.IncludeDateTime)
|
||||
return systemPromptText;
|
||||
|
||||
@@ -320,4 +381,4 @@ public sealed record ChatThread
|
||||
|
||||
return new Tools.ERIClient.DataModel.ChatThread { ContentBlocks = contentBlocks };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,15 +28,22 @@ public static class ChatThreadExtensions
|
||||
return true;
|
||||
|
||||
var settingsManager = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
var providerConfidenceLevel = provider switch
|
||||
var providerConfidence = provider switch
|
||||
{
|
||||
IProvider p => p.GetConfidenceLevel(settingsManager),
|
||||
AIStudio.Settings.Provider p => p.GetConfidenceLevel(settingsManager),
|
||||
|
||||
_ => ConfidenceLevel.NONE,
|
||||
_ => ConfidenceLevel.UNKNOWN,
|
||||
};
|
||||
|
||||
if (!providerConfidenceLevel.AllowsDataSourceConfidenceLevel(chatThread.DataConfidenceLevel))
|
||||
//
|
||||
// The confidence axis is checked on its own: a provider trusted by configuration counts as
|
||||
// self-hosted for data-source security, which is the check further down, but that trust
|
||||
// says nothing about how confidential the provider is. An organization which wants its
|
||||
// contractually covered cloud provider to pass here raises its level through the custom
|
||||
// confidence scheme instead.
|
||||
//
|
||||
if (providerConfidence < chatThread.RequiredProviderConfidence)
|
||||
return false;
|
||||
|
||||
// The chat thread is available, but the data security is not specified.
|
||||
@@ -68,4 +75,4 @@ public static class ChatThreadExtensions
|
||||
false => chatThread.DataSecurity is not DataSourceSecurity.SELF_HOSTED,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,60 +11,98 @@
|
||||
</MudAvatar>
|
||||
</CardHeaderAvatar>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.body1">
|
||||
@this.Role.ToName() (@this.Time.LocalDateTime)
|
||||
</MudText>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body1">
|
||||
@this.Role.ToName() (@this.Time.LocalDateTime)
|
||||
</MudText>
|
||||
@if (this.HasToolTrace)
|
||||
{
|
||||
<MudTooltip Text="@this.GetToolTraceTooltip()" Placement="Placement.Bottom">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Default"
|
||||
Size="Size.Small"
|
||||
Class="px-2 py-1 rounded-pill"
|
||||
Style="min-width:auto; border-width:1px; text-transform:none;"
|
||||
OnClick="@this.ToggleToolTrace">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Build" Color="Color.Default" Size="Size.Small" />
|
||||
<MudIcon Icon="@(this.showToolTrace ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Size="Size.Small" />
|
||||
</MudStack>
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
@if (this.Content.FileAttachments.Count > 0)
|
||||
{
|
||||
<MudTooltip Text="@T("Number of attachments")" Placement="Placement.Bottom">
|
||||
<MudBadge Content="@this.Content.FileAttachments.Count" Color="Color.Primary" Overlap="true" BadgeClass="sources-card-header">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.AttachFile"
|
||||
OnClick="@this.OpenAttachmentsDialog"/>
|
||||
</MudBadge>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.Content.Sources.Count > 0)
|
||||
{
|
||||
<MudTooltip Text="@T("Number of sources")" Placement="Placement.Bottom">
|
||||
<MudBadge Content="@this.Content.Sources.Count" Color="Color.Primary" Overlap="true" BadgeClass="sources-card-header">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Link"/>
|
||||
</MudBadge>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.IsSecondToLastBlock && this.Role is ChatRole.USER && this.EditLastUserBlockFunc is not null)
|
||||
{
|
||||
<MudTooltip Text="@T("Edit")" Placement="Placement.Bottom">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Default" OnClick="@this.EditLastUserBlock"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.IsLastContentBlock && this.Role is ChatRole.USER && this.EditLastBlockFunc is not null)
|
||||
{
|
||||
<MudTooltip Text="@T("Edit")" Placement="Placement.Bottom">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Default" OnClick="@this.EditLastBlock"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.IsLastContentBlock && this.Role is ChatRole.AI && this.RegenerateFunc is not null)
|
||||
{
|
||||
<MudTooltip Text="@T("Regenerate")" Placement="Placement.Bottom">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Recycling" Color="Color.Default" Disabled="@(!this.RegenerateEnabled())" OnClick="@this.RegenerateBlock"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.RemoveBlockFunc is not null)
|
||||
{
|
||||
<MudTooltip Text="@T("Removes this block")" Placement="Placement.Bottom">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@this.RemoveBlock"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
<div class="d-flex align-center">
|
||||
@if (this.Content.FileAttachments.Count > 0)
|
||||
{
|
||||
<MudTooltip Text="@T("Number of attachments")" Placement="Placement.Bottom">
|
||||
<MudBadge Content="@this.Content.FileAttachments.Count" Color="Color.Primary" Overlap="true" BadgeClass="sources-card-header">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.AttachFile"
|
||||
OnClick="@this.OpenAttachmentsDialog"/>
|
||||
</MudBadge>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.Content.Sources.Count > 0)
|
||||
{
|
||||
<MudTooltip Text="@T("Number of sources")" Placement="Placement.Bottom">
|
||||
<MudBadge Content="@this.Content.Sources.Count" Color="Color.Primary" Overlap="true" BadgeClass="sources-card-header">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Link"/>
|
||||
</MudBadge>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.IsSecondToLastBlock && this.Role is ChatRole.USER && this.EditLastUserBlockFunc is not null)
|
||||
{
|
||||
<MudTooltip Text="@T("Edit")" Placement="Placement.Bottom">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Default" OnClick="@this.EditLastUserBlock"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.IsLastContentBlock && this.Role is ChatRole.USER && this.EditLastBlockFunc is not null)
|
||||
{
|
||||
<MudTooltip Text="@T("Edit")" Placement="Placement.Bottom">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Default" OnClick="@this.EditLastBlock"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.IsLastContentBlock && this.Role is ChatRole.AI && this.RegenerateFunc is not null)
|
||||
{
|
||||
<MudTooltip Text="@T("Regenerate")" Placement="Placement.Bottom">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Recycling" Color="Color.Default" Disabled="@(!this.RegenerateEnabled())" OnClick="@this.RegenerateBlock"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.RemoveBlockFunc is not null)
|
||||
{
|
||||
<MudTooltip Text="@T("Removes this block")" Placement="Placement.Bottom">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@this.RemoveBlock"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (this.Role is ChatRole.AI)
|
||||
{
|
||||
<MudTooltip Text="@T("Export Chat to Microsoft Word")" Placement="Placement.Bottom">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Save" OnClick="@this.ExportToWord"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudCopyClipboardButton Content="@this.Content" Type="@this.Type" Size="Size.Medium"/>
|
||||
@if (this.Role is ChatRole.AI && this.CanExport)
|
||||
{
|
||||
<MudTooltip Text="@this.EffectiveExportTitle" Placement="Placement.Bottom">
|
||||
<MudMenu Icon="@Icons.Material.Filled.Save">
|
||||
@foreach (var documentFormat in FileExportFormatExtensions.DOCUMENT_FORMATS)
|
||||
{
|
||||
<MudMenuItem OnClick="@(() => this.ExportDocument(documentFormat))" Icon="@documentFormat.ToIcon()" Label="@documentFormat.ToName()"/>
|
||||
}
|
||||
@if (this.MessageTables.Count > 0)
|
||||
{
|
||||
<MudDivider/>
|
||||
@foreach (var messageTable in this.MessageTables)
|
||||
{
|
||||
<MudMenuItem OnClick="@(() => this.ExportTable(messageTable))" Icon="@messageTable.Format.ToIcon()" Label="@this.ExportLabel(messageTable)"/>
|
||||
}
|
||||
}
|
||||
<MudDivider/>
|
||||
@foreach (var textFormat in FileExportFormatExtensions.TEXT_FORMATS)
|
||||
{
|
||||
<MudMenuItem OnClick="@(() => this.ExportDocument(textFormat))" Icon="@textFormat.ToIcon()" Label="@textFormat.ToName()"/>
|
||||
}
|
||||
</MudMenu>
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudCopyClipboardButton Content="@this.Content" Type="@this.Type" Size="Size.Medium"/>
|
||||
</div>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
@@ -80,42 +118,121 @@
|
||||
case ContentType.TEXT:
|
||||
if (this.Content is ContentText textContent)
|
||||
{
|
||||
@*
|
||||
The tool trace and the running-tool status stand outside the waiting and
|
||||
streaming branches on purpose. While the model works through its tool
|
||||
calls, nothing has been streamed yet, so those branches show a skeleton
|
||||
or nothing at all — and that is exactly when the user wants to watch
|
||||
what the tools are doing.
|
||||
*@
|
||||
@if (this.HasToolTrace && this.showToolTrace)
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-3 border rounded-lg" Style="border-width:1px;">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">
|
||||
@string.Format(T("Tool Calls ({0})"), textContent.ToolInvocations.Count)
|
||||
</MudText>
|
||||
@foreach (var invocation in textContent.ToolInvocations.OrderBy(x => x.Order))
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-3 border rounded-lg" Style="border-width:1px;">
|
||||
<MudButton Variant="Variant.Text"
|
||||
Color="Color.Default"
|
||||
FullWidth="@true"
|
||||
Class="px-0 py-0 justify-space-between"
|
||||
Style="min-width:auto; text-transform:none;"
|
||||
OnClick="@(() => this.ToggleToolInvocation(invocation.Order))">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="w-100">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@invocation.ToolIcon" Color="Color.Info" />
|
||||
<MudText Typo="Typo.subtitle1">@($"{invocation.Order}. {invocation.ToolName}")</MudText>
|
||||
<MudChip T="string" Color="@ContentBlockComponent.GetTraceColor(invocation.Status)" Size="Size.Small" Variant="Variant.Outlined">
|
||||
@this.GetTraceStatusText(invocation)
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudIcon Icon="@(this.IsToolInvocationExpanded(invocation.Order) ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Size="Size.Small" />
|
||||
</MudStack>
|
||||
</MudButton>
|
||||
|
||||
@if (this.IsToolInvocationExpanded(invocation.Order))
|
||||
{
|
||||
@if (!string.IsNullOrWhiteSpace(invocation.StatusMessage))
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Warning" Class="mt-3 mb-3">@invocation.StatusMessage</MudText>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.subtitle2">@T("Arguments")</MudText>
|
||||
@if (invocation.Arguments.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mb-3">@T("No arguments")</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="string" Dense="@true" Class="mb-0">
|
||||
@foreach (var argument in invocation.Arguments)
|
||||
{
|
||||
<MudListItem T="string">
|
||||
<MudText Typo="Typo.body2"><strong>@argument.Key:</strong> @argument.Value</MudText>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-3">@T("Result")</MudText>
|
||||
<MudPaper Class="pa-3 mt-2 mb-3">
|
||||
@if (invocation.JsonResult is not null)
|
||||
{
|
||||
<JsonTreeView Value="@invocation.JsonResult" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Style="white-space: pre-wrap; overflow-wrap: anywhere;">@this.GetToolInvocationResult(invocation)</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
if (textContent.InitialRemoteWait)
|
||||
{
|
||||
<MudSkeleton Width="30%" Height="42px;"/>
|
||||
<MudSkeleton Width="80%"/>
|
||||
<MudSkeleton Width="100%"/>
|
||||
}
|
||||
else if (this.Content.IsStreaming)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Style="white-space: pre-wrap;">
|
||||
@textContent.Text.RemoveThinkTags()
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (this.Content.IsStreaming)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Style="white-space: pre-wrap;">
|
||||
@textContent.Text.RemoveThinkTags()
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
var renderPlan = this.GetMarkdownRenderPlan(textContent.Text);
|
||||
<div @ref="this.mathContentContainer" class="chat-math-container">
|
||||
@foreach (var segment in renderPlan.Segments)
|
||||
var renderPlan = this.GetMarkdownRenderPlan(textContent.Text);
|
||||
<div @ref="this.mathContentContainer" class="chat-math-container">
|
||||
@foreach (var segment in renderPlan.Segments)
|
||||
{
|
||||
var segmentContent = segment.GetContent(renderPlan.Source);
|
||||
if (segment.Type is MarkdownRenderSegmentType.MARKDOWN)
|
||||
{
|
||||
var segmentContent = segment.GetContent(renderPlan.Source);
|
||||
if (segment.Type is MarkdownRenderSegmentType.MARKDOWN)
|
||||
{
|
||||
<MudMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MathJaxBlock @key="@segment.RenderKey" Value="@segmentContent" Class="mb-5" />
|
||||
}
|
||||
<MudMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" />
|
||||
}
|
||||
@if (textContent.Sources.Count > 0)
|
||||
else
|
||||
{
|
||||
<MudMarkdown Value="@textContent.Sources.ToMarkdown()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
|
||||
<MathJaxBlock @key="@segment.RenderKey" Value="@segmentContent" Class="mb-5" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (textContent.Sources.Count > 0)
|
||||
{
|
||||
<MudMarkdown Value="@textContent.Sources.ToMarkdown()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (this.Role is ChatRole.AI && !string.IsNullOrWhiteSpace(textContent.ToolRuntimeStatus.Message))
|
||||
{
|
||||
<MudAlert Dense="@true" Severity="Severity.Info" Variant="Variant.Outlined" Class="mt-4">
|
||||
@textContent.ToolRuntimeStatus.Message
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.Services;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
@@ -8,7 +9,7 @@ namespace AIStudio.Chat;
|
||||
/// <summary>
|
||||
/// The UI component for a chat content block, i.e., for any IContent.
|
||||
/// </summary>
|
||||
public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
public partial class ContentBlockComponent : MSGComponentBase
|
||||
{
|
||||
private const string CHAT_MATH_SYNC_FUNCTION = "chatMath.syncContainer";
|
||||
private const string CHAT_MATH_DISPOSE_FUNCTION = "chatMath.disposeContainer";
|
||||
@@ -84,6 +85,19 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
|
||||
[Parameter]
|
||||
public Func<bool> RegenerateEnabled { get; set; } = () => false;
|
||||
|
||||
/// <summary>
|
||||
/// What the export offers, used both as the label of the export button and as the title of
|
||||
/// the save dialog.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only AI blocks can be exported, so this always names something the AI produced. In the chat
|
||||
/// that is its response, whereas in an assistant it is the result, and there the user sees no
|
||||
/// chat at all. Whoever renders this block knows which of the two it is. Null falls back to
|
||||
/// the chat wording.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public string? ExportTitle { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
@@ -94,15 +108,92 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<ContentBlockComponent> Logger { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private PandocAvailabilityService PandocAvailability { get; init; } = null!;
|
||||
|
||||
private bool HideContent { get; set; }
|
||||
private bool hasRenderHash;
|
||||
private int lastRenderHash;
|
||||
private string cachedMarkdownRenderPlanInput = string.Empty;
|
||||
private MarkdownRenderPlan cachedMarkdownRenderPlan = MarkdownRenderPlan.EMPTY;
|
||||
private string cachedMessageTablesInput = string.Empty;
|
||||
private IReadOnlyList<MessageTable> cachedMessageTables = [];
|
||||
private char csvSeparator = ',';
|
||||
private ElementReference mathContentContainer;
|
||||
private string lastMathRenderSignature = string.Empty;
|
||||
private bool hasActiveMathContainer;
|
||||
private bool isDisposed;
|
||||
private bool showToolTrace;
|
||||
private readonly HashSet<int> expandedToolInvocations = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether this block can be exported.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We wait for the stream to finish: half an answer is nothing anybody wants in a document,
|
||||
/// and waiting keeps us from searching for a text which still grows with every token. Only text
|
||||
/// can be completely exported; an image, for example, has no representation our formats could write.
|
||||
/// </remarks>
|
||||
private bool CanExport => this.Content is { InitialRemoteWait: false, IsStreaming: false } && this.Content.TryGetMarkdownText(out _);
|
||||
|
||||
/// <summary>
|
||||
/// The tables this block holds so that the export menu can offer each of them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cached the same way the Markdown render plan is: reading the tables means parsing the whole
|
||||
/// message, and a block re-renders for reasons which have nothing to do with its text, such as
|
||||
/// switching the theme, which would parse every message of a long chat again.
|
||||
/// </remarks>
|
||||
private IReadOnlyList<MessageTable> MessageTables
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!this.Content.TryGetMarkdownText(out var markdown))
|
||||
return [];
|
||||
|
||||
if (ReferenceEquals(this.cachedMessageTablesInput, markdown) || string.Equals(this.cachedMessageTablesInput, markdown, StringComparison.Ordinal))
|
||||
return this.cachedMessageTables;
|
||||
|
||||
this.cachedMessageTablesInput = markdown;
|
||||
this.cachedMessageTables = PlainFileExport.ExtractTables(markdown, this.csvSeparator);
|
||||
return this.cachedMessageTables;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names one table in the export menu.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// With a single table the format alone says everything. As soon as an answer holds more than
|
||||
/// one, the user has to be able to tell them apart: the heading above a table does that, unless
|
||||
/// it is missing or two tables share one, and then we count them.
|
||||
/// </remarks>
|
||||
private string ExportLabel(MessageTable table)
|
||||
{
|
||||
var tables = this.MessageTables;
|
||||
if (tables.Count < 2)
|
||||
return table.Format.ToName();
|
||||
|
||||
var captionIsTelling = !string.IsNullOrWhiteSpace(table.Caption)
|
||||
&& tables.Where(entry => entry.Ordinal != table.Ordinal).All(entry => !string.Equals(entry.Caption, table.Caption, StringComparison.Ordinal));
|
||||
|
||||
//
|
||||
// The caption is the heading the model wrote, so it already carries the language of the
|
||||
// answer and needs no translation of ours. Only the fallback, where we have to count the
|
||||
// tables ourselves, is our own wording.
|
||||
//
|
||||
return captionIsTelling
|
||||
? $"{table.Caption} ({table.Format.ToFileExtension()})"
|
||||
: string.Format(this.T("Table {0} ({1})"), table.Ordinal, table.Format.ToFileExtension());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the export offers, falling back to the chat wording when nobody named it.
|
||||
/// </summary>
|
||||
private string EffectiveExportTitle => this.ExportTitle ?? this.T("Export AI response");
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
@@ -110,6 +201,22 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
{
|
||||
this.RegisterStreamingEvents();
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
//
|
||||
// Which separator a CSV needs depends on the language, and asking for the language means
|
||||
// waiting for the settings. The first render therefore uses the comma we start with; once
|
||||
// we know better, we ask for another render. Nobody can have opened the export menu in
|
||||
// between, so no file is ever written with the wrong separator.
|
||||
//
|
||||
var languagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
|
||||
var separator = CsvWriter.SeparatorFor(languagePlugin.IETFTag);
|
||||
if (separator == this.csvSeparator)
|
||||
return;
|
||||
|
||||
this.csvSeparator = separator;
|
||||
this.cachedMessageTablesInput = string.Empty;
|
||||
this.cachedMessageTables = [];
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
protected override Task OnParametersSetAsync()
|
||||
@@ -199,6 +306,28 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
hash.Add(textValue.Length);
|
||||
hash.Add(textValue.GetHashCode(StringComparison.Ordinal));
|
||||
hash.Add(text.Sources.Count);
|
||||
hash.Add(text.ToolInvocations.Count);
|
||||
hash.Add(text.ToolRuntimeStatus.IsRunning);
|
||||
hash.Add(text.ToolRuntimeStatus.Message);
|
||||
hash.Add(this.showToolTrace);
|
||||
hash.Add(this.expandedToolInvocations.Count);
|
||||
foreach (var expandedInvocation in this.expandedToolInvocations.Order())
|
||||
hash.Add(expandedInvocation);
|
||||
foreach (var invocation in text.ToolInvocations)
|
||||
{
|
||||
hash.Add(invocation.Order);
|
||||
hash.Add(invocation.ToolId);
|
||||
hash.Add(invocation.Status);
|
||||
hash.Add(invocation.StatusMessage);
|
||||
hash.Add(invocation.Result);
|
||||
hash.Add(invocation.JsonResult is not null);
|
||||
hash.Add(invocation.Arguments.Count);
|
||||
foreach (var argument in invocation.Arguments)
|
||||
{
|
||||
hash.Add(argument.Key);
|
||||
hash.Add(argument.Value);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ContentImage image:
|
||||
@@ -214,8 +343,55 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
|
||||
private string CardClasses => $"my-2 rounded-lg {this.Class}";
|
||||
|
||||
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
|
||||
{
|
||||
ToolInvocationTraceStatus.SUCCESS => Color.Success,
|
||||
ToolInvocationTraceStatus.ERROR => Color.Error,
|
||||
ToolInvocationTraceStatus.BLOCKED => Color.Warning,
|
||||
_ => Color.Default,
|
||||
};
|
||||
|
||||
private string GetTraceStatusText(ToolInvocationTrace trace) => trace.Status switch
|
||||
{
|
||||
ToolInvocationTraceStatus.SUCCESS => this.T("Executed"),
|
||||
ToolInvocationTraceStatus.ERROR => this.T("Failed"),
|
||||
ToolInvocationTraceStatus.BLOCKED => this.T("Blocked"),
|
||||
_ => this.T("Unknown"),
|
||||
};
|
||||
|
||||
private IReadOnlyList<ToolInvocationTrace> GetToolInvocations() => this.Content is ContentText textContent
|
||||
? textContent.ToolInvocations.OrderBy(x => x.Order).ToList()
|
||||
: [];
|
||||
|
||||
private string GetToolTraceTooltip()
|
||||
{
|
||||
var invocations = this.GetToolInvocations();
|
||||
return invocations.Count switch
|
||||
{
|
||||
0 => this.T("No tool calls"),
|
||||
1 => string.Format(this.T("Show tool call for {0}"), invocations[0].ToolName),
|
||||
_ => string.Format(this.T("Show {0} tool calls"), invocations.Count),
|
||||
};
|
||||
}
|
||||
|
||||
private void ToggleToolTrace() => this.showToolTrace = !this.showToolTrace;
|
||||
|
||||
private bool IsToolInvocationExpanded(int order) => this.expandedToolInvocations.Contains(order);
|
||||
|
||||
private void ToggleToolInvocation(int order)
|
||||
{
|
||||
if (!this.expandedToolInvocations.Add(order))
|
||||
this.expandedToolInvocations.Remove(order);
|
||||
}
|
||||
|
||||
private string GetToolInvocationResult(ToolInvocationTrace invocation) => string.IsNullOrWhiteSpace(invocation.Result)
|
||||
? this.T("No result")
|
||||
: invocation.Result;
|
||||
|
||||
private MudMarkdownStyling MarkdownStyling => new()
|
||||
{
|
||||
CodeBlock = { Theme = this.CodeColorPalette },
|
||||
@@ -245,7 +421,13 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
if (string.Equals(this.lastMathRenderSignature, mathRenderSignature, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
await this.JsRuntime.InvokeVoidAsync(CHAT_MATH_SYNC_FUNCTION, this.mathContentContainer, mathRenderSignature);
|
||||
//
|
||||
// Remember what the browser shows only when it really got the call: otherwise, a call which was
|
||||
// lost while the connection was down would make us skip the math rendering after the reconnect.
|
||||
//
|
||||
if (!await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, CHAT_MATH_SYNC_FUNCTION, this.mathContentContainer, mathRenderSignature))
|
||||
return;
|
||||
|
||||
this.lastMathRenderSignature = mathRenderSignature;
|
||||
this.hasActiveMathContainer = true;
|
||||
}
|
||||
@@ -258,16 +440,7 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await this.JsRuntime.InvokeVoidAsync(CHAT_MATH_DISPOSE_FUNCTION, this.mathContentContainer);
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, CHAT_MATH_DISPOSE_FUNCTION, this.mathContentContainer);
|
||||
|
||||
this.hasActiveMathContainer = false;
|
||||
this.lastMathRenderSignature = string.Empty;
|
||||
@@ -546,9 +719,47 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
await this.RemoveBlockFunc(this.Content);
|
||||
}
|
||||
|
||||
private async Task ExportToWord()
|
||||
/// <summary>
|
||||
/// Exports the entire message.
|
||||
/// </summary>
|
||||
private async Task ExportDocument(FileExportFormat format)
|
||||
{
|
||||
await PandocExport.ToMicrosoftWord(this.RustService, this.DialogService, T("Export Chat to Microsoft Word"), this.Content);
|
||||
try
|
||||
{
|
||||
//
|
||||
// The format itself knows who writes it, so we do not have to keep a list of formats
|
||||
// here which would fall out of sync with the one in FileExportFormatExtensions.
|
||||
//
|
||||
if (format.UsesPandoc())
|
||||
await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content);
|
||||
else if (this.Content.TryGetMarkdownText(out var markdown))
|
||||
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException e)
|
||||
{
|
||||
await this.ReportUnknownExportFormat(e, format);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports one table out of the message, exactly as the menu offered it.
|
||||
/// </summary>
|
||||
private async Task ExportTable(MessageTable table)
|
||||
{
|
||||
try
|
||||
{
|
||||
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, table.Format, table.Content, table.Caption);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException e)
|
||||
{
|
||||
await this.ReportUnknownExportFormat(e, table.Format);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReportUnknownExportFormat(ArgumentOutOfRangeException exception, FileExportFormat format)
|
||||
{
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Error, string.Format(this.T("Failed to export this message, because the file format '{0}' is unknown."), format)));
|
||||
this.Logger.LogError(exception, "Failed to export the content, because no exporter writes the format {ExportFormat}.", format);
|
||||
}
|
||||
|
||||
private async Task RegenerateBlock()
|
||||
@@ -601,16 +812,24 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
private async Task OpenAttachmentsDialog()
|
||||
{
|
||||
var result = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.Content.FileAttachments.ToHashSet());
|
||||
this.Content.FileAttachments = result.ToList();
|
||||
this.Content.FileAttachments = [.. result];
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
protected override async ValueTask DisposeResourcesAsync()
|
||||
{
|
||||
if (this.isDisposed)
|
||||
return;
|
||||
|
||||
this.isDisposed = true;
|
||||
|
||||
//
|
||||
// Our handlers close over this component, while the content belongs to the chat thread and
|
||||
// outlives us. We only detach what is still ours, though: when this content is streaming
|
||||
// again, another component has registered its own handlers in the meantime.
|
||||
//
|
||||
if (this.Content.StreamingDone == this.AfterStreaming)
|
||||
this.Content.ResetStreamingHandlers();
|
||||
|
||||
await this.DisposeMathContainerIfNeededAsync();
|
||||
this.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,11 @@ public sealed class ContentImage : IContent, IImageSource
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingDone { get; set; } = () => Task.CompletedTask;
|
||||
public Func<Task> StreamingDone { get; set; } = IContent.NO_STREAMING_HANDLER;
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingEvent { get; set; } = () => Task.CompletedTask;
|
||||
public Func<Task> StreamingEvent { get; set; } = IContent.NO_STREAMING_HANDLER;
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<Source> Sources { get; set; } = [];
|
||||
|
||||
@@ -5,6 +5,9 @@ using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.RAG.RAGProcesses;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Security;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
@@ -14,6 +17,7 @@ namespace AIStudio.Chat;
|
||||
public sealed class ContentText : IContent
|
||||
{
|
||||
private static readonly ILogger<ContentText> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ContentText>();
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ContentText).Namespace, nameof(ContentText));
|
||||
|
||||
/// <summary>
|
||||
@@ -34,11 +38,11 @@ public sealed class ContentText : IContent
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingDone { get; set; } = () => Task.CompletedTask;
|
||||
public Func<Task> StreamingDone { get; set; } = IContent.NO_STREAMING_HANDLER;
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingEvent { get; set; } = () => Task.CompletedTask;
|
||||
public Func<Task> StreamingEvent { get; set; } = IContent.NO_STREAMING_HANDLER;
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<Source> Sources { get; set; } = [];
|
||||
@@ -46,6 +50,11 @@ public sealed class ContentText : IContent
|
||||
/// <inheritdoc />
|
||||
public List<FileAttachment> FileAttachments { get; set; } = [];
|
||||
|
||||
public List<ToolInvocationTrace> ToolInvocations { get; set; } = [];
|
||||
|
||||
[JsonIgnore]
|
||||
public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default)
|
||||
{
|
||||
@@ -248,6 +257,20 @@ public sealed class ContentText : IContent
|
||||
IsStreaming = this.IsStreaming,
|
||||
Sources = [..this.Sources],
|
||||
FileAttachments = [..this.FileAttachments],
|
||||
ToolInvocations = [..this.ToolInvocations.Select(x => new ToolInvocationTrace
|
||||
{
|
||||
Order = x.Order,
|
||||
ToolId = x.ToolId,
|
||||
ToolName = x.ToolName,
|
||||
ToolIcon = x.ToolIcon,
|
||||
ToolCallId = x.ToolCallId,
|
||||
Status = x.Status,
|
||||
WasExecuted = x.WasExecuted,
|
||||
StatusMessage = x.StatusMessage,
|
||||
Arguments = new Dictionary<string, string>(x.Arguments, StringComparer.Ordinal),
|
||||
Result = x.Result,
|
||||
JsonResult = x.JsonResult?.DeepClone(),
|
||||
})],
|
||||
};
|
||||
|
||||
#endregion
|
||||
@@ -266,50 +289,113 @@ public sealed class ContentText : IContent
|
||||
// Get the list of existing documents:
|
||||
var existingDocuments = normalizedAttachments.Where(x => x.Type is FileAttachmentType.DOCUMENT && x.Exists).ToList();
|
||||
|
||||
// Log warning for missing files:
|
||||
//
|
||||
// Report missing files. We tell the user about them instead of only logging: on a
|
||||
// network drive, a file which is temporarily unreachable looks exactly like a deleted
|
||||
// one, and silently dropping it would let the AI answer without that document.
|
||||
//
|
||||
var missingDocuments = normalizedAttachments.Except(existingDocuments).Where(x => x.Type is FileAttachmentType.DOCUMENT).ToList();
|
||||
if (missingDocuments.Count > 0)
|
||||
foreach (var missingDocument in missingDocuments)
|
||||
LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingDocument}'.", missingDocument.FilePath);
|
||||
|
||||
foreach (var missingDocument in missingDocuments)
|
||||
{
|
||||
LOGGER.LogWarning("File attachment no longer exists and will be skipped: '{MissingDocument}'.", missingDocument.FilePath);
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.FindInPage, string.Format(TB("The file '{0}' is currently not available and was not sent."), missingDocument.FileName)));
|
||||
}
|
||||
|
||||
// Only proceed if there are existing, allowed documents:
|
||||
if (existingDocuments.Count > 0)
|
||||
{
|
||||
// Check Pandoc availability once before processing file attachments
|
||||
var pandocState = await Pandoc.CheckAvailabilityAsync(Program.RUST_SERVICE, showMessages: true, showSuccessMessage: false);
|
||||
//
|
||||
// Pandoc is only needed for the few formats we convert with it. PDFs, text files,
|
||||
// spreadsheets, and presentations are read by the runtime itself, so a missing
|
||||
// Pandoc installation must not stop them.
|
||||
//
|
||||
var pandocIsUsable = true;
|
||||
if (existingDocuments.Any(document => FileTypes.RequiresPandoc(document.FilePath)))
|
||||
{
|
||||
var pandocState = await Pandoc.CheckAvailabilityAsync(Program.RUST_SERVICE, showMessages: true, showSuccessMessage: false);
|
||||
pandocIsUsable = pandocState is { IsAvailable: true, CheckWasSuccessful: true };
|
||||
|
||||
if (!pandocState.IsAvailable)
|
||||
LOGGER.LogWarning("File attachments could not be processed because Pandoc is not available.");
|
||||
else if (!pandocState.CheckWasSuccessful)
|
||||
LOGGER.LogWarning("File attachments could not be processed because the Pandoc version check failed.");
|
||||
else
|
||||
if (!pandocState.IsAvailable)
|
||||
LOGGER.LogWarning("File attachments which need Pandoc could not be processed because Pandoc is not available.");
|
||||
else if (!pandocState.CheckWasSuccessful)
|
||||
LOGGER.LogWarning("File attachments which need Pandoc could not be processed because the Pandoc version check failed.");
|
||||
}
|
||||
|
||||
//
|
||||
// One report for the whole batch: attaching 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();
|
||||
|
||||
//
|
||||
// The document blocks are collected separately, so we only announce attached
|
||||
// files when at least one of them could actually be read. Announcing files we
|
||||
// then hand over as empty blocks makes the AI answer about an empty document.
|
||||
//
|
||||
var documentBlocks = new StringBuilder();
|
||||
foreach(var document in existingDocuments)
|
||||
{
|
||||
if (document.IsForbidden)
|
||||
{
|
||||
LOGGER.LogWarning("File attachment '{FilePath}' has a forbidden file type and will be skipped.", document.FilePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pandocIsUsable && FileTypes.RequiresPandoc(document.FilePath))
|
||||
{
|
||||
LOGGER.LogWarning("The file attachment '{FilePath}' needs Pandoc and will be skipped.", document.FilePath);
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Description, FileExtractionErrorCode.PANDOC_UNAVAILABLE.ToUserMessage(document.FileName)));
|
||||
continue;
|
||||
}
|
||||
|
||||
var extraction = await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue);
|
||||
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);
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Description, extraction.ToUserMessage(document.FileName)));
|
||||
continue;
|
||||
}
|
||||
|
||||
//
|
||||
// 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)));
|
||||
}
|
||||
|
||||
documentBlocks.AppendLine();
|
||||
documentBlocks.AppendLine("---------------------------------------");
|
||||
documentBlocks.AppendLine($"File path: {document.FilePath}");
|
||||
documentBlocks.AppendLine("File content:");
|
||||
documentBlocks.AppendLine("````");
|
||||
documentBlocks.AppendLine(extraction.Content);
|
||||
documentBlocks.AppendLine("````");
|
||||
}
|
||||
|
||||
if (documentBlocks.Length > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("The following files are attached to this message:");
|
||||
foreach(var document in existingDocuments)
|
||||
{
|
||||
if (document.IsForbidden)
|
||||
{
|
||||
LOGGER.LogWarning("File attachment '{FilePath}' has a forbidden file type and will be skipped.", document.FilePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---------------------------------------");
|
||||
sb.AppendLine($"File path: {document.FilePath}");
|
||||
sb.AppendLine("File content:");
|
||||
sb.AppendLine("````");
|
||||
sb.AppendLine(await Program.RUST_SERVICE.ReadArbitraryFileData(document.FilePath, int.MaxValue));
|
||||
sb.AppendLine("````");
|
||||
}
|
||||
|
||||
var numImages = normalizedAttachments.Count(x => x is { IsImage: true, Exists: true });
|
||||
if (numImages > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Additionally, there are {numImages} image file(s) attached to this message. ");
|
||||
sb.AppendLine("Please consider them as part of the message content and use them to answer accordingly.");
|
||||
}
|
||||
sb.Append(documentBlocks);
|
||||
}
|
||||
|
||||
var numImages = normalizedAttachments.Count(x => x is { IsImage: true, Exists: true });
|
||||
if (numImages > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Additionally, there are {numImages} image file(s) attached to this message. ");
|
||||
sb.AppendLine("Please consider them as part of the message content and use them to answer accordingly.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,4 +407,4 @@ public sealed class ContentText : IContent
|
||||
/// The text content.
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ public interface IContent
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingDone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// What a content does while nobody listens to its stream: nothing.
|
||||
/// </summary>
|
||||
public static readonly Func<Task> NO_STREAMING_HANDLER = () => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// The provided sources, if any.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
public static class IContentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Detaches whoever listens to the stream of this content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The streaming handlers are closures over the component which registered them. A content
|
||||
/// object belongs to the chat thread and therefore outlives every component which renders it,
|
||||
/// so handlers left behind would keep those components alive for as long as the thread exists.
|
||||
/// Whoever registers a handler calls this when it is no longer needed.
|
||||
/// </remarks>
|
||||
/// <param name="content">The content whose streaming handlers you want to detach.</param>
|
||||
public static void ResetStreamingHandlers(this IContent content)
|
||||
{
|
||||
content.StreamingEvent = IContent.NO_STREAMING_HANDLER;
|
||||
content.StreamingDone = IContent.NO_STREAMING_HANDLER;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads this content as the Markdown text the AI produced.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only text content carries Markdown. Everything else, an image for example, has no text
|
||||
/// representation at all, which is why this reports failure instead of returning a placeholder:
|
||||
/// a caller which writes files must not put an excuse into the file it writes.
|
||||
/// </remarks>
|
||||
/// <param name="content">The content to read.</param>
|
||||
/// <param name="markdown">The Markdown text, or an empty string when there is none.</param>
|
||||
/// <returns>True, when this content carries Markdown text.</returns>
|
||||
public static bool TryGetMarkdownText(this IContent content, out string markdown)
|
||||
{
|
||||
if (content is ContentText text)
|
||||
{
|
||||
markdown = text.Text;
|
||||
return true;
|
||||
}
|
||||
|
||||
markdown = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user