Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/MindWork AI Studio/Assistants/AssistantBase.razor
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@

@if (this.ShowResult && !this.ShowEntireChatThread && this.ResultingContentBlock?.Content != null)
{
<ContentBlockComponent Role="@(this.ResultingContentBlock.Role)" Type="@(this.ResultingContentBlock.ContentType)" Time="@(this.ResultingContentBlock.Time)" Content="@this.ResultingContentBlock.Content" ExportTitle="@TB("Export result")"/>
<ContentBlockComponent Role="@(this.ResultingContentBlock.Role)" Type="@(this.ResultingContentBlock.ContentType)" Time="@(this.ResultingContentBlock.Time)" Content="@this.ResultingContentBlock.Content" ExportTitle="@TB("Export result")" ExportFileName="@this.ExportFileName"/>
}

@if(this.ShowResult && this.ShowEntireChatThread && this.ChatThread is not null)
Expand All @@ -88,7 +88,7 @@
{
@if (block is { HideFromUser: false, Content: not null })
{
<ContentBlockComponent Role="@block.Role" Type="@block.ContentType" Time="@block.Time" Content="@block.Content" ExportTitle="@TB("Export result")"/>
<ContentBlockComponent Role="@block.Role" Type="@block.ContentType" Time="@block.Time" Content="@block.Content" ExportTitle="@TB("Export result")" ExportFileName="@this.ExportFileName"/>
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions app/MindWork AI Studio/Assistants/AssistantBase.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
_ => string.Empty,
};

/// <summary>
/// What an export of the result is named after, which the save dialog suggests as file name.
/// An assistant whose result is about something more specific than the assistant itself names that.
/// </summary>
protected virtual string ExportFileName => this.Title;

protected abstract void ResetForm();

protected abstract bool MightPreselectValues();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
protected override IReadOnlySet<string> AssistantManagedToolIds => this.policyAllowedToolIds;

protected override string Title => T("Document Analysis Assistant");


/// <summary>
/// An analysis is named after its policy, which says far more than the name of the assistant.
/// </summary>
protected override string ExportFileName => string.IsNullOrWhiteSpace(this.analyzedPolicyName) ? this.Title : this.analyzedPolicyName;

protected override string Description => T("The document analysis assistant helps you to analyze and extract information from documents based on predefined policies. You can create, edit, and manage document analysis policies that define how documents should be processed and what information should be extracted. Some policies might be protected by your organization and cannot be modified or deleted.");

protected override string SystemPrompt =>
Expand Down Expand Up @@ -368,6 +373,15 @@ private bool ApplyFormToPolicy(DataDocumentAnalysisPolicy policy, bool force)
private string policyPreselectedProviderId = string.Empty;
private ProfilePreselection policyPreselectedProfile = ProfilePreselection.NoProfile;
private HashSet<FileAttachment> loadedDocumentPaths = [];

/// <summary>
/// The name of the policy the result on screen was produced with.
/// </summary>
/// <remarks>
/// Switching to another policy keeps the result, so the selected policy may no longer be the
/// one behind it. An export has to be named after the analysis it holds.
/// </remarks>
private string analyzedPolicyName = string.Empty;
private readonly List<ConfigurationSelectData<string>> availableLLMProviders = new();
private static readonly AssistantSessionStateKey<DataDocumentAnalysisPolicy?> SELECTED_POLICY_STATE_KEY = new(nameof(selectedPolicy));
private static readonly AssistantSessionStateKey<bool> POLICY_IS_PROTECTED_STATE_KEY = new(nameof(policyIsProtected));
Expand All @@ -381,6 +395,7 @@ private bool ApplyFormToPolicy(DataDocumentAnalysisPolicy policy, bool force)
private static readonly AssistantSessionStateKey<string> POLICY_PRESELECTED_PROVIDER_ID_STATE_KEY = new(nameof(policyPreselectedProviderId));
private static readonly AssistantSessionStateKey<ProfilePreselection> POLICY_PRESELECTED_PROFILE_STATE_KEY = new(nameof(policyPreselectedProfile));
private static readonly AssistantSessionStateKey<HashSet<FileAttachment>> LOADED_DOCUMENT_PATHS_STATE_KEY = new(nameof(loadedDocumentPaths));
private static readonly AssistantSessionStateKey<string> ANALYZED_POLICY_NAME_STATE_KEY = new(nameof(analyzedPolicyName));
private static readonly AssistantSessionStateKey<List<ConfigurationSelectData<string>>> AVAILABLE_LLM_PROVIDERS_STATE_KEY = new(nameof(availableLLMProviders));

/// <inheritdoc />
Expand All @@ -398,6 +413,7 @@ protected override void CaptureCustomAssistantSessionState(AssistantSessionState
state.Set(POLICY_PRESELECTED_PROVIDER_ID_STATE_KEY, this.policyPreselectedProviderId);
state.Set(POLICY_PRESELECTED_PROFILE_STATE_KEY, this.policyPreselectedProfile);
state.SetHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths);
state.Set(ANALYZED_POLICY_NAME_STATE_KEY, this.analyzedPolicyName);
state.SetList(AVAILABLE_LLM_PROVIDERS_STATE_KEY, this.availableLLMProviders);
}

