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)
{
diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs
index 6b592716d..a20453357 100644
--- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs
+++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs
@@ -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);
diff --git a/app/MindWork AI Studio/Tools/Services/AIGeneratedContentDisclosureService.cs b/app/MindWork AI Studio/Tools/Services/AIGeneratedContentDisclosureService.cs
new file mode 100644
index 000000000..9a7d9e3b9
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/Services/AIGeneratedContentDisclosureService.cs
@@ -0,0 +1,44 @@
+using AIStudio.Settings;
+using AIStudio.Tools.PluginSystem;
+
+namespace AIStudio.Tools.Services;
+
+///
+/// Adds a notice to AI-generated content before it leaves AI Studio, which says that AI generated it.
+///
+///
+/// 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.
+///
+public sealed class AIGeneratedContentDisclosureService(SettingsManager settingsManager)
+{
+ private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AIGeneratedContentDisclosureService).Namespace, nameof(AIGeneratedContentDisclosureService));
+
+ ///
+ /// Adds the notice below the given Markdown text, when it is switched on.
+ ///
+ /// The Markdown text which AI generated.
+ /// The text followed by the notice, or the text itself when the notice is off or there is no text.
+ 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}**");
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
index 49996b016..c97f65149 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
@@ -17,6 +17,8 @@
- Added hints when you import a configuration which somebody exported from AI Studio, for example, when it preselects a provider or a tool which does not exist on your computer, or when a chat template would attach files from your computer to every chat. You decide whether to continue.
- Added a way to relink or remove the file attachments which are missing on your computer when you import a chat template which somebody exported from AI Studio.
- Added organization-wide management for importing configurations which somebody exported from AI Studio. IT departments can allow or prevent such imports, either altogether or for each kind, such as providers or chat templates. A locked Import button stays visible.
+- Added an optional notice for AI-generated content. When you switch it on in the app settings, the AI answers you copy or export end with a note that AI generated them, in the language of the app, for example, when rules on transparency such as the EU AI Act ask for one. Thanks, Sabrina (`Sabrina-devops`), for this contribution.
+- Added organization-wide management for the notice on AI-generated content. IT departments can switch it on for everybody, either fixed or as a default which users may change.
- Improved the Read Web Page tool. When you have not allowed the AI to choose web addresses freely, the tool now refuses every address that does not appear in your chat or in what the tools returned, instead of only asking the AI not to read it.
- Improved the installation on macOS: AI Studio is now signed and checked by Apple, so it opens like any other app. You no longer need the Terminal.
- Improved the installation on Windows: AI Studio is now signed, so Windows shows who published it. For a while, Windows may still ask you to confirm the installation until it knows the app better.
@@ -68,6 +70,7 @@
- Fixed exporting an LLM provider or an embedding provider with a tokenizer. On Windows, the export broke the configuration plugin, and on every system, the plugin ignored the tokenizer because the export named a folder on your computer. The export now names the place in your configuration plugin where the tokenizer file belongs.
- Fixed building AI Studio from source on Windows, where the checks that run before every build failed because two of them expected the line breaks and file paths of macOS and Linux. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
- Fixed a problem on Windows where AI Studio could fail to read the configuration of your organization when the name of an environment variable on your computer contained umlauts or other special characters.
+- Fixed the localization assistant writing the translation instead of the English text into the comment above an entry of a language plugin. This happened when the English language plugin did not contain that text yet.
- Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far.
- Upgraded several libraries to improve security.
- Upgraded to Rust v1.99.0
diff --git a/app/Tests/Tools/AIGeneratedContentDisclosureServiceTests.cs b/app/Tests/Tools/AIGeneratedContentDisclosureServiceTests.cs
new file mode 100644
index 000000000..72bce01dc
--- /dev/null
+++ b/app/Tests/Tools/AIGeneratedContentDisclosureServiceTests.cs
@@ -0,0 +1,82 @@
+using AIStudio.Settings;
+using AIStudio.Tools;
+using AIStudio.Tools.Services;
+
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace AIStudio.Tests.Tools;
+
+///
+/// Checks the notice which says that AI generated an answer the user copies or exports.
+///
+///
+/// The notice must leave the answer itself untouched, and it must read as a notice: not as part of the
+/// last list item, not inside a code block the answer left open, and not with stray backslashes. Until
+/// somebody switches it on, it stays away entirely.
+///
+[TestFixture]
+public sealed class AIGeneratedContentDisclosureServiceTests
+{
+ private const string NOTICE = @"**This content was generated using artificial intelligence \(AI\)\.**";
+
+ [Test]
+ public void TheNoticeIsOffByDefault()
+ {
+ var settingsManager = CreateSettingsManager();
+ var service = new AIGeneratedContentDisclosureService(settingsManager);
+
+ Assert.That(settingsManager.ConfigurationData.App.AddAIGeneratedContentDisclosure, Is.False);
+ Assert.That(service.AddDisclosureToMarkdown("The answer."), Is.EqualTo("The answer."), "Without the setting, the answer leaves AI Studio as it is.");
+ }
+
+ [Test]
+ public void TheNoticeEndsTheAnswerAsAParagraphOfItsOwn()
+ {
+ var answer = Lines("- first point", "- second point");
+
+ Assert.That(CreateServiceWithNotice().AddDisclosureToMarkdown(answer), Is.EqualTo(Lines(answer, string.Empty, NOTICE)));
+ }
+
+ [Test]
+ public void TrailingLineBreaksDoNotWidenTheGap()
+ {
+ var answer = Lines("The answer.", string.Empty, string.Empty);
+
+ Assert.That(CreateServiceWithNotice().AddDisclosureToMarkdown(answer), Is.EqualTo(Lines("The answer.", string.Empty, NOTICE)));
+ }
+
+ [TestCase("")]
+ [TestCase(" ")]
+ public void AnEmptyAnswerStaysEmpty(string answer)
+ {
+ Assert.That(CreateServiceWithNotice().AddDisclosureToMarkdown(answer), Is.EqualTo(answer), "A notice alone would say that AI wrote something which is not there.");
+ }
+
+ [Test]
+ public void AnOpenCodeBlockIsClosedBeforeTheNotice()
+ {
+ var answer = Lines("Here is the code:", string.Empty, "```csharp", "var answer = 42;");
+
+ Assert.That(CreateServiceWithNotice().AddDisclosureToMarkdown(answer), Is.EqualTo(Lines(answer, "```", string.Empty, NOTICE)));
+ }
+
+ [Test]
+ public void TheNoticeIsShownAsBoldTextBelowTheCode()
+ {
+ var answer = Lines("```csharp", "var answer = 42;");
+ var html = Markdig.Markdown.ToHtml(CreateServiceWithNotice().AddDisclosureToMarkdown(answer), Markdown.SAFE_MARKDOWN_PIPELINE);
+
+ Assert.That(html.TrimEnd(), Does.EndWith("\nThis content was generated using artificial intelligence (AI).
"), "The escapes keep the parentheses and the period as plain text, and the closed fence keeps the notice out of the code block.");
+ }
+
+ private static AIGeneratedContentDisclosureService CreateServiceWithNotice()
+ {
+ var settingsManager = CreateSettingsManager();
+ settingsManager.ConfigurationData.App.AddAIGeneratedContentDisclosure = true;
+ return new AIGeneratedContentDisclosureService(settingsManager);
+ }
+
+ private static SettingsManager CreateSettingsManager() => new(NullLogger.Instance, null!);
+
+ private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines);
+}
diff --git a/app/Tests/Tools/MarkdownTests.cs b/app/Tests/Tools/MarkdownTests.cs
index 9344de8a9..bd7bb9e1b 100644
--- a/app/Tests/Tools/MarkdownTests.cs
+++ b/app/Tests/Tools/MarkdownTests.cs
@@ -3,7 +3,8 @@ using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
///
-/// Checks that an answer which opens a code fence without closing it ends where it ends.
+/// Checks that an answer which opens a code fence without closing it ends where it ends, and that
+/// whatever AI Studio puts below an answer stands on its own.
///
///
/// A fence without its counterpart runs to the end of the document, so whatever is appended below an
@@ -62,5 +63,35 @@ public sealed class MarkdownTests
Assert.That(Markdown.CloseOpenCodeFence(string.Empty), Is.Empty);
}
+ [Test]
+ public void AnAppendedBlockFollowsABlankLine()
+ {
+ var answer = Lines("- first point", "- second point");
+
+ Assert.That(Markdown.AppendBlock(answer, "**Notice**"), Is.EqualTo(Lines(answer, string.Empty, "**Notice**")), "Without the blank line, the block would continue the last item of the list.");
+ }
+
+ [Test]
+ public void AnAppendedBlockStaysOutsideOfAnOpenFence()
+ {
+ var answer = Lines("```csharp", "var answer = 42;");
+
+ Assert.That(Markdown.AppendBlock(answer, "**Notice**"), Is.EqualTo(Lines(answer, "```", string.Empty, "**Notice**")));
+ }
+
+ [Test]
+ public void AnEmptyBlockLeavesTheTextAlone()
+ {
+ const string ANSWER = "Nothing goes below this answer.";
+
+ Assert.That(Markdown.AppendBlock(ANSWER, string.Empty), Is.EqualTo(ANSWER));
+ }
+
+ [Test]
+ public void AnEmptyTextGivesTheBlockAlone()
+ {
+ Assert.That(Markdown.AppendBlock(string.Empty, "**Notice**"), Is.EqualTo("**Notice**"), "With nothing above it, a blank line would only start the text with an empty line.");
+ }
+
private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines);
}
\ No newline at end of file
diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md
index 5eb98621e..a945b3e03 100644
--- a/documentation/Enterprise IT.md
+++ b/documentation/Enterprise IT.md
@@ -1046,6 +1046,54 @@ Two things to keep in mind when you prepare the icon:
`IconPath`, or for the configuration plugin itself through its `icon.lua` -- your organization is
responsible for holding the rights to use it.
+## Labeling AI-generated content
+
+Rules on transparency, such as the EU AI Act, may require that a text says when AI generated it, for
+example, when your colleagues publish it. AI Studio can add such a notice: the AI answers which users
+copy or export then end with a paragraph of its own, in bold, which says that the content was
+generated using artificial intelligence (AI). The notice is written in the language of the app. This
+is off by default.
+
+### What gets the notice
+
+- **Copied answers:** the copy button of an answer in the chat or in an assistant, and the "Copy
+ result" button of the assistants.
+- **Exported answers:** every format of the export menu, i.e., Word, OpenDocument, LaTeX, web page,
+ and Markdown.
+- **Files of the batch processing,** when it writes one file per document.
+
+Content which one more sentence would break, or which goes on to a program or another model instead
+of a reader, stays unchanged:
+
+- code blocks copied on their own, and single files or tables exported out of an answer,
+- the CSV table of the batch processing,
+- the Lua code of the assistant builder and the prompt of the prompt optimizer,
+- visual briefings, which AI Studio exports as the very version it checked, byte for byte,
+- messages of the users, transcripts, IDs, and paths.
+
+The notice covers what AI Studio copies and exports itself. Text which somebody selects and copies
+by hand carries no notice, so the setting supports the rules of your organization, but it cannot
+enforce them.
+
+### Switching on the notice
+
+Switch it on for everybody, without a way to opt out:
+
+```lua
+CONFIG["SETTINGS"]["DataApp.AddAIGeneratedContentDisclosure"] = true
+```
+
+Add `AllowUserOverride` to make it the default of your organization, which users may still change
+in the app settings:
+
+```lua
+CONFIG["SETTINGS"]["DataApp.AddAIGeneratedContentDisclosure"] = true
+CONFIG["SETTINGS"]["DataApp.AddAIGeneratedContentDisclosure.AllowUserOverride"] = true
+```
+
+Set it to `false` without `AllowUserOverride` to keep everybody from switching it on. Either way,
+users find the setting in the app settings; when you lock it, they see your value there.
+
## Feature usage statistics (telemetry)
When your organization runs its own AI servers, for example behind a LiteLLM gateway, you may want