From 623404ce9a8c7b5cfc5b026c0e905749301dfb48 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Tue, 13 Oct 2026 02:12:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E5=A4=B1=E8=B4=A5=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E6=95=B4=E5=8C=85=20JSON=20=E4=B8=8D=E5=86=8D=E8=BF=9B=20UI?= =?UTF-8?q?=EF=BC=8C=E5=85=9C=E5=BA=95=E6=94=B6=E6=95=9B=E5=88=B0=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E6=A8=A1=E5=9D=97=EF=BC=88issue=2010=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin 端原本**没有**任何 AI 结果兜底公共实现(src/utils/ 下零命中 pickDisplayable / isFailedPayload / FALLBACK_TEXT / FAILED_TEXT), 四个 AI 页面各写一份,且方向相反——都保留了「JSON.stringify 整包」兜底。 后果是解析不出内容时把内部标识符渲染给用户,实测: {"answer":"","conversationId":"c1d2e3f4-…","messageId":"m1"} 改动: - 新增 src/utils/ai-result.ts,从 tuanwei-uniapp/utils/ai-result.js 移植, 逐函数对齐语义(getPayloadStatus / isFailedPayload / isDisplayableAnswer / pickDisplayable / resolveDisplayText / 两个文案常量)。原始报文只进 console.error,不进 UI。 - 四处整包 JSON 兜底全部替换为 resolveDisplayText,并删掉各自的 stringifyUnknown:oa/chatgpt(两处)、gxmu/manuscriptGen、 gxmu/activityOutlineGen。 - 四页正文候选改用 pickDisplayable(原 pickString 只判非空, 失败报文里 UUID 形状的 answer 会被当正文)。 - 失败态优先判定:isFailedPayload 为真时直接给 FAILED_TEXT, 不再走「主题 + 失败文案」的拼接。 - gxmu/creativeAssistant:删掉已无引用的 rawOutputsText(模板引用早被注释, 留下一个"看似有原始输出视图"的死代码),并给「未识别到输出字段」补可读文案。 评审跟进修掉的两个真缺陷: - creativeAssistant 的兜底文案**原本不可达**:模板 a-empty 的 v-if="!loading && !hasAnyResult" 会遮蔽 v-else 里的 status-alert, 失败时 hasAnyResult 为假 → 用户仍看到「填写文本指令并上传附件后执行工作流」。 条件补上 && !workflowResponse。 - WS 路径不消费 reset:后端失败时带 reset:"true" 要求覆盖半截内容, 两个页面都忽略它,于是编辑器变成「半截正文 + 生成失败,请稍后重试」; 且 streamStarted 为真时会直接丢弃 HTTP 返回体,新加的 status 也就读不到。 appendOutlineChunk / appendAssistantChunk 增加 reset 参数并在 handleSocketMessage 里透传。 验证: - .scratch/ai-assistant/verify-admin-ai-result.js 用项目自带 TypeScript 编译器转译后求值,并从真实 .vue 抽取 parseOutline / parseResult / formatWritingResult 跑断言(验真实代码而非重写副本)→ 41/41 PASS。 - vue-tsc --noEmit 改动前后错误集合完全一致(1365 条全在 .bak 等预存文件), 本次改动的 4 个文件零错误。 - eslint:ai-result.ts 零问题;creativeAssistant 的未使用变量由 3 个降到 2 个。 --- src/api/ai/activityOutlineGen/model/index.ts | 5 + src/utils/ai-result.ts | 154 +++++++++++++++++++ src/views/gxmu/activityOutlineGen/index.vue | 57 ++++--- src/views/gxmu/creativeAssistant/index.vue | 50 +++--- src/views/gxmu/manuscriptGen/index.vue | 53 +++++-- src/views/oa/chatgpt/index.vue | 40 +++-- 6 files changed, 297 insertions(+), 62 deletions(-) create mode 100644 src/utils/ai-result.ts diff --git a/src/api/ai/activityOutlineGen/model/index.ts b/src/api/ai/activityOutlineGen/model/index.ts index a54fa88..c32808c 100644 --- a/src/api/ai/activityOutlineGen/model/index.ts +++ b/src/api/ai/activityOutlineGen/model/index.ts @@ -20,6 +20,11 @@ export interface ActivityOutlineGenParams { export interface ActivityOutlineGenResponse { answer?: string; + /** + * 上游工作流状态。失败时为 `'failed'` —— 前端据此把失败文案与正文区分开 + * (见 `@/utils/ai-result` 的 `isFailedPayload` 与 issue 10)。 + */ + status?: string; conversationId?: string; messageId?: string; raw?: Record; diff --git a/src/utils/ai-result.ts b/src/utils/ai-result.ts new file mode 100644 index 0000000..13b4629 --- /dev/null +++ b/src/utils/ai-result.ts @@ -0,0 +1,154 @@ +/** + * AI 结果的展示兜底。 + * + * 与小程序端 `tuanwei-uniapp/utils/ai-result.js` 保持同一套语义 —— 同一批 AI 链路 + * 在两端各有入口,兜底规则必须一致,否则同一个缺陷会在两端以不同面目出现 + * (见 `.scratch/ai-assistant/issues/01`、`03`、`10`)。 + * + * 背景:各 AI 链路在「拿不到已知字段」时会退回 `JSON.stringify(整包)`, + * 把后端原始报文(含 `conversationId` / `messageId` / `taskId` / `workflowRunId` + * 等内部标识符与上游错误栈)直接渲染给用户。同一个缺陷曾在多端、多页面里各写一份, + * 因此这里收敛为一处公共实现。 + * + * 约定: + * - 解析不出内容时,一律给**可读文案**,绝不 stringify 整包; + * - 原始报文只应进日志(`console.error`),不进 UI; + * - 「内部标识符形状」的值(UUID、无空格长 token)不算可展示内容。 + */ + +/** + * 解析不出内容时的通用兜底文案。 + */ +export const FALLBACK_TEXT = '接口已返回,但未解析到可展示的内容。'; + +/** + * 工作流失败时的统一提示。 + */ +export const FAILED_TEXT = '生成失败,请稍后重试'; + +/** + * 取载荷里的工作流执行状态。 + * + * 后端把 `status` 放在 data 的**顶层**(见 `buildManuscriptResult` / + * `buildActivityOutlineResult`);兼容 `data.status` 的嵌套写法 + * (后端 `workflowStatus` 两处都看,前端不能只看一处)。 + * 注释口径与 `tuanwei-uniapp/utils/ai-result.js` 保持一致。 + * + * @param payload 接口返回 + * @returns 小写、去空白的状态;取不到时为空串 + */ +export function getPayloadStatus(payload: unknown): string { + if (!payload || typeof payload !== 'object') { + return ''; + } + const record = payload as Record; + const status = + record.status !== undefined && record.status !== null + ? record.status + : record.data && record.data.status; + return String(status || '') + .trim() + .toLowerCase(); +} + +/** + * 工作流是否以失败收场。 + * + * 只在拿到明确 `failed` 时为真;状态缺失视为未失败,避免误伤正常响应。 + * + * @param payload 接口返回 + */ +export function isFailedPayload(payload: unknown): boolean { + return getPayloadStatus(payload) === 'failed'; +} + +/** + * 判断一个字符串是否适合直接展示给用户。 + * + * 失败报文里 `answer` 常被填成 `workflow_run_id`(UUID)这类内部标识符; + * 原样展示对用户毫无意义且构成泄露。 + * + * @param value 待判断的值 + */ +export function isDisplayableAnswer(value: unknown): boolean { + 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 挑中的值(已 trim);没有则空串 + */ +export function pickDisplayable(...values: unknown[]): string { + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (isDisplayableAnswer(value)) { + return (value as string).trim(); + } + } + return ''; +} + +/** + * 把原始报文记进日志(不进 UI)。 + * + * @param scope 调用方标识,便于定位 + * @param payload 原始载荷 + */ +export function logRawPayload(scope: string, payload: unknown): void { + try { + console.error( + `[ai-result] ${scope} 未解析出可展示内容,原始载荷:`, + payload + ); + } catch (error) { + // 日志失败不影响渲染 + } +} + +export interface ResolveDisplayTextOptions { + /** 原始载荷(用于失败判定与日志) */ + payload?: unknown; + /** 日志标识 */ + scope?: string; + /** 候选文案,按优先级排列(已剔除失败文案后的正文候选) */ + candidates?: unknown[]; +} + +/** + * 展示兜底:优先可展示的候选值,其次失败文案 / 通用兜底文案。 + * + * @returns 可读文案 + */ +export function resolveDisplayText( + options: ResolveDisplayTextOptions = {} +): string { + const { payload, scope, candidates } = options; + if (isFailedPayload(payload)) { + return FAILED_TEXT; + } + const picked = pickDisplayable(...(candidates || [])); + if (picked) { + return picked; + } + logRawPayload(scope || 'unknown', payload); + return FALLBACK_TEXT; +} diff --git a/src/views/gxmu/activityOutlineGen/index.vue b/src/views/gxmu/activityOutlineGen/index.vue index bb2ef82..5165260 100644 --- a/src/views/gxmu/activityOutlineGen/index.vue +++ b/src/views/gxmu/activityOutlineGen/index.vue @@ -205,6 +205,7 @@ import { API_BASE_URL } from '@/config/setting'; import { useUserStore } from '@/store/modules/user'; import { copyText } from '@/utils/common'; + import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result'; type ExampleType = 'volunteer' | 'salon' | 'sports'; type StreamResolver = { @@ -364,15 +365,6 @@ }; }; - const pickNonEmptyString = (...values: unknown[]) => { - for (const value of values) { - if (typeof value === 'string' && value.length > 0) { - return value; - } - } - return ''; - }; - const pickRawString = (...values: unknown[]) => { for (const value of values) { if (typeof value === 'string') { @@ -382,19 +374,23 @@ return undefined; }; - const stringifyUnknown = (value: unknown) => { - try { - return JSON.stringify(value, null, 2); - } catch (e) { - return String(value ?? ''); - } - }; + // 原本这里还有 pickNonEmptyString 与 stringifyUnknown 两个局部兜底,已移除: + // stringifyUnknown 会把整包原始报文(含 conversationId / messageId)渲染进编辑器; + // pickNonEmptyString 则只判「非空字符串」,会在失败报文里把 UUID 形状的 answer + // 当正文。现在统一走 @/utils/ai-result(见 issue 10)。 const parseOutline = (payload?: ActivityOutlineGenResponse | null) => { if (!payload) { return ''; } - const answer = pickNonEmptyString( + // 失败态优先:后端失败时 answer 是失败文案(且带 status=failed), + // 不先判失败就会把失败文案当正文渲染(见 issue 10)。 + if (isFailedPayload(payload)) { + return FAILED_TEXT; + } + // 用 pickDisplayable 而非 pickNonEmptyString:失败报文里的 answer + // 可能是 workflow_run_id 这类 UUID,非空判断挡不住。 + const answer = pickDisplayable( payload.answer, payload.raw?.answer, payload.raw?.data?.answer, @@ -406,7 +402,13 @@ if (answer) { return answer; } - return stringifyUnknown(payload.raw || payload); + // 不再 stringifyUnknown(payload.raw || payload):整包会带出 + // conversationId / messageId 等内部标识符,且此值会直接进编辑器(见 issue 10)。 + return resolveDisplayText({ + payload, + scope: 'gxmu/activityOutlineGen.parseOutline', + candidates: [] + }); }; const buildSummary = () => { @@ -468,7 +470,8 @@ const appendOutlineChunk = ( chunk: string, conversationId?: string, - messageId?: string + messageId?: string, + reset?: boolean ) => { if (!chunk) { return; @@ -483,7 +486,14 @@ streamStarted.value = true; outlineMarkdown.value = ''; } - outlineMarkdown.value += chunk; + // reset=true 表示「丢弃已累积的片段,只用本条内容」。 + // 后端在失败收场时带这个标志覆盖半截内容;不处理就会变成 + // 「半截正文 + 生成失败,请稍后重试」(见 issue 03 / 10)。 + if (reset) { + outlineMarkdown.value = chunk; + } else { + outlineMarkdown.value += chunk; + } syncEditableOutline(); resultSummary.value = buildSummary() || '正在通过 WebSocket 接收流式内容'; }; @@ -517,7 +527,12 @@ } const chunk = pickRawString(data?.notice, data?.answer); if (typeof chunk === 'string') { - appendOutlineChunk(chunk, data?.conversationId, data?.messageId); + appendOutlineChunk( + chunk, + data?.conversationId, + data?.messageId, + data?.reset === 'true' || data?.reset === true + ); } } catch (e) { appendOutlineChunk(raw); diff --git a/src/views/gxmu/creativeAssistant/index.vue b/src/views/gxmu/creativeAssistant/index.vue index 252e0cb..5e5e4cd 100644 --- a/src/views/gxmu/creativeAssistant/index.vue +++ b/src/views/gxmu/creativeAssistant/index.vue @@ -92,8 +92,15 @@ + @@ -199,11 +206,6 @@ - - - - - @@ -230,6 +232,7 @@ CreativeAssistantResponse } from '@/api/ai/creativeAssistant/model'; import { copyText } from '@/utils/common'; + import { resolveDisplayText, isFailedPayload, FAILED_TEXT } from '@/utils/ai-result'; type ExampleType = 'video' | 'script' | 'image'; @@ -256,11 +259,27 @@ const scriptMarkdown = computed(() => workflowOutputs.value?.Script_result || ''); const photoFiles = computed(() => workflowOutputs.value?.photo_files || []); const statusMessage = computed(() => { - return ( + // 失败态优先:后端失败时 outputs 为空,若不先判失败,用户既看不到正文 + // 也看不到原因,只有一个 toast 一闪而过(见 issue 10)。 + if (isFailedPayload(workflowResponse.value)) { + return FAILED_TEXT; + } + const text = workflowOutputs.value?.video_message || workflowOutputs.value?.photo_message || - '' - ); + ''; + if (text) { + return text; + } + // 接口已返回但认不出约定的输出字段:给可读文案,不要静默什么都不显示。 + if (workflowResponse.value && !hasAnyResult.value) { + return resolveDisplayText({ + payload: workflowResponse.value, + scope: 'gxmu/creativeAssistant.statusMessage', + candidates: [] + }); + } + return ''; }); const hasAnyResult = computed(() => { return !!( @@ -269,16 +288,9 @@ photoFiles.value.length ); }); - const rawOutputsText = computed(() => { - if (!workflowOutputs.value) { - return ''; - } - try { - return JSON.stringify(workflowOutputs.value, null, 2); - } catch (e) { - return ''; - } - }); + // 原本这里有一个 rawOutputsText(JSON.stringify 整包),已移除: + // 它会把 taskId / workflowRunId 等内部标识符渲染给用户,且模板里的引用 + // 早已被注释掉、留下一个「看似有原始输出视图」的死代码(见 issue 10)。 const resultTagText = computed(() => { if (loading.value) { return '执行中'; diff --git a/src/views/gxmu/manuscriptGen/index.vue b/src/views/gxmu/manuscriptGen/index.vue index c4a44ae..b6cf69f 100644 --- a/src/views/gxmu/manuscriptGen/index.vue +++ b/src/views/gxmu/manuscriptGen/index.vue @@ -224,6 +224,7 @@ import { API_BASE_URL } from '@/config/setting'; import { useUserStore } from '@/store/modules/user'; import { copyText } from '@/utils/common'; + import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result'; interface FormState { docType: string; @@ -412,13 +413,9 @@ return ''; }; - const stringifyUnknown = (value: unknown) => { - try { - return JSON.stringify(value, null, 2); - } catch (e) { - return String(value ?? ''); - } - }; + // 原本这里有一个 stringifyUnknown(JSON.stringify 整包兜底),已移除: + // 它会把 taskId / workflowRunId 等内部标识符渲染进编辑器。 + // 现在统一走 @/utils/ai-result(见 issue 10)。 const parseResult = (payload: ManuscriptGenResult): ParsedResult => { if (typeof payload === 'string') { @@ -429,6 +426,16 @@ }; } + // 失败态优先:后端失败时 answer 是失败文案(且带 status=failed), + // 不先判失败就会把失败文案当正文渲染(见 issue 10)。 + if (isFailedPayload(payload)) { + return { + title: form.theme.trim(), + content: FAILED_TEXT, + summary: '上游工作流执行失败' + }; + } + const title = pickString( payload.title, payload.theme, @@ -436,7 +443,8 @@ payload.subject, payload.data?.title ); - const content = pickString( + // 用 pickDisplayable 而非 pickString:后者会把裸 UUID 当正文 + const content = pickDisplayable( payload.content, payload.text, payload.result, @@ -458,7 +466,13 @@ return { title: title || form.theme.trim(), - content: content || stringifyUnknown(payload), + // 不再 stringifyUnknown(payload):整包会带出 taskId / workflowRunId 等 + // 内部标识符,且此 content 会直接进编辑器(见 issue 10)。 + content: content || resolveDisplayText({ + payload, + scope: 'gxmu/manuscriptGen.parseResult', + candidates: [] + }), summary }; }; @@ -556,7 +570,11 @@ } }; - const appendAssistantChunk = (chunk: string, conversationId?: string) => { + const appendAssistantChunk = ( + chunk: string, + conversationId?: string, + reset?: boolean + ) => { if (!chunk) { return; } @@ -568,7 +586,14 @@ generatedTitle.value = generatedTitle.value || form.theme.trim(); generatedContent.value = ''; } - generatedContent.value += chunk; + // reset=true 表示「丢弃已累积的片段,只用本条内容」。 + // 后端在失败收场时带这个标志覆盖半截内容;不处理就会变成 + // 「半截正文 + 生成失败,请稍后重试」(见 issue 03 / 10)。 + if (reset) { + generatedContent.value = chunk; + } else { + generatedContent.value += chunk; + } responseSummary.value = '正在通过 WebSocket 接收流式内容'; }; @@ -598,7 +623,11 @@ } const chunk = pickRawStreamString(data?.notice, data?.answer); if (typeof chunk === 'string' && chunk.length > 0) { - appendAssistantChunk(chunk, data?.conversationId); + appendAssistantChunk( + chunk, + data?.conversationId, + data?.reset === 'true' || data?.reset === true + ); } } catch (e) { appendAssistantChunk(raw); diff --git a/src/views/oa/chatgpt/index.vue b/src/views/oa/chatgpt/index.vue index e0e1786..9924ad1 100644 --- a/src/views/oa/chatgpt/index.vue +++ b/src/views/oa/chatgpt/index.vue @@ -322,6 +322,7 @@ import type { Menu } from "@/api/system/menu/model"; import { useUserStore } from "@/store/modules/user"; import { logout } from "@/utils/page-tab-util"; import { copyText } from "@/utils/common"; +import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from "@/utils/ai-result"; import { getToken } from "@/utils/token-util"; import { isOnlyStudent } from "@/utils/permission"; @@ -946,13 +947,9 @@ const startNewChat = () => { content.value = ""; }; -const stringifyUnknown = (value: unknown) => { - try { - return JSON.stringify(value, null, 2); - } catch (e) { - return String(value ?? ""); - } -}; +// 原本这里有一个 stringifyUnknown(JSON.stringify 整包兜底),已移除: +// 它会把 conversationId / taskId / workflowRunId 等内部标识符渲染给用户。 +// 现在统一走 @/utils/ai-result 的 resolveDisplayText(见 issue 10)。 const pickString = (...values: unknown[]) => { for (const value of values) { @@ -997,6 +994,11 @@ const formatWritingResult = (payload: unknown, query: string) => { if (typeof payload === "string") { return payload; } + // 失败态优先:此时 answer 里是失败文案(后端已带 status=failed)。 + // 不先判失败就会渲染成「主题 + 生成失败,请稍后重试」(见 issue 10)。 + if (isFailedPayload(payload)) { + return FAILED_TEXT; + } const data = (payload || {}) as Record; const title = pickString( data.title, @@ -1005,7 +1007,8 @@ const formatWritingResult = (payload: unknown, query: string) => { data.subject, data.data?.title ); - const body = pickString( + // 用 pickDisplayable 而非 pickString:后者会把裸 UUID 当正文 + const body = pickDisplayable( data.content, data.text, data.result, @@ -1023,7 +1026,13 @@ const formatWritingResult = (payload: unknown, query: string) => { if (title || body) { return `${title || buildWritingPayload(query).theme}\n\n${body}`; } - return stringifyUnknown(payload); + // 不再 stringifyUnknown(payload):那会把 conversationId / taskId 这类 + // 内部标识符整包渲染给用户。 + return resolveDisplayText({ + payload, + scope: "oa/chatgpt.formatWritingResult", + candidates: [] + }); }; const getCreativeOutputs = (payload: any) => { @@ -1103,6 +1112,11 @@ const formatImageList = (title: string, list: any[] = []) => { }; const formatCreativeResult = (payload: unknown) => { + // 失败态优先:此时 outputs 为空、answer 是失败文案,不判失败就会落到 + // 整包兜底把内部标识符渲染出去(见 issue 10)。 + if (isFailedPayload(payload)) { + return FAILED_TEXT; + } const outputs = getCreativeOutputs(payload as any); const script = pickString(outputs.Script_result, outputs.script, outputs.text); const videoMessage = pickString(outputs.video_message); @@ -1127,7 +1141,13 @@ const formatCreativeResult = (payload: unknown) => { ] .filter(Boolean) .join("\n\n"); - return text || stringifyUnknown(payload); + // 不再 stringifyUnknown(payload):整包会带出 taskId / workflowRunId 等内部标识符。 + // 有可读内容就给内容,否则给通用兜底文案(原报文进日志)。 + return resolveDisplayText({ + payload, + scope: "oa/chatgpt.formatCreativeResult", + candidates: [text] + }); }; const buildImagePrompt = (query: string) => {