From efac4d57f16114ced5b19dccd7c88e285257c6f8 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 13 Sep 2026 19:09:58 +0200 Subject: [PATCH] Write the sources of an answer into every exported document --- .../Assistants/I18N/allTexts.lua | 3 ++ .../Chat/ContentBlockComponent.razor.cs | 2 +- .../Chat/IContentExtensions.cs | 48 ++++++++++++++++++- .../plugin.lua | 3 ++ .../plugin.lua | 3 ++ app/MindWork AI Studio/Tools/Markdown.cs | 25 ++++++++++ app/MindWork AI Studio/Tools/PandocExport.cs | 2 +- .../Tools/SourceExtensions.cs | 20 ++++++++ 8 files changed, 103 insertions(+), 3 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 849acf37..93267adf 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -12241,6 +12241,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Fail -- Failed to install update automatically. Please try again manually. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T3709709946"] = "Failed to install update automatically. Please try again manually." +-- Sources +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T2730980305"] = "Sources" + -- Sources provided by the data providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Sources provided by the data providers" diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index b2ec2bab..9ee23715 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -732,7 +732,7 @@ public partial class ContentBlockComponent : MSGComponentBase // if (format.UsesPandoc()) await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content); - else if (this.Content.TryGetMarkdownText(out var markdown)) + else if (this.Content.TryGetExportMarkdown(out var markdown)) await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown); } catch (ArgumentOutOfRangeException e) diff --git a/app/MindWork AI Studio/Chat/IContentExtensions.cs b/app/MindWork AI Studio/Chat/IContentExtensions.cs index 4b86cd72..431b70b8 100644 --- a/app/MindWork AI Studio/Chat/IContentExtensions.cs +++ b/app/MindWork AI Studio/Chat/IContentExtensions.cs @@ -24,7 +24,9 @@ public static class IContentExtensions /// /// Only text content carries Markdown. Everything else, an image for example, has no text /// representation at all, which is why this reports failure instead of returning a placeholder: - /// a caller which writes files must not put an excuse into the file it writes. + /// a caller which writes files must not put an excuse into the file it writes. This is the text + /// the model wrote and nothing else: whoever reads a table out of a message wants exactly that, + /// while whoever writes a file wants the sources along with it and asks for the export reading. /// /// The content to read. /// The Markdown text, or an empty string when there is none. @@ -40,4 +42,48 @@ public static class IContentExtensions markdown = string.Empty; return false; } + + /// + /// Reads this content the way it leaves AI Studio, as a file or through the clipboard. + /// + /// + /// What the user sees is the answer together with the sources AI Studio collected for it, and + /// that is what a document has to hold as well: an answer built on a web page a tool read, or on + /// a document of the user, is worth little when the reader cannot tell which one it was. Those + /// sources are not part of the text the model wrote, they hang on the content, which is why + /// every path out of the app asks for this and not for the text alone. + /// + /// The content to read. + /// The Markdown text including its sources, or an empty string when there is none. + /// True, when this content carries Markdown text. + public static bool TryGetExportMarkdown(this IContent content, out string markdown) + { + if (content is not ContentText text) + { + markdown = string.Empty; + return false; + } + + var answer = text.Text.Trim(); + var sources = text.Sources.ToExportMarkdown(); + if (sources.Length == 0) + { + markdown = answer; + return true; + } + + if (answer.Length == 0) + { + markdown = sources; + return true; + } + + // + // 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; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 80ec3969..fd4f3497 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -12243,6 +12243,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Die -- Failed to install update automatically. Please try again manually. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T3709709946"] = "Fehler bei der automatischen Installation des Updates. Bitte versuchen Sie es manuell erneut." +-- Sources +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T2730980305"] = "Quellen" + -- Sources provided by the data providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Von den Datenanbietern bereitgestellte Quellen" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 640e220c..2b02dbe2 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -12243,6 +12243,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Fail -- Failed to install update automatically. Please try again manually. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T3709709946"] = "Failed to install update automatically. Please try again manually." +-- Sources +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T2730980305"] = "Sources" + -- Sources provided by the data providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Sources provided by the data providers" diff --git a/app/MindWork AI Studio/Tools/Markdown.cs b/app/MindWork AI Studio/Tools/Markdown.cs index c523795b..d43e2979 100644 --- a/app/MindWork AI Studio/Tools/Markdown.cs +++ b/app/MindWork AI Studio/Tools/Markdown.cs @@ -1,4 +1,5 @@ using Markdig; +using Markdig.Syntax; using System.Text; namespace AIStudio.Tools; @@ -58,6 +59,30 @@ public static class Markdown return escaped.ToString(); } + /// Closes a code fence which the text opened but never closed. + /// + /// An unclosed fence runs to the end of the document, so anything appended after it would be + /// read as code instead of as Markdown. The chat never shows this, because it renders the answer + /// and what belongs below it separately. A document is one text, and there an answer which ends + /// in an open fence would swallow whatever follows it. + /// + /// The Markdown text to inspect. + /// The text with its open fence closed, or the text itself when no fence is open. + public static string CloseOpenCodeFence(string markdownText) + { + if (string.IsNullOrWhiteSpace(markdownText)) + return markdownText; + + var document = Markdig.Markdown.Parse(markdownText, SAFE_MARKDOWN_PIPELINE); + + // Only the last fence of a text can be an open one: an open fence takes everything + // after it with it, so no other block is able to follow it. + if (document.Descendants().LastOrDefault() is not { ClosingFencedCharCount: 0 } openFence) + return markdownText; + + return $"{markdownText}{Environment.NewLine}{new string(openFence.FencedChar, openFence.OpeningFencedCharCount)}"; + } + public static string RemoveSharedIndentation(string value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/app/MindWork AI Studio/Tools/PandocExport.cs b/app/MindWork AI Studio/Tools/PandocExport.cs index db804017..c7ce9738 100644 --- a/app/MindWork AI Studio/Tools/PandocExport.cs +++ b/app/MindWork AI Studio/Tools/PandocExport.cs @@ -118,7 +118,7 @@ public static class PandocExport // We read the text before we ask for a path: when there is nothing to convert, the user // should learn that right away instead of picking a file first and getting an error afterwards. // - if (!markdownContent.TryGetMarkdownText(out var markdownText)) + if (!markdownContent.TryGetExportMarkdown(out var markdownText)) { LOGGER.LogWarning("Cannot export the content as {ExportFormat}, because it carries no text.", format); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Only text messages can be exported."))); diff --git a/app/MindWork AI Studio/Tools/SourceExtensions.cs b/app/MindWork AI Studio/Tools/SourceExtensions.cs index d8c5e1af..0d3ade3f 100644 --- a/app/MindWork AI Studio/Tools/SourceExtensions.cs +++ b/app/MindWork AI Studio/Tools/SourceExtensions.cs @@ -153,6 +153,26 @@ public static partial class SourceExtensions return sb.ToString(); } + /// + /// Converts a list of sources to a markdown-formatted string, headed by a title of its own. + /// + /// + /// The chat shows the sources in a box below the answer, so the reader sees where the one ends + /// and the others begin. An exported document is one text: without a heading of its own, the + /// source list would read like one more section the model wrote. This is why the export asks + /// for this and the chat does not. + /// + /// The list of sources to convert. + /// A markdown-formatted string representing the sources, or an empty string when there are none. + public static string ToExportMarkdown(this IList sources) + { + var sourcesMarkdown = sources.ToMarkdown(); + if (string.IsNullOrWhiteSpace(sourcesMarkdown)) + return string.Empty; + + return $"# {TB("Sources")}{Environment.NewLine}{Environment.NewLine}{sourcesMarkdown}"; + } + /// /// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs. ///