fix(ai): 失败态与整包 JSON 不再进 UI,兜底收敛到公共模块(issue 10)

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 个。
This commit is contained in:
2026-10-13 02:12:58 +08:00
parent 930ef760f1
commit 623404ce9a
6 changed files with 297 additions and 62 deletions
@@ -20,6 +20,11 @@ export interface ActivityOutlineGenParams {
export interface ActivityOutlineGenResponse { export interface ActivityOutlineGenResponse {
answer?: string; answer?: string;
/**
* 上游工作流状态。失败时为 `'failed'` —— 前端据此把失败文案与正文区分开
* (见 `@/utils/ai-result` 的 `isFailedPayload` 与 issue 10)。
*/
status?: string;
conversationId?: string; conversationId?: string;
messageId?: string; messageId?: string;
raw?: Record<string, any>; raw?: Record<string, any>;
+154
View File
@@ -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<string, any>;
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;
}
+36 -21
View File
@@ -205,6 +205,7 @@
import { API_BASE_URL } from '@/config/setting'; import { API_BASE_URL } from '@/config/setting';
import { useUserStore } from '@/store/modules/user'; import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common'; import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
type ExampleType = 'volunteer' | 'salon' | 'sports'; type ExampleType = 'volunteer' | 'salon' | 'sports';
type StreamResolver = { 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[]) => { const pickRawString = (...values: unknown[]) => {
for (const value of values) { for (const value of values) {
if (typeof value === 'string') { if (typeof value === 'string') {
@@ -382,19 +374,23 @@
return undefined; return undefined;
}; };
const stringifyUnknown = (value: unknown) => { // 原本这里还有 pickNonEmptyString 与 stringifyUnknown 两个局部兜底,已移除:
try { // stringifyUnknown 会把整包原始报文(含 conversationId / messageId)渲染进编辑器;
return JSON.stringify(value, null, 2); // pickNonEmptyString 则只判「非空字符串」,会在失败报文里把 UUID 形状的 answer
} catch (e) { // 当正文。现在统一走 @/utils/ai-result(见 issue 10)。
return String(value ?? '');
}
};
const parseOutline = (payload?: ActivityOutlineGenResponse | null) => { const parseOutline = (payload?: ActivityOutlineGenResponse | null) => {
if (!payload) { if (!payload) {
return ''; 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.answer,
payload.raw?.answer, payload.raw?.answer,
payload.raw?.data?.answer, payload.raw?.data?.answer,
@@ -406,7 +402,13 @@
if (answer) { if (answer) {
return 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 = () => { const buildSummary = () => {
@@ -468,7 +470,8 @@
const appendOutlineChunk = ( const appendOutlineChunk = (
chunk: string, chunk: string,
conversationId?: string, conversationId?: string,
messageId?: string messageId?: string,
reset?: boolean
) => { ) => {
if (!chunk) { if (!chunk) {
return; return;
@@ -483,7 +486,14 @@
streamStarted.value = true; streamStarted.value = true;
outlineMarkdown.value = ''; outlineMarkdown.value = '';
} }
outlineMarkdown.value += chunk; // reset=true 表示「丢弃已累积的片段,只用本条内容」。
// 后端在失败收场时带这个标志覆盖半截内容;不处理就会变成
// 「半截正文 + 生成失败,请稍后重试」(见 issue 03 / 10)。
if (reset) {
outlineMarkdown.value = chunk;
} else {
outlineMarkdown.value += chunk;
}
syncEditableOutline(); syncEditableOutline();
resultSummary.value = buildSummary() || '正在通过 WebSocket 接收流式内容'; resultSummary.value = buildSummary() || '正在通过 WebSocket 接收流式内容';
}; };
@@ -517,7 +527,12 @@
} }
const chunk = pickRawString(data?.notice, data?.answer); const chunk = pickRawString(data?.notice, data?.answer);
if (typeof chunk === 'string') { if (typeof chunk === 'string') {
appendOutlineChunk(chunk, data?.conversationId, data?.messageId); appendOutlineChunk(
chunk,
data?.conversationId,
data?.messageId,
data?.reset === 'true' || data?.reset === true
);
} }
} catch (e) { } catch (e) {
appendOutlineChunk(raw); appendOutlineChunk(raw);
+31 -19
View File
@@ -92,8 +92,15 @@
</a-space> </a-space>
</template> </template>
<!--
必须同时判 `!workflowResponse`:若只判 `!hasAnyResult`,
则「已执行但失败 / 未识别到输出」时 hasAnyResult 为假,
会落回这个空状态,把下方 result-body 里的 status-alert
(失败文案 / 兜底文案)整个盖住 —— 用户看到的仍是
「填写文本指令并上传附件后执行工作流」,等于什么都没发生(见 issue 10)。
-->
<a-empty <a-empty
v-if="!loading && !hasAnyResult" v-if="!loading && !hasAnyResult && !workflowResponse"
description="填写文本指令并上传附件后执行工作流" description="填写文本指令并上传附件后执行工作流"
/> />
@@ -199,11 +206,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- <div v-if="rawOutputsText" class="result-section">-->
<!-- <div class="section-title">原始输出</div>-->
<!-- <pre class="raw-output">{{ rawOutputsText }}</pre>-->
<!-- </div>-->
</div> </div>
</a-card> </a-card>
</a-col> </a-col>
@@ -230,6 +232,7 @@
CreativeAssistantResponse CreativeAssistantResponse
} from '@/api/ai/creativeAssistant/model'; } from '@/api/ai/creativeAssistant/model';
import { copyText } from '@/utils/common'; import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, FAILED_TEXT } from '@/utils/ai-result';
type ExampleType = 'video' | 'script' | 'image'; type ExampleType = 'video' | 'script' | 'image';
@@ -256,11 +259,27 @@
const scriptMarkdown = computed(() => workflowOutputs.value?.Script_result || ''); const scriptMarkdown = computed(() => workflowOutputs.value?.Script_result || '');
const photoFiles = computed(() => workflowOutputs.value?.photo_files || []); const photoFiles = computed(() => workflowOutputs.value?.photo_files || []);
const statusMessage = computed(() => { const statusMessage = computed(() => {
return ( // 失败态优先:后端失败时 outputs 为空,若不先判失败,用户既看不到正文
// 也看不到原因,只有一个 toast 一闪而过(见 issue 10)。
if (isFailedPayload(workflowResponse.value)) {
return FAILED_TEXT;
}
const text =
workflowOutputs.value?.video_message || workflowOutputs.value?.video_message ||
workflowOutputs.value?.photo_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(() => { const hasAnyResult = computed(() => {
return !!( return !!(
@@ -269,16 +288,9 @@
photoFiles.value.length photoFiles.value.length
); );
}); });
const rawOutputsText = computed(() => { // 原本这里有一个 rawOutputsText(JSON.stringify 整包),已移除:
if (!workflowOutputs.value) { // 它会把 taskId / workflowRunId 等内部标识符渲染给用户,且模板里的引用
return ''; // 早已被注释掉、留下一个「看似有原始输出视图」的死代码(见 issue 10)。
}
try {
return JSON.stringify(workflowOutputs.value, null, 2);
} catch (e) {
return '';
}
});
const resultTagText = computed(() => { const resultTagText = computed(() => {
if (loading.value) { if (loading.value) {
return '执行中'; return '执行中';
+41 -12
View File
@@ -224,6 +224,7 @@
import { API_BASE_URL } from '@/config/setting'; import { API_BASE_URL } from '@/config/setting';
import { useUserStore } from '@/store/modules/user'; import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common'; import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
interface FormState { interface FormState {
docType: string; docType: string;
@@ -412,13 +413,9 @@
return ''; return '';
}; };
const stringifyUnknown = (value: unknown) => { // 原本这里有一个 stringifyUnknown(JSON.stringify 整包兜底),已移除:
try { // 它会把 taskId / workflowRunId 等内部标识符渲染进编辑器。
return JSON.stringify(value, null, 2); // 现在统一走 @/utils/ai-result(见 issue 10)。
} catch (e) {
return String(value ?? '');
}
};
const parseResult = (payload: ManuscriptGenResult): ParsedResult => { const parseResult = (payload: ManuscriptGenResult): ParsedResult => {
if (typeof payload === 'string') { 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( const title = pickString(
payload.title, payload.title,
payload.theme, payload.theme,
@@ -436,7 +443,8 @@
payload.subject, payload.subject,
payload.data?.title payload.data?.title
); );
const content = pickString( // 用 pickDisplayable 而非 pickString:后者会把裸 UUID 当正文
const content = pickDisplayable(
payload.content, payload.content,
payload.text, payload.text,
payload.result, payload.result,
@@ -458,7 +466,13 @@
return { return {
title: title || form.theme.trim(), 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 summary
}; };
}; };
@@ -556,7 +570,11 @@
} }
}; };
const appendAssistantChunk = (chunk: string, conversationId?: string) => { const appendAssistantChunk = (
chunk: string,
conversationId?: string,
reset?: boolean
) => {
if (!chunk) { if (!chunk) {
return; return;
} }
@@ -568,7 +586,14 @@
generatedTitle.value = generatedTitle.value || form.theme.trim(); generatedTitle.value = generatedTitle.value || form.theme.trim();
generatedContent.value = ''; generatedContent.value = '';
} }
generatedContent.value += chunk; // reset=true 表示「丢弃已累积的片段,只用本条内容」。
// 后端在失败收场时带这个标志覆盖半截内容;不处理就会变成
// 「半截正文 + 生成失败,请稍后重试」(见 issue 03 / 10)。
if (reset) {
generatedContent.value = chunk;
} else {
generatedContent.value += chunk;
}
responseSummary.value = '正在通过 WebSocket 接收流式内容'; responseSummary.value = '正在通过 WebSocket 接收流式内容';
}; };
@@ -598,7 +623,11 @@
} }
const chunk = pickRawStreamString(data?.notice, data?.answer); const chunk = pickRawStreamString(data?.notice, data?.answer);
if (typeof chunk === 'string' && chunk.length > 0) { if (typeof chunk === 'string' && chunk.length > 0) {
appendAssistantChunk(chunk, data?.conversationId); appendAssistantChunk(
chunk,
data?.conversationId,
data?.reset === 'true' || data?.reset === true
);
} }
} catch (e) { } catch (e) {
appendAssistantChunk(raw); appendAssistantChunk(raw);
+30 -10
View File
@@ -322,6 +322,7 @@ import type { Menu } from "@/api/system/menu/model";
import { useUserStore } from "@/store/modules/user"; import { useUserStore } from "@/store/modules/user";
import { logout } from "@/utils/page-tab-util"; import { logout } from "@/utils/page-tab-util";
import { copyText } from "@/utils/common"; import { copyText } from "@/utils/common";
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from "@/utils/ai-result";
import { getToken } from "@/utils/token-util"; import { getToken } from "@/utils/token-util";
import { isOnlyStudent } from "@/utils/permission"; import { isOnlyStudent } from "@/utils/permission";
@@ -946,13 +947,9 @@ const startNewChat = () => {
content.value = ""; content.value = "";
}; };
const stringifyUnknown = (value: unknown) => { // 原本这里有一个 stringifyUnknown(JSON.stringify 整包兜底),已移除:
try { // 它会把 conversationId / taskId / workflowRunId 等内部标识符渲染给用户。
return JSON.stringify(value, null, 2); // 现在统一走 @/utils/ai-result 的 resolveDisplayText(见 issue 10)。
} catch (e) {
return String(value ?? "");
}
};
const pickString = (...values: unknown[]) => { const pickString = (...values: unknown[]) => {
for (const value of values) { for (const value of values) {
@@ -997,6 +994,11 @@ const formatWritingResult = (payload: unknown, query: string) => {
if (typeof payload === "string") { if (typeof payload === "string") {
return payload; return payload;
} }
// 失败态优先:此时 answer 里是失败文案(后端已带 status=failed)。
// 不先判失败就会渲染成「主题 + 生成失败,请稍后重试」(见 issue 10)。
if (isFailedPayload(payload)) {
return FAILED_TEXT;
}
const data = (payload || {}) as Record<string, any>; const data = (payload || {}) as Record<string, any>;
const title = pickString( const title = pickString(
data.title, data.title,
@@ -1005,7 +1007,8 @@ const formatWritingResult = (payload: unknown, query: string) => {
data.subject, data.subject,
data.data?.title data.data?.title
); );
const body = pickString( // 用 pickDisplayable 而非 pickString:后者会把裸 UUID 当正文
const body = pickDisplayable(
data.content, data.content,
data.text, data.text,
data.result, data.result,
@@ -1023,7 +1026,13 @@ const formatWritingResult = (payload: unknown, query: string) => {
if (title || body) { if (title || body) {
return `${title || buildWritingPayload(query).theme}\n\n${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) => { const getCreativeOutputs = (payload: any) => {
@@ -1103,6 +1112,11 @@ const formatImageList = (title: string, list: any[] = []) => {
}; };
const formatCreativeResult = (payload: unknown) => { const formatCreativeResult = (payload: unknown) => {
// 失败态优先:此时 outputs 为空、answer 是失败文案,不判失败就会落到
// 整包兜底把内部标识符渲染出去(见 issue 10)。
if (isFailedPayload(payload)) {
return FAILED_TEXT;
}
const outputs = getCreativeOutputs(payload as any); const outputs = getCreativeOutputs(payload as any);
const script = pickString(outputs.Script_result, outputs.script, outputs.text); const script = pickString(outputs.Script_result, outputs.script, outputs.text);
const videoMessage = pickString(outputs.video_message); const videoMessage = pickString(outputs.video_message);
@@ -1127,7 +1141,13 @@ const formatCreativeResult = (payload: unknown) => {
] ]
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
return text || stringifyUnknown(payload); // 不再 stringifyUnknown(payload):整包会带出 taskId / workflowRunId 等内部标识符。
// 有可读内容就给内容,否则给通用兜底文案(原报文进日志)。
return resolveDisplayText({
payload,
scope: "oa/chatgpt.formatCreativeResult",
candidates: [text]
});
}; };
const buildImagePrompt = (query: string) => { const buildImagePrompt = (query: string) => {