024d3dd122
「拿不到字段就 stringify 整包」的兜底此前在 3 个页面各写一份, 其中 activity-outline:332 显式把 payload.raw 当兜底内容源,是最严重的一例。 - 新增 utils/ai-result.js:getPayloadStatus / isFailedPayload / isDisplayableAnswer / pickDisplayable / resolveDisplayText / logRawPayload 统一一处;解析不出内容给可读文案,原始报文只进 console.error 不进 UI;UUID 与无空格长 token 不算可展示内容 - manuscript parseResult:失败态短路、pickDisplayable 挡标识符、 summary 不再回显内部字段名 - activity-outline parseOutline:删掉 payload.raw 显式兜底,失败态短路 - assistant 页删除自带副本,改为 import 公共模块 - 流式收尾:appendAssistantChunk / appendOutlineChunk 支持 reset—— 后端判定失败时丢弃已累积的半截内容;新增 streamFailed 跟踪, 失败后不再 toast「内容生成完成」/「活动策划大纲生成完成」 - 删除死代码:两页 stringifyUnknown、大纲页 pickNonEmptyString 验证:verify-other-links.js 27/27(修复前代码跑同脚本 13 失败,判别力已证伪); verify-frontend-fallback.js 17/17 回归通过。 Refs: .scratch/ai-assistant/issues/03-other-links-raw-payload-fallback.md
137 lines
4.0 KiB
JavaScript
137 lines
4.0 KiB
JavaScript
/**
|
||
* AI 结果的展示兜底。
|
||
*
|
||
* 背景:各 AI 链路在「拿不到已知字段」时会退回 `JSON.stringify(整包)`,
|
||
* 把后端原始报文(含 `workflow_id` / `task_id` / `req_id` 等内部标识符与上游错误栈)
|
||
* 直接渲染给用户。同一个缺陷曾在 3 个页面里各写一份,因此这里收敛为一处公共实现。
|
||
*
|
||
* 约定:
|
||
* - 解析不出内容时,一律给**可读文案**,绝不 stringify 整包;
|
||
* - 原始报文只应进日志(`console.error`),不进 UI;
|
||
* - 「内部标识符形状」的值(UUID、无空格长 token)不算可展示内容。
|
||
*
|
||
* 见 `.scratch/ai-assistant/issues/01-creative-assistant-raw-json-bubble.md`
|
||
* 与 `.scratch/ai-assistant/issues/03-other-links-raw-payload-fallback.md`。
|
||
*/
|
||
|
||
/**
|
||
* 解析不出内容时的通用兜底文案。
|
||
*/
|
||
export const FALLBACK_TEXT = '接口已返回,但未解析到可展示的内容。'
|
||
|
||
/**
|
||
* 工作流失败时的统一提示。
|
||
*/
|
||
export const FAILED_TEXT = '生成失败,请稍后重试'
|
||
|
||
/**
|
||
* 取载荷里的工作流执行状态。
|
||
*
|
||
* 后端把 status 放在顶层;兼容 `data.status` 的嵌套写法
|
||
* (后端 `workflowStatus` 两处都看,前端不能只看一处)。
|
||
*
|
||
* @param {*} payload 接口返回
|
||
* @returns {string} 小写、去空白的状态;取不到时为空串
|
||
*/
|
||
export function getPayloadStatus(payload) {
|
||
if (!payload || typeof payload !== 'object') {
|
||
return ''
|
||
}
|
||
const status =
|
||
payload.status !== undefined && payload.status !== null
|
||
? payload.status
|
||
: payload.data && payload.data.status
|
||
return String(status || '').trim().toLowerCase()
|
||
}
|
||
|
||
/**
|
||
* 工作流是否以失败收场。
|
||
*
|
||
* 只在拿到明确 `failed` 时为真;状态缺失视为未失败,避免误伤正常响应。
|
||
*
|
||
* @param {*} payload 接口返回
|
||
* @returns {boolean}
|
||
*/
|
||
export function isFailedPayload(payload) {
|
||
return getPayloadStatus(payload) === 'failed'
|
||
}
|
||
|
||
/**
|
||
* 判断一个字符串是否适合直接展示给用户。
|
||
*
|
||
* 失败报文里 `answer` 常被填成 `workflow_run_id`(UUID)这类内部标识符;
|
||
* 原样展示对用户毫无意义且构成泄露。
|
||
*
|
||
* @param {*} value 待判断的值
|
||
* @returns {boolean}
|
||
*/
|
||
export function isDisplayableAnswer(value) {
|
||
if (typeof value !== 'string') {
|
||
return false
|
||
}
|
||
const text = value.trim()
|
||
if (!text) {
|
||
return false
|
||
}
|
||
// 纯 UUID
|
||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) {
|
||
return false
|
||
}
|
||
// 无空格的长 token(task_id / run_id 等)
|
||
if (!/\s/.test(text) && /^[0-9a-zA-Z_-]{32,}$/.test(text)) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 在候选值里挑第一个可展示的字符串。
|
||
*
|
||
* @param {...*} values 候选值,按优先级排列
|
||
* @returns {string} 挑中的值(已 trim);没有则空串
|
||
*/
|
||
export function pickDisplayable(...values) {
|
||
for (let index = 0; index < values.length; index += 1) {
|
||
const value = values[index]
|
||
if (isDisplayableAnswer(value)) {
|
||
return value.trim()
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/**
|
||
* 把原始报文记进日志(不进 UI)。
|
||
*
|
||
* @param {string} scope 调用方标识,便于定位
|
||
* @param {*} payload 原始载荷
|
||
*/
|
||
export function logRawPayload(scope, payload) {
|
||
try {
|
||
console.error(`[ai-result] ${scope} 未解析出可展示内容,原始载荷:`, payload)
|
||
} catch (error) {
|
||
// 日志失败不影响渲染
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 展示兜底:优先可展示的候选值,其次失败文案 / 通用兜底文案。
|
||
*
|
||
* @param {object} options
|
||
* @param {*} options.payload 原始载荷(用于失败判定与日志)
|
||
* @param {string} options.scope 日志标识
|
||
* @param {Array<*>} options.candidates 候选文案,按优先级排列
|
||
* @returns {string} 可读文案
|
||
*/
|
||
export function resolveDisplayText({ payload, scope, candidates } = {}) {
|
||
if (isFailedPayload(payload)) {
|
||
return FAILED_TEXT
|
||
}
|
||
const picked = pickDisplayable(...(candidates || []))
|
||
if (picked) {
|
||
return picked
|
||
}
|
||
logRawPayload(scope || 'unknown', payload)
|
||
return FALLBACK_TEXT
|
||
}
|