Added an optional notice for AI-generated content (#884)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Sabrina-devopsandThorsten Sommer authored and GitHub committed 2026-10-16 20:42:04 +02:00
1 parent f5a257270e
commit 249fc778a2
26 files changed
+406 -46

No files matched your search

+19
View File
@@ -83,6 +83,25 @@ public static class Markdown
return $"{markdownText}{Environment.NewLine}{new string(openFence.FencedChar, openFence.OpeningFencedCharCount)}";
}
/// <summary>Puts a block of Markdown below a Markdown text, such as its sources or a notice.</summary>
/// <param name="markdownText">The Markdown text.</param>
/// <param name="block">The block to put below the text, or an empty string when there is none.</param>
/// <returns>The text followed by the block.</returns>
public static string AppendBlock(string markdownText, string block)
{
if (block.Length == 0)
return markdownText;
if (markdownText.Length == 0)
return block;
//
// The blank line is not cosmetic: it ends a paragraph, a list, a table, or a block quote, so
// that the block stands on its own instead of being pulled into the last block of the text.
//
return $"{CloseOpenCodeFence(markdownText)}{Environment.NewLine}{Environment.NewLine}{block}";
}
public static string RemoveSharedIndentation(string value)
{
if (string.IsNullOrWhiteSpace(value))
+8 -1
View File
@@ -20,6 +20,8 @@ public static class PandocExport
/// This says nothing to the user: it reports what happened and lets the caller decide. A batch
/// run over hundreds of documents would otherwise bury the user under notifications. Pandoc
/// must be available, which PandocAvailabilityService.EnsureAvailabilityAsync takes care of.
/// The text is converted as it is: whether it ends with the notice that AI generated it is up
/// to the caller, which knows what the text is.
/// </remarks>
/// <param name="rustService">The Rust service, used to build the Pandoc call.</param>
/// <param name="markdownText">The Markdown text to convert.</param>
@@ -113,6 +115,9 @@ public static class PandocExport
/// </summary>
/// <param name="rustService">The Rust service, used for the save dialog and for Pandoc.</param>
/// <param name="pandocAvailability">Makes sure Pandoc is there and offers its installation.</param>
/// <param name="disclosureService">Ends the text with the notice that AI generated it, when that is
/// switched on. Null for content which never gets the notice, such as the answer of the prompt
/// optimizer, which is meant for another model.</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 uses Pandoc.</param>
@@ -120,7 +125,7 @@ public static class PandocExport
/// <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, string? fileName = null)
public static async Task<bool> ToDocument(RustService rustService, PandocAvailabilityService pandocAvailability, AIGeneratedContentDisclosureService? disclosureService, 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.");
@@ -136,6 +141,8 @@ public static class PandocExport
return false;
}
markdownText = disclosureService?.AddDisclosureToMarkdown(markdownText) ?? markdownText;
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName));
if (response.UserCancelled)
{
@@ -304,6 +304,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: show administration settings?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowAdminSettings, this.Id, settingsTable, dryRun);
// Config: add a disclosure to copied and exported AI-generated content?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AddAIGeneratedContentDisclosure, this.Id, settingsTable, dryRun);
// Config: preview features visibility
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreviewVisibility, this.Id, settingsTable, dryRun);
@@ -0,0 +1,44 @@
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.Services;
/// <summary>
/// Adds a notice to AI-generated content before it leaves AI Studio, which says that AI generated it.
/// </summary>
/// <remarks>
/// The notice is off by default. Whether a text has to say that AI generated it depends on what happens
/// to it afterward, for example, whether it is published, as transparency rules such as the EU AI Act
/// ask. Only the user or their organization knows that, so the user switches it on in the app settings,
/// or an organization does so for everybody with a configuration plugin.
///
/// The notice ends the content as a paragraph of its own, in the language of the app. It leaves the
/// answer itself untouched, and a reader finds it where the text ends, below the sources as well.
/// It is Markdown, like everything else AI Studio copies or exports, which is why it is bold in a
/// document and stays readable as plain text.
///
/// Only content a reader takes as a whole gets the notice: an answer which is copied or exported, and
/// the files of a batch run. Where one sentence more breaks what the user wanted, it stays away: a code
/// block, a single file or table out of an answer, the table of a batch run, and the Lua code of the
/// assistant builder. The prompt of the prompt optimizer goes on to another model instead of a reader,
/// and a visual briefing is exported as the very version AI Studio checked, byte for byte, so neither
/// of them gets the notice. Messages of the user, transcripts, IDs, and paths are no AI answers.
/// </remarks>
public sealed class AIGeneratedContentDisclosureService(SettingsManager settingsManager)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AIGeneratedContentDisclosureService).Namespace, nameof(AIGeneratedContentDisclosureService));
/// <summary>
/// Adds the notice below the given Markdown text, when it is switched on.
/// </summary>
/// <param name="markdown">The Markdown text which AI generated.</param>
/// <returns>The text followed by the notice, or the text itself when the notice is off or there is no text.</returns>
public string AddDisclosureToMarkdown(string markdown)
{
if (!settingsManager.ConfigurationData.App.AddAIGeneratedContentDisclosure || string.IsNullOrWhiteSpace(markdown))
return markdown;
var disclosure = Markdown.EscapeInlineText(TB("This content was generated using artificial intelligence (AI)."));
return Markdown.AppendBlock(markdown.TrimEnd('\r', '\n'), $"**{disclosure}**");
}
}