mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 18:32:12 +00:00
Added a default label to copied AI-generated text
This commit is contained in:
parent
7595def8f3
commit
e15e9a0607
@ -31,6 +31,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 NavigationManager NavigationManager { get; init; } = null!;
|
||||
|
||||
@ -529,7 +532,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
|
||||
protected async Task CopyToClipboard()
|
||||
{
|
||||
await this.RustService.CopyText2Clipboard(this.Snackbar, this.Result2Copy());
|
||||
await this.DisclosureService.CopyText2Clipboard(this.Snackbar, this.Result2Copy());
|
||||
}
|
||||
|
||||
private ChatThread CreateSendToChatThread()
|
||||
|
||||
@ -3166,6 +3166,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAGENTRETRIEVALCONT
|
||||
-- Spellchecking is disabled
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1059411425"] = "Spellchecking is disabled"
|
||||
|
||||
-- AI-generated content is labeled
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1096690933"] = "AI-generated content is labeled"
|
||||
|
||||
-- Do you want to show preview features in the app?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Do you want to show preview features in the app?"
|
||||
|
||||
@ -3190,6 +3193,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1454730224"]
|
||||
-- Root certificate bundle path
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1471315821"] = "Root certificate bundle path"
|
||||
|
||||
-- Label AI-generated content?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1503137557"] = "Label AI-generated content?"
|
||||
|
||||
-- Select the desired behavior for the navigation bar.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1555038969"] = "Select the desired behavior for the navigation bar."
|
||||
|
||||
@ -3298,6 +3304,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3914529369"]
|
||||
-- Additional root certificates are disabled
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3985928190"] = "Additional root certificates are disabled"
|
||||
|
||||
-- When enabled, copied AI-generated text and Microsoft Word exports include a bold notice that the content was generated using AI.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3990854800"] = "When enabled, copied AI-generated text and Microsoft Word exports include a bold notice that the content was generated using AI."
|
||||
|
||||
-- Preselect one of your profiles?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4004501229"] = "Preselect one of your profiles?"
|
||||
|
||||
@ -3334,6 +3343,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T716338721"]
|
||||
-- Start page
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T78084670"] = "Start page"
|
||||
|
||||
-- AI-generated content is not labeled
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T807210448"] = "AI-generated content is not labeled"
|
||||
|
||||
-- Preview feature visibility
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T817101267"] = "Preview feature visibility"
|
||||
|
||||
@ -8773,6 +8785,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like p
|
||||
-- Document
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document"
|
||||
|
||||
-- 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 Assistant Builder context could not be loaded.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T108292972"] = "The Assistant Builder context could not be loaded."
|
||||
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Save" OnClick="@this.ExportToWord"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudCopyClipboardButton Content="@this.Content" Type="@this.Type" Size="Size.Medium"/>
|
||||
<MudCopyClipboardButton Content="@this.Content" Type="@this.Type" Size="Size.Medium" IsAIGenerated="@(this.Role is ChatRole.AI)"/>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
|
||||
@ -91,6 +91,9 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private AIGeneratedContentDisclosureService DisclosureService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
@ -548,7 +551,7 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
||||
|
||||
private async Task ExportToWord()
|
||||
{
|
||||
await PandocExport.ToMicrosoftWord(this.RustService, this.DialogService, T("Export Chat to Microsoft Word"), this.Content);
|
||||
await PandocExport.ToMicrosoftWord(this.RustService, this.DisclosureService, this.DialogService, T("Export Chat to Microsoft Word"), this.Content);
|
||||
}
|
||||
|
||||
private async Task RegenerateBlock()
|
||||
|
||||
@ -33,6 +33,12 @@ public partial class MudCopyClipboardButton : ComponentBase
|
||||
[Parameter]
|
||||
public string TooltipMessage { get; set; } = TB("Copies the content to the clipboard");
|
||||
|
||||
/// <summary>
|
||||
/// Whether the copied content was generated by AI and should receive the configured disclosure.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool IsAIGenerated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The size of the button. The default size is small.
|
||||
/// </summary>
|
||||
@ -45,6 +51,9 @@ public partial class MudCopyClipboardButton : ComponentBase
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private AIGeneratedContentDisclosureService DisclosureService { get; init; } = null!;
|
||||
|
||||
private async Task HandleCopyClick()
|
||||
{
|
||||
if (this.Type is ContentType.NONE)
|
||||
@ -58,7 +67,10 @@ public partial class MudCopyClipboardButton : ComponentBase
|
||||
/// </summary>
|
||||
private async Task CopyToClipboard(string textContent)
|
||||
{
|
||||
await this.RustService.CopyText2Clipboard(this.Snackbar, textContent);
|
||||
if (this.IsAIGenerated)
|
||||
await this.DisclosureService.CopyText2Clipboard(this.Snackbar, textContent);
|
||||
else
|
||||
await this.RustService.CopyText2Clipboard(this.Snackbar, textContent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -73,7 +85,10 @@ public partial class MudCopyClipboardButton : ComponentBase
|
||||
{
|
||||
case ContentType.TEXT:
|
||||
var textContent = (ContentText) contentToCopy;
|
||||
await this.RustService.CopyText2Clipboard(this.Snackbar, textContent.Text);
|
||||
if (this.IsAIGenerated)
|
||||
await this.DisclosureService.CopyText2Clipboard(this.Snackbar, textContent.Text);
|
||||
else
|
||||
await this.RustService.CopyText2Clipboard(this.Snackbar, textContent.Text);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@ -20,6 +20,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("Label AI-generated content?")" LabelOn="@T("AI-generated content is labeled")" LabelOff="@T("AI-generated content is not labeled")" State="@(() => this.SettingsManager.ConfigurationData.App.AddAIGeneratedContentDisclosure)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.AddAIGeneratedContentDisclosure = updatedState)" OptionHelp="@T("When enabled, copied AI-generated text and Microsoft Word exports include a bold notice that the content was generated using AI.")" 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)
|
||||
|
||||
@ -238,6 +238,11 @@ CONFIG["SETTINGS"] = {}
|
||||
-- Configure whether administration settings are visible in the UI:
|
||||
-- CONFIG["SETTINGS"]["DataApp.ShowAdminSettings"] = true
|
||||
|
||||
-- Configure whether copied and exported AI-generated content includes a disclosure.
|
||||
-- The disclosure is enabled by default and is localized using the active app language.
|
||||
-- CONFIG["SETTINGS"]["DataApp.AddAIGeneratedContentDisclosure"] = true
|
||||
-- CONFIG["SETTINGS"]["DataApp.AddAIGeneratedContentDisclosure.AllowUserOverride"] = false
|
||||
|
||||
-- Configure the visibility of preview features:
|
||||
-- Allowed values are: NONE, RELEASE_CANDIDATE, BETA, ALPHA, PROTOTYPE, EXPERIMENTAL
|
||||
-- Please note:
|
||||
|
||||
@ -3168,6 +3168,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAGENTRETRIEVALCONT
|
||||
-- Spellchecking is disabled
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1059411425"] = "Rechtschreibprüfung ist deaktiviert"
|
||||
|
||||
-- AI-generated content is labeled
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1096690933"] = "KI-generierte Inhalte sind gekennzeichnet"
|
||||
|
||||
-- Do you want to show preview features in the app?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Möchten Sie Vorschaufunktionen in der App anzeigen lassen?"
|
||||
|
||||
@ -3192,6 +3195,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1454730224"]
|
||||
-- Root certificate bundle path
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1471315821"] = "Pfad zum Stammzertifikatsbundle"
|
||||
|
||||
-- Label AI-generated content?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1503137557"] = "Soll KI-generierter Inhalt gekennzeichnet werden?"
|
||||
|
||||
-- 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."
|
||||
|
||||
@ -3300,6 +3306,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3914529369"]
|
||||
-- Additional root certificates are disabled
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3985928190"] = "Zusätzliche Stammzertifikate sind deaktiviert"
|
||||
|
||||
-- When enabled, copied AI-generated text and Microsoft Word exports include a bold notice that the content was generated using AI.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3990854800"] = "Wenn aktiviert, enthalten kopierter, von KI generierter Text und Microsoft Word-Exporte einen fett gedruckten Hinweis, dass der Inhalt mit KI erstellt wurde."
|
||||
|
||||
-- Preselect one of your profiles?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4004501229"] = "Möchten Sie eines ihrer Profile vorauswählen?"
|
||||
|
||||
@ -3336,6 +3345,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T716338721"]
|
||||
-- Start page
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T78084670"] = "Startseite"
|
||||
|
||||
-- AI-generated content is not labeled
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T807210448"] = "KI-generierte Inhalte sind nicht gekennzeichnet"
|
||||
|
||||
-- Preview feature visibility
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T817101267"] = "Sichtbarkeit der Vorschaufunktion"
|
||||
|
||||
@ -8775,6 +8787,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source Code
|
||||
-- Document
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Dokument"
|
||||
|
||||
-- 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 Assistant Builder context could not be loaded.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T108292972"] = "Der Kontext des Assistenten-Builders konnte nicht geladen werden."
|
||||
|
||||
|
||||
@ -159,6 +159,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<ThreadSafeRandom>();
|
||||
builder.Services.AddSingleton<AIJobService>();
|
||||
builder.Services.AddSingleton<AssistantSessionService>();
|
||||
|
||||
@ -154,6 +154,11 @@ 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 copied and exported AI-generated content include a disclosure?
|
||||
/// </summary>
|
||||
public bool AddAIGeneratedContentDisclosure { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AddAIGeneratedContentDisclosure, true);
|
||||
|
||||
/// <summary>
|
||||
/// List of assistants that should be hidden from the UI.
|
||||
/// </summary>
|
||||
|
||||
@ -15,7 +15,7 @@ public static class PandocExport
|
||||
|
||||
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(PandocExport).Namespace, nameof(PandocExport));
|
||||
|
||||
public static async Task<bool> ToMicrosoftWord(RustService rustService, IDialogService dialogService, string dialogTitle, IContent markdownContent)
|
||||
public static async Task<bool> ToMicrosoftWord(RustService rustService, AIGeneratedContentDisclosureService disclosureService, IDialogService dialogService, string dialogTitle, IContent markdownContent)
|
||||
{
|
||||
var response = await rustService.SaveFile(dialogTitle, [FileTypes.MS_WORD]);
|
||||
if (response.UserCancelled)
|
||||
@ -41,6 +41,8 @@ public static class PandocExport
|
||||
_ => "Unknown content type. Cannot export to Word."
|
||||
};
|
||||
|
||||
markdownText = await disclosureService.AddDisclosureToMarkdown(markdownText);
|
||||
|
||||
// Write text content to a temporary file:
|
||||
await File.WriteAllTextAsync(tempMarkdownFilePath, markdownText);
|
||||
|
||||
|
||||
@ -182,6 +182,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);
|
||||
|
||||
|
||||
@ -269,6 +269,10 @@ public static partial class PluginFactory
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowAdminSettings, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check for the AI-generated content disclosure:
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AddAIGeneratedContentDisclosure, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check for preview visibility:
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreviewVisibility, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// Rich clipboard content with a plain-text fallback.
|
||||
/// </summary>
|
||||
/// <param name="PlainText">The text used by applications that do not accept HTML.</param>
|
||||
/// <param name="HtmlText">The HTML used by rich-text applications.</param>
|
||||
public readonly record struct RichClipboardContent(string PlainText, string HtmlText);
|
||||
@ -0,0 +1,64 @@
|
||||
using System.Net;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a disclosure to AI-generated text before it leaves AI Studio.
|
||||
/// </summary>
|
||||
public sealed class AIGeneratedContentDisclosureService(SettingsManager settingsManager, RustService rustService)
|
||||
{
|
||||
private const string DISCLOSURE_DE = "Dieser Inhalt wurde mit künstlicher Intelligenz (KI) erstellt.";
|
||||
private const string DISCLOSURE_EN = "This content was generated using artificial intelligence (AI).";
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AIGeneratedContentDisclosureService).Namespace, nameof(AIGeneratedContentDisclosureService));
|
||||
|
||||
private SettingsManager SettingsManager { get; } = settingsManager;
|
||||
|
||||
private RustService RustService { get; } = rustService;
|
||||
|
||||
/// <summary>
|
||||
/// Adds the disclosure to non-empty text when the feature is enabled.
|
||||
/// </summary>
|
||||
public async Task<string> AddDisclosureToMarkdown(string text)
|
||||
{
|
||||
if (!this.SettingsManager.ConfigurationData.App.AddAIGeneratedContentDisclosure || string.IsNullOrWhiteSpace(text))
|
||||
return text;
|
||||
|
||||
var contentWithoutTrailingLineBreaks = text.TrimEnd('\r', '\n');
|
||||
var disclosure = AIStudio.Tools.Markdown.EscapeInlineText(await this.GetDisclosure());
|
||||
return $"{contentWithoutTrailingLineBreaks}{Environment.NewLine}{Environment.NewLine}**{disclosure}**";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies AI-generated text with the configured disclosure.
|
||||
/// </summary>
|
||||
public async Task CopyText2Clipboard(ISnackbar snackbar, string text)
|
||||
{
|
||||
if (!this.SettingsManager.ConfigurationData.App.AddAIGeneratedContentDisclosure || string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
await this.RustService.CopyText2Clipboard(snackbar, text);
|
||||
return;
|
||||
}
|
||||
|
||||
var contentWithoutTrailingLineBreaks = text.TrimEnd('\r', '\n');
|
||||
var disclosure = await this.GetDisclosure();
|
||||
var plainText = $"{contentWithoutTrailingLineBreaks}{Environment.NewLine}{Environment.NewLine}{disclosure}";
|
||||
var htmlText = $"<div style=\"white-space: pre-wrap;\">{WebUtility.HtmlEncode(contentWithoutTrailingLineBreaks)}</div><div style=\"font-weight: bold; margin-top: 1em;\">{WebUtility.HtmlEncode(disclosure)}</div>";
|
||||
await this.RustService.CopyRichText2Clipboard(snackbar, plainText, htmlText);
|
||||
}
|
||||
|
||||
private async Task<string> GetDisclosure()
|
||||
{
|
||||
var translatedDisclosure = TB("This content was generated using artificial intelligence (AI).");
|
||||
if (!string.Equals(translatedDisclosure, DISCLOSURE_EN, StringComparison.Ordinal))
|
||||
return translatedDisclosure;
|
||||
|
||||
var language = await this.SettingsManager.GetActiveLanguagePlugin();
|
||||
return language.IETFTag.StartsWith("de-", StringComparison.OrdinalIgnoreCase)
|
||||
? DISCLOSURE_DE
|
||||
: translatedDisclosure;
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
@ -47,4 +49,50 @@ public sealed partial class RustService
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to copy HTML and its plain-text fallback to the clipboard.
|
||||
/// </summary>
|
||||
/// <param name="snackbar">The snackbar to show the result.</param>
|
||||
/// <param name="plainText">The content used by applications that do not accept HTML.</param>
|
||||
/// <param name="htmlText">The content used by applications that accept HTML.</param>
|
||||
public async Task CopyRichText2Clipboard(ISnackbar snackbar, string plainText, string htmlText)
|
||||
{
|
||||
var message = TB("Successfully copied the text to your clipboard");
|
||||
var iconColor = Color.Error;
|
||||
var severity = Severity.Error;
|
||||
try
|
||||
{
|
||||
var content = JsonSerializer.Serialize(new RichClipboardContent(plainText, htmlText), this.jsonRustSerializerOptions);
|
||||
var encryptedText = await content.Encrypt(this.encryptor!);
|
||||
var response = await this.http.PostAsync("/clipboard/set-rich-text", new StringContent(encryptedText.EncryptedData));
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogError($"Failed to copy rich text to the clipboard due to a network error: '{response.StatusCode}'");
|
||||
message = TB("Failed to copy the text to your clipboard.");
|
||||
return;
|
||||
}
|
||||
|
||||
var state = await response.Content.ReadFromJsonAsync<SetClipboardResponse>(this.jsonRustSerializerOptions);
|
||||
if (!state.Success)
|
||||
{
|
||||
this.logger!.LogError("Failed to copy rich text to the clipboard.");
|
||||
message = TB("Failed to copy the text to your clipboard.");
|
||||
return;
|
||||
}
|
||||
|
||||
iconColor = Color.Success;
|
||||
severity = Severity.Success;
|
||||
this.logger!.LogDebug("Successfully copied rich text to the clipboard.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
snackbar.Add(message, severity, config =>
|
||||
{
|
||||
config.Icon = Icons.Material.Filled.ContentCopy;
|
||||
config.IconSize = Size.Large;
|
||||
config.IconColor = iconColor;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1 +1,3 @@
|
||||
# v26.7.4, build 249 (2026-07-xx xx:xx UTC)
|
||||
|
||||
- Added a default label to copied AI-generated text and Microsoft Word exports, making it clear when content was generated using AI. The label follows the app language and can be disabled in the app settings.
|
||||
@ -4,7 +4,7 @@ use arboard::Clipboard;
|
||||
use axum::Json;
|
||||
use log::{debug, error, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::api_token::APIToken;
|
||||
use crate::encryption::{EncryptedText, ENCRYPTION};
|
||||
|
||||
@ -16,6 +16,8 @@ trait ClipboardBackend {
|
||||
type Error: Display;
|
||||
|
||||
fn set_text(&mut self, text: String) -> Result<(), Self::Error>;
|
||||
|
||||
fn set_html(&mut self, html: String, alt_text: String) -> Result<(), Self::Error>;
|
||||
}
|
||||
|
||||
impl ClipboardBackend for Clipboard {
|
||||
@ -24,6 +26,10 @@ impl ClipboardBackend for Clipboard {
|
||||
fn set_text(&mut self, text: String) -> Result<(), Self::Error> {
|
||||
Clipboard::set_text(self, text)
|
||||
}
|
||||
|
||||
fn set_html(&mut self, html: String, alt_text: String) -> Result<(), Self::Error> {
|
||||
Clipboard::set_html(self, html, Some(alt_text))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@ -71,6 +77,37 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_html_with_retry<B, F>(
|
||||
clipboard: &mut Option<B>,
|
||||
html: String,
|
||||
alt_text: String,
|
||||
mut create_clipboard: F,
|
||||
) -> Result<(), ClipboardOperationError<B::Error>>
|
||||
where
|
||||
B: ClipboardBackend,
|
||||
F: FnMut() -> Result<B, B::Error>,
|
||||
{
|
||||
if clipboard.is_none() {
|
||||
*clipboard = Some(create_clipboard().map_err(ClipboardOperationError::Initialization)?);
|
||||
}
|
||||
|
||||
let first_result = clipboard.as_mut().unwrap().set_html(html.clone(), alt_text.clone());
|
||||
if let Err(first_error) = first_result {
|
||||
warn!(Source = "Clipboard"; "Failed to set rich text using the current clipboard backend; reinitializing it once: {first_error}.");
|
||||
*clipboard = None;
|
||||
|
||||
let mut retry_clipboard = create_clipboard().map_err(ClipboardOperationError::Initialization)?;
|
||||
if let Err(retry_error) = retry_clipboard.set_html(html, alt_text) {
|
||||
error!(Source = "Clipboard"; "Failed to set rich text after reinitializing the clipboard backend: {retry_error}.");
|
||||
return Err(ClipboardOperationError::Write(retry_error));
|
||||
}
|
||||
|
||||
*clipboard = Some(retry_clipboard);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn release_clipboard<B>(clipboard: &mut Option<B>) -> bool {
|
||||
clipboard.take().is_some()
|
||||
}
|
||||
@ -111,6 +148,51 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json<Set
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets rich clipboard content with a plain-text fallback.
|
||||
pub async fn set_rich_clipboard(_token: APIToken, encrypted_text: String) -> Json<SetClipboardResponse> {
|
||||
let encrypted_text = EncryptedText::new(encrypted_text);
|
||||
let decrypted_text = match ENCRYPTION.decrypt(&encrypted_text) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
error!(Source = "Clipboard"; "Failed to decrypt rich clipboard content: {e}.");
|
||||
return Json(SetClipboardResponse {
|
||||
success: false,
|
||||
issue: e,
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
let content: RichClipboardContent = match serde_json::from_str(&decrypted_text) {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
error!(Source = "Clipboard"; "Failed to deserialize rich clipboard content: {e}.");
|
||||
return Json(SetClipboardResponse {
|
||||
success: false,
|
||||
issue: e.to_string(),
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
let mut clipboard = CLIPBOARD.lock().unwrap();
|
||||
match set_html_with_retry(&mut clipboard, content.html_text, content.plain_text, Clipboard::new) {
|
||||
Ok(_) => {
|
||||
debug!(Source = "Clipboard"; "Rich text was set to the clipboard successfully.");
|
||||
Json(SetClipboardResponse {
|
||||
success: true,
|
||||
issue: String::from(""),
|
||||
})
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
error!(Source = "Clipboard"; "Rich clipboard operation failed: {e}.");
|
||||
Json(SetClipboardResponse {
|
||||
success: false,
|
||||
issue: e.to_string(),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases the process-wide clipboard instance during application shutdown.
|
||||
pub fn shutdown_clipboard() {
|
||||
let mut clipboard = CLIPBOARD.lock().unwrap();
|
||||
@ -126,12 +208,18 @@ pub struct SetClipboardResponse {
|
||||
issue: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RichClipboardContent {
|
||||
plain_text: String,
|
||||
html_text: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use super::{ClipboardOperationError, release_clipboard, set_text_with_retry, ClipboardBackend};
|
||||
use super::{ClipboardOperationError, release_clipboard, set_html_with_retry, set_text_with_retry, ClipboardBackend};
|
||||
|
||||
struct MockClipboard {
|
||||
id: usize,
|
||||
@ -151,6 +239,15 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn set_html(&mut self, html: String, alt_text: String) -> Result<(), Self::Error> {
|
||||
self.writes.lock().unwrap().push((self.id, format!("{html}|{alt_text}")));
|
||||
if self.fail_write {
|
||||
Err(format!("backend {} failed", self.id))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MockClipboard {
|
||||
@ -249,6 +346,41 @@ mod tests {
|
||||
assert_eq!(*factory.writes.lock().unwrap(), vec![(0, "text".to_string()), (1, "text".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_rich_text_with_plain_text_fallback() {
|
||||
let mut clipboard = None;
|
||||
let mut factory = MockFactory::new([false]);
|
||||
|
||||
set_html_with_retry(&mut clipboard, "<strong>notice</strong>".to_string(), "notice".to_string(), || factory.create()).unwrap();
|
||||
|
||||
assert_eq!(factory.created, 1);
|
||||
assert_eq!(*factory.writes.lock().unwrap(), vec![(0, "<strong>notice</strong>|notice".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_rich_text_once_with_a_new_instance_after_a_write_failure() {
|
||||
let mut clipboard = None;
|
||||
let mut factory = MockFactory::new([true, false]);
|
||||
|
||||
set_html_with_retry(&mut clipboard, "<strong>notice</strong>".to_string(), "notice".to_string(), || factory.create()).unwrap();
|
||||
|
||||
assert_eq!(factory.created, 2);
|
||||
assert_eq!(clipboard.as_ref().unwrap().id, 1);
|
||||
assert_eq!(*factory.writes.lock().unwrap(), vec![(0, "<strong>notice</strong>|notice".to_string()), (1, "<strong>notice</strong>|notice".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_the_rich_text_retry_error_and_discards_the_failed_instance() {
|
||||
let mut clipboard = None;
|
||||
let mut factory = MockFactory::new([true, true]);
|
||||
|
||||
let error = set_html_with_retry(&mut clipboard, "<strong>notice</strong>".to_string(), "notice".to_string(), || factory.create()).unwrap_err();
|
||||
|
||||
assert_eq!(error, ClipboardOperationError::Write("backend 1 failed".to_string()));
|
||||
assert_eq!(factory.created, 2);
|
||||
assert!(clipboard.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_reinitialization_failures_and_discards_the_failed_instance() {
|
||||
let mut clipboard = None;
|
||||
|
||||
@ -38,6 +38,7 @@ pub fn start_runtime_api() {
|
||||
.route("/system/qdrant-edge/delete-file", post(crate::qdrant_edge_database::delete_qdrant_edge_embedding_by_file))
|
||||
.route("/system/qdrant-edge/delete-store", post(crate::qdrant_edge_database::delete_qdrant_edge_store))
|
||||
.route("/clipboard/set", post(crate::clipboard::set_clipboard))
|
||||
.route("/clipboard/set-rich-text", post(crate::clipboard::set_rich_clipboard))
|
||||
.route("/events", get(crate::app_window::get_event_stream))
|
||||
.route("/updates/check", get(crate::app_window::check_for_update))
|
||||
.route("/updates/install", get(crate::app_window::install_update))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user