From 024d3dd1223eb79f96d2f1097a133004cb0610aa Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Thu, 24 Sep 2026 04:22:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E5=85=9C=E5=BA=95=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=80=81=E4=B8=8D=E5=86=8D=20stringify=20=E6=95=B4=E5=8C=85?= =?UTF-8?q?=EF=BC=88issue=2003=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「拿不到字段就 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 --- pages/activity-outline/index.vue | 59 +++++++++----- pages/assistant/index.vue | 80 ++++-------------- pages/manuscript/index.vue | 53 ++++++++---- utils/ai-result.js | 136 +++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 97 deletions(-) create mode 100644 utils/ai-result.js diff --git a/pages/activity-outline/index.vue b/pages/activity-outline/index.vue index d8f6fef..cfef6e8 100644 --- a/pages/activity-outline/index.vue +++ b/pages/activity-outline/index.vue @@ -161,6 +161,11 @@ buildWebSocketUrl, getCurrentUser } from './service' + import { + isFailedPayload, + pickDisplayable, + resolveDisplayText + } from '../../utils/ai-result' const STREAM_END_TEXT = '__END__' const SOCKET_CONNECTED_TEXT = '连接成功' @@ -191,6 +196,7 @@ socketTask: null, socketConnectPromise: null, streamStarted: false, + streamFailed: false, streamResolver: null, currentUser: { userId: null @@ -288,15 +294,6 @@ event_meme: String(this.form.event_meme || '').trim() } }, - pickNonEmptyString() { - for (let index = 0; index < arguments.length; index += 1) { - const value = arguments[index] - if (typeof value === 'string' && value.length > 0) { - return value - } - } - return '' - }, pickRawString() { for (let index = 0; index < arguments.length; index += 1) { const value = arguments[index] @@ -306,18 +303,16 @@ } return undefined }, - stringifyUnknown(value) { - try { - return JSON.stringify(value, null, 2) - } catch (error) { - return String(value || '') - } - }, parseOutline(payload) { if (!payload) { return '' } - const answer = this.pickNonEmptyString( + // 失败态优先:此时 answer 可能为空或就是 workflow_run_id, + // 且 raw 里含内部标识符,绝不能作为内容源。 + if (isFailedPayload(payload)) { + return resolveDisplayText({ payload, scope: 'activity-outline' }) + } + const answer = pickDisplayable( payload.answer, payload.raw && payload.raw.answer, payload.raw && payload.raw.data && payload.raw.data.answer, @@ -329,7 +324,10 @@ if (answer) { return answer } - return this.stringifyUnknown((payload && payload.raw) || payload) + // 原实现是 return this.stringifyUnknown((payload && payload.raw) || payload), + // 即**主动**把后端整包当兜底内容渲染 —— 这是本 issue 最严重的一处。 + // 现改为可读文案,原始载荷只进日志。 + return resolveDisplayText({ payload, scope: 'activity-outline' }) }, buildSummary() { const parts = [] @@ -377,7 +375,7 @@ } } }, - appendOutlineChunk(chunk, conversationId, messageId) { + appendOutlineChunk(chunk, conversationId, messageId, reset) { if (!chunk) { return } @@ -387,10 +385,15 @@ if (messageId && messageId !== '0') { this.activeMessageId = messageId } - if (!this.streamStarted) { + if (!this.streamStarted || reset) { this.streamStarted = true + // reset 表示后端判定失败、本条覆盖之前的流式残片 this.outlineMarkdown = '' } + if (reset) { + // 记住失败态,供收尾时给出正确提示而不是「生成完成」 + this.streamFailed = true + } this.outlineMarkdown += chunk this.syncEditableOutline() this.resultSummary = this.buildSummary() || '正在通过 WebSocket 接收流式内容' @@ -417,13 +420,21 @@ this.finishStream(data.conversationId, data.messageId) return } + // reset 需在 loading 之前读取:失败消息不带 loading, + // 但语义上必须先清掉流式残片(见 issue 03)。 + const reset = !!(data && (data.reset === 'true' || data.reset === true)) if (data && (data.loading === 'true' || data.loading === true)) { this.resultSummary = 'AI 正在组织活动策划大纲结构...' return } const chunk = this.pickRawString(data && data.notice, data && data.answer) if (typeof chunk === 'string') { - this.appendOutlineChunk(chunk, data && data.conversationId, data && data.messageId) + this.appendOutlineChunk( + chunk, + data && data.conversationId, + data && data.messageId, + reset + ) } } catch (error) { this.appendOutlineChunk(raw) @@ -512,6 +523,7 @@ this.resultSummary = '' this.activeConversationId = '' this.activeMessageId = '' + this.streamFailed = false }, resetForm() { this.form = createDefaultForm() @@ -544,6 +556,11 @@ } else { await waitStream } + if (this.streamFailed) { + // 后端判定工作流失败,正文区已显示失败文案,这里同步提示 + this.showToast('生成失败,请稍后重试') + return + } if (!this.visibleOutlineMarkdown) { this.showToast('接口已返回,但未解析到正文内容') return diff --git a/pages/assistant/index.vue b/pages/assistant/index.vue index 80f55c2..db61914 100644 --- a/pages/assistant/index.vue +++ b/pages/assistant/index.vue @@ -189,6 +189,13 @@ removeAiChatList, sendMessage as sendChatMessage } from './chat-service' + import { + FALLBACK_TEXT, + FAILED_TEXT, + isDisplayableAnswer, + isFailedPayload, + pickDisplayable + } from '../../utils/ai-result' const ASSISTANT_PENDING_TEXT = '正在思考中...' const THINK_OPEN_TAG = '' @@ -793,79 +800,28 @@ this.draft = '' this.showHistoryPanel = false }, - stringifyUnknown(value) { - try { - return JSON.stringify(value, null, 2) - } catch (error) { - return String(value ?? '') - } - }, - /** - * 取载荷里的工作流执行状态。 - * 后端会把 status 放在顶层;兼容 data.status 的嵌套写法。 - */ - 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 时为真,状态缺失视为未失败,避免误伤正常响应。 - */ - isFailedPayload(payload) { - return this.getPayloadStatus(payload) === 'failed' - }, /** * 兜底文案:解析不出业务字段时的可读提示。 * - *

绝不能退回 JSON.stringify 整包 —— 那会把 workflow_id / req_id - * 等内部标识符与上游错误栈直接贴进聊天气泡(见 issue 01)。 + *

统一来自 `utils/ai-result`,不再各页面自带一份(见 issue 03 定位第 1 条)。 */ buildFallbackText() { - return '接口已返回,但未解析到可展示的内容。' + return FALLBACK_TEXT }, /** - * 判断一个 `answer` 值是否可以直接展示给用户。 - * - *

issue 01 的失败报文里 `extractAnswer` 会把顶层 `answer` 填成 - * `workflow_run_id`(UUID)。status 缺失时(如 `message_end` 事件) - * 失败短路不生效,此时若直接信任 `answer`,气泡里就是一个裸 UUID。 - * 宁可回落到兜底文案,也不要把内部标识符当正文。 + * 工作流是否以失败收场。判定逻辑统一在 `utils/ai-result`。 */ - isDisplayableAnswer(value) { - if (typeof value !== 'string') { - return false - } - const text = value.trim() - if (!text) { - return false - } - // 纯 UUID / 十六进制串 / 无空格的超长 token,都视为内部标识符 - 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 - } - if (!/\s/.test(text) && /^[0-9a-zA-Z_-]{32,}$/.test(text)) { - return false - } - return true + isFailedPayload(payload) { + return isFailedPayload(payload) }, /** * 同 `pickString`,但会跳过不像给人看的 `answer`(如 UUID)。 */ - pickDisplayable(...values) { - for (let index = 0; index < values.length; index += 1) { - const value = values[index] - if (typeof value === 'string' && value.trim() && this.isDisplayableAnswer(value)) { - return value.trim() - } - } - return '' + pickDisplayableText(...values) { + return pickDisplayable(...values) + }, + isDisplayableAnswer(value) { + return isDisplayableAnswer(value) }, pickString(...values) { for (let index = 0; index < values.length; index += 1) { @@ -961,7 +917,7 @@ } const data = payload || {} return ( - this.pickDisplayable( + this.pickDisplayableText( data.answer, data.raw && data.raw.answer, data.raw && data.raw.data && data.raw.data.answer, diff --git a/pages/manuscript/index.vue b/pages/manuscript/index.vue index 32d7dbb..88c15e6 100644 --- a/pages/manuscript/index.vue +++ b/pages/manuscript/index.vue @@ -196,6 +196,11 @@ manuscriptGen, uploadTempFile } from './service' + import { + isFailedPayload, + pickDisplayable, + resolveDisplayText + } from '../../utils/ai-result' const STREAM_END_TEXT = '__END__' const SOCKET_CONNECTED_TEXT = '连接成功' @@ -233,6 +238,7 @@ socketTask: null, socketConnectPromise: null, streamStarted: false, + streamFailed: false, streamResolver: null, currentUser: { userId: null @@ -360,15 +366,10 @@ } return '' }, - stringifyUnknown(value) { - try { - return JSON.stringify(value, null, 2) - } catch (error) { - return String(value || '') - } - }, parseResult(payload) { if (typeof payload === 'string') { + // 注:失败态判定只对对象形态生效;字符串若是未解析成功的 + // 序列化报文,其中的字段取不出来,按原文展示,不做失败推断。 return { title: String(this.form.theme || '').trim(), content: payload, @@ -376,6 +377,14 @@ } } + if (isFailedPayload(payload)) { + return { + title: String(this.form.theme || '').trim(), + content: resolveDisplayText({ payload, scope: 'manuscript' }), + summary: '工作流执行失败' + } + } + const title = this.pickString( payload && payload.title, payload && payload.theme, @@ -383,7 +392,9 @@ payload && payload.subject, payload && payload.data && payload.data.title ) - const content = this.pickString( + // 用 pickDisplayable 而非 pickString:answer 可能是 workflow_run_id + // 这类内部标识符,原样展示对用户没有意义且构成泄露。 + const content = pickDisplayable( payload && payload.content, payload && payload.text, payload && payload.result, @@ -399,11 +410,13 @@ payload && payload.choices && payload.choices[0] && payload.choices[0].message && payload.choices[0].message.content ) - const keys = payload && typeof payload === 'object' ? Object.keys(payload) : [] + // 兜底改为可读文案,绝不把整包报文 stringify 进正文区。 + // 原始载荷交给 logRawPayload(在 resolveDisplayText 内)记日志。 + const resolved = content || resolveDisplayText({ payload, scope: 'manuscript' }) return { title: title || String(this.form.theme || '').trim(), - content: content || this.stringifyUnknown(payload), - summary: keys.length ? `已解析返回字段:${keys.join('、')}` : '返回对象为空' + content: resolved, + summary: content ? '已解析返回字段' : '接口已返回,但未解析到可展示的内容' } }, parseSerializedResult(payload) { @@ -459,6 +472,7 @@ }, createStreamWaiter() { this.streamStarted = false + this.streamFailed = false return new Promise((resolve, reject) => { this.streamResolver = { resolve, @@ -479,18 +493,23 @@ } } }, - appendAssistantChunk(chunk, conversationId) { + appendAssistantChunk(chunk, conversationId, reset) { if (!chunk) { return } if (conversationId) { this.activeConversationId = conversationId } - if (!this.streamStarted) { + if (!this.streamStarted || reset) { this.streamStarted = true + // reset 表示后端判定失败、本条覆盖之前的流式残片 this.generatedTitle = this.generatedTitle || String(this.form.theme || '').trim() this.generatedContent = '' } + if (reset) { + // 记住失败态,供收尾时给出正确提示而不是「生成完成」 + this.streamFailed = true + } this.generatedContent += chunk this.responseSummary = '正在通过 WebSocket 接收流式内容' }, @@ -514,12 +533,13 @@ this.finishStream(data.conversationId) return } + const reset = !!(data && (data.reset === 'true' || data.reset === true)) if (data && (data.loading === 'true' || data.loading === true)) { return } const chunk = this.pickRawStreamString(data && data.notice, data && data.answer) if (chunk.length > 0) { - this.appendAssistantChunk(chunk, data && data.conversationId) + this.appendAssistantChunk(chunk, data && data.conversationId, reset) } } catch (error) { this.appendAssistantChunk(raw) @@ -734,6 +754,11 @@ } else { await waitStream } + if (this.streamFailed) { + // 后端判定工作流失败,正文区已显示失败文案,这里同步提示 + this.showToast('生成失败,请稍后重试') + return + } this.showToast('内容生成完成') } catch (error) { this.disconnectWebSocket() diff --git a/utils/ai-result.js b/utils/ai-result.js new file mode 100644 index 0000000..1d0d022 --- /dev/null +++ b/utils/ai-result.js @@ -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 +}