Expand All @@ -420,6 +436,7 @@ protected override void RestoreCustomAssistantSessionState(AssistantSessionState
state.Restore(POLICY_PRESELECTED_PROVIDER_ID_STATE_KEY, value => this.policyPreselectedProviderId = value);
state.Restore(POLICY_PRESELECTED_PROFILE_STATE_KEY, value => this.policyPreselectedProfile = value);
state.RestoreHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths);
state.Restore(ANALYZED_POLICY_NAME_STATE_KEY, value => this.analyzedPolicyName = value);
state.RestoreList(AVAILABLE_LLM_PROVIDERS_STATE_KEY, this.availableLLMProviders);
}

Expand Down Expand Up @@ -926,7 +943,8 @@ private async Task Analyze()

this.CreateChatThread();
this.ChatThread!.IncludeDateTime = true;

this.analyzedPolicyName = this.selectedPolicy?.PolicyName ?? string.Empty;

var userRequest = this.AddUserRequest(
await this.PromptLoadDocumentsContent(),
hideContentFromUser: true,
Expand Down
6 changes: 6 additions & 0 deletions app/MindWork AI Studio/Assistants/I18N/allTexts.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3295,6 +3295,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1820166585"] = "Yes, re
-- Number of sources
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1848978959"] = "Number of sources"

-- Code block {0} ({1})
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1934297017"] = "Code block {0} ({1})"

-- Show {0} tool calls
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1981771421"] = "Show {0} tool calls"

Expand Down Expand Up @@ -3343,6 +3346,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3587744975"] = "Regener
-- Blocked
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked"

-- Code block: {0}
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3840086915"] = "Code block: {0}"

-- Do you really want to regenerate this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?"

Expand Down
6 changes: 3 additions & 3 deletions app/MindWork AI Studio/Chat/ContentBlockComponent.razor
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,12 @@
{
<MudMenuItem OnClick="@(() => this.ExportDocument(documentFormat))" Icon="@documentFormat.ToIcon()" Label="@documentFormat.ToName()"/>
}
@if (this.MessageTables.Count > 0)
@if (this.MessageFiles.Count > 0)
{
<MudDivider/>
@foreach (var messageTable in this.MessageTables)
@foreach (var messageFile in this.MessageFiles)
{
<MudMenuItem OnClick="@(() => this.ExportTable(messageTable))" Icon="@messageTable.Format.ToIcon()" Label="@this.ExportLabel(messageTable)"/>
<MudMenuItem OnClick="@(() => this.ExportFile(messageFile))" Icon="@messageFile.Format.ToIcon()" Label="@this.ExportLabel(messageFile)"/>
}
}
<MudDivider/>
Expand Down
88 changes: 55 additions & 33 deletions app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ public partial class ContentBlockComponent : MSGComponentBase
/// </remarks>
[Parameter]
public string? ExportTitle { get; set; }

/// <summary>
/// What an export of this block is named after, which the save dialog suggests as file name.
/// </summary>
/// <remarks>
/// In the chat that is the name of the chat, in an assistant whatever the assistant says its
/// result is about. Whoever renders this block knows which of the two it is. A table or a code
/// block with a heading above it is named after that heading instead. Null falls back to a
/// generic name.
/// </remarks>
[Parameter]
public string? ExportFileName { get; set; }

[Inject]
private IDialogService DialogService { get; init; } = null!;
Expand All @@ -125,8 +137,8 @@ public partial class ContentBlockComponent : MSGComponentBase
private int lastRenderHash;
private string cachedMarkdownRenderPlanInput = string.Empty;
private MarkdownRenderPlan cachedMarkdownRenderPlan = MarkdownRenderPlan.EMPTY;
private string cachedMessageTablesInput = string.Empty;
private IReadOnlyList<MessageTable> cachedMessageTables = [];
private string cachedMessageFilesInput = string.Empty;
private IReadOnlyList<MessageFile> cachedMessageFiles = [];
private char csvSeparator = ',';
private ElementReference mathContentContainer;
private SourcesList? sourcesList;
Expand All @@ -147,54 +159,62 @@ public partial class ContentBlockComponent : MSGComponentBase
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.
/// The files this block holds, tables and code blocks, 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
/// Cached the same way the Markdown render plan is: reading the files 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
private IReadOnlyList<MessageFile> MessageFiles
{
get
{
if (!this.Content.TryGetMarkdownText(out var markdown))
return [];

if (ReferenceEquals(this.cachedMessageTablesInput, markdown) || string.Equals(this.cachedMessageTablesInput, markdown, StringComparison.Ordinal))
return this.cachedMessageTables;
if (ReferenceEquals(this.cachedMessageFilesInput, markdown) || string.Equals(this.cachedMessageFilesInput, markdown, StringComparison.Ordinal))
return this.cachedMessageFiles;

this.cachedMessageTablesInput = markdown;
this.cachedMessageTables = PlainFileExport.ExtractTables(markdown, this.csvSeparator);
return this.cachedMessageTables;
this.cachedMessageFilesInput = markdown;
this.cachedMessageFiles = PlainFileExport.ExtractFiles(markdown, this.csvSeparator);
return this.cachedMessageFiles;
}
}

/// <summary>
/// Names one table in the export menu.
/// Names one file 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.
/// Tables and code blocks are named apart, just as they are counted apart. With a single file of
/// its kind 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 file does that, unless it is missing
/// or two files of the kind share one, and then we count them. A code block always says that it
/// is one, because the menu offers the entire answer as a web page or a LaTeX document right
/// below, and the two entries must not read alike.
/// </remarks>
private string ExportLabel(MessageTable table)
private string ExportLabel(MessageFile file)
{
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));
var isTable = file.Format.IsTabular();
var filesOfItsKind = this.MessageFiles.Where(entry => entry.Format.IsTabular() == isTable).ToList();
var extension = file.Format.ToFileExtension();

