Write the sources of an answer into every exported document

This commit is contained in:
Thorsten Sommer 2026-09-13 19:09:58 +02:00
parent bb3ec5da92
commit efac4d57f1
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
8 changed files with 103 additions and 3 deletions

View File

@ -12241,6 +12241,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Fail
-- Failed to install update automatically. Please try again manually. -- 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." 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 -- Sources provided by the data providers
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Sources provided by the data providers" UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Sources provided by the data providers"

View File

@ -732,7 +732,7 @@ public partial class ContentBlockComponent : MSGComponentBase
// //
if (format.UsesPandoc()) 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);
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); await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown);
} }
catch (ArgumentOutOfRangeException e) catch (ArgumentOutOfRangeException e)

View File

@ -24,7 +24,9 @@ public static class IContentExtensions
/// <remarks> /// <remarks>
/// Only text content carries Markdown. Everything else, an image for example, has no text /// 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: /// 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.
/// </remarks> /// </remarks>
/// <param name="content">The content to read.</param> /// <param name="content">The content to read.</param>
/// <param name="markdown">The Markdown text, or an empty string when there is none.</param> /// <param name="markdown">The Markdown text, or an empty string when there is none.</param>
@ -40,4 +42,48 @@ public static class IContentExtensions
markdown = string.Empty; markdown = string.Empty;
return false; return false;
} }
/// <summary>
/// Reads this content the way it leaves AI Studio, as a file or through the clipboard.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="content">The content to read.</param>
/// <param name="markdown">The Markdown text including its sources, or an empty string when there is none.</param>
/// <returns>True, when this content carries Markdown text.</returns>
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;
}
} }

View File

@ -12243,6 +12243,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Die
-- Failed to install update automatically. Please try again manually. -- 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." 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 -- Sources provided by the data providers
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Von den Datenanbietern bereitgestellte Quellen" UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Von den Datenanbietern bereitgestellte Quellen"

View File

@ -12243,6 +12243,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Fail
-- Failed to install update automatically. Please try again manually. -- 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." 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 -- Sources provided by the data providers
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Sources provided by the data providers" UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Sources provided by the data providers"

View File

@ -1,4 +1,5 @@
using Markdig; using Markdig;
using Markdig.Syntax;
using System.Text; using System.Text;
namespace AIStudio.Tools; namespace AIStudio.Tools;
@ -58,6 +59,30 @@ public static class Markdown
return escaped.ToString(); return escaped.ToString();
} }
/// <summary>Closes a code fence which the text opened but never closed.</summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="markdownText">The Markdown text to inspect.</param>
/// <returns>The text with its open fence closed, or the text itself when no fence is open.</returns>
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<FencedCodeBlock>().LastOrDefault() is not { ClosingFencedCharCount: 0 } openFence)
return markdownText;
return $"{markdownText}{Environment.NewLine}{new string(openFence.FencedChar, openFence.OpeningFencedCharCount)}";
}
public static string RemoveSharedIndentation(string value) public static string RemoveSharedIndentation(string value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))

View File

@ -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 // 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. // 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); 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."))); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Only text messages can be exported.")));

View File

@ -153,6 +153,26 @@ public static partial class SourceExtensions
return sb.ToString(); return sb.ToString();
} }
/// <summary>
/// Converts a list of sources to a markdown-formatted string, headed by a title of its own.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="sources">The list of sources to convert.</param>
/// <returns>A markdown-formatted string representing the sources, or an empty string when there are none.</returns>
public static string ToExportMarkdown(this IList<Source> sources)
{
var sourcesMarkdown = sources.ToMarkdown();
if (string.IsNullOrWhiteSpace(sourcesMarkdown))
return string.Empty;
return $"# {TB("Sources")}{Environment.NewLine}{Environment.NewLine}{sourcesMarkdown}";
}
/// <summary> /// <summary>
/// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs. /// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs.
/// </summary> /// </summary>