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:
@@ -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);
|
||||
|
||||
@@ -92,8 +92,15 @@
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
必须同时判 `!workflowResponse`:若只判 `!hasAnyResult`,
|
||||
则「已执行但失败 / 未识别到输出」时 hasAnyResult 为假,
|
||||
会落回这个空状态,把下方 result-body 里的 status-alert
|
||||
(失败文案 / 兜底文案)整个盖住 —— 用户看到的仍是
|
||||
「填写文本指令并上传附件后执行工作流」,等于什么都没发生(见 issue 10)。
|
||||
-->
|
||||
<a-empty
|
||||
v-if="!loading && !hasAnyResult"
|
||||
v-if="!loading && !hasAnyResult && !workflowResponse"
|
||||
description="填写文本指令并上传附件后执行工作流"
|
||||
/>
|
||||
|
||||
@@ -199,11 +206,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="rawOutputsText" class="result-section">-->
|
||||
<!-- <div class="section-title">原始输出</div>-->
|
||||
<!-- <pre class="raw-output">{{ rawOutputsText }}</pre>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
@@ -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 '执行中';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user