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

@@ -80,7 +80,7 @@
@if (this.ShowResult && !this.ShowEntireChatThread && this.ResultingContentBlock?.Content != null)
{
<ContentBlockComponent Role="@(this.ResultingContentBlock.Role)" Type="@(this.ResultingContentBlock.ContentType)" Time="@(this.ResultingContentBlock.Time)" Content="@this.ResultingContentBlock.Content" ExportTitle="@TB("Export result")" ExportFileName="@this.ExportFileName"/>
<ContentBlockComponent Role="@(this.ResultingContentBlock.Role)" Type="@(this.ResultingContentBlock.ContentType)" Time="@(this.ResultingContentBlock.Time)" Content="@this.ResultingContentBlock.Content" ExportTitle="@TB("Export result")" ExportFileName="@this.ExportFileName" AddsAIGeneratedContentDisclosure="@this.AddsAIGeneratedContentDisclosure"/>
}
@if(this.ShowResult && this.ShowEntireChatThread && this.ChatThread is not null)
@@ -89,7 +89,7 @@
{
@if (block is { HideFromUser: false, Content: not null })
{
<ContentBlockComponent Role="@block.Role" Type="@block.ContentType" Time="@block.Time" Content="@block.Content" ExportTitle="@TB("Export result")" ExportFileName="@this.ExportFileName"/>
<ContentBlockComponent Role="@block.Role" Type="@block.ContentType" Time="@block.Time" Content="@block.Content" ExportTitle="@TB("Export result")" ExportFileName="@this.ExportFileName" AddsAIGeneratedContentDisclosure="@this.AddsAIGeneratedContentDisclosure"/>
}
}
}
@@ -30,6 +30,9 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
[Inject]
protected RustService RustService { get; init; } = null!;
[Inject]
protected AIGeneratedContentDisclosureService DisclosureService { get; init; } = null!;
[Inject]
protected ToolRegistry ToolRegistry { get; init; } = null!;
@@ -108,7 +111,17 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected virtual bool ShowSendTo => true;
protected virtual bool ShowCopyResult => true;
/// <summary>
/// Whether "Copy result", and copying or exporting the answers this assistant shows, end them with the
/// notice that AI generated them, when that is switched on.
/// </summary>
/// <remarks>
/// An assistant whose result is meant for a program or another model rather than for a reader turns
/// this off, because one sentence more would change what the result does.
/// </remarks>
protected virtual bool AddsAIGeneratedContentDisclosure => true;
protected virtual bool ShowReset => true;
protected virtual string? SendToChatVisibleUserPromptPrefix => null;
@@ -652,7 +665,11 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected async Task CopyToClipboard()
{
await this.RustService.CopyText2Clipboard(this.Result2Copy());
var result = this.Result2Copy();
if (this.AddsAIGeneratedContentDisclosure)
result = this.DisclosureService.AddDisclosureToMarkdown(result);
await this.RustService.CopyText2Clipboard(result);
}
private ChatThread CreateSendToChatThread()
@@ -228,6 +228,12 @@ public partial class AssistantBatchProcessing
{
try
{
//
// Only the file of a document ends with the notice that AI generated it, when that is
// switched on. The table of results and a CSV file keep the answer as it is: there,
// the notice would repeat in every row and get in the way of whoever processes the table.
//
var resultText = this.DisclosureService.AddDisclosureToMarkdown(aiAnswer);
var resultFilePath = Path.Join(resolvedOutputDirectory, this.CreateResultFileName(fileResult.FileName));
if (this.resultFileFormat.UsesPandoc())
{
@@ -235,14 +241,14 @@ public partial class AssistantBatchProcessing
// Pandoc reports a failure instead of throwing, because one document which
// cannot be converted must not end a run over hundreds of them:
//
if (!await PandocExport.ConvertAsync(this.RustService, aiAnswer, resultFilePath, this.resultFileFormat, token))
if (!await PandocExport.ConvertAsync(this.RustService, resultText, resultFilePath, this.resultFileFormat, token))
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("Was not able to convert the answer into the chosen file format."));
return;
}
}
else
await File.WriteAllTextAsync(resultFilePath, aiAnswer, this.resultFileFormat.ToFileEncoding(), CancellationToken.None);
await File.WriteAllTextAsync(resultFilePath, resultText, this.resultFileFormat.ToFileEncoding(), CancellationToken.None);
this.FinishFileResult(fileResult, BatchProcessingFileStatus.DONE, Path.GetFileName(resultFilePath));
}
@@ -79,6 +79,9 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
protected override bool ShowCopyResult => this.step is BuilderStep.DONE;
// The result is the Lua code of a plugin, and a notice below it would make that code invalid:
protected override bool AddsAIGeneratedContentDisclosure => false;
protected override bool HasSettingsPanel => false;
protected override Func<string> Result2Copy => () => !string.IsNullOrWhiteSpace(this.generatedLuaAssistant) ? this.generatedLuaAssistant : this.generatedAssistantSpec;
@@ -114,6 +114,11 @@ public partial class AssistantI18N : AssistantBaseCore<SettingsDialogI18N>
private string searchString = string.Empty;
private Guid selectedLanguagePluginId;
private ILanguagePlugin? selectedLanguagePlugin;
/// <summary>
/// The English text of every key, as the I18N collector wrote it into allTexts.lua.
/// </summary>
private Dictionary<string, string> sourceContent = [];
private Dictionary<string, string> addedContent = [];
private Dictionary<string, string> removedContent = [];
private Dictionary<string, string> localizedContent = [];
@@ -127,6 +132,7 @@ public partial class AssistantI18N : AssistantBaseCore<SettingsDialogI18N>
private static readonly AssistantSessionStateKey<string> SEARCH_STRING_STATE_KEY = new(nameof(searchString));
private static readonly AssistantSessionStateKey<Guid> SELECTED_LANGUAGE_PLUGIN_ID_STATE_KEY = new(nameof(selectedLanguagePluginId));
private static readonly AssistantSessionStateKey<ILanguagePlugin?> SELECTED_LANGUAGE_PLUGIN_STATE_KEY = new(nameof(selectedLanguagePlugin));
private static readonly AssistantSessionStateKey<Dictionary<string, string>> SOURCE_CONTENT_STATE_KEY = new(nameof(sourceContent));
private static readonly AssistantSessionStateKey<Dictionary<string, string>> ADDED_CONTENT_STATE_KEY = new(nameof(addedContent));
private static readonly AssistantSessionStateKey<Dictionary<string, string>> REMOVED_CONTENT_STATE_KEY = new(nameof(removedContent));
private static readonly AssistantSessionStateKey<Dictionary<string, string>> LOCALIZED_CONTENT_STATE_KEY = new(nameof(localizedContent));
@@ -143,6 +149,7 @@ public partial class AssistantI18N : AssistantBaseCore<SettingsDialogI18N>
state.Set(SEARCH_STRING_STATE_KEY, this.searchString);
state.Set(SELECTED_LANGUAGE_PLUGIN_ID_STATE_KEY, this.selectedLanguagePluginId);
state.Set(SELECTED_LANGUAGE_PLUGIN_STATE_KEY, this.selectedLanguagePlugin);
state.SetDictionary(SOURCE_CONTENT_STATE_KEY, this.sourceContent);
state.SetDictionary(ADDED_CONTENT_STATE_KEY, this.addedContent);
state.SetDictionary(REMOVED_CONTENT_STATE_KEY, this.removedContent);
state.SetDictionary(LOCALIZED_CONTENT_STATE_KEY, this.localizedContent);
@@ -160,6 +167,7 @@ public partial class AssistantI18N : AssistantBaseCore<SettingsDialogI18N>
state.Restore(SEARCH_STRING_STATE_KEY, value => this.searchString = value);
state.Restore(SELECTED_LANGUAGE_PLUGIN_ID_STATE_KEY, value => this.selectedLanguagePluginId = value);
state.Restore(SELECTED_LANGUAGE_PLUGIN_STATE_KEY, value => this.selectedLanguagePlugin = value);
state.RestoreDictionary(SOURCE_CONTENT_STATE_KEY, this.sourceContent);
state.RestoreDictionary(ADDED_CONTENT_STATE_KEY, this.addedContent);
state.RestoreDictionary(REMOVED_CONTENT_STATE_KEY, this.removedContent);
state.RestoreDictionary(LOCALIZED_CONTENT_STATE_KEY, this.localizedContent);
@@ -275,6 +283,7 @@ public partial class AssistantI18N : AssistantBaseCore<SettingsDialogI18N>
var newI18NContent = pluginLanguage.Content;
var currentI18NContent = this.selectedLanguagePlugin.Content;
this.sourceContent = newI18NContent.ToDictionary();
this.addedContent = newI18NContent.ExceptBy(currentI18NContent.Keys, n => n.Key).ToDictionary();
this.removedContent = currentI18NContent.ExceptBy(newI18NContent.Keys, n => n.Key).ToDictionary();
this.localizationPossible = true;
@@ -354,7 +363,7 @@ public partial class AssistantI18N : AssistantBaseCore<SettingsDialogI18N>
var removedContentSnapshot = this.removedContent.ToArray();
var removedContentKeys = removedContentSnapshot.Select(keyValuePair => keyValuePair.Key).ToHashSet(StringComparer.Ordinal);
var selectedLanguageContentSnapshot = this.selectedLanguagePlugin.Content.ToArray();
var baseLanguageContentSnapshot = PluginFactory.BaseLanguage.Content.ToArray();
var sourceContentSnapshot = this.sourceContent.ToDictionary(keyValuePair => keyValuePair.Key, keyValuePair => keyValuePair.Value, StringComparer.Ordinal);
this.localizedContent.Clear();
this.activeSystemPromptLanguage = this.SystemPromptLanguage();
@@ -398,22 +407,12 @@ public partial class AssistantI18N : AssistantBaseCore<SettingsDialogI18N>
return;
//
// Phase 2: Create the Lua code. We want to use the base language
// for the comments, though:
// Phase 2: Create the Lua code. Each comment names the English text, which
// allTexts.lua holds for every key of the result. The English language plugin
// would not do: a text which is missing there, e.g., because somebody added it
// to this language by hand, would get its translation as a comment instead.
//
var commentContent = addedContentSnapshot.ToDictionary(keyValuePair => keyValuePair.Key, keyValuePair => keyValuePair.Value, StringComparer.Ordinal);
foreach (var keyValuePair in baseLanguageContentSnapshot)
{
if (this.CancellationTokenSource!.IsCancellationRequested)
break;
if (removedContentKeys.Contains(keyValuePair.Key))
continue;
commentContent.TryAdd(keyValuePair.Key, keyValuePair.Value);
}
this.Phase2CreateLuaCode(commentContent);
this.Phase2CreateLuaCode(sourceContentSnapshot);
}
finally
{
@@ -4948,6 +4948,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T148008546"]
-- Select the desired behavior for the navigation bar.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1555038969"] = "Select the desired behavior for the navigation bar."
-- When enabled, AI answers which you copy or export end with a notice that they were generated using artificial intelligence (AI), in the language of the app. The same holds for the files of the Batch Processing Assistant. Code blocks, single files or tables taken from an answer, visual briefings, and the results of the Prompt Optimizer and the Assistant Builder stay unchanged. Rules on transparency, such as the EU AI Act, may require such a notice, for example, when you publish a text.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1594053136"] = "When enabled, AI answers which you copy or export end with a notice that they were generated using artificial intelligence (AI), in the language of the app. The same holds for the files of the Batch Processing Assistant. Code blocks, single files or tables taken from an answer, visual briefings, and the results of the Prompt Optimizer and the Assistant Builder stay unchanged. Rules on transparency, such as the EU AI Act, may require such a notice, for example, when you publish a text."
-- Color theme
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] = "Color theme"
@@ -5008,6 +5011,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"]
-- Update installation method
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T237706157"] = "Update installation method"
-- AI-generated content gets no notice
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2533412202"] = "AI-generated content gets no notice"
-- AI-generated content gets a notice
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2544374612"] = "AI-generated content gets a notice"
-- Language
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591284123"] = "Language"
@@ -5029,6 +5038,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2918560776"]
-- Enter one host pattern per line. Exact hosts such as data.intra.example.org and one-label wildcards such as *.intra.example.org are supported. Cloud provider endpoints built into AI Studio, such as OpenAI, Google, etc., never use these additional root certificates.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2960110864"] = "Enter one host pattern per line. Exact hosts such as data.intra.example.org and one-label wildcards such as *.intra.example.org are supported. Cloud provider endpoints built into AI Studio, such as OpenAI, Google, etc., never use these additional root certificates."
-- Add a notice to AI-generated content?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3070682674"] = "Add a notice to AI-generated content?"
-- Save energy?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Save energy?"
@@ -13132,6 +13144,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SECURITY::PROMPTINJECTIONSOURCEKINDEXTENSIONS:
-- Mail content
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SECURITY::PROMPTINJECTIONSOURCEKINDEXTENSIONS::T841590371"] = "Mail content"
-- This content was generated using artificial intelligence (AI).
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::AIGENERATEDCONTENTDISCLOSURESERVICE::T3440957850"] = "This content was generated using artificial intelligence (AI)."
-- The revised assistant plugin asks for tools this AI Studio does not have: '{0}'. Please try again.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T1002777578"] = "The revised assistant plugin asks for tools this AI Studio does not have: '{0}'. Please try again."
@@ -99,6 +99,9 @@ public partial class AssistantPromptOptimizer : AssistantBaseCore<SettingsDialog
protected override Func<string> Result2Copy => () => this.optimizedPrompt;
// The optimized prompt goes on to a model instead of a reader, and a notice would become part of the prompt:
protected override bool AddsAIGeneratedContentDisclosure => false;
protected override IReadOnlyList<IButtonData> FooterButtons =>
[
new ButtonData
@@ -129,6 +129,10 @@ public partial class VisualBriefingAssistant
return;
}
//
// The export is the version which the integrity check just verified, byte for byte. That is
// why it gets no notice that AI generated it: any change would make it a version nobody checked.
//
await using var source = verified.Value.Stream;
await using var destination = new FileStream(response.SaveFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 65_536, true);
await source.CopyToAsync(destination);
@@ -107,7 +107,7 @@
</MudMenu>
</MudTooltip>
}
<MudCopyClipboardButton Content="@this.Content" Type="@this.Type" Size="Size.Medium"/>
<MudCopyClipboardButton Content="@this.Content" Type="@this.Type" Size="Size.Medium" IsAIGenerated="@this.GetsAIGeneratedContentDisclosure"/>
</div>
</CardHeaderActions>
</MudCardHeader>
@@ -123,13 +123,27 @@ public partial class ContentBlockComponent : MSGComponentBase
/// </remarks>
[Parameter]
public string? ExportFileName { get; set; }
/// <summary>
/// Whether copying or exporting an AI answer of this block ends it with the notice that AI
/// generated it, when that is switched on.
/// </summary>
/// <remarks>
/// An assistant whose answer is meant for another model rather than for a reader, such as the
/// prompt optimizer, turns this off, the same way as for its "Copy result" button.
/// </remarks>
[Parameter]
public bool AddsAIGeneratedContentDisclosure { get; set; } = true;
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private AIGeneratedContentDisclosureService DisclosureService { get; init; } = null!;
[Inject]
private IJSRuntime JsRuntime { get; init; } = null!;
@@ -165,6 +179,11 @@ public partial class ContentBlockComponent : MSGComponentBase
/// </remarks>
private bool CanExport => this.Content is { InitialRemoteWait: false, IsStreaming: false } && this.Content.TryGetMarkdownText(out _);
/// <summary>
/// Whether this block leaves AI Studio with the notice that AI generated it, when that is switched on.
/// </summary>
private bool GetsAIGeneratedContentDisclosure => this.Role is ChatRole.AI && this.AddsAIGeneratedContentDisclosure;
/// <summary>
/// The files this block holds, tables and code blocks, so that the export menu can offer each of them.
/// </summary>
@@ -755,7 +774,7 @@ public partial class ContentBlockComponent : MSGComponentBase
}
/// <summary>
/// Exports the entire message.
/// Exports the entire message, ending with the notice that AI generated it when that is switched on.
/// </summary>
private async Task ExportDocument(FileExportFormat format)
{
@@ -765,10 +784,11 @@ public partial class ContentBlockComponent : MSGComponentBase
// The format itself knows who writes it, so we do not have to keep a list of formats
// here which would fall out of sync with the one in FileExportFormatExtensions.
//
var disclosureService = this.GetsAIGeneratedContentDisclosure ? this.DisclosureService : null;
if (format.UsesPandoc())
await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content, this.ExportFileName);
await PandocExport.ToDocument(this.RustService, this.PandocAvailability, disclosureService, this.EffectiveExportTitle, format, this.Content, this.ExportFileName);
else if (this.Content.TryGetExportMarkdown(out var markdown))
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown, this.ExportFileName);
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, disclosureService?.AddDisclosureToMarkdown(markdown) ?? markdown, this.ExportFileName);
}
catch (ArgumentOutOfRangeException e)
{
@@ -780,6 +800,11 @@ public partial class ContentBlockComponent : MSGComponentBase
/// Exports one file out of the message, along with the sources the answer rests on wherever its
/// format has room for them.
/// </summary>
/// <remarks>
/// Unlike the entire message, the file never gets the notice that AI generated it. It is meant for
/// a program as often as for a reader, a table or a web page for instance, and one sentence more
/// would break most of them.
/// </remarks>
private async Task ExportFile(MessageFile file)
{
try
@@ -113,19 +113,5 @@ public static class IContentExtensions
/// <param name="sources">The source list as SourceExtensions.ToExportMarkdown writes it, or an
/// empty string when there are no sources.</param>
/// <returns>The text followed by its sources.</returns>
private static string AppendSources(string markdown, string sources)
{
if (sources.Length == 0)
return markdown;
if (markdown.Length == 0)
return sources;
//
// 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.
//
return $"{Markdown.CloseOpenCodeFence(markdown)}{Environment.NewLine}{Environment.NewLine}{sources}";
}
private static string AppendSources(string markdown, string sources) => Markdown.AppendBlock(markdown, sources);
}
@@ -33,6 +33,12 @@ public partial class MudCopyClipboardButton : ComponentBase
[Parameter]
public string TooltipMessage { get; set; } = TB("Copies the content to the clipboard");
/// <summary>
/// Whether AI generated the content. It then ends with a notice saying so, when that is switched on.
/// </summary>
[Parameter]
public bool IsAIGenerated { get; set; }
/// <summary>
/// The size of the button. The default size is small.
/// </summary>
@@ -41,6 +47,9 @@ public partial class MudCopyClipboardButton : ComponentBase
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private AIGeneratedContentDisclosureService DisclosureService { get; init; } = null!;
private async Task HandleCopyClick()
{
@@ -55,7 +64,8 @@ public partial class MudCopyClipboardButton : ComponentBase
/// </summary>
private async Task CopyToClipboard(string textContent)
{
await this.RustService.CopyText2Clipboard(textContent);
var text = this.IsAIGenerated ? this.DisclosureService.AddDisclosureToMarkdown(textContent) : textContent;
await this.RustService.CopyText2Clipboard(text);
}
/// <summary>
@@ -74,7 +84,7 @@ public partial class MudCopyClipboardButton : ComponentBase
switch (this.Type)
{
case ContentType.TEXT when contentToCopy.TryGetExportMarkdown(out var markdown):
await this.RustService.CopyText2Clipboard(markdown);
await this.CopyToClipboard(markdown);
break;
default:
@@ -22,6 +22,7 @@
<ConfigurationSelect OptionDescription="@T("Navigation bar behavior")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.NavigationBehavior)" Data="@ConfigurationSelectDataFactory.GetNavBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.NavigationBehavior = selectedValue)" OptionHelp="@T("Select the desired behavior for the navigation bar.")"/>
<ConfigurationSelect OptionDescription="@T("Start page")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.StartPage)" Data="@ConfigurationSelectDataFactory.GetStartPageData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.StartPage = selectedValue)" OptionHelp="@this.GetStartPageHelpText()" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.StartPage, out var meta) && meta.IsLocked"/>
<ConfigurationOption OptionDescription="@T("Show administration settings?")" LabelOn="@T("Administration settings are visible")" LabelOff="@T("Administration settings are not visible")" State="@(() => this.SettingsManager.ConfigurationData.App.ShowAdminSettings)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.ShowAdminSettings = updatedState)" OptionHelp="@T("When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.ShowAdminSettings, out var meta) && meta.IsLocked"/>
<ConfigurationOption OptionDescription="@T("Add a notice to AI-generated content?")" LabelOn="@T("AI-generated content gets a notice")" LabelOff="@T("AI-generated content gets no notice")" State="@(() => this.SettingsManager.ConfigurationData.App.AddAIGeneratedContentDisclosure)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.AddAIGeneratedContentDisclosure = updatedState)" OptionHelp="@T("When enabled, AI answers which you copy or export end with a notice that they were generated using artificial intelligence (AI), in the language of the app. The same holds for the files of the Batch Processing Assistant. Code blocks, single files or tables taken from an answer, visual briefings, and the results of the Prompt Optimizer and the Assistant Builder stay unchanged. Rules on transparency, such as the EU AI Act, may require such a notice, for example, when you publish a text.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.AddAIGeneratedContentDisclosure, out var meta) && meta.IsLocked"/>
<ConfigurationSelect OptionDescription="@T("Preview feature visibility")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreviewVisibility)" Data="@ConfigurationSelectDataFactory.GetPreviewVisibility()" SelectionUpdate="@this.UpdatePreviewFeatures" OptionHelp="@T("Do you want to show preview features in the app?")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.PreviewVisibility, out var meta) && meta.IsLocked"/>
@if (this.SettingsManager.ConfigurationData.App.PreviewVisibility > PreviewVisibility.NONE)
@@ -482,6 +482,21 @@ CONFIG["SETTINGS"] = {}
-- Configure whether administration settings are visible in the UI:
-- CONFIG["SETTINGS"]["DataApp.ShowAdminSettings"] = true
-- Configure whether AI answers which users copy or export end with a notice that they were
-- generated using artificial intelligence (AI). The notice is written in the language of the app.
-- It also ends the files of the batch processing. Code blocks, single files or tables taken from
-- an answer, visual briefings, and the results of the prompt optimizer and the assistant builder
-- stay unchanged. The default is false: AI Studio then adds no notice. The Enterprise IT
-- documentation describes what gets the notice.
-- Allowed values are: true, false
-- CONFIG["SETTINGS"]["DataApp.AddAIGeneratedContentDisclosure"] = true
--
-- Allow users to change this setting locally.
-- Allowed values are: true, false
-- When set to true, the configured value becomes the organization default,
-- but users can still switch it on or off in the app settings.
-- CONFIG["SETTINGS"]["DataApp.AddAIGeneratedContentDisclosure.AllowUserOverride"] = true
-- Configure the visibility of preview features:
-- Allowed values are: NONE, RELEASE_CANDIDATE, BETA, ALPHA, PROTOTYPE, EXPERIMENTAL
-- Please note:
@@ -4950,6 +4950,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T148008546"]
-- Select the desired behavior for the navigation bar.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1555038969"] = "Wählen Sie das gewünschte Verhalten für die Navigationsleiste aus."
-- When enabled, AI answers which you copy or export end with a notice that they were generated using artificial intelligence (AI), in the language of the app. The same holds for the files of the Batch Processing Assistant. Code blocks, single files or tables taken from an answer, visual briefings, and the results of the Prompt Optimizer and the Assistant Builder stay unchanged. Rules on transparency, such as the EU AI Act, may require such a notice, for example, when you publish a text.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1594053136"] = "Wenn diese Option aktiviert ist, enden KI-Antworten, die Sie kopieren oder exportieren, mit einem Hinweis in der Sprache der App, dass sie mit künstlicher Intelligenz (KI) erstellt wurden. Dasselbe gilt für die Dateien des Assistenten für die Stapelverarbeitung. Codeblöcke, einzelne Dateien oder Tabellen aus einer Antwort, visuelle Briefings sowie die Ergebnisse des Prompt-Optimierers und des Assistenten-Builders bleiben unverändert. Transparenzpflichten wie die der KI-Verordnung der EU können einen solchen Hinweis verlangen, zum Beispiel wenn Sie einen Text veröffentlichen."
-- Color theme
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] = "Farbschema"
@@ -5010,6 +5013,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"]
-- Update installation method
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T237706157"] = "Installationsmethode für Updates"
-- AI-generated content gets no notice
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2533412202"] = "KI-generierte Inhalte werden nicht gekennzeichnet"
-- AI-generated content gets a notice
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2544374612"] = "KI-generierte Inhalte werden gekennzeichnet"
-- Language
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591284123"] = "Sprache"
@@ -5031,6 +5040,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2918560776"]
-- Enter one host pattern per line. Exact hosts such as data.intra.example.org and one-label wildcards such as *.intra.example.org are supported. Cloud provider endpoints built into AI Studio, such as OpenAI, Google, etc., never use these additional root certificates.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2960110864"] = "Geben Sie pro Zeile ein Hostmuster ein. Exakte Hosts wie data.intra.example.org sowie Wildcards mit einem Label wie *.intra.example.org werden unterstützt. In AI Studio integrierte Endpunkte von Cloud-Anbietern wie OpenAI, Google usw. verwenden diese zusätzlichen Stammzertifikate nicht."
-- Add a notice to AI-generated content?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3070682674"] = "KI-generierte Inhalte kennzeichnen?"
-- Save energy?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Energie sparen?"
@@ -13134,6 +13146,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SECURITY::PROMPTINJECTIONSOURCEKINDEXTENSIONS:
-- Mail content
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SECURITY::PROMPTINJECTIONSOURCEKINDEXTENSIONS::T841590371"] = "E-Mail-Inhalt"
-- This content was generated using artificial intelligence (AI).
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::AIGENERATEDCONTENTDISCLOSURESERVICE::T3440957850"] = "Dieser Inhalt wurde mit künstlicher Intelligenz (KI) erstellt."
-- The revised assistant plugin asks for tools this AI Studio does not have: '{0}'. Please try again.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T1002777578"] = "Das überarbeitete Assistenten-Plugin fordert Werkzeuge an, die dieses AI Studio nicht hat: „{0}“. Bitte versuchen Sie es erneut."
@@ -4950,6 +4950,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T148008546"]
-- Select the desired behavior for the navigation bar.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1555038969"] = "Select the desired behavior for the navigation bar."
-- When enabled, AI answers which you copy or export end with a notice that they were generated using artificial intelligence (AI), in the language of the app. The same holds for the files of the Batch Processing Assistant. Code blocks, single files or tables taken from an answer, visual briefings, and the results of the Prompt Optimizer and the Assistant Builder stay unchanged. Rules on transparency, such as the EU AI Act, may require such a notice, for example, when you publish a text.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1594053136"] = "When enabled, AI answers which you copy or export end with a notice that they were generated using artificial intelligence (AI), in the language of the app. The same holds for the files of the Batch Processing Assistant. Code blocks, single files or tables taken from an answer, visual briefings, and the results of the Prompt Optimizer and the Assistant Builder stay unchanged. Rules on transparency, such as the EU AI Act, may require such a notice, for example, when you publish a text."
-- Color theme
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] = "Color theme"
@@ -5010,6 +5013,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"]
-- Update installation method
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T237706157"] = "Update installation method"
-- AI-generated content gets no notice
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2533412202"] = "AI-generated content gets no notice"
-- AI-generated content gets a notice
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2544374612"] = "AI-generated content gets a notice"
-- Language
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591284123"] = "Language"
@@ -5031,6 +5040,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2918560776"]
-- Enter one host pattern per line. Exact hosts such as data.intra.example.org and one-label wildcards such as *.intra.example.org are supported. Cloud provider endpoints built into AI Studio, such as OpenAI, Google, etc., never use these additional root certificates.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2960110864"] = "Enter one host pattern per line. Exact hosts such as data.intra.example.org and one-label wildcards such as *.intra.example.org are supported. Cloud provider endpoints built into AI Studio, such as OpenAI, Google, etc., never use these additional root certificates."
-- Add a notice to AI-generated content?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3070682674"] = "Add a notice to AI-generated content?"
-- Save energy?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Save energy?"
@@ -13134,6 +13146,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SECURITY::PROMPTINJECTIONSOURCEKINDEXTENSIONS:
-- Mail content
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SECURITY::PROMPTINJECTIONSOURCEKINDEXTENSIONS::T841590371"] = "Mail content"
-- This content was generated using artificial intelligence (AI).
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::AIGENERATEDCONTENTDISCLOSURESERVICE::T3440957850"] = "This content was generated using artificial intelligence (AI)."
-- The revised assistant plugin asks for tools this AI Studio does not have: '{0}'. Please try again.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T1002777578"] = "The revised assistant plugin asks for tools this AI Studio does not have: '{0}'. Please try again."
+1
View File
@@ -173,6 +173,7 @@ internal sealed class Program
builder.Services.AddSingleton(typeof(RuntimeInfoResponse), runtimeInfo);
builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>();
builder.Services.AddSingleton<SettingsManager>();
builder.Services.AddSingleton<AIGeneratedContentDisclosureService>();
builder.Services.AddSingleton<PromptInjectionGuardService>();
builder.Services.AddSingleton<ToolSettingsService>();
builder.Services.AddSingleton<WebPageRetrievalService>();
@@ -315,6 +315,14 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary>
public bool ShowAdminSettings { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShowAdminSettings, false);
/// <summary>
/// Should AI answers which are copied or exported end with a notice that AI generated them?
/// </summary>
/// <remarks>
/// Off by default. AIGeneratedContentDisclosureService explains which content gets the notice and why.
/// </remarks>
public bool AddAIGeneratedContentDisclosure { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AddAIGeneratedContentDisclosure, false);
/// <summary>
/// Settings for indexing local data sources.
/// </summary>
+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}**");
}
}
@@ -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
@@ -0,0 +1,82 @@
using AIStudio.Settings;
using AIStudio.Tools;
using AIStudio.Tools.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks the notice which says that AI generated an answer the user copies or exports.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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("</code></pre>\n<p><strong>This content was generated using artificial intelligence (AI).</strong></p>"), "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<SettingsManager>.Instance, null!);
private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines);
}
+32 -1
View File
@@ -3,7 +3,8 @@ using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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);
}
+48
View File
@@ -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