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:
@@ -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
|
||||
|
||||
+18
-62
@@ -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 = '<think>'
|
||||
@@ -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'
|
||||
},
|
||||
/**
|
||||
* 兜底文案:解析不出业务字段时的可读提示。
|
||||
*
|
||||
* <p>绝不能退回 JSON.stringify 整包 —— 那会把 workflow_id / req_id
|
||||
* 等内部标识符与上游错误栈直接贴进聊天气泡(见 issue 01)。
|
||||
* <p>统一来自 `utils/ai-result`,不再各页面自带一份(见 issue 03 定位第 1 条)。
|
||||
*/
|
||||
buildFallbackText() {
|
||||
return '接口已返回,但未解析到可展示的内容。'
|
||||
return FALLBACK_TEXT
|
||||
},
|
||||
/**
|
||||
* 判断一个 `answer` 值是否可以直接展示给用户。
|
||||
*
|
||||
* <p>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,
|
||||
|
||||
+39
-14
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user