/** * 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 }