//
// 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.
// files ourselves, is our own wording.
//
return captionIsTelling
? $"{table.Caption} ({table.Format.ToFileExtension()})"
: string.Format(this.T("Table {0} ({1})"), table.Ordinal, table.Format.ToFileExtension());
string name;
if (filesOfItsKind.Count < 2)
name = file.Format.ToName();
else if (!string.IsNullOrWhiteSpace(file.Caption) && filesOfItsKind.Count(entry => string.Equals(entry.Caption, file.Caption, StringComparison.Ordinal)) is 1)
name = $"{file.Caption} ({extension})";
else
return isTable
? string.Format(T("Table {0} ({1})"), file.Ordinal, extension)
: string.Format(T("Code block {0} ({1})"), file.Ordinal, extension);

return isTable ? name : string.Format(T("Code block: {0}"), name);
}

/// <summary>
Expand All @@ -221,8 +241,8 @@ protected override async Task OnInitializedAsync()
return;

this.csvSeparator = separator;
this.cachedMessageTablesInput = string.Empty;
this.cachedMessageTables = [];
this.cachedMessageFilesInput = string.Empty;
this.cachedMessageFiles = [];
await this.InvokeAsync(this.StateHasChanged);
}

Expand Down Expand Up @@ -738,9 +758,9 @@ private async Task ExportDocument(FileExportFormat format)
// 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);
await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content, this.ExportFileName);
else if (this.Content.TryGetExportMarkdown(out var markdown))
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown);
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown, this.ExportFileName);
}
catch (ArgumentOutOfRangeException e)
{
Expand All @@ -749,17 +769,19 @@ private async Task ExportDocument(FileExportFormat format)
}

/// <summary>
/// Exports one table out of the message, exactly as the menu offered it.
/// Exports one file out of the message, along with the sources the answer rests on wherever its
/// format has room for them.
/// </summary>
private async Task ExportTable(MessageTable table)
private async Task ExportFile(MessageFile file)
{
try
{
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, table.Format, table.Content, table.Caption);
var fileName = string.IsNullOrWhiteSpace(file.Caption) ? this.ExportFileName : file.Caption;
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, file.Format, this.Content.ToExportContent(file), fileName);
}
catch (ArgumentOutOfRangeException e)
{
await this.ReportUnknownExportFormat(e, table.Format);
await this.ReportUnknownExportFormat(e, file.Format);
}
}

Expand Down
Loading
Loading