6af8c186d8
- 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>
802 lines
28 KiB
TypeScript
802 lines
28 KiB
TypeScript
/**
|
||
* 配置中心提示词/词表/凭据编辑模型(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 消费)'
|
||
}
|
||
]
|
||
}
|