Files
ai-kefu-admin/admin/src/views/config/center/model.ts
T
浅念 6af8c186d8 fix: Phase3 终审修复(env入库/通用配置组面板/锁屏通知摘除/记档修正)
- I-2: admin/.env(被根 .gitignore 误伤)删除,内容并入入库的
  .env.development/.env.production(公共块两份都写,PORT 仅 dev)
- I-1: 配置中心补 contact/engine/misc 三组通用 JSON 编辑面板
  (JsonGroupPane:等宽 textarea 整组编辑 + JSON.parse 预检 +
  PUT 整组 + 重置出厂 + 顶部字段说明;挂进 GROUP_NAV 三入口);
  修组键缺失租户的禁存 bug(父层快照加载后挂载,空组可建组)
- I-3: config_routes.py docstring 改「接线归 Phase 4」;
  CLAUDE.md Phase 4 清理单补 resolver 回退链与 email/push test 限流
- M-1: 锁屏摘除入口(用户菜单项删 + 全局组件 enabled:false)
- M-2: 顶栏通知铃铛隐藏(headerBar notification.enabled:false)
- M-5/M-8: admin/README 换三行指针(删模板 README.zh-CN.md);
  quickLinks 删「登录」项
- M-3/M-4 不做记档入 Phase 4 清理单
- 测试基线:后端 pytest 595 / 前端 vitest 156(+3 通用 JSON 组)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 00:05:40 +08:00

802 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 配置中心提示词/词表/凭据编辑模型(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<PromptKey, string>
/** 各件字节上限(UTF-8 口径;config_validation.PROMPTS_SIZE_CAPS 镜像) */
export const PROMPTS_CAPS: Record<PromptKey, number> = {
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<string, unknown> | 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<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string, unknown> | null {
if (!row.pattern) return null
if (!row.flags && !row.comment.trim()) return row.pattern
const out: Record<string, unknown> = { 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<string | Record<string, unknown>> {
return rows
.map(rowToEntry)
.filter((e): e is string | Record<string, unknown> => e !== null)
}
/** 单条目字段(triggers 三键 / zero_number 两键)编辑行 → 条目:
* 无标志无备注且 pattern 非空 → 纯字符串(wall.json zero_number 出厂
* 形态,编译等价、往返无损);否则 dict 只带非空键——空 pattern 也走
* dict 分支,后端报「缺非空 pattern」字段路径(裸 '' 会编成匹配一切的
* 空正则,必须显性失败) */
export function rowToSingleEntry(
row: RegexRow
): string | Record<string, unknown> {
if (row.pattern && !row.flags && !row.comment.trim()) return row.pattern
const out: Record<string, unknown> = { 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<string, string[]> = {
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<string, string[]> = {
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<string, unknown>
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<PoolField, Record<string, string[]>>
noContact: Record<(typeof NO_CONTACT_KEYS)[number], string>
/** 六类 × 变体 → 话术文本(变体键封闭集合,缺项补空串交后端报路径) */
reminders: Record<string, Record<string, string>>
/** 四组 × 语言 → 模板文本({intl}/{cc_script} 等占位符白名单由后端校验) */
ccTemplates: Record<string, Record<string, string>>
}
function clone<T>(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<string, string[]> {
const source = asRecord(value)
if (!source) return {}
const out: Record<string, string[]> = {}
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<string, string> {
const source = asRecord(value) ?? {}
const out: Record<string, string> = {}
for (const key of variants) {
out[key] = asStr(source[key])
}
return out
}
/** 结构化编辑接管的顶层键(其余透传) */
const KNOWN_WALL_EDIT_KEYS = new Set<string>([
'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<string, unknown> | 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<string, Record<string, string>> = {}
const remindersSrc = asRecord(record.reminders)
for (const [cat, variants] of Object.entries(REMINDER_VARIANTS)) {
reminders[cat] = asVariantMap(remindersSrc?.[cat], variants)
}
const ccTemplates: Record<string, Record<string, string>> = {}
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<string, string[]>): Record<string, string[]> {
const out: Record<string, string[]> = {}
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<string, unknown> {
const triggers: Record<string, unknown> = {}
for (const key of TRIGGER_KEYS) {
triggers[key] = rowToSingleEntry(model.singles[key])
}
const zeroNumber: Record<string, unknown> = {}
for (const key of ZERO_NUMBER_KEYS) {
zeroNumber[key] = rowToSingleEntry(model.singles[key])
}
const pools: Record<string, unknown> = {}
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, string> = {
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<ConfigGroupKey, string> = {
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<T extends { version: number }>(
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<string, unknown> | null | undefined
): string[] {
return config ? Object.keys(config).sort() : []
}
/**
* 范围视图 → 等宽面板 JSON 串(spec §7.3 最低形态:纯并排展示,不做差异
* 高亮)。密钥字段显示为 '__SET__'——后端对所有携带 config 的响应统一
* 掩码,历史行存原值但绝不回传。
*/
export function scopedConfigJson(
config: Record<string, unknown> | 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<string, unknown> | null | undefined
): string {
return JSON.stringify(group ?? {}, null, 2)
}
/** 编辑器解析结果:ok=false 时 error 带可直接展示的中文原因 */
export type JsonDraftParse =
| { ok: true; value: Record<string, unknown> }
| { 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<string, unknown> }
}
/** 三组的字段说明(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 消费)'
}
]
}