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
+30 -10
View File
@@ -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<string, any>;
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) => {