Added a default label to copied AI-generated text

This commit is contained in:
hart_s3 2026-07-21 14:49:20 +02:00
parent 7595def8f3
commit e15e9a0607
19 changed files with 335 additions and 8 deletions

View File

@ -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()

View File

@ -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."

View File

@ -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>

View File

@ -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()

View File

@ -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,6 +67,9 @@ public partial class MudCopyClipboardButton : ComponentBase
/// </summary>
private async Task CopyToClipboard(string textContent)
{
if (this.IsAIGenerated)
await this.DisclosureService.CopyText2Clipboard(this.Snackbar, textContent);
else
await this.RustService.CopyText2Clipboard(this.Snackbar, textContent);
}
@ -73,6 +85,9 @@ public partial class MudCopyClipboardButton : ComponentBase
{
case ContentType.TEXT:
var textContent = (ContentText) contentToCopy;
if (this.IsAIGenerated)
await this.DisclosureService.CopyText2Clipboard(this.Snackbar, textContent.Text);
else
await this.RustService.CopyText2Clipboard(this.Snackbar, textContent.Text);
break;

View File

@ -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)

View File

@ -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:

View File

@ -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."

View File

@ -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>();

View File

@ -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>

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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;
});
}
}
}

View File

@ -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.

View File

@ -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;

View File

@ -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))