diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index c483fcb..3ae5c73 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -682,16 +682,12 @@ export const jaOverrides = { 'config.tabs.software': 'ソフトウェア設定', 'config.tabs.sensitiveWords': '機密語の難読化', 'config.sensitiveWords.title': 'システムプロンプトの機密語', - 'config.sensitiveWords.intro': 'CPA コアの antigravity.sensitive-words と devin.sensitive-words を個別に設定します。1 行に 1 語またはフレーズを入力してください。保存時に空行と前後の空白を除去します。', - 'config.sensitiveWords.antigravity': 'Antigravity の機密語の難読化', - 'config.sensitiveWords.antigravityDescription': 'システムプロンプト内の一致した語にゼロ幅文字を挿入し、上流の完全一致を避けます。', - 'config.sensitiveWords.antigravityPlaceholder': '例:proxy', - 'config.sensitiveWords.antigravityHint': '大文字と小文字は区別しません。2 文字未満の項目はコア側で無視されます。', - 'config.sensitiveWords.devin': 'Devin のシステムプロンプト機密語', - 'config.sensitiveWords.devinDescription': '一致した語を含むシステムプロンプトの行全体を削除し、残りを難読化します。通常のメッセージやツール引数は変更しません。', - 'config.sensitiveWords.devinPlaceholder': '例:機密フレーズ', - 'config.sensitiveWords.devinHint': '注意:一致するとシステムプロンプトの行全体が削除されます。', - 'config.sensitiveWords.restartHint': '実行中のコアが新しい設定を自動で読み込まない場合は、保存後にコアを再起動してください。', + 'config.sensitiveWords.addPlaceholder': '語句を入力して Enter で追加', + 'config.sensitiveWords.empty': '項目なし', + 'config.sensitiveWords.discardTitle': '未保存の機密語を破棄しますか?', + 'config.sensitiveWords.discardMessage': '更新すると、未保存の項目は現在のコア設定で上書きされます。', + 'config.sensitiveWords.antigravity': 'Antigravity', + 'config.sensitiveWords.devin': 'Devin', 'config.error.emptyKey': '認証キーを入力してください', 'config.error.invalidKey': '空白を含まない表示可能な ASCII 文字のみ使用できます', 'config.error.duplicateKey': '同じ認証キーがすでに存在します', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1573035..653bea7 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -679,16 +679,12 @@ export const en: Record = { 'config.tabs.software': 'Software Settings', 'config.tabs.sensitiveWords': 'Sensitive Words', 'config.sensitiveWords.title': 'System Prompt Sensitive Words', - 'config.sensitiveWords.intro': 'Configure antigravity.sensitive-words and devin.sensitive-words in the CPA core separately. Enter one word or phrase per line; blank lines and surrounding spaces are removed on save.', - 'config.sensitiveWords.antigravity': 'Antigravity Word Obfuscation', - 'config.sensitiveWords.antigravityDescription': 'Inserts zero-width characters into matching system-prompt terms to reduce exact upstream keyword matches.', - 'config.sensitiveWords.antigravityPlaceholder': 'For example: proxy', - 'config.sensitiveWords.antigravityHint': 'Case-insensitive; the core ignores entries shorter than two characters.', - 'config.sensitiveWords.devin': 'Devin System Prompt Sensitive Words', - 'config.sensitiveWords.devinDescription': 'Removes entire system-prompt lines containing a configured term, then obfuscates the remaining prompt. Other messages and tool arguments are not changed.', - 'config.sensitiveWords.devinPlaceholder': 'For example: sensitive phrase', - 'config.sensitiveWords.devinHint': 'Use caution: a match deletes the entire system-prompt line.', - 'config.sensitiveWords.restartHint': 'If the running core does not reload the new configuration automatically, restart it after saving.', + 'config.sensitiveWords.addPlaceholder': 'Enter a word and press Enter', + 'config.sensitiveWords.empty': 'No entries', + 'config.sensitiveWords.discardTitle': 'Discard unsaved sensitive words?', + 'config.sensitiveWords.discardMessage': 'Refreshing replaces your unsaved entries with the current core configuration.', + 'config.sensitiveWords.antigravity': 'Antigravity', + 'config.sensitiveWords.devin': 'Devin', 'config.error.emptyKey': 'The authentication key cannot be empty', 'config.error.invalidKey': 'Use visible ASCII characters only, without spaces', 'config.error.duplicateKey': 'This authentication key already exists', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 88c2052..31332cf 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -678,16 +678,12 @@ export const zhCN = { 'config.tabs.software': '软件设置', 'config.tabs.sensitiveWords': '敏感词混淆', 'config.sensitiveWords.title': '系统提示词敏感词', - 'config.sensitiveWords.intro': '分别配置 CPA 内核的 antigravity.sensitive-words 与 devin.sensitive-words。每行输入一个词或短语,保存时自动去除空行和首尾空格。', - 'config.sensitiveWords.antigravity': 'Antigravity 敏感词混淆', - 'config.sensitiveWords.antigravityDescription': '在系统提示词匹配项中插入零宽字符,降低上游精确关键词匹配。', - 'config.sensitiveWords.antigravityPlaceholder': '例如:proxy', - 'config.sensitiveWords.antigravityHint': '不区分大小写;少于 2 个字符的词条会被内核忽略。', - 'config.sensitiveWords.devin': 'Devin 系统提示词敏感词', - 'config.sensitiveWords.devinDescription': '含配置词的整行系统提示词会被删除,其余系统提示词再进行混淆;普通消息及工具参数不受影响。', - 'config.sensitiveWords.devinPlaceholder': '例如:敏感短语', - 'config.sensitiveWords.devinHint': '请谨慎配置:命中内容会导致整行系统提示词被删除。', - 'config.sensitiveWords.restartHint': '保存后若运行中的内核未自动加载新配置,请重启内核。', + 'config.sensitiveWords.addPlaceholder': '输入词条,回车添加', + 'config.sensitiveWords.empty': '暂无词条', + 'config.sensitiveWords.discardTitle': '放弃未保存的敏感词?', + 'config.sensitiveWords.discardMessage': '刷新会用当前内核配置覆盖尚未保存的词条。', + 'config.sensitiveWords.antigravity': 'Antigravity', + 'config.sensitiveWords.devin': 'Devin', 'config.error.emptyKey': '鉴权密钥不能为空', 'config.error.invalidKey': '只能使用 ASCII 可见字符,且不能包含空格', 'config.error.duplicateKey': '该鉴权密钥已经存在', diff --git a/src/pages/SensitiveWordsPage.tsx b/src/pages/SensitiveWordsPage.tsx index 9626ad9..b0e593d 100644 --- a/src/pages/SensitiveWordsPage.tsx +++ b/src/pages/SensitiveWordsPage.tsx @@ -1,62 +1,204 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type ClipboardEvent } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { Check, RefreshCw, ShieldAlert } from 'lucide-react'; +import { Check, Plus, RefreshCw, ShieldAlert, Trash2 } from 'lucide-react'; import { useI18n } from '../i18n'; +import { useConfirmation } from '../components/ConfirmationDialog'; type SensitiveWordsSettings = { antigravitySensitiveWords: string[]; devinSensitiveWords: string[]; }; -const wordsFromText = (text: string): string[] => text.split(/\r?\n/).map((word) => word.trim()).filter(Boolean); +type Provider = 'antigravity' | 'devin'; +type WordEntry = { id: number; value: string }; +type WordDraft = Record; + +const normalizedWords = (entries: WordEntry[]) => entries.map(({ value }) => value.trim()).filter(Boolean); + +type WordEditorProps = { + provider: Provider; + entries: WordEntry[]; + composer: string; + disabled: boolean; + onAdd: (provider: Provider, value: string) => void; + onCompose: (provider: Provider, value: string) => void; + onChange: (provider: Provider, id: number, value: string) => void; + onRemove: (provider: Provider, id: number) => void; + onPaste: (provider: Provider, id: number, event: ClipboardEvent) => void; +}; + +function WordEditor({ provider, entries, composer, disabled, onAdd, onCompose, onChange, onRemove, onPaste }: WordEditorProps) { + const { t } = useI18n(); + const title = t(provider === 'antigravity' ? 'config.sensitiveWords.antigravity' : 'config.sensitiveWords.devin'); + + return ( +
+
+

{title}

+ {entries.length} +
+
+ onCompose(provider, event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== 'Enter' || event.nativeEvent.isComposing) return; + event.preventDefault(); + onAdd(provider, composer); + }} + onPaste={(event) => { + const pasted = event.clipboardData.getData('text'); + if (!/[\r\n]/.test(pasted)) return; + event.preventDefault(); + const input = event.currentTarget; + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? start; + onAdd(provider, input.value.slice(0, start) + pasted + input.value.slice(end)); + }} + /> + +
+ {entries.length === 0 ? ( +

{t('config.sensitiveWords.empty')}

+ ) : ( +
+ {entries.map((entry, index) => ( +
+ onChange(provider, entry.id, event.currentTarget.value)} + onPaste={(event) => onPaste(provider, entry.id, event)} + /> + +
+ ))} +
+ )} +
+ ); +} export function SensitiveWordsPage() { const { t } = useI18n(); + const { askConfirmation, confirmationDialog } = useConfirmation(); + const nextEntryId = useRef(0); const [saved, setSaved] = useState(null); - const [antigravityDraft, setAntigravityDraft] = useState(''); - const [devinDraft, setDevinDraft] = useState(''); + const [draft, setDraft] = useState({ antigravity: [], devin: [] }); + const [composer, setComposer] = useState>({ antigravity: '', devin: '' }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [savedNotice, setSavedNotice] = useState(false); + const replaceDraft = useCallback((settings: SensitiveWordsSettings) => { + const toEntries = (words: string[]) => words.map((value) => ({ id: nextEntryId.current++, value })); + setDraft({ + antigravity: toEntries(settings.antigravitySensitiveWords), + devin: toEntries(settings.devinSensitiveWords), + }); + setComposer({ antigravity: '', devin: '' }); + }, []); + const load = useCallback(async () => { setLoading(true); setError(''); try { const result = await invoke('get_core_sensitive_words_settings'); setSaved(result); - setAntigravityDraft(result.antigravitySensitiveWords.join('\n')); - setDevinDraft(result.devinSensitiveWords.join('\n')); + replaceDraft(result); setSavedNotice(false); } catch (cause) { - setSaved(null); setError(String(cause)); } finally { setLoading(false); } - }, []); + }, [replaceDraft]); useEffect(() => { void load(); }, [load]); - const antigravitySensitiveWords = wordsFromText(antigravityDraft); - const devinSensitiveWords = wordsFromText(devinDraft); + const words: SensitiveWordsSettings = { + antigravitySensitiveWords: [...normalizedWords(draft.antigravity), ...(composer.antigravity.trim() ? [composer.antigravity.trim()] : [])], + devinSensitiveWords: [...normalizedWords(draft.devin), ...(composer.devin.trim() ? [composer.devin.trim()] : [])], + }; const dirty = saved !== null && ( - JSON.stringify(antigravitySensitiveWords) !== JSON.stringify(saved.antigravitySensitiveWords) - || JSON.stringify(devinSensitiveWords) !== JSON.stringify(saved.devinSensitiveWords) + JSON.stringify(words.antigravitySensitiveWords) !== JSON.stringify(saved.antigravitySensitiveWords) + || JSON.stringify(words.devinSensitiveWords) !== JSON.stringify(saved.devinSensitiveWords) ); + const changeWord = (provider: Provider, id: number, value: string) => { + setDraft((current) => ({ + ...current, + [provider]: current[provider].map((entry) => entry.id === id ? { ...entry, value } : entry), + })); + setSavedNotice(false); + }; + + const composeWord = (provider: Provider, value: string) => { + setComposer((current) => ({ ...current, [provider]: value })); + setSavedNotice(false); + }; + + const addWords = (provider: Provider, value: string) => { + const entries = value.split(/\r\n|\n|\r/).map((word) => word.trim()).filter(Boolean) + .map((word) => ({ id: nextEntryId.current++, value: word })); + if (entries.length === 0) return; + setDraft((current) => ({ ...current, [provider]: [...current[provider], ...entries] })); + setComposer((current) => ({ ...current, [provider]: '' })); + setSavedNotice(false); + }; + + const removeWord = (provider: Provider, id: number) => { + setDraft((current) => ({ ...current, [provider]: current[provider].filter((entry) => entry.id !== id) })); + setSavedNotice(false); + }; + + const pasteWords = (provider: Provider, id: number, event: ClipboardEvent) => { + const pasted = event.clipboardData.getData('text'); + if (!/[\r\n]/.test(pasted)) return; + event.preventDefault(); + const input = event.currentTarget; + const lines = pasted.split(/\r\n|\n|\r/).filter((line) => line.trim()); + if (lines.length === 0) return; + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? start; + const entries = lines.map((value, index) => ({ id: index === 0 ? id : nextEntryId.current++, value })); + entries[0].value = input.value.slice(0, start) + entries[0].value; + entries[entries.length - 1].value += input.value.slice(end); + setDraft((current) => ({ + ...current, + [provider]: current[provider].flatMap((entry) => entry.id === id ? entries : [entry]), + })); + setSavedNotice(false); + }; + const save = async () => { if (!dirty || saving) return; setSaving(true); setError(''); try { const result = await invoke('save_core_sensitive_words_settings', { - settings: { antigravitySensitiveWords, devinSensitiveWords }, + settings: words, }); setSaved(result); - setAntigravityDraft(result.antigravitySensitiveWords.join('\n')); - setDevinDraft(result.devinSensitiveWords.join('\n')); + replaceDraft(result); setSavedNotice(true); } catch (cause) { setError(String(cause)); @@ -65,6 +207,15 @@ export function SensitiveWordsPage() { } }; + const refresh = async () => { + if (dirty && !await askConfirmation({ + title: t('config.sensitiveWords.discardTitle'), + message: t('config.sensitiveWords.discardMessage'), + confirmText: t('common.refresh'), + })) return; + await load(); + }; + return (
@@ -76,7 +227,7 @@ export function SensitiveWordsPage() { {dirty ? {t('config.network.unsaved')} : savedNotice ? {t('config.network.saved')} : null}
-

{t('config.sensitiveWords.intro')}

{error ?
{error}
: null} {loading ?

{t('common.loading')}

: saved === null ? ( ) : (
-
- -

{t('config.sensitiveWords.antigravityDescription')}

-