diff --git a/apps/desktop/preload/apis/ai.ts b/apps/desktop/preload/apis/ai.ts index 1bc985ab..f8d07960 100644 --- a/apps/desktop/preload/apis/ai.ts +++ b/apps/desktop/preload/apis/ai.ts @@ -1,8 +1,6 @@ /** * AI 相关 API — 仅保留 IPC 必须的能力 * - * Conversation/message CRUD 已迁移到 HTTP 共享路由(FetchAIAdapter)。 - * LLM/Agent streaming 已迁移到 SSE 共享路由(useAgentStreamService/useLlmStreamService)。 * 此处只保留需要 worker、native shell、工具注册表等 IPC 才能提供的功能。 */ import { ipcRenderer } from 'electron' @@ -20,6 +18,7 @@ export interface DesensitizeRule { enabled: boolean builtin: boolean locales: string[] + group?: string } export interface PreprocessConfig { @@ -29,6 +28,8 @@ export interface PreprocessConfig { blacklistKeywords: string[] denoise: boolean desensitize: boolean + desensitizeRulesSchemaVersion?: number + desensitizeBuiltinRuleOverrides?: Record desensitizeRules: DesensitizeRule[] anonymizeNames: boolean } @@ -70,6 +71,3 @@ export const aiApi = { // Desensitize rules, tool testing, estimateContextTokens have been migrated to shared HTTP routes. } - -// LLM chat (non-streaming) IPC has been removed — no frontend consumers remain. -// All LLM calls now go through shared HTTP/SSE routes. diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index b5203b90..02ae557e 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -231,6 +231,7 @@ interface DesensitizeRule { enabled: boolean builtin: boolean locales: string[] + group?: string } /** 聊天记录预处理配置 */ @@ -241,6 +242,8 @@ interface PreprocessConfig { blacklistKeywords: string[] denoise: boolean desensitize: boolean + desensitizeRulesSchemaVersion?: number + desensitizeBuiltinRuleOverrides?: Record desensitizeRules: DesensitizeRule[] anonymizeNames: boolean } diff --git a/packages/http-routes/src/routes/web/ai-llm.ts b/packages/http-routes/src/routes/web/ai-llm.ts index 1e159daf..ae56b581 100644 --- a/packages/http-routes/src/routes/web/ai-llm.ts +++ b/packages/http-routes/src/routes/web/ai-llm.ts @@ -208,7 +208,7 @@ export function registerAiLlmRoutes(server: FastifyInstance, ctx: HttpRouteConte Body: { provider: string; apiKey: string; baseUrl?: string; model?: string; apiFormat?: string; configId?: string } }>('/_web/ai/llm/validate-key', async (request) => { const { provider, apiKey, baseUrl, model, apiFormat, configId } = request.body - const resolvedKey = apiKey?.trim() ? apiKey : (configId ? store.getConfigById(configId)?.apiKey || '' : '') + const resolvedKey = apiKey?.trim() ? apiKey : configId ? store.getConfigById(configId)?.apiKey || '' : '' return validateApiKey(provider, resolvedKey, baseUrl, model, apiFormat) }) @@ -216,7 +216,7 @@ export function registerAiLlmRoutes(server: FastifyInstance, ctx: HttpRouteConte Body: { provider: string; apiKey: string; baseUrl?: string; apiFormat?: string; configId?: string } }>('/_web/ai/llm/remote-models', async (request) => { const { provider, apiKey, baseUrl, apiFormat, configId } = request.body - const resolvedKey = apiKey?.trim() ? apiKey : (configId ? store.getConfigById(configId)?.apiKey || '' : '') + const resolvedKey = apiKey?.trim() ? apiKey : configId ? store.getConfigById(configId)?.apiKey || '' : '' return fetchRemoteModels(provider, resolvedKey, baseUrl, apiFormat) }) @@ -229,8 +229,8 @@ export function registerAiLlmRoutes(server: FastifyInstance, ctx: HttpRouteConte }) server.post<{ - Body: { existingRules: unknown[]; locale: string } + Body: { existingRules: unknown[]; locale: string; overrides?: Record } }>('/_web/ai/desensitize-rules/merge', async (request) => { - return mergeRulesForLocale(request.body.existingRules as any[], request.body.locale) + return mergeRulesForLocale(request.body.existingRules as any[], request.body.locale, request.body.overrides ?? {}) }) } diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index c2712565..dc821b08 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -63,13 +63,17 @@ export type { PreprocessConfig, PreprocessableMessage, DesensitizeRule, + DesensitizeRuleGroup, TruncationStrategy, PreprocessLogger, } from './preprocessor' export { preprocessMessages, BUILTIN_DESENSITIZE_RULES, + DESENSITIZE_RULES_SCHEMA_VERSION, + applyDesensitizeRuleOverrides, getDefaultRulesForLocale, + getRuleGroupsForLocale, mergeRulesForLocale, formatMessageCompact, formatTimeRange, diff --git a/packages/node-runtime/src/ai/preprocessor/builtin-rules.test.ts b/packages/node-runtime/src/ai/preprocessor/builtin-rules.test.ts new file mode 100644 index 00000000..d869a3b6 --- /dev/null +++ b/packages/node-runtime/src/ai/preprocessor/builtin-rules.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { applyDesensitizeRuleOverrides, getDefaultRulesForLocale, getRuleGroupsForLocale } from './builtin-rules' + +test('groups built-in desensitize rules by global groups and current locale', () => { + const groups = getRuleGroupsForLocale('zh-CN') + const groupIds = groups.map((group) => group.id) + + assert.deepEqual(groupIds, ['credentials', 'global_contact', 'global_financial', 'global_network', 'region_cn']) + assert.deepEqual( + groups.find((group) => group.id === 'region_cn')?.rules.map((rule) => rule.id), + ['cn_phone', 'cn_id_card', 'cn_bank_card', 'cn_landline'] + ) + assert.equal( + groups.some((group) => group.id === 'region_us'), + false + ) +}) + +test('enables all visible built-in desensitize rules by default', () => { + const rules = getDefaultRulesForLocale('zh-CN') + + assert.ok(rules.length > 0) + assert.equal( + rules.every((rule) => rule.enabled), + true + ) +}) + +test('applies explicit built-in rule overrides without storing rule bodies', () => { + const rules = applyDesensitizeRuleOverrides(getDefaultRulesForLocale('zh-CN'), { + api_key_prefix: false, + cn_bank_card: true, + }) + + assert.equal(rules.find((rule) => rule.id === 'api_key_prefix')?.enabled, false) + assert.equal(rules.find((rule) => rule.id === 'cn_bank_card')?.enabled, true) +}) diff --git a/packages/node-runtime/src/ai/preprocessor/builtin-rules.ts b/packages/node-runtime/src/ai/preprocessor/builtin-rules.ts index 0ae25050..c6aa3a0d 100644 --- a/packages/node-runtime/src/ai/preprocessor/builtin-rules.ts +++ b/packages/node-runtime/src/ai/preprocessor/builtin-rules.ts @@ -4,6 +4,76 @@ */ import type { DesensitizeRule } from './types' +export const DESENSITIZE_RULES_SCHEMA_VERSION = 2 + +export interface DesensitizeRuleGroup { + id: string + label: string + description: string + locales: string[] + order: number + rules: DesensitizeRule[] +} + +const GROUP_DEFS: Array> = [ + { + id: 'credentials', + label: 'desensitize.groups.credentials', + description: 'desensitize.groups.credentials_desc', + locales: [], + order: 10, + }, + { + id: 'global_contact', + label: 'desensitize.groups.global_contact', + description: 'desensitize.groups.global_contact_desc', + locales: [], + order: 20, + }, + { + id: 'global_financial', + label: 'desensitize.groups.global_financial', + description: 'desensitize.groups.global_financial_desc', + locales: [], + order: 30, + }, + { + id: 'global_network', + label: 'desensitize.groups.global_network', + description: 'desensitize.groups.global_network_desc', + locales: [], + order: 40, + }, + { + id: 'region_cn', + label: 'desensitize.groups.region_cn', + description: 'desensitize.groups.region_cn_desc', + locales: ['zh-CN'], + order: 50, + }, + { + id: 'region_us', + label: 'desensitize.groups.region_us', + description: 'desensitize.groups.region_us_desc', + locales: ['en-US'], + order: 50, + }, + { + id: 'region_jp', + label: 'desensitize.groups.region_jp', + description: 'desensitize.groups.region_jp_desc', + locales: ['ja-JP'], + order: 50, + }, + { + id: 'region_kr', + label: 'desensitize.groups.region_kr', + description: 'desensitize.groups.region_kr_desc', + locales: ['ko-KR'], + order: 50, + }, +] + export const BUILTIN_DESENSITIZE_RULES: DesensitizeRule[] = [ // ==================== 中国 (zh-CN) ==================== { @@ -14,6 +84,7 @@ export const BUILTIN_DESENSITIZE_RULES: DesensitizeRule[] = [ enabled: true, builtin: true, locales: ['zh-CN'], + group: 'region_cn', }, { id: 'cn_id_card', @@ -23,24 +94,27 @@ export const BUILTIN_DESENSITIZE_RULES: DesensitizeRule[] = [ enabled: true, builtin: true, locales: ['zh-CN'], + group: 'region_cn', }, { id: 'cn_bank_card', label: 'desensitize.rules.cn_bank_card', pattern: '(?"]+', replacement: '[URL]', - enabled: false, + enabled: true, builtin: true, locales: [], + group: 'global_network', }, ] @@ -182,14 +269,50 @@ export function getDefaultRulesForLocale(locale: string): DesensitizeRule[] { ) } +export function applyDesensitizeRuleOverrides( + rules: DesensitizeRule[], + overrides: Record = {} +): DesensitizeRule[] { + return rules.map((rule) => ({ + ...rule, + enabled: overrides[rule.id] ?? rule.enabled, + })) +} + +export function getRuleGroupsForLocale( + locale: string, + overrides: Record = {} +): DesensitizeRuleGroup[] { + const rules = applyDesensitizeRuleOverrides(getDefaultRulesForLocale(locale), overrides) + const rulesByGroup = new Map() + for (const rule of rules) { + if (!rule.group) continue + const groupRules = rulesByGroup.get(rule.group) ?? [] + groupRules.push(rule) + rulesByGroup.set(rule.group, groupRules) + } + + return GROUP_DEFS.filter((group) => { + if (group.locales.length > 0 && !group.locales.includes(locale)) return false + return (rulesByGroup.get(group.id)?.length ?? 0) > 0 + }) + .map((group) => ({ + ...group, + rules: rulesByGroup.get(group.id) ?? [], + })) + .sort((a, b) => a.order - b.order) +} + /** * 合并新 locale 的规则到现有规则列表 */ -export function mergeRulesForLocale(existing: DesensitizeRule[], locale: string): DesensitizeRule[] { - const existingIds = new Set(existing.map((r) => r.id)) - const newRules = BUILTIN_DESENSITIZE_RULES.filter( - (rule) => !existingIds.has(rule.id) && (rule.locales.length === 0 || rule.locales.includes(locale)) - ).map((rule) => ({ ...rule })) +export function mergeRulesForLocale( + existing: DesensitizeRule[], + locale: string, + overrides: Record = {} +): DesensitizeRule[] { + const customRules = existing.filter((rule) => !rule.builtin) + const builtinRules = applyDesensitizeRuleOverrides(getDefaultRulesForLocale(locale), overrides) - return [...existing, ...newRules] + return [...builtinRules, ...customRules] } diff --git a/packages/node-runtime/src/ai/preprocessor/index.ts b/packages/node-runtime/src/ai/preprocessor/index.ts index ef8bd713..c003f919 100644 --- a/packages/node-runtime/src/ai/preprocessor/index.ts +++ b/packages/node-runtime/src/ai/preprocessor/index.ts @@ -7,7 +7,15 @@ export type { PreprocessConfig, PreprocessableMessage, DesensitizeRule, TruncationStrategy } from './types' export { preprocessMessages } from './pipeline' export type { PreprocessLogger } from './pipeline' -export { BUILTIN_DESENSITIZE_RULES, getDefaultRulesForLocale, mergeRulesForLocale } from './builtin-rules' +export { + BUILTIN_DESENSITIZE_RULES, + DESENSITIZE_RULES_SCHEMA_VERSION, + applyDesensitizeRuleOverrides, + getDefaultRulesForLocale, + getRuleGroupsForLocale, + mergeRulesForLocale, +} from './builtin-rules' +export type { DesensitizeRuleGroup } from './builtin-rules' export { formatMessageCompact, formatTimeRange, diff --git a/packages/node-runtime/src/ai/preprocessor/types.ts b/packages/node-runtime/src/ai/preprocessor/types.ts index 574f5156..2e70a7d0 100644 --- a/packages/node-runtime/src/ai/preprocessor/types.ts +++ b/packages/node-runtime/src/ai/preprocessor/types.ts @@ -14,6 +14,8 @@ export interface DesensitizeRule { builtin: boolean /** 适用的 locale 列表(空数组表示通用) */ locales: string[] + /** 预置规则所属分组 */ + group?: string } /** 预处理配置 */ @@ -30,6 +32,10 @@ export interface PreprocessConfig { denoise: boolean /** 数据脱敏总开关 */ desensitize: boolean + /** 脱敏规则存储格式版本 */ + desensitizeRulesSchemaVersion?: number + /** 内置脱敏规则显式覆盖表 */ + desensitizeBuiltinRuleOverrides?: Record /** 脱敏规则列表(预置 + 自定义,按优先级排序) */ desensitizeRules: DesensitizeRule[] /** 昵称匿名化:用 U{id} 替代真实昵称,减少 AI 幻觉 */ diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 2b396221..95da1517 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -144,13 +144,17 @@ export type { PreprocessConfig, PreprocessableMessage, DesensitizeRule, + DesensitizeRuleGroup, TruncationStrategy, PreprocessLogger, } from './ai' export { preprocessMessages, BUILTIN_DESENSITIZE_RULES, + DESENSITIZE_RULES_SCHEMA_VERSION, + applyDesensitizeRuleOverrides, getDefaultRulesForLocale, + getRuleGroupsForLocale, mergeRulesForLocale, formatMessageCompact, formatTimeRange, diff --git a/packages/node-runtime/src/preferences.test.ts b/packages/node-runtime/src/preferences.test.ts new file mode 100644 index 00000000..87e5f944 --- /dev/null +++ b/packages/node-runtime/src/preferences.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { PreferencesManager } from './preferences' + +function createTempSystemDir(): string { + return mkdtempSync(join(tmpdir(), 'chatlab-preferences-')) +} + +function readPreferencesFile(systemDir: string): any { + return JSON.parse(readFileSync(join(systemDir, 'preferences.json'), 'utf-8')) +} + +test('migrates old built-in desensitize rules out of preferences with a backup', () => { + const systemDir = createTempSystemDir() + try { + writeFileSync( + join(systemDir, 'preferences.json'), + JSON.stringify({ + aiPreprocessConfig: { + desensitizeRules: [ + { + id: 'api_key_prefix', + label: 'API Key', + pattern: 'sk-[A-Za-z0-9]+', + replacement: '[API Key]', + enabled: false, + builtin: true, + locales: [], + }, + { + id: 'custom_staff_id', + label: 'Staff ID', + pattern: 'EMP-\\d+', + replacement: '[Staff ID]', + enabled: true, + builtin: false, + locales: [], + }, + ], + }, + }) + ) + + const prefs = new PreferencesManager(systemDir).load() + const saved = readPreferencesFile(systemDir) + const backupsDir = join(systemDir, 'backups') + + assert.equal(prefs.aiPreprocessConfig.desensitizeRulesSchemaVersion, 2) + assert.deepEqual(prefs.aiPreprocessConfig.desensitizeBuiltinRuleOverrides, {}) + assert.deepEqual( + prefs.aiPreprocessConfig.desensitizeRules.map((rule) => rule.id), + ['custom_staff_id'] + ) + assert.equal(saved.aiPreprocessConfig.desensitizeRules[0].id, 'custom_staff_id') + assert.equal( + saved.aiPreprocessConfig.desensitizeRules.some((rule: any) => rule.builtin), + false + ) + assert.equal(existsSync(backupsDir), true) + assert.equal( + readdirSync(backupsDir).some((name) => name.startsWith('preferences-pre-desensitize-groups-')), + true + ) + } finally { + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('sanitizes built-in desensitize rule bodies on save', () => { + const systemDir = createTempSystemDir() + try { + const manager = new PreferencesManager(systemDir) + const result = manager.save({ + aiPreprocessConfig: { + dataCleaning: true, + mergeConsecutive: true, + mergeWindowSeconds: 180, + blacklistKeywords: [], + denoise: true, + desensitize: true, + anonymizeNames: false, + desensitizeRulesSchemaVersion: 2, + desensitizeBuiltinRuleOverrides: { + api_key_prefix: true, + url: false, + }, + desensitizeRules: [ + { + id: 'api_key_prefix', + label: 'API Key', + pattern: 'sk-[A-Za-z0-9]+', + replacement: '[API Key]', + enabled: true, + builtin: true, + locales: [], + }, + { + id: 'custom_staff_id', + label: 'Staff ID', + pattern: 'EMP-\\d+', + replacement: '[Staff ID]', + enabled: true, + builtin: false, + locales: [], + }, + ], + }, + }) + + const saved = readPreferencesFile(systemDir) + + assert.equal(result.success, true) + assert.deepEqual(saved.aiPreprocessConfig.desensitizeBuiltinRuleOverrides, { + api_key_prefix: true, + url: false, + }) + assert.deepEqual( + saved.aiPreprocessConfig.desensitizeRules.map((rule: any) => rule.id), + ['custom_staff_id'] + ) + assert.equal(JSON.stringify(saved).includes('sk-[A-Za-z0-9]+'), false) + } finally { + rmSync(systemDir, { recursive: true, force: true }) + } +}) diff --git a/packages/node-runtime/src/preferences.ts b/packages/node-runtime/src/preferences.ts index 19b9c6a3..e078641c 100644 --- a/packages/node-runtime/src/preferences.ts +++ b/packages/node-runtime/src/preferences.ts @@ -7,6 +7,7 @@ import * as fs from 'fs' import * as path from 'path' +import { DESENSITIZE_RULES_SCHEMA_VERSION } from './ai/preprocessor' import type { Preferences, AIGlobalSettings, @@ -38,6 +39,8 @@ const DEFAULTS: Preferences = { blacklistKeywords: [], denoise: true, desensitize: true, + desensitizeRulesSchemaVersion: DESENSITIZE_RULES_SCHEMA_VERSION, + desensitizeBuiltinRuleOverrides: {}, desensitizeRules: [], anonymizeNames: false, }, @@ -80,7 +83,11 @@ export class PreferencesManager { if (fs.existsSync(this.filePath)) { const raw = fs.readFileSync(this.filePath, 'utf-8') const parsed = JSON.parse(raw) as Partial - this.cache = this.mergeDefaults(parsed) + const migrated = this.migrateLegacyDesensitizeRules(parsed, raw) + this.cache = this.mergeDefaults(migrated.preferences) + if (migrated.changed) { + this.writePreferences(this.cache) + } return this.cache } } catch (err) { @@ -98,13 +105,9 @@ export class PreferencesManager { current as unknown as Record, partial as unknown as Record ) - this.cache = merged as unknown as Preferences + this.cache = this.mergeDefaults(merged as unknown as Partial) - const dir = path.dirname(this.filePath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - fs.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2), 'utf-8') + this.writePreferences(this.cache) return { success: true } } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -125,7 +128,7 @@ export class PreferencesManager { return { pinnedSessionIds: partial.pinnedSessionIds ?? DEFAULTS.pinnedSessionIds, aiPreprocessConfig: partial.aiPreprocessConfig - ? { ...DEFAULTS.aiPreprocessConfig, ...partial.aiPreprocessConfig } + ? this.normalizeAiPreprocessConfig(partial.aiPreprocessConfig) : { ...DEFAULTS.aiPreprocessConfig }, aiGlobalSettings: partial.aiGlobalSettings ? { @@ -144,6 +147,77 @@ export class PreferencesManager { } } + private normalizeAiPreprocessConfig(partial: Partial | undefined): AIPreprocessConfig { + const defaults = DEFAULTS.aiPreprocessConfig + const rules = Array.isArray(partial?.desensitizeRules) ? partial.desensitizeRules : [] + const customRules = rules.filter((rule) => !rule.builtin) + return { + dataCleaning: partial?.dataCleaning ?? defaults.dataCleaning, + mergeConsecutive: partial?.mergeConsecutive ?? defaults.mergeConsecutive, + mergeWindowSeconds: partial?.mergeWindowSeconds ?? defaults.mergeWindowSeconds, + blacklistKeywords: partial?.blacklistKeywords ?? defaults.blacklistKeywords, + denoise: partial?.denoise ?? defaults.denoise, + desensitize: partial?.desensitize ?? defaults.desensitize, + desensitizeRulesSchemaVersion: DESENSITIZE_RULES_SCHEMA_VERSION, + desensitizeBuiltinRuleOverrides: this.normalizeBooleanMap(partial?.desensitizeBuiltinRuleOverrides), + desensitizeRules: customRules, + anonymizeNames: partial?.anonymizeNames ?? defaults.anonymizeNames, + } + } + + private normalizeBooleanMap(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + const result: Record = {} + for (const [key, flag] of Object.entries(value as Record)) { + if (typeof flag === 'boolean') result[key] = flag + } + return result + } + + private migrateLegacyDesensitizeRules( + partial: Partial, + rawContent: string + ): { preferences: Partial; changed: boolean } { + const rules = partial.aiPreprocessConfig?.desensitizeRules + if (!Array.isArray(rules) || !rules.some((rule) => rule.builtin)) { + return { preferences: partial, changed: false } + } + + this.backupLegacyPreferences(rawContent) + + return { + preferences: { + ...partial, + aiPreprocessConfig: { + ...partial.aiPreprocessConfig, + desensitizeRulesSchemaVersion: DESENSITIZE_RULES_SCHEMA_VERSION, + desensitizeBuiltinRuleOverrides: {}, + desensitizeRules: rules.filter((rule) => !rule.builtin), + }, + } as Partial, + changed: true, + } + } + + private backupLegacyPreferences(rawContent: string): void { + const backupDir = path.join(path.dirname(this.filePath), 'backups') + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }) + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const backupPath = path.join(backupDir, `preferences-pre-desensitize-groups-${timestamp}.json`) + fs.writeFileSync(backupPath, rawContent, 'utf-8') + console.info(`[Preferences] Backed up legacy desensitize rules to ${backupPath}`) + } + + private writePreferences(preferences: Preferences): void { + const dir = path.dirname(this.filePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(this.filePath, JSON.stringify(preferences, null, 2), 'utf-8') + } + /** * Deep merge: arrays are replaced (not concatenated), objects are merged. */ diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 42ce9443..dbc78dfa 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -151,6 +151,7 @@ export interface DesensitizeRule { enabled: boolean builtin: boolean locales: string[] + group?: string } export interface AIPreprocessConfig { @@ -160,6 +161,8 @@ export interface AIPreprocessConfig { blacklistKeywords: string[] denoise: boolean desensitize: boolean + desensitizeRulesSchemaVersion?: number + desensitizeBuiltinRuleOverrides?: Record desensitizeRules: DesensitizeRule[] anonymizeNames: boolean } diff --git a/src/components/common/Settings/AI/AIPreprocessTab.vue b/src/components/common/Settings/AI/AIPreprocessTab.vue index e944b8e3..891a2a0d 100644 --- a/src/components/common/Settings/AI/AIPreprocessTab.vue +++ b/src/components/common/Settings/AI/AIPreprocessTab.vue @@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue' import { storeToRefs } from 'pinia' import { useI18n } from 'vue-i18n' import { useSettingsStore } from '@/stores/settings' +import type { PreprocessConfig } from '@electron/preload/index' const { t, te } = useI18n() const settingsStore = useSettingsStore() @@ -11,6 +12,20 @@ const { aiPreprocessConfig } = storeToRefs(settingsStore) onMounted(() => settingsStore.ensureDesensitizeRules()) const newKeyword = ref('') +const expandedBuiltinGroups = ref>({}) + +type DesensitizeRuleView = PreprocessConfig['desensitizeRules'][number] + +const BUILTIN_GROUP_ORDER = [ + 'credentials', + 'global_contact', + 'global_financial', + 'global_network', + 'region_cn', + 'region_us', + 'region_jp', + 'region_kr', +] function addKeyword() { const kw = newKeyword.value.trim() @@ -30,6 +45,16 @@ function removeKeyword(index: number) { // 脱敏规则:内置 vs 自定义 const builtinRules = computed(() => aiPreprocessConfig.value.desensitizeRules.filter((r) => r.builtin)) const customRules = computed(() => aiPreprocessConfig.value.desensitizeRules.filter((r) => !r.builtin)) +const builtinRuleGroups = computed(() => { + const groups = new Map() + for (const rule of builtinRules.value) { + const groupId = rule.group ?? 'global_contact' + groups.set(groupId, [...(groups.get(groupId) ?? []), rule]) + } + return BUILTIN_GROUP_ORDER.map((id) => ({ id, rules: groups.get(id) ?? [] })).filter( + (group) => group.rules.length > 0 + ) +}) // 自定义规则表单 const customForm = ref({ name: '', pattern: '', replacement: '' }) @@ -45,9 +70,71 @@ function getRuleDesc(rule: { id: string; builtin: boolean }): string { return te(key) ? t(key) : '' } +function getGroupLabel(groupId: string): string { + const key = `settings.desensitize.groups.${groupId}` + return te(key) ? t(key) : groupId +} + +function getGroupDesc(groupId: string): string { + const key = `settings.desensitize.groups.${groupId}_desc` + return te(key) ? t(key) : '' +} + +function isGroupExpanded(groupId: string): boolean { + return expandedBuiltinGroups.value[groupId] ?? false +} + +function toggleGroupExpanded(groupId: string) { + expandedBuiltinGroups.value = { + ...expandedBuiltinGroups.value, + [groupId]: !isGroupExpanded(groupId), + } +} + +function getGroupState(rules: Array<{ enabled: boolean }>): { checked: boolean; indeterminate: boolean } { + const enabledCount = rules.filter((rule) => rule.enabled).length + return { + checked: rules.length > 0 && enabledCount === rules.length, + indeterminate: enabledCount > 0 && enabledCount < rules.length, + } +} + +function setBuiltinOverride(ruleId: string, enabled: boolean) { + aiPreprocessConfig.value.desensitizeBuiltinRuleOverrides = { + ...(aiPreprocessConfig.value.desensitizeBuiltinRuleOverrides ?? {}), + [ruleId]: enabled, + } +} + function toggleRule(ruleId: string) { const rule = aiPreprocessConfig.value.desensitizeRules.find((r) => r.id === ruleId) - if (rule) rule.enabled = !rule.enabled + if (!rule) return + rule.enabled = !rule.enabled + if (rule.builtin) { + setBuiltinOverride(rule.id, rule.enabled) + } +} + +function setRuleEnabled(rule: { id: string; enabled: boolean; builtin: boolean }, enabled: boolean) { + rule.enabled = enabled + if (rule.builtin) { + setBuiltinOverride(rule.id, enabled) + } +} + +function setGroupEnabled(rules: Array<{ id: string; enabled: boolean; builtin: boolean }>, enabled: boolean) { + for (const rule of rules) { + setRuleEnabled(rule, enabled) + } +} + +function resetBuiltinRules() { + const overrides = { ...(aiPreprocessConfig.value.desensitizeBuiltinRuleOverrides ?? {}) } + for (const rule of builtinRules.value) { + delete overrides[rule.id] + rule.enabled = true + } + aiPreprocessConfig.value.desensitizeBuiltinRuleOverrides = overrides } function addCustomRule() { @@ -176,24 +263,74 @@ function removeCustomRule(ruleId: string) {
-

- {{ t('settings.aiPreprocess.desensitizeBuiltin') }} -

-
-
+
+
+
+ + +
+ +
+ +
+
diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index 40b2924c..acd07746 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -423,8 +423,9 @@ "desensitize": "Data Masking", "desensitizeDesc": "Replace sensitive information (phone numbers, emails, ID numbers, etc.) based on rules", "desensitizeRules": "Masking Rules", - "desensitizeRulesDesc": "Check the rules to enable. Rules are matched in list order", + "desensitizeRulesDesc": "Enable masking rules by group. Individual rules can still be disabled and are matched in list order", "desensitizeBuiltin": "Built-in Rules", + "desensitizeResetBuiltin": "Restore Defaults", "desensitizeCustom": "Custom Rules", "desensitizeAddCustom": "Add Custom Rule", "desensitizeRuleName": "Rule Name", @@ -443,6 +444,24 @@ "blacklistAdd": "Add" }, "desensitize": { + "groups": { + "credentials": "Passwords & Tokens", + "credentials_desc": "Credentials such as API keys and Bearer tokens", + "global_contact": "Global Contact Info", + "global_contact_desc": "Cross-region contact details such as email addresses", + "global_financial": "Global Financial Info", + "global_financial_desc": "Financial identifiers such as credit card numbers", + "global_network": "Global Network Info", + "global_network_desc": "Network identifiers such as URLs and IP addresses", + "region_cn": "Mainland China", + "region_cn_desc": "Rules for phone numbers, IDs, and bank cards in Mainland China", + "region_us": "United States", + "region_us_desc": "Rules for phone numbers and IDs in the United States", + "region_jp": "Japan", + "region_jp_desc": "Rules for phone numbers and My Number in Japan", + "region_kr": "Korea", + "region_kr_desc": "Rules for phone numbers and resident registration numbers in Korea" + }, "rules": { "cn_phone": "China Mobile Number", "cn_phone_desc": "11-digit starting with 1xx", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 0c95584d..98b94109 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -423,8 +423,9 @@ "desensitize": "データマスキング", "desensitizeDesc": "ルールに基づいて機密情報(電話番号、メールアドレス、身分証番号など)をプレースホルダーに置換します", "desensitizeRules": "マスキングルール", - "desensitizeRulesDesc": "有効にするマスキングルールにチェックを入れてください。リスト順に優先マッチングされます", + "desensitizeRulesDesc": "グループ単位でマスキングルールを有効化します。グループ内のルールは個別に無効化でき、リスト順に優先マッチングされます", "desensitizeBuiltin": "プリセットルール", + "desensitizeResetBuiltin": "既定に戻す", "desensitizeCustom": "カスタムルール", "desensitizeAddCustom": "カスタムルールを追加", "desensitizeRuleName": "ルール名", @@ -443,6 +444,24 @@ "blacklistAdd": "追加" }, "desensitize": { + "groups": { + "credentials": "パスワードと Token", + "credentials_desc": "API Key、Bearer Token などの認証情報", + "global_contact": "汎用連絡先情報", + "global_contact_desc": "メールアドレスなど地域をまたぐ連絡先情報", + "global_financial": "汎用金融情報", + "global_financial_desc": "クレジットカード番号などの金融識別子", + "global_network": "汎用ネットワーク情報", + "global_network_desc": "URL、IP アドレスなどのネットワーク識別子", + "region_cn": "中国本土", + "region_cn_desc": "中国本土の電話番号、証明書、銀行カードなどのルール", + "region_us": "米国", + "region_us_desc": "米国の電話番号、証明書などのルール", + "region_jp": "日本", + "region_jp_desc": "日本の電話番号、マイナンバーなどのルール", + "region_kr": "韓国", + "region_kr_desc": "韓国の電話番号、住民登録番号などのルール" + }, "rules": { "cn_phone": "中国の携帯電話番号", "cn_phone_desc": "1xx xxxx xxxx 形式の11桁の番号", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ac7138c6..eeeafa37 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -423,8 +423,9 @@ "desensitize": "数据脱敏", "desensitizeDesc": "根据规则将敏感信息(手机号、邮箱、证件号等)替换为占位符", "desensitizeRules": "脱敏规则", - "desensitizeRulesDesc": "勾选需要启用的脱敏规则,按列表顺序优先匹配", + "desensitizeRulesDesc": "按组启用脱敏规则,组内规则仍可单独关闭,按列表顺序优先匹配", "desensitizeBuiltin": "预置规则", + "desensitizeResetBuiltin": "恢复默认", "desensitizeCustom": "自定义规则", "desensitizeAddCustom": "添加自定义规则", "desensitizeRuleName": "规则名称", @@ -443,6 +444,24 @@ "blacklistAdd": "添加" }, "desensitize": { + "groups": { + "credentials": "密码与 Token", + "credentials_desc": "API Key、Bearer Token 等凭据", + "global_contact": "通用联系方式", + "global_contact_desc": "邮箱等跨地区联系方式", + "global_financial": "通用金融信息", + "global_financial_desc": "信用卡等金融编号", + "global_network": "通用网络信息", + "global_network_desc": "URL、IP 地址等网络标识", + "region_cn": "中国大陆", + "region_cn_desc": "中国大陆电话、证件和银行卡等规则", + "region_us": "美国", + "region_us_desc": "美国电话、证件等规则", + "region_jp": "日本", + "region_jp_desc": "日本电话、个人编号等规则", + "region_kr": "韩国", + "region_kr_desc": "韩国电话、身份证号等规则" + }, "rules": { "cn_phone": "中国手机号", "cn_phone_desc": "1xx xxxx xxxx 格式的 11 位号码", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index ccfdaebf..4847cd73 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -423,8 +423,9 @@ "desensitize": "資料脫敏", "desensitizeDesc": "根據規則將敏感資訊(手機號、電子郵件、證件號等)替換為佔位符", "desensitizeRules": "脫敏規則", - "desensitizeRulesDesc": "勾選需要啟用的脫敏規則,按清單順序優先匹配", + "desensitizeRulesDesc": "按組啟用脫敏規則,組內規則仍可單獨關閉,按清單順序優先匹配", "desensitizeBuiltin": "預置規則", + "desensitizeResetBuiltin": "恢復預設", "desensitizeCustom": "自訂規則", "desensitizeAddCustom": "新增自訂規則", "desensitizeRuleName": "規則名稱", @@ -443,6 +444,24 @@ "blacklistAdd": "新增" }, "desensitize": { + "groups": { + "credentials": "密碼與 Token", + "credentials_desc": "API Key、Bearer Token 等憑證", + "global_contact": "通用聯絡方式", + "global_contact_desc": "電子郵件等跨地區聯絡方式", + "global_financial": "通用金融資訊", + "global_financial_desc": "信用卡等金融編號", + "global_network": "通用網路資訊", + "global_network_desc": "URL、IP 地址等網路識別", + "region_cn": "中國大陸", + "region_cn_desc": "中國大陸電話、證件和銀行卡等規則", + "region_us": "美國", + "region_us_desc": "美國電話、證件等規則", + "region_jp": "日本", + "region_jp_desc": "日本電話、個人編號等規則", + "region_kr": "韓國", + "region_kr_desc": "韓國電話、身份證號等規則" + }, "rules": { "cn_phone": "中國手機號", "cn_phone_desc": "1xx xxxx xxxx 格式的 11 位號碼", diff --git a/src/plugins/backendPersist.ts b/src/plugins/backendPersist.ts index 2b28b30a..099417c6 100644 --- a/src/plugins/backendPersist.ts +++ b/src/plugins/backendPersist.ts @@ -20,6 +20,8 @@ export interface BackendPersistConfig { pick: string[] /** Nest picked fields under this key in preferences.json */ key?: string + /** Serialize store state before saving to preferences.json */ + serialize?: (store: Record) => Partial } declare module 'pinia' { @@ -50,6 +52,12 @@ function scheduleSave() { } function collectAndQueue(store: Record, config: BackendPersistConfig) { + if (config.serialize) { + Object.assign(pendingSave, config.serialize(store)) + scheduleSave() + return + } + const data: Record = {} for (const field of config.pick) { data[field] = JSON.parse(JSON.stringify(store[field])) diff --git a/src/services/ai/fetch.ts b/src/services/ai/fetch.ts index 8bff34c2..9265df3c 100644 --- a/src/services/ai/fetch.ts +++ b/src/services/ai/fetch.ts @@ -238,9 +238,13 @@ export class FetchAIAdapter implements AIAdapter { } } - async mergeDesensitizeRules(existingRules: DesensitizeRule[], locale: string): Promise { + async mergeDesensitizeRules( + existingRules: DesensitizeRule[], + locale: string, + overrides: Record = {} + ): Promise { try { - return await post('/ai/desensitize-rules/merge', { existingRules, locale }) + return await post('/ai/desensitize-rules/merge', { existingRules, locale, overrides }) } catch { return existingRules } diff --git a/src/services/ai/types.ts b/src/services/ai/types.ts index 12d4bf62..06c06256 100644 --- a/src/services/ai/types.ts +++ b/src/services/ai/types.ts @@ -55,6 +55,7 @@ export interface DesensitizeRule { enabled: boolean builtin: boolean locales: string[] + group?: string } export interface ToolCatalogEntry { @@ -220,7 +221,11 @@ export interface AIAdapter { // ===== 脱敏 ===== getDefaultDesensitizeRules(locale: string): Promise - mergeDesensitizeRules(existingRules: DesensitizeRule[], locale: string): Promise + mergeDesensitizeRules( + existingRules: DesensitizeRule[], + locale: string, + overrides?: Record + ): Promise // ===== 日志 ===== showAiLogFile(): Promise<{ success: boolean; path?: string; error?: string }> diff --git a/src/services/ai/web.ts b/src/services/ai/web.ts index ed54d545..eb510a93 100644 --- a/src/services/ai/web.ts +++ b/src/services/ai/web.ts @@ -182,7 +182,11 @@ export class WebAIAdapter implements AIAdapter { return [] } - async mergeDesensitizeRules(existingRules: DesensitizeRule[], _locale: string): Promise { + async mergeDesensitizeRules( + existingRules: DesensitizeRule[], + _locale: string, + _overrides: Record = {} + ): Promise { return existingRules } diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 12ae8d16..2d757e71 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -7,9 +7,26 @@ import 'dayjs/locale/en' import 'dayjs/locale/ja' import { type LocaleType, setLocale as setI18nLocale, getLocale, getDayjsLocale } from '@/i18n' import type { PreprocessConfig } from '@electron/preload/index' -import { IS_ELECTRON } from '@/utils/platform' +import type { AIPreprocessConfig } from '@openchatlab/shared-types' import { useAIService } from '@/services' +const DESENSITIZE_RULES_SCHEMA_VERSION = 2 + +function serializeAiPreprocessConfig(config: PreprocessConfig): AIPreprocessConfig { + return { + ...config, + desensitizeRulesSchemaVersion: DESENSITIZE_RULES_SCHEMA_VERSION, + desensitizeBuiltinRuleOverrides: { ...(config.desensitizeBuiltinRuleOverrides ?? {}) }, + mergeWindowSeconds: config.mergeWindowSeconds ?? 180, + desensitizeRules: config.desensitizeRules + .filter((rule) => !rule.builtin) + .map((rule) => ({ + ...rule, + locales: [...rule.locales], + })), + } +} + export const useSettingsStore = defineStore( 'settings', () => { @@ -31,6 +48,8 @@ export const useSettingsStore = defineStore( blacklistKeywords: [], denoise: true, desensitize: true, + desensitizeRulesSchemaVersion: DESENSITIZE_RULES_SCHEMA_VERSION, + desensitizeBuiltinRuleOverrides: {}, desensitizeRules: [], anonymizeNames: false, }) @@ -39,10 +58,12 @@ export const useSettingsStore = defineStore( * 确保脱敏规则已初始化(首次使用或升级时通过 IPC 从主进程获取) */ async function ensureDesensitizeRules() { - if (!IS_ELECTRON) return - if (aiPreprocessConfig.value.desensitizeRules.length === 0) { - aiPreprocessConfig.value.desensitizeRules = await useAIService().getDefaultDesensitizeRules(locale.value) - } + const plainRules = JSON.parse(JSON.stringify(aiPreprocessConfig.value.desensitizeRules)) + aiPreprocessConfig.value.desensitizeRules = await useAIService().mergeDesensitizeRules( + plainRules, + locale.value, + aiPreprocessConfig.value.desensitizeBuiltinRuleOverrides ?? {} + ) } /** @@ -57,11 +78,7 @@ export const useSettingsStore = defineStore( window.electron?.ipcRenderer.send('locale:change', newLocale) - if (IS_ELECTRON) { - // Vue 响应式 Proxy 无法通过 Electron IPC structured clone,需转为普通对象 - const plainRules = JSON.parse(JSON.stringify(aiPreprocessConfig.value.desensitizeRules)) - aiPreprocessConfig.value.desensitizeRules = await useAIService().mergeDesensitizeRules(plainRules, newLocale) - } + await ensureDesensitizeRules() } /** @@ -99,6 +116,9 @@ export const useSettingsStore = defineStore( }, backendPersist: { pick: ['aiPreprocessConfig'], + serialize: (state) => ({ + aiPreprocessConfig: serializeAiPreprocessConfig(state.aiPreprocessConfig as PreprocessConfig), + }), }, } )