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.
///