/** * 配置中心提示词/词表/凭据编辑模型(Phase 3 Task 7b + 7c;Task 7d 补 * 版本历史展示辅助) * * 消费方:config/center 的 PromptsPane.vue / WallPane.vue / CredsPane.vue / * VersionsPane.vue。纯逻辑无 DOM 依赖,vitest 直接覆盖(__tests__/model.test.ts)。 * * 事实源对齐: * - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS * 与 app/platform/config_validation.PROMPTS_SIZE_CAPS(company_facts * 50KB 软上限 spec §5.7,其余 100KB;UTF-8 字节口径——与后端 * len(text.encode('utf-8')) 一致,用 TextEncoder 计数); * - 词表结构:app/engine/wall_schema.py(seed_profiles/jiancai/wall.json * 为真实形态样本)。正则条目双形态:纯字符串 | {pattern, flags?, * comment?}(flags 仅支持 'i');PUT wall 整组提交,未结构化编辑的 * 顶层键(含 "_" 前缀元信息键)原样透传; * - 语言池必填 en/zh(wall_schema._REQUIRED_POOLS),reminders 六类 / * cc_templates 四组的变体键为封闭集合(未知键编译期显性拒绝); * - 凭据三组(Task 7c):DEFAULT_CONFIG 的 email/push/ai 组键 + * config_validation 数值边界(push.timeout/backoff 正数、max_attempts * 1-10 整数、notify_emails 项含 @)+ config_views 空槽回退语义 * (空串/None = 未设置 → 回退 env,表单提示「留空 = 平台默认」)。 * 密钥哨兵交互见 src/api/mask.ts(GET '__SET__' → 空输入 + 占位符; * 不动保存回传哨兵保持库值;「清除」= 提交空串)。 */ import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask' import type { ConfigGroupKey } from '@/api/config' // ---- 提示词四件套 ----------------------------------------------------------------- /** 四件套键名(ai_service.PROMPTS_KEYS 镜像;prompts 是整组 PUT 的四键 dict) */ export const PROMPTS_KEYS = [ 'system', 'company_facts', 'extractor', 'evaluator' ] as const export type PromptKey = (typeof PROMPTS_KEYS)[number] export type PromptsDraft = Record /** 各件字节上限(UTF-8 口径;config_validation.PROMPTS_SIZE_CAPS 镜像) */ export const PROMPTS_CAPS: Record = { system: 100 * 1024, company_facts: 50 * 1024, extractor: 100 * 1024, evaluator: 100 * 1024 } /** 四件套编辑器元数据(label/hint 为 UI 展示;deflectionPoolNote 仅 system) */ export const PROMPTS_META: Array<{ key: PromptKey label: string rows: number hint: string }> = [ { key: 'system', label: '系统提示词 system.md', rows: 14, hint: 'AI 行为总纲(职能边界/语言跟随等)' }, { key: 'company_facts', label: '公司事实卡 company_facts.md', rows: 10, hint: '每轮整段拼入 system prompt——保持精炼(50KB 软上限)' }, { key: 'extractor', label: '线索提取器 extractor.md', rows: 8, hint: '$existing_info / $messages 占位符由运行时回填' }, { key: 'evaluator', label: '意向评估器 evaluator.md', rows: 8, hint: '$profile / $recent_messages 占位符由运行时回填' } ] /** UTF-8 字节数(后端 len(text.encode('utf-8')) 同口径) */ export function utf8Bytes(text: string): number { return new TextEncoder().encode(text).length } /** 字节数展示(KB 一位小数) */ export function formatKB(bytes: number): string { return `${(bytes / 1024).toFixed(1)}KB` } /** 单件字节状态(实时提示用) */ export interface PromptByteInfo { bytes: number cap: number over: boolean label: string } export function promptByteInfo(key: PromptKey, text: string): PromptByteInfo { const bytes = utf8Bytes(text) const cap = PROMPTS_CAPS[key] return { bytes, cap, over: bytes > cap, label: `${formatKB(bytes)} / ${formatKB(cap)}` } } /** GET 的 prompts 组(原样快照,可能缺键/类型漂移)→ 四键字符串表单值 */ export function normalizePromptsGroup( value: Record | null | undefined ): PromptsDraft { const source = value ?? {} const out = {} as PromptsDraft for (const key of PROMPTS_KEYS) { const v = source[key] out[key] = typeof v === 'string' ? v : '' } return out } /** 四件套是否有超限件(超限即禁存——后端同样硬拒,前移到按钮态) */ export function promptsOverCap(draft: PromptsDraft): boolean { return PROMPTS_KEYS.some((key) => utf8Bytes(draft[key]) > PROMPTS_CAPS[key]) } // ---- 词表(wall 组)--------------------------------------------------------------- /** 正则条目双形态(wall_schema._compile_pattern):纯字符串 | dict */ export type WallPatternEntry = | string | { pattern?: unknown; flags?: unknown; comment?: unknown } /** 正则条目编辑行(双形态归一;flags 仅 '' | 'i'——_FLAG_MAP 只认 'i') */ export interface RegexRow { pattern: string flags: '' | 'i' comment: string /** 备注编辑展开态(瞬态 UI 状态,不参与序列化) */ showComment: boolean } function blankRow(): RegexRow { return { pattern: '', flags: '', comment: '', showComment: false } } /** 防御读取:一层对象或 null */ function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null } /** 条目 → 编辑行(字符串/dict 双形态;异常值防御归空) */ export function parsePatternEntry(entry: unknown): RegexRow { if (typeof entry === 'string') { return { pattern: entry, flags: '', comment: '', showComment: false } } const dict = asRecord(entry) if (dict) { return { pattern: asStr(dict.pattern), flags: dict.flags === 'i' ? 'i' : '', comment: asStr(dict.comment), showComment: false } } return blankRow() } /** 编辑行 → 条目:无 flags 无备注 → 纯字符串(两种形态对编译等价, * 与 wall.json 的极简条目保持一致);否则 dict 且只带非空键。 * 空 pattern 行返回 null(编译必 400「缺非空 pattern」,序列化期剔除)。 */ export function rowToEntry( row: RegexRow ): string | Record | null { if (!row.pattern) return null if (!row.flags && !row.comment.trim()) return row.pattern const out: Record = { pattern: row.pattern } if (row.flags) out.flags = row.flags if (row.comment.trim()) out.comment = row.comment return out } /** 正则列表序列化(空 pattern 行剔除;列表可为空——由后端编译校验报路径) */ export function rowsToEntries( rows: RegexRow[] ): Array> { return rows .map(rowToEntry) .filter((e): e is string | Record => e !== null) } /** 单条目字段(triggers 三键 / zero_number 两键)编辑行 → 条目: * 无标志无备注且 pattern 非空 → 纯字符串(wall.json zero_number 出厂 * 形态,编译等价、往返无损);否则 dict 只带非空键——空 pattern 也走 * dict 分支,后端报「缺非空 pattern」字段路径(裸 '' 会编成匹配一切的 * 空正则,必须显性失败) */ export function rowToSingleEntry( row: RegexRow ): string | Record { if (row.pattern && !row.flags && !row.comment.trim()) return row.pattern const out: Record = { pattern: row.pattern } if (row.flags) out.flags = row.flags if (row.comment.trim()) out.comment = row.comment return out } // ---- 语言池 / 话术区结构(wall_schema 变体键镜像;封闭集合) ------------------------ /** 四个语言池字段(en/zh 必填——_REQUIRED_POOLS) */ export const POOL_FIELDS = [ 'deflections', 'deflections_contact', 'pivots', 'pivots_contact' ] as const export type PoolField = (typeof POOL_FIELDS)[number] /** 池必填语言(删除时前端拦截;后端编译同样拒绝缺失) */ export const POOL_REQUIRED_LANGS = ['en', 'zh'] as const /** reminders 六类 × 变体(wall_schema._REMINDER_VARIANTS 镜像) */ export const REMINDER_VARIANTS: Record = { boundary: ['budget', 'normal'], contact_ask: ['app_already', 'app', 'web'], language_follow: ['zh', 'es'], lead_given: ['forbidden', 'respond_script', 'boundary_tail', 'suspect'], cc_guide: ['echo', 'ask'], email_light: ['en', 'zh'] } /** cc_templates 四组 × 语言(wall_schema._CC_TEMPLATE_VARIANTS 镜像) */ export const CC_TEMPLATE_VARIANTS: Record = { ask: ['en', 'zh', 'es'], wall: ['en', 'zh', 'es'], note: ['en', 'zh'], echo: ['en', 'zh', 'es'] } export const NO_CONTACT_KEYS = ['web', 'app', 'already'] as const /** triggers 三键 / zero_number 两键(单条目正则字段) */ export const TRIGGER_KEYS = ['boundary', 'budget', 'contact_ask'] as const export const ZERO_NUMBER_KEYS = ['long_number', 'any_email'] as const // ---- 词表整体编辑模型 -------------------------------------------------------------- /** 词表编辑模型:结构化编辑区(全部归一为可 v-model 的形态)+ 透传区 */ export interface WallEditModel { /** 未提供结构化编辑的顶层键原样透传(含 "_" 前缀元信息键) */ passthrough: Record priceRows: RegexRow[] reaskRows: RegexRow[] /** triggers.boundary/budget/contact_ask + zero_number.long_number/any_email */ singles: Record<(typeof TRIGGER_KEYS)[number] | (typeof ZERO_NUMBER_KEYS)[number], RegexRow> /** 四池:语言 → 句子列表(可增删行;序列化时空行剔除) */ pools: Record> noContact: Record<(typeof NO_CONTACT_KEYS)[number], string> /** 六类 × 变体 → 话术文本(变体键封闭集合,缺项补空串交后端报路径) */ reminders: Record> /** 四组 × 语言 → 模板文本({intl}/{cc_script} 等占位符白名单由后端校验) */ ccTemplates: Record> } function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T } /** 防御读取:字符串或空串 */ function asStr(value: unknown): string { return typeof value === 'string' ? value : '' } /** 防御读取:语言池 → 语言到字符串数组的映射(非字符串项剔除) */ function asPool(value: unknown): Record { const source = asRecord(value) if (!source) return {} const out: Record = {} for (const [lang, items] of Object.entries(source)) { if (Array.isArray(items)) { const rows = items.filter((t): t is string => typeof t === 'string') if (rows.length > 0) out[lang] = rows } } return out } /** 防御读取:一层字符串映射 → 只保留 variant 集合内的键(缺项补空串) */ function asVariantMap( value: unknown, variants: string[] ): Record { const source = asRecord(value) ?? {} const out: Record = {} for (const key of variants) { out[key] = asStr(source[key]) } return out } /** 结构化编辑接管的顶层键(其余透传) */ const KNOWN_WALL_EDIT_KEYS = new Set([ 'price_patterns', 'reask_patterns', 'triggers', 'zero_number', ...POOL_FIELDS, 'no_contact', 'reminders', 'cc_templates' ]) /** * GET 的 wall 组 → 编辑模型。wall 缺失/非对象/空对象 → null(面板出空态 * ——wall 组来自租户 seed 的 wall.json,缺失时无从结构化编辑)。结构上 * 无法通过 PUT 校验的存量数据(理论不可达——PUT 前有编译校验)也归一为 * 可编辑形态,保存时由后端 400 带字段路径显性兜底。 */ export function initWallEdit( source: Record | null | undefined ): WallEditModel | null { const record = asRecord(source) if (!record || Object.keys(record).length === 0) return null const rest = Object.fromEntries( Object.entries(record).filter(([k]) => !KNOWN_WALL_EDIT_KEYS.has(k)) ) const triggersSrc = asRecord(record.triggers) const zeroSrc = asRecord(record.zero_number) const singles = {} as WallEditModel['singles'] for (const key of TRIGGER_KEYS) { singles[key] = parsePatternEntry(triggersSrc?.[key]) } for (const key of ZERO_NUMBER_KEYS) { singles[key] = parsePatternEntry(zeroSrc?.[key]) } const pools = {} as WallEditModel['pools'] for (const field of POOL_FIELDS) { pools[field] = asPool(record[field]) } const reminders: Record> = {} const remindersSrc = asRecord(record.reminders) for (const [cat, variants] of Object.entries(REMINDER_VARIANTS)) { reminders[cat] = asVariantMap(remindersSrc?.[cat], variants) } const ccTemplates: Record> = {} const ccSrc = asRecord(record.cc_templates) for (const [group, langs] of Object.entries(CC_TEMPLATE_VARIANTS)) { ccTemplates[group] = asVariantMap(ccSrc?.[group], langs) } const noContact = asVariantMap(record.no_contact, [...NO_CONTACT_KEYS]) as WallEditModel['noContact'] return { passthrough: clone(rest), priceRows: parsePatternList(record.price_patterns), reaskRows: parsePatternList(record.reask_patterns), singles, pools, noContact, reminders, ccTemplates } } function parsePatternList(value: unknown): RegexRow[] { return Array.isArray(value) ? value.map(parsePatternEntry) : [] } /** 池序列化:空行剔除;剔除后为空的语言整键丢弃(空列表必被编译拒绝, * 丢弃后缺 en/zh 由后端报「缺语言」路径——同为显性失败,语义更准) */ function cleanPool(pool: Record): Record { const out: Record = {} for (const [lang, rows] of Object.entries(pool)) { const cleaned = rows.filter((t) => t.trim() !== '') if (cleaned.length > 0) out[lang] = cleaned } return out } /** * 编辑模型 → PUT config/wall 整组载荷:结构化区按编辑值重组,其余顶层键 * 原样透传。正则编译/结构问题不在前端造校验器——后端 compile_wall 的 * 400 自带字段路径(含试金石预演路径),内联展示即可。 */ export function buildWallPayload(model: WallEditModel): Record { const triggers: Record = {} for (const key of TRIGGER_KEYS) { triggers[key] = rowToSingleEntry(model.singles[key]) } const zeroNumber: Record = {} for (const key of ZERO_NUMBER_KEYS) { zeroNumber[key] = rowToSingleEntry(model.singles[key]) } const pools: Record = {} for (const field of POOL_FIELDS) { pools[field] = cleanPool(model.pools[field]) } return { ...model.passthrough, price_patterns: rowsToEntries(model.priceRows), reask_patterns: rowsToEntries(model.reaskRows), triggers, zero_number: zeroNumber, ...pools, no_contact: { ...model.noContact }, reminders: clone(model.reminders), cc_templates: clone(model.ccTemplates) } } // ---- 试金石 ------------------------------------------------------------------------ /** 试金石默认示例句:一句价格墙(blocked)/ 一句零号码墙(replaced)/ * 一句干净(pass)——三色判定一屏可见 */ export const DEFAULT_TOUCHSTONE_SENTENCES = [ 'The price is 500 USD per ton.', 'You can reach me at john.doe@example.com.', 'What sizes of PPR pipes do you have in stock?' ] /** 试金石请求句集:去空白过滤空行(后端要求非空字符串列表) */ export function touchstoneSentences(sentences: string[]): string[] { return sentences.map((s) => s.trim()).filter(Boolean) } // ---- 凭据三组(email / push / ai,Phase 3 Task 7c) -------------------------------- /** 邮件凭据表单(数值/密钥输入均为字符串形态——空串 = 未设置回退 env) */ export interface EmailCredsForm { smtpHost: string smtpPort: string smtpUser: string /** 密钥输入:初始恒空,「已设置」态由占位符表达(见 mask.ts) */ smtpPassword: string senderName: string notifyEmails: string[] } /** 推送凭据表单(timeout/maxAttempts/backoffBase:'' = null = 平台默认) */ export interface PushCredsForm { enabled: boolean url: string apiKey: string prefix: string timeout: string maxAttempts: string backoffBase: string } /** AI 凭据表单 */ export interface AiCredsForm { provider: string apiKey: string baseUrl: string model: string } /** GET 组 → 表单 + 密钥掩码状态(掩码值驱动占位符与 secretToSubmit) */ export interface EmailCredsState { form: EmailCredsForm passwordMasked: string } export interface PushCredsState { form: PushCredsForm apiKeyMasked: string } export interface AiCredsState { apiKeyMasked: string form: AiCredsForm } /** * 任意非空字符串 → 掩码哨兵。比 isMaskedSentinel 更保守:即使后端掩码 * 失效漏出原始密钥(理论不可达),也按「已设置」处理——不动保存回传 * 哨兵由后端还原库值,绝不把原始值再提交回去;空串/null/undefined → '' * (未设置)。 */ export function asSecretMasked(value: unknown): string { return typeof value === 'string' && value !== '' ? MASKED_SENTINEL : '' } /** 防御读取:数值 → 表单串(非有限数值/缺省 → '') */ function numberToForm(value: unknown): string { return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' } /** 防御读取:字符串数组(非字符串项剔除) */ function strList(value: unknown): string[] { return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [] } /** 表单串 → 整数 | null(空串 = 未设置;非整数由 validate 前置拦截) */ function intOrNull(value: string): number | null { const n = Number(value.trim()) return value.trim() !== '' && Number.isInteger(n) ? n : null } /** 表单串 → 数值 | null(空串 = 未设置;非数值由 validate 前置拦截) */ function numOrNull(value: string): number | null { const n = Number(value.trim()) return value.trim() !== '' && Number.isFinite(n) ? n : null } /** GET 的 email 组 → 邮件表单(smtp 缺省/null → 全空槽,回退 env) */ export function normalizeEmailCredsGroup( value: Record | null | undefined ): EmailCredsState { const source = value ?? {} const smtp = asRecord(source.smtp) ?? {} return { form: { smtpHost: asStr(smtp.host), smtpPort: numberToForm(smtp.port), smtpUser: asStr(smtp.user), smtpPassword: '', senderName: asStr(source.sender_name), notifyEmails: strList(source.notify_emails) }, passwordMasked: asSecretMasked(smtp.password) } } /** GET 的 push 组 → 推送表单 */ export function normalizePushCredsGroup( value: Record | null | undefined ): PushCredsState { const source = value ?? {} return { form: { enabled: source.enabled === true, url: asStr(source.url), apiKey: '', prefix: asStr(source.prefix), timeout: numberToForm(source.timeout), maxAttempts: numberToForm(source.max_attempts), backoffBase: numberToForm(source.retry_backoff_base) }, apiKeyMasked: asSecretMasked(source.api_key) } } /** GET 的 ai 组 → AI 表单 */ export function normalizeAiCredsGroup( value: Record | null | undefined ): AiCredsState { const source = value ?? {} return { form: { provider: asStr(source.provider), apiKey: '', baseUrl: asStr(source.base_url), model: asStr(source.model) }, apiKeyMasked: asSecretMasked(source.api_key) } } /** * 邮件表单 → PUT config/email 整组载荷。密钥走 secretToSubmit(未动 → * 哨兵保持库值;「清除」按钮已把 maskedState 置 '',提交空串 = 显式清空)。 * 数值/邮箱格式由 validateEmailCredsForm 前置把关(后端 400 二层兜底)。 */ export function buildEmailCredsPayload( form: EmailCredsForm, passwordMasked: string ): Record { return { smtp: { host: form.smtpHost.trim(), port: intOrNull(form.smtpPort), user: form.smtpUser.trim(), password: secretToSubmit(form.smtpPassword, passwordMasked) }, sender_name: form.senderName.trim(), notify_emails: form.notifyEmails .map((e) => e.trim()) .filter((e) => e !== '') } } /** 推送表单 → PUT config/push 整组载荷(prefix 冲突 400 由后端跨租户查重) */ export function buildPushCredsPayload( form: PushCredsForm, apiKeyMasked: string ): Record { return { enabled: form.enabled, url: form.url.trim(), api_key: secretToSubmit(form.apiKey, apiKeyMasked), prefix: form.prefix.trim(), timeout: numOrNull(form.timeout), max_attempts: intOrNull(form.maxAttempts), retry_backoff_base: numOrNull(form.backoffBase) } } /** AI 表单 → PUT config/ai 整组载荷 */ export function buildAiCredsPayload( form: AiCredsForm, apiKeyMasked: string ): Record { return { provider: form.provider.trim(), api_key: secretToSubmit(form.apiKey, apiKeyMasked), base_url: form.baseUrl.trim(), model: form.model.trim() } } /** 邮件表单预检(空槽合法——回退 env;只查非空行的格式) */ export function validateEmailCredsForm(form: EmailCredsForm): string[] { const errors: string[] = [] const port = form.smtpPort.trim() if (port !== '') { const n = Number(port) if (!Number.isInteger(n) || n < 1 || n > 65535) { errors.push('SMTP 端口:须为 1-65535 的整数(留空 = 平台默认)') } } form.notifyEmails.forEach((row, i) => { const t = row.trim() if (t !== '' && !t.includes('@')) { errors.push(`通知邮箱第 ${i + 1} 行:须为含 @ 的邮箱地址`) } }) return errors } /** 推送表单预检(与后端 _check_push 的数值边界同款:正数 / 1-10 整数) */ export function validatePushCredsForm(form: PushCredsForm): string[] { const errors: string[] = [] const url = form.url.trim() if (url !== '' && !/^https?:\/\//.test(url)) { errors.push('推送 URL:须以 http:// 或 https:// 开头') } const timeout = form.timeout.trim() if (timeout !== '') { const n = Number(timeout) if (!Number.isFinite(n) || n <= 0) { errors.push('推送超时(秒):须为正数(留空 = 平台默认)') } } const attempts = form.maxAttempts.trim() if (attempts !== '') { const n = Number(attempts) if (!Number.isInteger(n) || n < 1 || n > 10) { errors.push('最大尝试次数:须为 1-10 的整数(留空 = 平台默认)') } } const backoff = form.backoffBase.trim() if (backoff !== '') { const n = Number(backoff) if (!Number.isFinite(n) || n <= 0) { errors.push('重试退避基数(秒):须为正数(留空 = 平台默认)') } } return errors } /** 推送探测不可达原因 → 修复提示文案(后端 reason 分类的前端展示层) */ export const PUSH_PROBE_REASON_LABELS: Record = { dns: '域名解析失败(检查 URL 拼写)', timeout: '连接超时', connect: '连接被拒绝或网络不可达' } // ---- 版本历史 / 两版对比 / 组级重置(Phase 3 Task 7d) ------------------------------ /** * 可重置组镜像(后端 config_validation.CONFIG_GROUPS = DEFAULT_CONFIG 键 + * prompts,共 9 组;reset 路由同款 allowlist——未知组 400)。标签用于 * 确认框明示组名(「将把「品牌」组恢复为出厂档案值」)。 */ export const RESET_GROUP_LABELS: Record = { brand: '品牌', prompts: '提示词', wall: '安全词表', contact: '联系方式', email: '邮件', push: '推送', ai: 'AI', engine: '引擎', misc: '杂项' } /** * 版本时间展示:后端 created_at 是 naive isoformat(无时区后缀)——直接 * 字符串切形(T → 空格,截到秒),不做 Date 解析避免被浏览器当作 UTC * 再偏移出错误时刻。 */ export function formatVersionTime(iso: string): string { return iso.replace('T', ' ').slice(0, 19) } /** 版本列表倒序(后端升序返回——展示最新在前) */ export function descendingVersions( items: T[] ): T[] { return [...items].sort((a, b) => b.version - a.version) } /** 对比范围哨兵:整份快照(其余值 = 单组键——wall 等大组 20KB+ 按组聚焦) */ export const COMPARE_SCOPE_ALL = '__all__' /** 快照顶层组键(对比范围下拉选项;键序排序稳定) */ export function snapshotGroupKeys( config: Record | null | undefined ): string[] { return config ? Object.keys(config).sort() : [] } /** * 范围视图 → 等宽面板 JSON 串(spec §7.3 最低形态:纯并排展示,不做差异 * 高亮)。密钥字段显示为 '__SET__'——后端对所有携带 config 的响应统一 * 掩码,历史行存原值但绝不回传。 */ export function scopedConfigJson( config: Record | null | undefined, scope: string ): string { if (!config) return '' const view = scope === COMPARE_SCOPE_ALL ? config : { [scope]: config[scope] } return JSON.stringify(view, null, 2) } // ---- 通用 JSON 组编辑(Phase 3 终审 I-1:contact / engine / misc) ------------------- /** 走通用 JSON pane 的组(无密钥字段、结构低频变更——表单化收益低) */ export type JsonGroupKey = 'contact' | 'engine' | 'misc' /** 组取值 → 编辑器初稿(null 组 = 空对象;2 空格缩进等宽展示) */ export function jsonGroupDraft( group: Record | null | undefined ): string { return JSON.stringify(group ?? {}, null, 2) } /** 编辑器解析结果:ok=false 时 error 带可直接展示的中文原因 */ export type JsonDraftParse = | { ok: true; value: Record } | { ok: false; error: string } /** 编辑稿预检:JSON 语法 + 顶层须为对象(后端 _check_* 类型校验兜底) */ export function parseJsonGroupDraft(text: string): JsonDraftParse { let value: unknown try { value = JSON.parse(text) } catch (error) { const detail = error instanceof Error ? error.message : String(error) return { ok: false, error: `JSON 语法错误——${detail}` } } if (typeof value !== 'object' || value === null || Array.isArray(value)) { return { ok: false, error: '顶层须为 JSON 对象({ ... })——整组替换语义' } } // typeof 收窄只到 object(无索引签名)——JSON.parse 产物必为普通对象,断言收形 return { ok: true, value: value as Record } } /** 三组的字段说明(pane 顶部展示;事实源 app/platform/defaults.py) */ export const JSON_GROUP_FIELDS: Record< JsonGroupKey, Array<{ key: string; desc: string }> > = { contact: [ { key: 'cc_completion', desc: 'bool——cc 区号补全判定树开关' }, { key: 'email_light_ask', desc: 'bool——dual-contact 分步双要的邮箱轻补' }, { key: 'template_languages', desc: 'string[]——邮件模板语言清单(运行期接线归 Phase 4,清理单 #8)' } ], engine: [ { key: 'rate_limit_per_hour', desc: '正整数——聊天限流(次/小时/IP)' }, { key: 'ws_conn_limit', desc: '正整数——每 IP WebSocket 连接上限' }, { key: 'context_seed_keys', desc: '映射——产品字段 → 会话上下文种子键(如 product_name → app_product_name)' } ], misc: [ { key: 'name_to_region_extras', desc: '映射——国名 → ISO 区域码扩展(resolve_region_code 消费)' } ] }