From 8f716f9e01ba059b6630f6edbd35b7254689c42d Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Wed, 23 Sep 2026 18:07:01 +0800 Subject: [PATCH] feat(config): add sensitive words settings for Antigravity and Devin --- src-tauri/src/core_config/commands.rs | 14 +++ src-tauri/src/core_config/yaml.rs | 166 ++++++++++++++++++++++++++ src-tauri/src/main.rs | 9 ++ src-tauri/src/tests/core_config.rs | 90 ++++++++++++++ src/i18n/ja.ts | 12 ++ src/i18n/locales/en.ts | 12 ++ src/i18n/locales/zh-CN.ts | 12 ++ src/pages/ConfigPanel.tsx | 35 +++++- src/pages/SensitiveWordsPage.tsx | 116 ++++++++++++++++++ src/styles.css | 50 ++++++++ 10 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 src/pages/SensitiveWordsPage.tsx diff --git a/src-tauri/src/core_config/commands.rs b/src-tauri/src/core_config/commands.rs index cb595d4..9e14a3d 100644 --- a/src-tauri/src/core_config/commands.rs +++ b/src-tauri/src/core_config/commands.rs @@ -21,6 +21,20 @@ pub(crate) fn save_core_tls_settings( Ok(settings) } +#[tauri::command] +pub(crate) fn get_core_sensitive_words_settings() -> Result { + read_core_sensitive_words_settings() +} + +#[tauri::command] +pub(crate) fn save_core_sensitive_words_settings( + settings: CoreSensitiveWordsSettings, +) -> Result { + let normalized = normalize_core_sensitive_words_settings(settings); + patch_core_sensitive_words_settings(&normalized)?; + Ok(normalized) +} + pub(crate) fn detect_lan_ipv4() -> Option { for target in ["192.0.2.1:80", "8.8.8.8:80"] { let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) else { diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 427dc29..a82ef27 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -224,6 +224,172 @@ pub(crate) fn patch_core_tls_settings(settings: &CoreTlsSettings) -> Result<(), write_yaml_if_changed(&config_path, &updated).map(|_| ()) } +pub(crate) fn normalize_core_sensitive_words_settings( + settings: CoreSensitiveWordsSettings, +) -> CoreSensitiveWordsSettings { + let normalize = |words: Vec| { + words + .into_iter() + .map(|word| word.trim().to_string()) + .filter(|word| !word.is_empty()) + .collect() + }; + CoreSensitiveWordsSettings { + antigravity_sensitive_words: normalize(settings.antigravity_sensitive_words), + devin_sensitive_words: normalize(settings.devin_sensitive_words), + } +} + +pub(crate) fn core_sensitive_words_settings_from_value( + document: &serde_norway::Value, +) -> Result { + let root = document + .as_mapping() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let read_words = |provider: &str| -> Result, String> { + let Some(section) = yaml_mapping_value(root, provider) else { + return Ok(Vec::new()); + }; + if section.is_null() { + return Ok(Vec::new()); + } + let section = section + .as_mapping() + .ok_or_else(|| format!("{provider} 必须是 YAML 映射"))?; + let Some(value) = yaml_mapping_value(section, "sensitive-words") else { + return Ok(Vec::new()); + }; + if value.is_null() { + return Ok(Vec::new()); + } + value + .as_sequence() + .ok_or_else(|| format!("{provider}.sensitive-words 必须是字符串列表"))? + .iter() + .map(|entry| { + entry + .as_str() + .map(str::to_string) + .ok_or_else(|| format!("{provider}.sensitive-words 必须是字符串列表")) + }) + .collect() + }; + Ok(CoreSensitiveWordsSettings { + antigravity_sensitive_words: read_words("antigravity")?, + devin_sensitive_words: read_words("devin")?, + }) +} + +pub(crate) fn read_core_sensitive_words_settings() -> Result { + let _config_guard = lock_core_config_file()?; + let (_, document) = read_core_config_document()?; + core_sensitive_words_settings_from_value(document.get()) +} + +pub(crate) fn patch_core_sensitive_words_yaml( + content: &str, + settings: &CoreSensitiveWordsSettings, +) -> Result, String> { + let original = serde_norway::from_str::(content) + .map_err(|error| format!("解析内核配置失败: {error}"))?; + let root = original + .as_mapping() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let mut prepared = content.to_string(); + for (provider, words) in [ + ("antigravity", &settings.antigravity_sensitive_words), + ("devin", &settings.devin_sensitive_words), + ] { + if words.is_empty() { + continue; + } + let Some(section) = yaml_mapping_value(root, provider) else { + continue; + }; + let needs_normalization = section.is_null() + || section + .as_mapping() + .and_then(|mapping| yaml_mapping_value(mapping, "sensitive-words")) + .is_some_and(serde_norway::Value::is_null); + if !needs_normalization { + continue; + } + let mut section = if section.is_null() { + serde_norway::Mapping::new() + } else { + section + .as_mapping() + .ok_or_else(|| format!("{provider} 必须是 YAML 映射"))? + .clone() + }; + section.insert( + yaml_key("sensitive-words"), + serde_norway::to_value(words).map_err(|error| format!("序列化敏感词失败: {error}"))?, + ); + let mut replacement = serde_norway::Mapping::new(); + replacement.insert(yaml_key(provider), serde_norway::Value::Mapping(section)); + let block = serde_norway::to_string(&replacement) + .map_err(|error| format!("序列化内核配置区段失败: {error}"))?; + prepared = replace_top_level_yaml_block(&prepared, provider, &block); + } + let updated = patch_core_yaml_document(&prepared, |document| { + let mut changed = false; + for (provider, words) in [ + ("antigravity", &settings.antigravity_sensitive_words), + ("devin", &settings.devin_sensitive_words), + ] { + let root = document + .as_mapping() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let section = yaml_mapping_value(root, provider); + let current_words = section + .and_then(serde_norway::Value::as_mapping) + .and_then(|mapping| yaml_mapping_value(mapping, "sensitive-words")); + if words.is_empty() && current_words.is_none_or(serde_norway::Value::is_null) { + continue; + } + if section.is_none_or(serde_norway::Value::is_null) { + changed |= set_core_yaml_top_level_value( + document, + provider, + serde_norway::Value::Mapping(serde_norway::Mapping::new()), + )?; + } + changed |= set_core_yaml_nested_value( + document, + provider, + "sensitive-words", + serde_norway::Value::Sequence( + words + .iter() + .cloned() + .map(serde_norway::Value::String) + .collect(), + ), + )?; + } + Ok(changed) + })? + .unwrap_or(prepared); + Ok((updated != content).then_some(updated)) +} + +pub(crate) fn patch_core_sensitive_words_settings( + settings: &CoreSensitiveWordsSettings, +) -> Result<(), String> { + let _config_guard = lock_core_config_file()?; + let config_path = core_install_dir()?.join(CORE_CONFIG_FILE); + if !config_path.is_file() { + return Err("内核配置尚未生成,请先启动 CPA 内核".to_string()); + } + let content = fs::read_to_string(&config_path) + .map_err(|error| format!("读取内核配置失败 {}: {error}", path_to_string(&config_path)))?; + if let Some(updated) = patch_core_sensitive_words_yaml(&content, settings)? { + write_yaml_if_changed(&config_path, &updated)?; + } + Ok(()) +} + pub(crate) fn patch_core_api_keys(api_keys: &[String]) -> Result<(), String> { let _config_guard = lock_core_config_file()?; let config_path = core_install_dir()?.join(CORE_CONFIG_FILE); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5dfa09a..91d5bfb 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1536,6 +1536,13 @@ struct CoreTlsSettings { key: String, } +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoreSensitiveWordsSettings { + antigravity_sensitive_words: Vec, + devin_sensitive_words: Vec, +} + #[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct CoreLoggingSettingsInput { @@ -2665,6 +2672,8 @@ fn main() { save_session_routing_settings, get_core_tls_settings, save_core_tls_settings, + get_core_sensitive_words_settings, + save_core_sensitive_words_settings, get_core_config_settings, save_core_logging_settings, set_core_request_log, diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index d5d9a12..bbda98c 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -1047,6 +1047,96 @@ fn tls_patch_preserves_unrelated_yaml_and_paths_when_disabled() { assert_eq!(document["custom"]["keep"], true); } +#[test] +fn sensitive_words_patch_preserves_other_config_and_clears_each_provider_independently() { + let input = "# keep this comment\nantigravity:\n sensitive-words:\n - old\n signature-cache: true\ndevin:\n sensitive-words:\n - old-devin\ncustom:\n keep: true\n"; + let settings = normalize_core_sensitive_words_settings(CoreSensitiveWordsSettings { + antigravity_sensitive_words: vec![" Hermes ".into(), "".into(), "Nous Research".into()], + devin_sensitive_words: Vec::new(), + }); + let patched = patch_core_sensitive_words_yaml(input, &settings) + .unwrap() + .expect("sensitive words should change"); + let document = serde_norway::from_str::(&patched).unwrap(); + assert!(patched.contains("# keep this comment")); + assert_eq!(document["custom"]["keep"], true); + assert_eq!(document["antigravity"]["signature-cache"], true); + assert_eq!( + document["antigravity"]["sensitive-words"], + serde_norway::to_value(vec!["Hermes", "Nous Research"]).unwrap() + ); + assert_eq!( + document["devin"]["sensitive-words"], + serde_norway::to_value(Vec::::new()).unwrap() + ); + assert_eq!( + core_sensitive_words_settings_from_value(&document).unwrap(), + settings + ); + assert!(patch_core_sensitive_words_yaml(&patched, &settings) + .unwrap() + .is_none()); +} + +#[test] +fn sensitive_words_patch_adds_missing_sections_without_touching_unrelated_fields() { + let input = "# config\ndebug: true\n"; + let empty = CoreSensitiveWordsSettings::default(); + assert!(patch_core_sensitive_words_yaml(input, &empty) + .unwrap() + .is_none()); + + let settings = CoreSensitiveWordsSettings { + antigravity_sensitive_words: vec!["proxy".into()], + devin_sensitive_words: vec!["Claude Code".into()], + }; + let patched = patch_core_sensitive_words_yaml(input, &settings) + .unwrap() + .expect("missing sections should be created"); + let document = serde_norway::from_str::(&patched).unwrap(); + assert!(patched.contains("# config")); + assert_eq!(document["debug"], true); + assert_eq!( + core_sensitive_words_settings_from_value(&document).unwrap(), + settings + ); + + let invalid = + serde_norway::from_str::("devin:\n sensitive-words: not-a-list\n") + .unwrap(); + assert!(core_sensitive_words_settings_from_value(&invalid).is_err()); + let empty = + serde_norway::from_str::("antigravity:\n sensitive-words:\ndevin:\n") + .unwrap(); + assert_eq!( + core_sensitive_words_settings_from_value(&empty).unwrap(), + CoreSensitiveWordsSettings::default() + ); + let updated = + patch_core_sensitive_words_yaml("antigravity:\n sensitive-words:\ndevin:\n", &settings) + .unwrap() + .unwrap(); + let parsed = serde_norway::from_str::(&updated).unwrap(); + assert_eq!( + core_sensitive_words_settings_from_value(&parsed).unwrap(), + settings + ); + let only_devin = CoreSensitiveWordsSettings { + antigravity_sensitive_words: Vec::new(), + devin_sensitive_words: vec!["another phrase".into()], + }; + let patched = + patch_core_sensitive_words_yaml("antigravity:\n sensitive-words:\ndevin:\n", &only_devin) + .unwrap() + .unwrap(); + let parsed = serde_norway::from_str::(&patched).unwrap(); + assert_eq!( + core_sensitive_words_settings_from_value(&parsed).unwrap(), + only_devin + ); + assert!(parsed["antigravity"]["sensitive-words"].is_null()); +} + #[test] fn enabled_tls_requires_both_paths() { let missing_key = CoreTlsSettings { diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 04ea709..c483fcb 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -680,6 +680,18 @@ export const jaOverrides = { 'config.tabs.network': 'ネットワーク設定', 'config.tabs.routing': 'セッションルーティング', '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.error.emptyKey': '認証キーを入力してください', 'config.error.invalidKey': '空白を含まない表示可能な ASCII 文字のみ使用できます', 'config.error.duplicateKey': '同じ認証キーがすでに存在します', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1b5bffc..1573035 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -677,6 +677,18 @@ export const en: Record = { 'config.tabs.network': 'Network Settings', 'config.tabs.routing': 'Session Routing', '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.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 b096f28..88c2052 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -676,6 +676,18 @@ export const zhCN = { 'config.tabs.network': '网络设置', 'config.tabs.routing': '会话路由', '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.error.emptyKey': '鉴权密钥不能为空', 'config.error.invalidKey': '只能使用 ASCII 可见字符,且不能包含空格', 'config.error.duplicateKey': '该鉴权密钥已经存在', diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index b433dd2..6777201 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -35,6 +35,7 @@ import { useI18n } from '../i18n'; import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice'; import { webUiManagementUrl } from '../services/clientAccess'; import { ThinkingAliasesPage } from './ThinkingAliasesPage'; +import { SensitiveWordsPage } from './SensitiveWordsPage'; type CoreConfigSettings = { apiKeys: CoreApiKey[]; @@ -78,7 +79,7 @@ type ConfigAction = | 'tls' | 'software' | null; -type ConfigSubpage = 'general' | 'network' | 'routing' | 'software' | 'aliases'; +type ConfigSubpage = 'general' | 'network' | 'routing' | 'software' | 'aliases' | 'sensitive-words'; type CloseBehavior = 'ask' | 'exit' | 'minimize-to-tray'; type NetworkDraftField = | 'port' @@ -190,6 +191,7 @@ export function ConfigPanelPage() { ); const [activeSubpage, setActiveSubpage] = useState('general'); + const [sensitiveWordsVisited, setSensitiveWordsVisited] = useState(false); const [portDraft, setPortDraft] = useState('8317'); const [hostDraft, setHostDraft] = useState('127.0.0.1'); const [proxyUrlDraft, setProxyUrlDraft] = useState(''); @@ -1017,6 +1019,21 @@ export function ConfigPanelPage() { > {t('config.tabs.software')} + {activeSubpage === 'general' ? ( @@ -2110,7 +2127,7 @@ export function ConfigPanelPage() { - ) : ( + ) : activeSubpage === 'aliases' ? (
- )} + ) : null} + + {sensitiveWordsVisited ? ( +
+ +
+ ) : null} {addDialogOpen ? (
{ diff --git a/src/pages/SensitiveWordsPage.tsx b/src/pages/SensitiveWordsPage.tsx new file mode 100644 index 0000000..9626ad9 --- /dev/null +++ b/src/pages/SensitiveWordsPage.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { Check, RefreshCw, ShieldAlert } from 'lucide-react'; +import { useI18n } from '../i18n'; + +type SensitiveWordsSettings = { + antigravitySensitiveWords: string[]; + devinSensitiveWords: string[]; +}; + +const wordsFromText = (text: string): string[] => text.split(/\r?\n/).map((word) => word.trim()).filter(Boolean); + +export function SensitiveWordsPage() { + const { t } = useI18n(); + const [saved, setSaved] = useState(null); + const [antigravityDraft, setAntigravityDraft] = useState(''); + const [devinDraft, setDevinDraft] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [savedNotice, setSavedNotice] = useState(false); + + 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')); + setSavedNotice(false); + } catch (cause) { + setSaved(null); + setError(String(cause)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + const antigravitySensitiveWords = wordsFromText(antigravityDraft); + const devinSensitiveWords = wordsFromText(devinDraft); + const dirty = saved !== null && ( + JSON.stringify(antigravitySensitiveWords) !== JSON.stringify(saved.antigravitySensitiveWords) + || JSON.stringify(devinSensitiveWords) !== JSON.stringify(saved.devinSensitiveWords) + ); + + const save = async () => { + if (!dirty || saving) return; + setSaving(true); + setError(''); + try { + const result = await invoke('save_core_sensitive_words_settings', { + settings: { antigravitySensitiveWords, devinSensitiveWords }, + }); + setSaved(result); + setAntigravityDraft(result.antigravitySensitiveWords.join('\n')); + setDevinDraft(result.devinSensitiveWords.join('\n')); + setSavedNotice(true); + } catch (cause) { + setError(String(cause)); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+
+
+ {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')}

+