mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-24 05:33:36 +00:00
Improved exports (#993)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
parent
e9ebc02a96
commit
fda42ac24b
@ -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)
|
||||
@ -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"/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 =>
|
||||
@ -368,6 +373,15 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
||||
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));
|
||||
@ -381,6 +395,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
||||
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 />
|
||||
@ -398,6 +413,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
||||
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);
|
||||
}
|
||||
|
||||
@ -420,6 +436,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
||||
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);
|
||||
}
|
||||
|
||||
@ -926,7 +943,8 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
||||
|
||||
this.CreateChatThread();
|
||||
this.ChatThread!.IncludeDateTime = true;
|
||||
|
||||
this.analyzedPolicyName = this.selectedPolicy?.PolicyName ?? string.Empty;
|
||||
|
||||
var userRequest = this.AddUserRequest(
|
||||
await this.PromptLoadDocumentsContent(),
|
||||
hideContentFromUser: true,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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?"
|
||||
|
||||
|
||||
@ -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/>
|
||||
|
||||
@ -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!;
|
||||
@ -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;
|
||||
@ -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>
|
||||
@ -221,8 +241,8 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
return;
|
||||
|
||||
this.csvSeparator = separator;
|
||||
this.cachedMessageTablesInput = string.Empty;
|
||||
this.cachedMessageTables = [];
|
||||
this.cachedMessageFilesInput = string.Empty;
|
||||
this.cachedMessageFiles = [];
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
@ -738,9 +758,9 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
// 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)
|
||||
{
|
||||
@ -749,17 +769,19 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -67,26 +67,65 @@ public static class IContentExtensions
|
||||
return false;
|
||||
}
|
||||
|
||||
var answer = text.Text.Trim();
|
||||
var sources = text.Sources.ToExportMarkdown(keepPageAnchors);
|
||||
if (sources.Length == 0)
|
||||
{
|
||||
markdown = answer;
|
||||
return true;
|
||||
}
|
||||
markdown = AppendSources(text.Text.Trim(), text.Sources.ToExportMarkdown(keepPageAnchors));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (answer.Length == 0)
|
||||
{
|
||||
markdown = sources;
|
||||
return true;
|
||||
}
|
||||
/// <summary>
|
||||
/// Reads one file out of this content the way it leaves AI Studio, together with the sources
|
||||
/// the answer rests on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A code block saved on its own came out of the same answer, so it rests on the same sources
|
||||
/// and takes them along. How depends on the format. Markdown is what the source list is written
|
||||
/// in, so a Markdown text gets it just as the entire answer does. A web page or a LaTeX document
|
||||
/// gets it as a comment at its end: anything visible would have to be woven into markup the
|
||||
/// model wrote. A fragment has no body to put it in, a page may hide whatever lies outside its
|
||||
/// layout, and one underscore in a title is enough to stop a LaTeX run. A comment breaks
|
||||
/// neither, and whoever opens the file finds it. A table gets no sources at all, since it has
|
||||
/// no column a link list would fit into.
|
||||
///
|
||||
/// Apart from that, the file is what the model wrote, scripts of a web page included. Saving it
|
||||
/// is what the user chose to do; the chat still never renders it.
|
||||
/// </remarks>
|
||||
/// <param name="content">The content the file was found in.</param>
|
||||
/// <param name="file">The file, as PlainFileExport.ExtractFiles read it out of this content.</param>
|
||||
/// <returns>The content of the file to write.</returns>
|
||||
public static string ToExportContent(this IContent content, MessageFile file)
|
||||
{
|
||||
if (file.Format.IsTabular())
|
||||
return file.Content;
|
||||
|
||||
var sources = content.Sources.ToExportMarkdown(file.Format.FollowsPageAnchors());
|
||||
if (file.Format is FileExportFormat.MARKDOWN)
|
||||
return AppendSources(file.Content, sources);
|
||||
|
||||
if (sources.Length is 0 || !file.Format.TryToComment(sources, out var comment))
|
||||
return file.Content;
|
||||
|
||||
return $"{file.Content}{Environment.NewLine}{Environment.NewLine}{comment}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the source list below a Markdown text.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The Markdown text.</param>
|
||||
/// <param name="sources">The source list as SourceExtensions.ToExportMarkdown writes it, or an
|
||||
/// empty string when there are no sources.</param>
|
||||
/// <returns>The text followed by its sources.</returns>
|
||||
private static string AppendSources(string markdown, string sources)
|
||||
{
|
||||
if (sources.Length == 0)
|
||||
return markdown;
|
||||
|
||||
if (markdown.Length == 0)
|
||||
return sources;
|
||||
|
||||
//
|
||||
// The blank line is not cosmetic: it ends a paragraph, a list, a table, or a block quote, so
|
||||
// that the heading of the source list stands on its own instead of being pulled into the
|
||||
// last block of the answer.
|
||||
//
|
||||
markdown = $"{Markdown.CloseOpenCodeFence(answer)}{Environment.NewLine}{Environment.NewLine}{sources}";
|
||||
return true;
|
||||
return $"{Markdown.CloseOpenCodeFence(markdown)}{Environment.NewLine}{Environment.NewLine}{sources}";
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,7 @@
|
||||
Type="@block.ContentType"
|
||||
Time="@block.Time"
|
||||
Content="@block.Content"
|
||||
ExportFileName="@this.ChatThread.Name"
|
||||
RemoveBlockFunc="@this.RemoveBlock"
|
||||
IsLastContentBlock="@isLastBlock"
|
||||
IsSecondToLastBlock="@isSecondLastBlock"
|
||||
|
||||
@ -3297,6 +3297,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1820166585"] = "Ja, ent
|
||||
-- Number of sources
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1848978959"] = "Anzahl der Quellen"
|
||||
|
||||
-- Code block {0} ({1})
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1934297017"] = "Codeblock {0} ({1})"
|
||||
|
||||
-- Show {0} tool calls
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1981771421"] = "{0} Werkzeugaufrufe anzeigen"
|
||||
|
||||
@ -3345,6 +3348,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3587744975"] = "Neu gen
|
||||
-- Blocked
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blockiert"
|
||||
|
||||
-- Code block: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3840086915"] = "Codeblock: {0}"
|
||||
|
||||
-- Do you really want to regenerate this message?
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Möchten Sie diese Nachricht wirklich neu generieren?"
|
||||
|
||||
|
||||
@ -3297,6 +3297,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"
|
||||
|
||||
@ -3345,6 +3348,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?"
|
||||
|
||||
|
||||
@ -103,6 +103,57 @@ public static class FileExportFormatExtensions
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Reads which format a model means when it names a language behind the opening fence of a
|
||||
/// code block.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A model which answers with a finished file puts it into a code block and names its language,
|
||||
/// as in ```html. Models do not agree on the spelling, so we accept the usual names of a format
|
||||
/// in any case. Only formats which are plain text appear here: a code block holds text, never
|
||||
/// a Word document.
|
||||
/// </remarks>
|
||||
/// <param name="language">The language behind the opening fence, as Markdig reads it into
|
||||
/// FencedCodeBlock.Info.</param>
|
||||
/// <param name="format">The format the language names, or NONE when it names none of ours.</param>
|
||||
/// <returns>True, when the language names a format AI Studio writes.</returns>
|
||||
public static bool TryFromCodeFenceLanguage(string? language, out FileExportFormat format)
|
||||
{
|
||||
format = language?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"html" => FileExportFormat.HTML,
|
||||
"latex" or "tex" => FileExportFormat.LATEX,
|
||||
"markdown" or "md" => FileExportFormat.MARKDOWN,
|
||||
"csv" => FileExportFormat.CSV,
|
||||
"tsv" => FileExportFormat.TSV,
|
||||
|
||||
_ => FileExportFormat.NONE,
|
||||
};
|
||||
|
||||
return format is not FileExportFormat.NONE;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the format holds a table rather than a text.
|
||||
/// </summary>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <returns>True for the formats a spreadsheet opens.</returns>
|
||||
public static bool IsTabular(this FileExportFormat format) => format is FileExportFormat.CSV or FileExportFormat.TSV;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a file of the format is plain text, which AI Studio writes as it is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// That holds for a web page and a LaTeX document as well, even though an entire answer needs
|
||||
/// Pandoc to become one: the answer is Markdown, whereas a page the model wrote is a finished
|
||||
/// file already. A Word or an OpenDocument file is an archive, and only Pandoc produces one. The
|
||||
/// list is spelled out on purpose, so a format added later counts as plain text only once
|
||||
/// somebody says so.
|
||||
/// </remarks>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <returns>True, when a text written as it is makes a valid file of the format.</returns>
|
||||
public static bool IsPlainText(this FileExportFormat format) => format is FileExportFormat.LATEX or FileExportFormat.MARKDOWN or FileExportFormat.HTML or FileExportFormat.CSV or FileExportFormat.TSV;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the file name the save dialog starts with.
|
||||
/// </summary>
|
||||
@ -204,6 +255,42 @@ public static class FileExportFormatExtensions
|
||||
_ => WITHOUT_BYTE_ORDER_MARK,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a text into a comment of the format: whoever opens the file in an editor reads it,
|
||||
/// while a browser or a LaTeX run skips it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A comment in HTML, and so in Markdown, ends at the first --> it holds, and a browser takes
|
||||
/// --!> for the same; the rest of the text would spill onto the page from there. The title of
|
||||
/// a web page may hold either, so a space goes in before the bracket, which keeps the text
|
||||
/// readable and ends nothing. Every other pair of dashes stays, because a web address may carry
|
||||
/// one, as in the xn-- of a domain with an umlaut. A LaTeX comment has no end to watch for: it
|
||||
/// runs to the end of its line, so every line starts one.
|
||||
/// </remarks>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <param name="text">The text to put into the comment.</param>
|
||||
/// <param name="comment">The comment, or an empty string when the format has none.</param>
|
||||
/// <returns>True, when the format knows comments.</returns>
|
||||
public static bool TryToComment(this FileExportFormat format, string text, out string comment)
|
||||
{
|
||||
var lines = text.TrimEnd().ReplaceLineEndings("\n").Split('\n');
|
||||
switch (format)
|
||||
{
|
||||
case FileExportFormat.HTML or FileExportFormat.MARKDOWN:
|
||||
var commentText = string.Join(Environment.NewLine, lines).Replace("-->", "-- >").Replace("--!>", "--! >");
|
||||
comment = $"<!--{Environment.NewLine}{commentText}{Environment.NewLine}-->";
|
||||
return true;
|
||||
|
||||
case FileExportFormat.LATEX:
|
||||
comment = string.Join(Environment.NewLine, lines.Select(line => line.Length is 0 ? "%" : $"% {line}"));
|
||||
return true;
|
||||
|
||||
default:
|
||||
comment = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a link into a local file may name the page it points at.
|
||||
/// </summary>
|
||||
@ -224,6 +311,19 @@ public static class FileExportFormatExtensions
|
||||
_ => true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether Pandoc has to be told the title of a document in the format.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A web page shows its title in the browser tab. Without one, Pandoc names the page after its
|
||||
/// input file, which is a temporary file of ours with a random name. Word and OpenDocument show
|
||||
/// no such title, and handed one anyway, they keep it as a document property nobody asked for;
|
||||
/// verified with Pandoc 3.8.3 on 2026-09-23. LaTeX ignores it.
|
||||
/// </remarks>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <returns>True, when a document of this format needs a title besides its content.</returns>
|
||||
public static bool NeedsPageTitle(this FileExportFormat format) => format is FileExportFormat.HTML;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name Pandoc knows the format by.
|
||||
/// </summary>
|
||||
|
||||
14
app/MindWork AI Studio/Tools/MessageFile.cs
Normal file
14
app/MindWork AI Studio/Tools/MessageFile.cs
Normal file
@ -0,0 +1,14 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// A file found in a message, ready to be written: a table the model wrote, or a code block the
|
||||
/// model marked as a format we write.
|
||||
/// </summary>
|
||||
/// <param name="Ordinal">Which table or which code block of the message this is, counting from one
|
||||
/// within its kind; the format tells the two kinds apart, see FileExportFormatExtensions.IsTabular.
|
||||
/// This is what tells two files of one kind apart even when they carry the same heading.</param>
|
||||
/// <param name="Caption">What the file is about: the heading above it, or else the first column
|
||||
/// heading of a table. Empty for a code block without a heading above it.</param>
|
||||
/// <param name="Format">The format this content is written as.</param>
|
||||
/// <param name="Content">The finished file content.</param>
|
||||
public sealed record MessageFile(int Ordinal, string Caption, FileExportFormat Format, string Content);
|
||||
@ -1,12 +0,0 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// A table found in a message, ready to be written to a file.
|
||||
/// </summary>
|
||||
/// <param name="Ordinal">Which table of the message this is, counting from one. The same table
|
||||
/// appears once per format we offer for it, so this is what tells two tables apart even when they
|
||||
/// carry the same heading.</param>
|
||||
/// <param name="Caption">What the table is about, taken from its first column heading.</param>
|
||||
/// <param name="Format">The format this content is written as.</param>
|
||||
/// <param name="Content">The finished file content.</param>
|
||||
public sealed record MessageTable(int Ordinal, string Caption, FileExportFormat Format, string Content);
|
||||
@ -43,14 +43,23 @@ public static class PandocExport
|
||||
await File.WriteAllTextAsync(tempMarkdownFilePath, markdownText, new UTF8Encoding(false), token);
|
||||
|
||||
// Call Pandoc to create the document:
|
||||
var pandoc = await PandocProcessBuilder
|
||||
var pandocBuilder = PandocProcessBuilder
|
||||
.Create()
|
||||
.UseStandaloneMode()
|
||||
.WithInputFormat("gfm+emoji+tex_math_dollars")
|
||||
.WithOutputFormat(format.ToPandocOutputFormat())
|
||||
.WithOutputFile(targetFilePath)
|
||||
.WithInputFile(tempMarkdownFilePath)
|
||||
.BuildAsync(rustService);
|
||||
.WithInputFile(tempMarkdownFilePath);
|
||||
|
||||
//
|
||||
// The document is named after the file it is written to. Set as metadata, the name
|
||||
// reaches the page as a string which Pandoc escapes; only a file named true or false
|
||||
// is read as a switch and keeps the temporary name.
|
||||
//
|
||||
if (format.NeedsPageTitle())
|
||||
pandocBuilder.AddArgument("-M").AddArgument($"pagetitle={Path.GetFileNameWithoutExtension(targetFilePath)}");
|
||||
|
||||
var pandoc = await pandocBuilder.BuildAsync(rustService);
|
||||
|
||||
using var process = Process.Start(pandoc.StartInfo);
|
||||
if (process is null)
|
||||
@ -108,8 +117,10 @@ public static class PandocExport
|
||||
/// looking at, a chat message or the result of an assistant, so the caller names it.</param>
|
||||
/// <param name="format">The format to write. Must be a format which uses Pandoc.</param>
|
||||
/// <param name="markdownContent">The content to export.</param>
|
||||
/// <param name="fileName">What the document is about, used to suggest a name in the save dialog.
|
||||
/// Null falls back to a generic name.</param>
|
||||
/// <returns>True, when the document was written.</returns>
|
||||
public static async Task<bool> ToDocument(RustService rustService, PandocAvailabilityService pandocAvailability, string dialogTitle, FileExportFormat format, IContent markdownContent)
|
||||
public static async Task<bool> ToDocument(RustService rustService, PandocAvailabilityService pandocAvailability, string dialogTitle, FileExportFormat format, IContent markdownContent, string? fileName = null)
|
||||
{
|
||||
if (!format.UsesPandoc() || format.ToFileTypeFilter() is not { } fileTypeFilter)
|
||||
throw new ArgumentOutOfRangeException(nameof(format), format, "Pandoc cannot write this format.");
|
||||
@ -125,7 +136,7 @@ public static class PandocExport
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName());
|
||||
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName));
|
||||
if (response.UserCancelled)
|
||||
{
|
||||
LOGGER.LogInformation("User cancelled the save dialog.");
|
||||
|
||||
@ -16,18 +16,21 @@ public static class PlainFileExport
|
||||
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(PlainFileExport).Namespace, nameof(PlainFileExport));
|
||||
|
||||
/// <summary>
|
||||
/// Reads every table a message holds, in the order they appear in it.
|
||||
/// Reads every file a message holds, in the order they appear in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two kinds of tables end up in an answer. Almost always it is a Markdown table written with
|
||||
/// pipes, which is what a model produces on its own; we turn its cells into a file. Rarely a
|
||||
/// model answers with a fenced code block marked as csv or tsv, which already is the finished
|
||||
/// file: we hand that through untouched rather than taking it apart and reassembling it.
|
||||
/// Two kinds of files end up in an answer. Almost always it is a Markdown table written with
|
||||
/// pipes, which is what a model produces on its own; we turn its cells into a file. Besides,
|
||||
/// a model answers with a fenced code block marked as a format we write, such as html, latex,
|
||||
/// markdown, or csv, whenever it was asked for a web page, a document, or data. Such a block
|
||||
/// already is the finished file: we hand it through untouched rather than taking it apart and
|
||||
/// reassembling it. We do not judge what the block holds, either. A browser shows a fragment of
|
||||
/// HTML just as well as an entire page, and a LaTeX fragment is still what the user asked for.
|
||||
/// </remarks>
|
||||
/// <param name="markdown">The Markdown text of the message.</param>
|
||||
/// <param name="separator">The separator to write a Markdown table with, see CsvWriter.SeparatorFor.</param>
|
||||
/// <returns>The tables, or an empty list when the message holds none.</returns>
|
||||
public static IReadOnlyList<MessageTable> ExtractTables(string markdown, char separator)
|
||||
/// <returns>The files, or an empty list when the message holds none.</returns>
|
||||
public static IReadOnlyList<MessageFile> ExtractFiles(string markdown, char separator)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(markdown))
|
||||
return [];
|
||||
@ -40,9 +43,10 @@ public static class PlainFileExport
|
||||
var document = Markdig.Markdown.Parse(markdown, Markdown.SAFE_MARKDOWN_PIPELINE);
|
||||
|
||||
//
|
||||
// What a table is about stands above it, not in it: models introduce their tables with a
|
||||
// heading. We remember every heading with its line so that each table can take the last
|
||||
// one before it, and fall back to its own first column heading when there is none.
|
||||
// What a file is about stands above it, not in it: models introduce their tables and code
|
||||
// blocks with a heading. We remember every heading with its line so that each file can take
|
||||
// the last one before it. A table falls back to its own first column heading when there is
|
||||
// none; a code block has nothing comparable and stays without a caption.
|
||||
//
|
||||
var headings = document.Descendants<HeadingBlock>()
|
||||
.Select(heading => (heading.Line, Text: ToPlainText(heading)))
|
||||
@ -56,11 +60,18 @@ public static class PlainFileExport
|
||||
var codeBlocks = document.Descendants<FencedCodeBlock>()
|
||||
.Select(block => (block.Line, Content: ToContent(block)));
|
||||
|
||||
//
|
||||
// Tables and code blocks are counted apart. The menu falls back to that number when a
|
||||
// heading cannot tell two files apart, and "Table 2" has to be the second table of the
|
||||
// answer, not the second entry of the menu.
|
||||
//
|
||||
var numberOfTables = 0;
|
||||
var numberOfCodeBlocks = 0;
|
||||
return tables.Concat(codeBlocks)
|
||||
.Where(entry => entry.Content is not null)
|
||||
.OrderBy(entry => entry.Line)
|
||||
.Select((entry, index) => new MessageTable(
|
||||
index + 1,
|
||||
.Select(entry => new MessageFile(
|
||||
entry.Content!.Value.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks,
|
||||
Caption: HeadingAbove(entry.Line) is { Length: > 0 } heading ? heading : entry.Content!.Value.Fallback,
|
||||
entry.Content!.Value.Format,
|
||||
entry.Content.Value.Text))
|
||||
@ -90,22 +101,22 @@ public static class PlainFileExport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a fenced code block into a file, when the model marked it as tabular data.
|
||||
/// Turns a fenced code block into a file, when the model marked it as a format we write.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A block the model never closed is left out. That happens when an answer broke off, at the
|
||||
/// output limit of the model for example, and the file would end wherever the answer did: half
|
||||
/// a web page or half a table is nothing anybody wants to save.
|
||||
/// </remarks>
|
||||
private static (string Fallback, FileExportFormat Format, string Text)? ToContent(FencedCodeBlock block)
|
||||
{
|
||||
var format = block.Info?.Trim() switch
|
||||
{
|
||||
"csv" => FileExportFormat.CSV,
|
||||
"tsv" => FileExportFormat.TSV,
|
||||
|
||||
_ => FileExportFormat.NONE,
|
||||
};
|
||||
|
||||
if (format is FileExportFormat.NONE)
|
||||
if (block.ClosingFencedCharCount is 0 || !FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format))
|
||||
return null;
|
||||
|
||||
var content = block.Lines.ToString();
|
||||
if (!format.IsTabular())
|
||||
return (string.Empty, format, content);
|
||||
|
||||
var blockSeparator = format is FileExportFormat.TSV ? '\t' : ',';
|
||||
var firstLine = content.AsSpan();
|
||||
var lineEnd = firstLine.IndexOf('\n');
|
||||
@ -167,20 +178,26 @@ public static class PlainFileExport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the given text to a plain text file and lets the user save it.
|
||||
/// Writes the given text to a plain text file as it is and lets the user save it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing is converted here, which is what sets this apart from PandocExport.ToDocument. A web
|
||||
/// page or a LaTeX document the model wrote is a finished file already and comes through here;
|
||||
/// an entire answer in one of these formats is Markdown and goes to Pandoc instead.
|
||||
/// </remarks>
|
||||
/// <param name="rustService">The Rust service, used for the save dialog.</param>
|
||||
/// <param name="dialogTitle">The title of the save dialog. The caller knows what the user is
|
||||
/// looking at, a chat message or the result of an assistant, so the caller names it.</param>
|
||||
/// <param name="format">The format to write. Must be a format which does not use Pandoc.</param>
|
||||
/// <param name="fileContent">What to write. The caller decides whether that is the entire
|
||||
/// message or one table out of it.</param>
|
||||
/// <param name="format">The format to write. Must be a plain text format, see
|
||||
/// FileExportFormatExtensions.IsPlainText.</param>
|
||||
/// <param name="fileContent">The finished file. The caller decides whether that is the entire
|
||||
/// message or one file out of it.</param>
|
||||
/// <param name="fileName">What the file is about, used to suggest a name in the save dialog.
|
||||
/// Null falls back to a generic name.</param>
|
||||
/// <returns>True, when the file was written.</returns>
|
||||
public static async Task<bool> ToFile(RustService rustService, string dialogTitle, FileExportFormat format, string fileContent, string? fileName = null)
|
||||
{
|
||||
if (format.UsesPandoc() || format.ToFileTypeFilter() is not { } fileTypeFilter)
|
||||
if (!format.IsPlainText() || format.ToFileTypeFilter() is not { } fileTypeFilter)
|
||||
throw new ArgumentOutOfRangeException(nameof(format), format, "AI Studio cannot write this format itself.");
|
||||
|
||||
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName));
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# v26.9.1, build 256 (2026-09-xx xx:xx UTC)
|
||||
- Added a way to copy an entire chat, either with the button in the chat toolbar or next to the chat in the chat list. The copy opens right away so you can continue in it, while the original conversation stays exactly as it was. Many thanks to Peer Hogeterp (`peerschuett`) and Jens Erler (`j-erler`) for this feature.
|
||||
- Added a way to roll a chat back to an earlier AI response. The response you pick stays, and every message after it is removed permanently, together with the attachments of those messages.
|
||||
- Added a way to save a single code block of an answer. When an answer holds a web page, a LaTeX document, or a Markdown text, the export menu now offers that block as a file of its own.
|
||||
- Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Hogeterp (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature.
|
||||
- Added answers that appear word by word even while the AI uses its tools. You read along as the model writes, including the short note it puts down before it looks something up, and the answer that follows a tool call arrives the same way instead of all at once at the end.
|
||||
- Added safeguards around everything these tools bring back. Anything fetched from the web is treated as untrusted: AI Studio removes instructions hidden in a page before a model reads it and tells you when it did, exactly as it already does for the documents and web pages you load yourself. A model can never point a tool at your own network. Each tool states how much you have to trust a provider before it may be used with it, so your questions do not travel further than you allow. You can adjust that requirement per tool in the app settings.
|
||||
@ -48,6 +49,7 @@
|
||||
- Improved the question AI Studio asks before you delete an embedding provider. It now names the data sources depending on that provider, together with what they can still do without it.
|
||||
- Improved what the AI is told when it answers from your own documents (RAG): it now learns which page a passage came from, so it can name the page an answer rests on.
|
||||
- Improved what happens when you ask for web content to be cleaned up and no model is available for it. AI Studio loads the page and tells you it arrived uncleaned, instead of quietly handing you the raw page with its navigation and advertising still in it.
|
||||
- Improved the file name AI Studio suggests when you export an answer. Instead of always proposing "export", it now suggests the name of your chat or of the assistant you are working in. In the Document Analysis assistant, it suggests the name of the policy.
|
||||
- Changed what a tile that opens a chat directly starts with: when it uses a chat template that brings its own tools or data sources, that template decides them. The Assistant Builder says so while you build such a tile.
|
||||
- Changed which model a security check of an assistant plugin may fall back on. When you have set none aside for these checks, AI Studio uses the model you were working with, but only when that model meets the trust your organization requires for a check. One ruled out for that purpose no longer gets to read a plugin's code.
|
||||
- Changed how provider trust and provider confidence work together. Marking a provider as trustworthy in a configuration no longer also satisfies a required confidence level: one says who runs the provider, the other how confidential it is. Organizations raise a provider's level in their own confidence scheme instead. This applies beyond local data sources, for example, when a model reads a page from your intranet.
|
||||
@ -71,6 +73,7 @@
|
||||
- Fixed the Visual Briefing assistant (in preview) not scrolling, which put everything below the window edge out of reach and made the assistant unusable. The briefing preview is now shown at its intended size inside its frame, and switching between the desktop, tablet, and mobile view changes its width as it should.
|
||||
- Fixed exported answers losing their sources. When an answer is based on web pages a tool read or on documents of your own, the exported file now lists those sources in every format AI Studio writes.
|
||||
- Fixed the copy button leaving the sources behind. Copy an answer, and its sources come along.
|
||||
- Fixed exported web pages showing a cryptic string of letters and digits as their title in the browser tab. They now carry the name of their file.
|
||||
- Fixed a tile that opens a chat directly always demanding a workspace. Leave the workspace empty in the Assistant Builder, and the tile opens a disappearing chat instead.
|
||||
- Fixed the same restriction for plugin authors: a direct-chat launcher can now open a chat without naming a workspace. The example assistant plugin shows both ways.
|
||||
- Fixed the security check of an assistant plugin being impossible when no model is set aside for such checks, and you have no app-wide default either. The dialog now lets you pick one, and that choice applies to this one check. Before, the button to start the check was greyed out with nothing saying why, so the plugin could not be enabled at all.
|
||||
|
||||
@ -162,16 +162,85 @@ public sealed class IContentExtensionsTests
|
||||
"|---|---|",
|
||||
"| Q1 | 100 |"), TOOL_SOURCE);
|
||||
|
||||
content.TryGetMarkdownText(out var markdown);
|
||||
var tables = PlainFileExport.ExtractTables(markdown, ',');
|
||||
var tables = FilesOf(content);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(tables, Has.Count.EqualTo(1), "One table in the message, one table offered for it.");
|
||||
Assert.That(tables[0].Content, Does.Not.Contain("example.org"), "A data table has no column a link list would fit into.");
|
||||
Assert.That(content.ToExportContent(tables[0]), Is.EqualTo(tables[0].Content), "A data table has no column a link list would fit into.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AMarkdownBlockCarriesTheSourcesVisibly()
|
||||
{
|
||||
var content = TextWith(Lines(
|
||||
"Here are your notes:",
|
||||
string.Empty,
|
||||
"```markdown",
|
||||
"# Notes",
|
||||
string.Empty,
|
||||
"The notes.",
|
||||
"```"), TOOL_SOURCE);
|
||||
|
||||
var exported = content.ToExportContent(FilesOf(content).Single());
|
||||
|
||||
Assert.That(TopLevelBlocksOf(exported), Is.EqualTo(new[] { "h1", "ParagraphBlock", "h1", "h2", "ListBlock" }), "The notes without the text around them, followed by the source list just as the entire answer carries it.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AWebPageCarriesTheSourcesInAComment()
|
||||
{
|
||||
var content = TextWith(Lines(
|
||||
"```html",
|
||||
"<!DOCTYPE html>",
|
||||
"<html><body><p>Hello</p></body></html>",
|
||||
"```"), TOOL_SOURCE);
|
||||
|
||||
var file = FilesOf(content).Single();
|
||||
var exported = content.ToExportContent(file);
|
||||
var appended = exported[file.Content.Length..].Trim();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exported, Does.StartWith(file.Content), "The page stays as the model wrote it.");
|
||||
Assert.That(appended, Does.StartWith("<!--").And.EndWith("-->"), "Below the page stands one comment and nothing a browser would show.");
|
||||
Assert.That(appended, Does.Contain(TOOL_SOURCE.URL));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALatexBlockCarriesTheSourcesInComments()
|
||||
{
|
||||
var content = TextWith(Lines(
|
||||
"```latex",
|
||||
@"\section{Results}",
|
||||
"```"), TOOL_SOURCE);
|
||||
|
||||
var file = FilesOf(content).Single();
|
||||
var exported = content.ToExportContent(file);
|
||||
var appended = exported[file.Content.Length..].Trim();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exported, Does.StartWith(file.Content), "The document stays as the model wrote it.");
|
||||
Assert.That(appended.Split(Environment.NewLine), Has.All.StartWith("%"), "A line LaTeX would read could stop the whole run.");
|
||||
Assert.That(appended, Does.Contain(TOOL_SOURCE.URL));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("markdown")]
|
||||
[TestCase("html")]
|
||||
[TestCase("latex")]
|
||||
public void WithoutSourcesACodeBlockStaysAsTheModelWroteIt(string language)
|
||||
{
|
||||
var content = TextWith(Lines($"```{language}", "The content.", "```"));
|
||||
|
||||
var file = FilesOf(content).Single();
|
||||
|
||||
Assert.That(content.ToExportContent(file), Is.EqualTo(file.Content), "No comment, no heading, no empty line.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A text message with the given sources hanging on it.
|
||||
/// </summary>
|
||||
@ -184,6 +253,17 @@ public sealed class IContentExtensionsTests
|
||||
Sources = [..sources],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Reads the files of a message the way the export menu does.
|
||||
/// </summary>
|
||||
/// <param name="content">The content to read.</param>
|
||||
/// <returns>The files the export menu offers for it.</returns>
|
||||
private static IReadOnlyList<MessageFile> FilesOf(IContent content)
|
||||
{
|
||||
content.TryGetMarkdownText(out var markdown);
|
||||
return PlainFileExport.ExtractFiles(markdown, ',');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names the blocks a Markdown text is made of, headings by their level.
|
||||
/// </summary>
|
||||
|
||||
@ -36,4 +36,134 @@ public sealed class FileExportFormatTests
|
||||
FileExportFormat.HTML,
|
||||
}), "A format was added to or removed from the export menu: say in FollowsPageAnchors whether its reader follows a page in a local link, then name it here.");
|
||||
}
|
||||
|
||||
[TestCase("Q3: Umsatz/Planung?", FileExportFormat.HTML, "Q3 Umsatz Planung.html", Description = "A chat is named after the first words of its question, and those may hold anything.")]
|
||||
[TestCase("Notes for the meeting.", FileExportFormat.MARKDOWN, "Notes for the meeting.md", Description = "Windows drops a trailing dot anyway.")]
|
||||
[TestCase(null, FileExportFormat.MICROSOFT_WORD, "export.docx")]
|
||||
[TestCase(" ", FileExportFormat.LATEX, "export.tex", Description = "A name made of nothing is no name.")]
|
||||
public void TheSaveDialogSuggestsAUsableFileName(string? name, FileExportFormat format, string expectedFileName)
|
||||
{
|
||||
Assert.That(format.ToSuggestedFileName(name), Is.EqualTo(expectedFileName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnOverlongFileNameIsShortened()
|
||||
{
|
||||
var fileName = FileExportFormat.HTML.ToSuggestedFileName(new string('a', 100));
|
||||
|
||||
Assert.That(fileName, Is.EqualTo($"{new string('a', 60)}.html"), "The first ten words of a question easily outgrow what a dialog shows.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyAWebPageNeedsAPageTitle()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(FileExportFormat.HTML.NeedsPageTitle(), Is.True, "Without one, the browser tab shows the random name of the temporary file Pandoc read.");
|
||||
Assert.That(FileExportFormat.MICROSOFT_WORD.NeedsPageTitle(), Is.False, "Word would keep it as a document property nobody asked for.");
|
||||
Assert.That(FileExportFormat.OPEN_DOCUMENT_TEXT.NeedsPageTitle(), Is.False, "The same goes for an OpenDocument file.");
|
||||
Assert.That(FileExportFormat.LATEX.NeedsPageTitle(), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("html", FileExportFormat.HTML)]
|
||||
[TestCase("latex", FileExportFormat.LATEX)]
|
||||
[TestCase("tex", FileExportFormat.LATEX)]
|
||||
[TestCase("markdown", FileExportFormat.MARKDOWN)]
|
||||
[TestCase("md", FileExportFormat.MARKDOWN)]
|
||||
[TestCase("csv", FileExportFormat.CSV)]
|
||||
[TestCase("tsv", FileExportFormat.TSV)]
|
||||
[TestCase("HTML", FileExportFormat.HTML, Description = "Models do not agree on the case.")]
|
||||
[TestCase("LaTeX", FileExportFormat.LATEX)]
|
||||
[TestCase("CSV", FileExportFormat.CSV)]
|
||||
[TestCase(" md ", FileExportFormat.MARKDOWN, Description = "Space around the name is no part of it.")]
|
||||
public void AFenceLanguageNamesItsFormat(string language, FileExportFormat expectedFormat)
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(FileExportFormatExtensions.TryFromCodeFenceLanguage(language, out var format), Is.True);
|
||||
Assert.That(format, Is.EqualTo(expectedFormat));
|
||||
Assert.That(format.IsPlainText(), Is.True, "A code block holds text, so the export writes it as it is.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyTheTwoOfficeFormatsAreNoPlainText()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(FileExportFormat.MICROSOFT_WORD.IsPlainText(), Is.False, "A Word file is an archive, and writing text into one breaks it.");
|
||||
Assert.That(FileExportFormat.OPEN_DOCUMENT_TEXT.IsPlainText(), Is.False);
|
||||
Assert.That(FileExportFormat.NONE.IsPlainText(), Is.False, "No format means no file.");
|
||||
Assert.That(FileExportFormat.UNKNOWN.IsPlainText(), Is.False);
|
||||
Assert.That(FileExportFormat.HTML.IsPlainText(), Is.True, "A page the model wrote is a finished file, even though an entire answer needs Pandoc to become one.");
|
||||
Assert.That(FileExportFormat.LATEX.IsPlainText(), Is.True);
|
||||
Assert.That(FileExportFormat.MARKDOWN.IsPlainText(), Is.True);
|
||||
Assert.That(FileExportFormat.CSV.IsPlainText(), Is.True);
|
||||
Assert.That(FileExportFormat.TSV.IsPlainText(), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("css", TestName = "A language AI Studio writes no file for")]
|
||||
[TestCase("docx", TestName = "A format no code block can hold")]
|
||||
[TestCase("", TestName = "A fence without a language")]
|
||||
[TestCase(null, TestName = "A fence Markdig read no language for")]
|
||||
public void AnyOtherFenceLanguageNamesNoFormat(string? language)
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(FileExportFormatExtensions.TryFromCodeFenceLanguage(language, out var format), Is.False);
|
||||
Assert.That(format, Is.EqualTo(FileExportFormat.NONE));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(FileExportFormat.HTML)]
|
||||
[TestCase(FileExportFormat.MARKDOWN)]
|
||||
public void AnHtmlCommentEndsWhereItShouldAndNowhereElse(FileExportFormat format)
|
||||
{
|
||||
var found = format.TryToComment("A page titled --> Start, and one titled --!> Next", out var comment);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(found, Is.True);
|
||||
Assert.That(comment, Does.StartWith("<!--"));
|
||||
Assert.That(comment.IndexOf("-->", StringComparison.Ordinal), Is.EqualTo(comment.Length - 3), "Only the end of the comment may end it; the title would spill onto the page otherwise.");
|
||||
Assert.That(comment, Does.Not.Contain("--!>"), "A browser ends a comment there as well.");
|
||||
Assert.That(comment, Does.Contain("Start").And.Contain("Next"), "The title stays readable.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnHtmlCommentKeepsTheDashesOfAnAddress()
|
||||
{
|
||||
FileExportFormat.HTML.TryToComment("https://xn--mnchen-3ya.de/", out var comment);
|
||||
|
||||
Assert.That(comment, Does.Contain("https://xn--mnchen-3ya.de/"), "A domain with an umlaut is written with two dashes, and the link has to keep working.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryLineOfALatexCommentIsOne()
|
||||
{
|
||||
var found = FileExportFormat.LATEX.TryToComment(Lines("# Sources", string.Empty, "- [1] A title with 100 % and a_b"), out var comment);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(found, Is.True);
|
||||
Assert.That(comment.Split(Environment.NewLine), Is.EqualTo(new[] { "% # Sources", "%", "% - [1] A title with 100 % and a_b" }), "LaTeX has no end of a comment, only the end of a line.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(FileExportFormat.CSV)]
|
||||
[TestCase(FileExportFormat.TSV)]
|
||||
[TestCase(FileExportFormat.MICROSOFT_WORD)]
|
||||
public void AFormatWithoutCommentsSaysSo(FileExportFormat format)
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(format.TryToComment("A text.", out var comment), Is.False);
|
||||
Assert.That(comment, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
201
app/Tests/Tools/Fixtures/standalone_page.html
Normal file
201
app/Tests/Tools/Fixtures/standalone_page.html
Normal file
@ -0,0 +1,201 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hello World</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300..900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0f10; --bone:#f4eee3; --ember:#e2653a; --mint:#7ac9b0;
|
||||
--mx:50%; --my:50%; --gx:50%; --gy:50%;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{margin:0;overflow:hidden;background:var(--bg);color:var(--bone);
|
||||
font-family:"IBM Plex Sans",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
||||
.stage{position:fixed;inset:0;display:grid;place-items:center;isolation:isolate}
|
||||
.stage > *{grid-area:1/1}
|
||||
|
||||
.field{
|
||||
position:absolute;inset:-20%;z-index:0;filter:blur(6px);
|
||||
background:
|
||||
radial-gradient(46% 40% at 24% 28%, rgba(226,101,58,.20), transparent 68%),
|
||||
radial-gradient(52% 46% at 78% 72%, rgba(122,201,176,.16), transparent 70%),
|
||||
radial-gradient(80% 70% at 50% 50%, #121a1b, #070b0c 78%);
|
||||
animation:breathe 18s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes breathe{
|
||||
from{transform:scale(1) translate(-1%,1%)}
|
||||
to{transform:scale(1.08) translate(1.5%,-1.5%)}
|
||||
}
|
||||
.grid-base{
|
||||
position:absolute;inset:0;z-index:1;opacity:.07;
|
||||
background-image:
|
||||
linear-gradient(rgba(244,238,227,.9) 1px,transparent 1px),
|
||||
linear-gradient(90deg,rgba(244,238,227,.9) 1px,transparent 1px);
|
||||
background-size:46px 46px;
|
||||
-webkit-mask-image:radial-gradient(70% 70% at 50% 50%,#000,transparent);
|
||||
mask-image:radial-gradient(70% 70% at 50% 50%,#000,transparent);
|
||||
}
|
||||
.grid-scan{
|
||||
position:absolute;inset:0;z-index:2;
|
||||
background-image:
|
||||
linear-gradient(rgba(122,201,176,.55) 1px,transparent 1px),
|
||||
linear-gradient(90deg,rgba(122,201,176,.55) 1px,transparent 1px);
|
||||
background-size:46px 46px;
|
||||
-webkit-mask-image:radial-gradient(230px 230px at var(--mx) var(--my),#000 0%,rgba(0,0,0,.35) 45%,transparent 72%);
|
||||
mask-image:radial-gradient(230px 230px at var(--mx) var(--my),#000 0%,rgba(0,0,0,.35) 45%,transparent 72%);
|
||||
}
|
||||
.glow{position:absolute;inset:0;z-index:2;mix-blend-mode:screen;
|
||||
background:radial-gradient(280px 280px at var(--gx) var(--gy),rgba(226,101,58,.22),transparent 65%)}
|
||||
.sweep{
|
||||
position:absolute;inset:-30% -60%;z-index:2;pointer-events:none;filter:blur(22px);
|
||||
background:linear-gradient(102deg,transparent 44%,rgba(244,238,227,.085) 50%,transparent 56%);
|
||||
animation:sweep 15s linear infinite;
|
||||
}
|
||||
@keyframes sweep{from{transform:translateX(-26%)}to{transform:translateX(26%)}}
|
||||
|
||||
.tilt{position:relative;z-index:5;will-change:transform}
|
||||
.hello{
|
||||
position:relative;display:inline-block;margin:0;text-align:center;
|
||||
font-family:"Fraunces",Georgia,serif;
|
||||
font-variation-settings:"opsz" 120;font-weight:500;
|
||||
font-size:clamp(2.4rem,12vw,10.5rem);line-height:.92;letter-spacing:-.025em;
|
||||
animation:float 11s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes float{from{transform:translateY(-.02em)}to{transform:translateY(.025em)}}
|
||||
.ghost{
|
||||
position:absolute;inset:0;pointer-events:none;color:transparent;
|
||||
-webkit-text-stroke:.012em rgba(244,238,227,.20);
|
||||
transform:translate(.045em,.045em);
|
||||
animation:drift 13s ease-in-out infinite alternate;
|
||||
}
|
||||
.ghost.deep{-webkit-text-stroke:.01em rgba(122,201,176,.16);transform:translate(-.05em,-.035em);animation-duration:16s}
|
||||
@keyframes drift{to{transform:translate(.075em,.02em)}}
|
||||
.w{display:inline-block;white-space:nowrap}
|
||||
.m{display:inline-block;overflow:hidden;padding-bottom:.1em;margin-bottom:-.1em;vertical-align:bottom}
|
||||
.l{display:inline-block;transform:translateY(115%)}
|
||||
.g{display:inline-block;transition:color .35s ease,transform .4s cubic-bezier(.2,.85,.2,1),text-shadow .4s ease}
|
||||
.l:hover .g{color:var(--mint);transform:translateY(-.055em);text-shadow:0 0 26px rgba(122,201,176,.45)}
|
||||
.caret{
|
||||
display:inline-block;width:.055em;height:.72em;margin-left:.07em;vertical-align:-.02em;
|
||||
background:var(--ember);box-shadow:0 0 22px rgba(226,101,58,.6);
|
||||
animation:blink 1.15s steps(1,end) infinite;
|
||||
}
|
||||
@keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}}
|
||||
body.play .l{animation:rise .82s var(--d) cubic-bezier(.16,1,.3,1) both}
|
||||
@keyframes rise{from{transform:translateY(115%)}to{transform:translateY(0)}}
|
||||
body.play .caret{animation:blink 1.15s steps(1,end) .95s infinite,caretin .5s .9s both}
|
||||
@keyframes caretin{from{opacity:0;transform:scaleY(.2)}to{opacity:1;transform:scaleY(1)}}
|
||||
body.play .ghost{animation:ghostin 1.4s .15s ease both,drift 13s 1.6s ease-in-out infinite alternate}
|
||||
@keyframes ghostin{from{opacity:0}to{opacity:1}}
|
||||
|
||||
.frame{position:absolute;inset:clamp(14px,3.2vw,42px);z-index:4;border:1px solid rgba(244,238,227,.09);
|
||||
clip-path:inset(0 0 100% 0);animation:open 1.5s .35s cubic-bezier(.16,1,.3,1) both}
|
||||
@keyframes open{to{clip-path:inset(0 0 0 0)}}
|
||||
.tick{position:absolute;width:14px;height:14px;z-index:4;opacity:0;animation:tickin .6s 1.2s ease both}
|
||||
@keyframes tickin{from{opacity:0;transform:scale(.4)}to{opacity:1;transform:scale(1)}}
|
||||
.tl{top:clamp(14px,3.2vw,42px);left:clamp(14px,3.2vw,42px);border-top:1px solid var(--ember);border-left:1px solid var(--ember)}
|
||||
.tr{top:clamp(14px,3.2vw,42px);right:clamp(14px,3.2vw,42px);border-top:1px solid var(--mint);border-right:1px solid var(--mint)}
|
||||
.bl{bottom:clamp(14px,3.2vw,42px);left:clamp(14px,3.2vw,42px);border-bottom:1px solid var(--mint);border-left:1px solid var(--mint)}
|
||||
.br{bottom:clamp(14px,3.2vw,42px);right:clamp(14px,3.2vw,42px);border-bottom:1px solid var(--ember);border-right:1px solid var(--ember)}
|
||||
.vignette{position:absolute;inset:0;z-index:3;
|
||||
background:radial-gradient(75% 65% at 50% 50%,transparent 40%,rgba(3,6,7,.72) 100%)}
|
||||
.grain{
|
||||
position:absolute;inset:-50%;z-index:6;pointer-events:none;opacity:.055;mix-blend-mode:overlay;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
animation:grain 5s steps(5) infinite;
|
||||
}
|
||||
@keyframes grain{
|
||||
0%{transform:translate(0,0)}20%{transform:translate(-3%,2%)}40%{transform:translate(2%,-3%)}
|
||||
60%{transform:translate(-2%,-2%)}80%{transform:translate(3%,1%)}100%{transform:translate(0,0)}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
*{animation-duration:.001s!important;animation-iteration-count:1!important;transition-duration:.001s!important}
|
||||
.l{transform:none}.caret{opacity:1;animation:none}.sweep,.grain,.field{animation:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="stage">
|
||||
<div class="field" aria-hidden="true"></div>
|
||||
<div class="grid-base" aria-hidden="true"></div>
|
||||
<div class="grid-scan" aria-hidden="true"></div>
|
||||
<div class="glow" aria-hidden="true"></div>
|
||||
<div class="sweep" aria-hidden="true"></div>
|
||||
|
||||
<div class="tilt">
|
||||
<h1 class="hello" id="hello" aria-label="Hello World"></h1>
|
||||
</div>
|
||||
|
||||
<div class="vignette" aria-hidden="true"></div>
|
||||
<div class="frame" aria-hidden="true"></div>
|
||||
<i class="tick tl" aria-hidden="true"></i><i class="tick tr" aria-hidden="true"></i>
|
||||
<i class="tick bl" aria-hidden="true"></i><i class="tick br" aria-hidden="true"></i>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const TEXT = "Hello World";
|
||||
const hello = document.getElementById("hello");
|
||||
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const words = TEXT.split(" ");
|
||||
|
||||
hello.innerHTML =
|
||||
`<span class="ghost deep" aria-hidden="true">${TEXT}</span>` +
|
||||
`<span class="ghost" aria-hidden="true">${TEXT}</span>`;
|
||||
|
||||
let i = 0;
|
||||
words.forEach((word, wi) => {
|
||||
const w = document.createElement("span");
|
||||
w.className = "w";
|
||||
w.setAttribute("aria-hidden", "true");
|
||||
[...word].forEach(ch => {
|
||||
const m = document.createElement("span"); m.className = "m";
|
||||
const l = document.createElement("span"); l.className = "l";
|
||||
l.style.setProperty("--d", (0.28 + i * 0.052) + "s");
|
||||
const g = document.createElement("span"); g.className = "g"; g.textContent = ch;
|
||||
m.appendChild(l); l.appendChild(g); w.appendChild(m); i++;
|
||||
});
|
||||
hello.appendChild(w);
|
||||
if (wi < words.length - 1) hello.appendChild(document.createTextNode(" "));
|
||||
});
|
||||
const caret = document.createElement("i");
|
||||
caret.className = "caret"; caret.setAttribute("aria-hidden", "true");
|
||||
hello.appendChild(caret);
|
||||
|
||||
const play = () => {
|
||||
document.body.classList.remove("play");
|
||||
void document.body.offsetWidth;
|
||||
document.body.classList.add("play");
|
||||
};
|
||||
play();
|
||||
addEventListener("pointerdown", play);
|
||||
|
||||
const root = document.documentElement, tilt = document.querySelector(".tilt");
|
||||
let tx = innerWidth / 2, ty = innerHeight / 2, gx = tx, gy = ty, px = 0, py = 0;
|
||||
|
||||
addEventListener("pointermove", e => {
|
||||
tx = e.clientX; ty = e.clientY;
|
||||
root.style.setProperty("--mx", tx + "px");
|
||||
root.style.setProperty("--my", ty + "px");
|
||||
}, {passive:true});
|
||||
addEventListener("pointerleave", () => { tx = innerWidth/2; ty = innerHeight/2; });
|
||||
|
||||
(function raf(){
|
||||
gx += (tx - gx) * 0.07; gy += (ty - gy) * 0.07;
|
||||
px += ((tx / innerWidth - .5) - px) * 0.05;
|
||||
py += ((ty / innerHeight - .5) - py) * 0.05;
|
||||
root.style.setProperty("--gx", gx + "px");
|
||||
root.style.setProperty("--gy", gy + "px");
|
||||
if (!reduce) tilt.style.transform =
|
||||
`perspective(900px) rotateY(${px * 9}deg) rotateX(${-py * 7}deg) translate3d(${px * 16}px,${py * 12}px,0)`;
|
||||
requestAnimationFrame(raf);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
134
app/Tests/Tools/PlainFileExportTests.cs
Normal file
134
app/Tests/Tools/PlainFileExportTests.cs
Normal file
@ -0,0 +1,134 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
using AIStudio.Tools;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which files the export menu finds in an answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Asked for a web page, a model answers with a code block marked as html, and the same goes for a
|
||||
/// LaTeX document or a Markdown text. That block already is the file the user wants. Converted along
|
||||
/// with the rest of the answer, Pandoc shows it as a listing of source code instead, which is what
|
||||
/// PR #993 reported. The fixture is the page attached to that PR, as the model wrote it.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class PlainFileExportTests
|
||||
{
|
||||
private static readonly string PAGE = ReadFixture("standalone_page.html");
|
||||
|
||||
[Test]
|
||||
public void AnAnswerMadeOfOneWebPageOffersThatPage()
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines("```html", PAGE, "```"), ',');
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(files, Has.Count.EqualTo(1));
|
||||
Assert.That(files[0].Format, Is.EqualTo(FileExportFormat.HTML));
|
||||
Assert.That(files[0].Content, Is.EqualTo(PAGE), "The page leaves the answer exactly as the model wrote it, without the fence around it.");
|
||||
Assert.That(files[0].Caption, Is.Empty, "Without a heading above it, a code block has nothing to be named after.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AWebPageAmidExplanationsIsOfferedAsWell()
|
||||
{
|
||||
var answer = Lines("Here is your page:", string.Empty, "```html", PAGE, "```", string.Empty, "Save it and open it in your browser.");
|
||||
|
||||
var files = PlainFileExport.ExtractFiles(answer, ',');
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(files, Has.Count.EqualTo(1), "Models rarely answer with the block alone, so the text around it must not hide it.");
|
||||
Assert.That(files[0].Content, Is.EqualTo(PAGE));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("HTML", FileExportFormat.HTML)]
|
||||
[TestCase("tex", FileExportFormat.LATEX)]
|
||||
[TestCase("markdown", FileExportFormat.MARKDOWN)]
|
||||
[TestCase("html title=\"index.html\"", FileExportFormat.HTML, Description = "Whatever follows the language is an argument, not part of it.")]
|
||||
public void ACodeBlockIsOfferedInTheFormatItsLanguageNames(string infoString, FileExportFormat expectedFormat)
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines($"```{infoString}", "The content.", "```"), ',');
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(files, Has.Count.EqualTo(1));
|
||||
Assert.That(files[0].Format, Is.EqualTo(expectedFormat));
|
||||
Assert.That(files[0].Content, Is.EqualTo("The content."));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ATildeFenceIsOfferedAsWell()
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines("~~~latex", @"\section{Results}", "~~~"), ',');
|
||||
|
||||
Assert.That(files.Select(file => file.Format), Is.EqualTo(new[] { FileExportFormat.LATEX }));
|
||||
}
|
||||
|
||||
[TestCase("```css", TestName = "A language AI Studio writes no file for")]
|
||||
[TestCase("```", TestName = "A fence without a language")]
|
||||
public void AnyOtherCodeBlockIsNotOffered(string openingFence)
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines(openingFence, "body { margin: 0; }", "```"), ',');
|
||||
|
||||
Assert.That(files, Is.Empty);
|
||||
}
|
||||
|
||||
[TestCase("```html", "<html><body><p>The answer broke off here", TestName = "Half a web page")]
|
||||
[TestCase("```csv", "Quarter,Revenue", TestName = "Half a table")]
|
||||
public void ACodeBlockTheModelNeverClosedIsNotOffered(string openingFence, string content)
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines("The answer starts normally.", string.Empty, openingFence, content), ',');
|
||||
|
||||
Assert.That(files, Is.Empty, "The file would end wherever the answer broke off.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TablesAndCodeBlocksAreCountedApart()
|
||||
{
|
||||
var answer = Lines(
|
||||
"# Revenue",
|
||||
string.Empty,
|
||||
"| Quarter | Revenue |",
|
||||
"|---|---|",
|
||||
"| Q1 | 100 |",
|
||||
string.Empty,
|
||||
"# Landing page",
|
||||
string.Empty,
|
||||
"```html",
|
||||
"<p>First block</p>",
|
||||
"```",
|
||||
string.Empty,
|
||||
"```latex",
|
||||
@"\section{Second block}",
|
||||
"```");
|
||||
|
||||
var files = PlainFileExport.ExtractFiles(answer, ',');
|
||||
|
||||
Assert.That(files.Select(file => (file.Ordinal, file.Caption, file.Format)), Is.EqualTo(new[]
|
||||
{
|
||||
(1, "Revenue", FileExportFormat.CSV),
|
||||
(1, "Landing page", FileExportFormat.HTML),
|
||||
(2, "Landing page", FileExportFormat.LATEX),
|
||||
}), "The first code block is code block 1, even though a table stands before it.");
|
||||
}
|
||||
|
||||
private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines);
|
||||
|
||||
/// <summary>
|
||||
/// Reads a file from the fixtures next to this test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read from the source tree, the way the capability snapshot is, so the fixture needs no entry in
|
||||
/// the project file. A checkout on Windows may have turned its line ends into CRLF, which the
|
||||
/// model never wrote.
|
||||
/// </remarks>
|
||||
private static string ReadFixture(string fileName, [CallerFilePath] string sourceFilePath = "") => File
|
||||
.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "Fixtures", fileName))
|
||||
.Replace("\r\n", "\n");
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user