fix(ai): 兜底收敛公共模块,失败态不再 stringify 整包(issue 03)

「拿不到字段就 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
This commit is contained in:
2026-09-24 04:22:41 +08:00
parent 6bbbd386c8
commit 024d3dd122
4 changed files with 231 additions and 97 deletions
+136
View File
@@ -0,0 +1,136 @@
/**
* 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
}