From 75dd5bdd172cb3e5d9dae7eb4a365ffcacf94524 Mon Sep 17 00:00:00 2001 From: hm1229 Date: Thu, 6 Aug 2026 17:17:26 +0800 Subject: [PATCH] fix(sop): expose expandable learning errors --- frontend-enterprise/src/i18n/en.json | 13 ++- frontend-enterprise/src/pages/DistillPage.tsx | 100 ++++++++++++++++-- .../src/pages/distillFailure.test.ts | 29 +++++ .../src/pages/distillFailure.ts | 44 ++++++++ .../src/pages/distillPageStyles.ts | 21 ++++ 5 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 frontend-enterprise/src/pages/distillFailure.test.ts create mode 100644 frontend-enterprise/src/pages/distillFailure.ts diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 66ad31a8..6c36a114 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -2203,5 +2203,16 @@ "StaffDeck 有新版本": "A new StaffDeck version is available", "v{1} 已发布,你正在使用 v{2}": "v{1} is available. You're using v{2}", "查看更新": "View update", - "关闭更新提醒": "Dismiss update reminder" + "关闭更新提醒": "Dismiss update reminder", + "当前草稿未变更,可以调整要求或模型配置后重试。": "The current draft is unchanged. Adjust the instructions or model configuration and try again.", + "错误码:{1}": "Error code: {1}", + "错误详情已复制": "Error details copied", + "复制错误详情失败": "Failed to copy error details", + "阶段": "Stage", + "错误码": "Error code", + "未提供": "Not provided", + "复制错误详情": "Copy error details", + "SOP 草稿生成": "SOP draft generation", + "SOP 局部改写": "SOP partial rewrite", + "上游没有返回详细错误信息,请检查所选模型配置及服务日志。": "The upstream service did not return error details. Check the selected model configuration and service logs." } diff --git a/frontend-enterprise/src/pages/DistillPage.tsx b/frontend-enterprise/src/pages/DistillPage.tsx index ab138cdc..bf2981ca 100644 --- a/frontend-enterprise/src/pages/DistillPage.tsx +++ b/frontend-enterprise/src/pages/DistillPage.tsx @@ -96,6 +96,17 @@ import { CHAT_EDIT_PANEL_CLASS, CHAT_EDIT_PANEL_USER_ATTACHMENTS_CLASS, CHAT_EDIT_TEXTAREA_CLASS, + CHAT_FAILURE_BUTTON_CLASS, + CHAT_FAILURE_CLASS, + CHAT_FAILURE_COPY_CLASS, + CHAT_FAILURE_DETAILS_CLASS, + CHAT_FAILURE_ICON_CLASS, + CHAT_FAILURE_LABEL_CLASS, + CHAT_FAILURE_META_CLASS, + CHAT_FAILURE_RAW_CLASS, + CHAT_FAILURE_STAGE_CLASS, + CHAT_FAILURE_SUMMARY_CLASS, + CHAT_FAILURE_VALUE_CLASS, CHAT_HOVER_ACTIONS_CLASS, CHAT_HOVER_BUTTON_CLASS, CHAT_MESSAGES_CLASS, @@ -275,7 +286,9 @@ import { uploadItemClass, type ToolStatusBadgeVariant, } from './distillPageStyles'; +import { buildDistillFailure, type DistillFailure } from './distillFailure'; import { api, ApiError, streamGet, streamPost, TENANT_ID } from '../api/client'; +import { copyTextToClipboard } from '@/lib/clipboard'; import type { GeneralSkillRead, KnowledgeBaseRead, @@ -297,6 +310,8 @@ type ChatItem = { thinking?: 'running' | 'done'; thinkingDetails?: string[]; thinkingOpen?: boolean; + failure?: DistillFailure; + failureOpen?: boolean; warnings?: string[]; toolSuggestions?: ToolSuggestionItem[]; actionState?: 'pending' | 'confirmed' | 'rejected'; @@ -969,7 +984,7 @@ export default function DistillPage({ active = true, searchParamsOverride, curre return; } if (item.event === 'error') { - updateMessage(assistantId, String(item.data.message || '生成失败,当前草稿未变更。'), { thinking: 'done' }); + applyDistillFailure(assistantId, item.data.message, 'distill'); setActiveJob(null); } }, @@ -978,7 +993,7 @@ export default function DistillPage({ active = true, searchParamsOverride, curre } catch (error) { if (controller.signal.aborted && !manualStopRef.current) return; appendThinkingDetail(assistantId, '生成失败,已保留当前草稿'); - updateMessage(assistantId, '生成失败,当前草稿未变更。', { thinking: 'done' }); + applyDistillFailure(assistantId, error, 'distill'); if (controller.signal.aborted) { notify.info('已停止生成'); } else { @@ -1097,7 +1112,7 @@ export default function DistillPage({ active = true, searchParamsOverride, curre return; } if (item.event === 'error') { - updateMessage(assistantId, String(item.data.message || '改写失败,当前草稿未变更。'), { thinking: 'done' }); + applyDistillFailure(assistantId, item.data.message, 'rewrite'); setActiveJob(null); } }, @@ -1106,7 +1121,7 @@ export default function DistillPage({ active = true, searchParamsOverride, curre } catch (error) { if (controller.signal.aborted && !manualStopRef.current) return; appendThinkingDetail(assistantId, '改写失败,已保留当前草稿'); - updateMessage(assistantId, '改写失败,当前草稿未变更。', { thinking: 'done' }); + applyDistillFailure(assistantId, error, 'rewrite'); if (controller.signal.aborted) { notify.info('已停止改写'); } else { @@ -1241,7 +1256,7 @@ export default function DistillPage({ active = true, searchParamsOverride, curre return; } if (item.event === 'error') { - updateMessage(job.assistantId, String(item.data.message || '生成失败'), { thinking: 'done' }); + applyDistillFailure(job.assistantId, item.data.message, job.kind); setActiveJob(null); setLoading(false); return; @@ -1249,7 +1264,7 @@ export default function DistillPage({ active = true, searchParamsOverride, curre if (item.event === 'job_complete') { const status = String(item.data.status || ''); if (status === 'failed') { - updateMessage(job.assistantId, String(item.data.error || '生成失败'), { thinking: 'done' }); + applyDistillFailure(job.assistantId, item.data.error, job.kind); setActiveJob(null); } } @@ -1771,6 +1786,43 @@ export default function DistillPage({ active = true, searchParamsOverride, curre ); } + function applyDistillFailure( + assistantId: string, + error: unknown, + kind: ActiveDistillJob['kind'], + ) { + const failure = buildDistillFailure(error, kind); + updateMessage(assistantId, '当前草稿未变更,可以调整要求或模型配置后重试。', { + thinking: 'done', + failure, + failureOpen: false, + }); + } + + function toggleFailureDetails(messageId: string) { + setMessages((current) => + current.map((item) => + item.id === messageId ? { ...item, failureOpen: !item.failureOpen } : item, + ), + ); + } + + async function copyFailureDetails(failure: DistillFailure) { + const text = [ + `${failure.summary} · ${failure.stage}`, + failure.code ? `错误码:${failure.code}` : '', + failure.detail, + ] + .filter(Boolean) + .join('\n'); + try { + await copyTextToClipboard(text); + notify.success('错误详情已复制'); + } catch { + notify.error('复制错误详情失败'); + } + } + function appendOperationToMessage(id: string, operation: DistillHistoryOperation) { setMessages((current) => current.map((item) => @@ -2149,6 +2201,42 @@ export default function DistillPage({ active = true, searchParamsOverride, curre )} )} + {item.role === 'assistant' && item.failure && ( +
+ + {item.failureOpen && ( +
+
+ 阶段 + {item.failure.stage} + 错误码 + {item.failure.code || '未提供'} +
+
{item.failure.detail}
+ +
+ )} +
+ )} {item.role === 'user' && item.attachments && item.attachments.length > 0 && (
{item.attachments.map((attachment) => ( diff --git a/frontend-enterprise/src/pages/distillFailure.test.ts b/frontend-enterprise/src/pages/distillFailure.test.ts new file mode 100644 index 00000000..b5b581bb --- /dev/null +++ b/frontend-enterprise/src/pages/distillFailure.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { buildDistillFailure } from './distillFailure'; + +describe('distill failure details', () => { + it('preserves the provider error and extracts its code', () => { + const failure = buildDistillFailure( + 'LLM provider request failed (APIConnectionError); message=Connection error; provider_code=upstream_timeout', + 'distill', + ); + + expect(failure).toEqual({ + code: 'upstream_timeout', + detail: + 'LLM provider request failed (APIConnectionError); message=Connection error; provider_code=upstream_timeout', + stage: 'SOP 草稿生成', + summary: '生成失败', + }); + }); + + it('provides an actionable fallback without inventing an error code', () => { + expect(buildDistillFailure('改写失败,当前草稿未变更。', 'rewrite')).toEqual({ + code: undefined, + detail: '上游没有返回详细错误信息,请检查所选模型配置及服务日志。', + stage: 'SOP 局部改写', + summary: '改写失败', + }); + }); +}); diff --git a/frontend-enterprise/src/pages/distillFailure.ts b/frontend-enterprise/src/pages/distillFailure.ts new file mode 100644 index 00000000..1f03f191 --- /dev/null +++ b/frontend-enterprise/src/pages/distillFailure.ts @@ -0,0 +1,44 @@ +export type DistillFailure = { + code?: string; + detail: string; + stage: string; + summary: string; +}; + +const GENERIC_FAILURE_DETAILS = new Set([ + '', + '生成失败', + '生成失败,当前草稿未变更。', + '改写失败', + '改写失败,当前草稿未变更。', +]); + +export function buildDistillFailure( + error: unknown, + kind: 'distill' | 'rewrite', +): DistillFailure { + const rawDetail = error instanceof Error ? error.message : String(error || '').trim(); + const stage = kind === 'distill' ? 'SOP 草稿生成' : 'SOP 局部改写'; + const summary = kind === 'distill' ? '生成失败' : '改写失败'; + const detail = GENERIC_FAILURE_DETAILS.has(rawDetail) + ? '上游没有返回详细错误信息,请检查所选模型配置及服务日志。' + : rawDetail; + + return { + code: extractFailureCode(detail), + detail, + stage, + summary, + }; +} + +function extractFailureCode(detail: string): string | undefined { + const explicit = detail.match(/(?:error[_ ]?code|provider_code|code)\s*[=:]\s*['"]?([\w.-]+)/i); + if (explicit?.[1]) return explicit[1]; + + const parenthesized = detail.match(/(?:request|provider)\s+failed\s+\(([^)]+)\)/i); + if (parenthesized?.[1]) return parenthesized[1].trim(); + + const stableCode = detail.match(/\b([A-Z][A-Z0-9_]{3,})\b/); + return stableCode?.[1]; +} diff --git a/frontend-enterprise/src/pages/distillPageStyles.ts b/frontend-enterprise/src/pages/distillPageStyles.ts index f81a5d5c..8ca4f684 100644 --- a/frontend-enterprise/src/pages/distillPageStyles.ts +++ b/frontend-enterprise/src/pages/distillPageStyles.ts @@ -72,6 +72,27 @@ export const CHAT_THINKING_DETAILS_CLASS = export const CHAT_THINKING_DETAIL_CLASS = 'relative min-w-0 pl-[14px] wrap-anywhere before:absolute before:left-0 before:top-[0.72em] before:size-[5px] before:rounded-full before:bg-[#d1d5db] before:content-[""]'; +export const CHAT_FAILURE_CLASS = + 'mb-[10px] min-w-0 overflow-hidden rounded-[10px] border border-[#f1cccc] bg-[#fffafa] text-[12px] text-[#5f2525]'; +export const CHAT_FAILURE_BUTTON_CLASS = + 'flex w-full cursor-pointer items-center gap-[8px] border-0 bg-transparent px-[11px] py-[9px] text-left transition-colors hover:bg-[#fff4f4]'; +export const CHAT_FAILURE_ICON_CLASS = + 'inline-flex size-[20px] shrink-0 items-center justify-center rounded-full bg-[#fbe4e4] text-[12px] text-[#c93c3c]'; +export const CHAT_FAILURE_SUMMARY_CLASS = + 'min-w-0 flex-1 truncate text-[12px] font-semibold text-[#9d3030]'; +export const CHAT_FAILURE_STAGE_CLASS = + 'shrink-0 rounded-full border border-[#efd3d3] bg-white px-[7px] py-[2px] text-[10px] leading-[1.4] text-[#a95a5a]'; +export const CHAT_FAILURE_DETAILS_CLASS = + 'grid gap-[9px] border-t border-[#f2dede] bg-white/70 px-[11px] py-[10px]'; +export const CHAT_FAILURE_META_CLASS = + 'grid grid-cols-[54px_minmax(0,1fr)] gap-x-[8px] gap-y-[5px] text-[11px] leading-[1.5]'; +export const CHAT_FAILURE_LABEL_CLASS = 'text-[#aa7777]'; +export const CHAT_FAILURE_VALUE_CLASS = 'min-w-0 wrap-anywhere text-[#623838]'; +export const CHAT_FAILURE_RAW_CLASS = + 'max-h-[180px] overflow-auto rounded-[7px] border border-[#f1dddd] bg-[#fffdfd] px-[9px] py-[8px] font-mono text-[11px] leading-[1.55] whitespace-pre-wrap wrap-anywhere text-[#6f3c3c]'; +export const CHAT_FAILURE_COPY_CLASS = + 'inline-flex w-fit items-center gap-[5px] whitespace-nowrap rounded-[7px] border border-[#ead5d5] bg-white px-[8px] py-[5px] text-[11px] text-[#8d5151] transition-colors hover:border-[#d9b6b6] hover:text-[#6f3030] [&_svg]:size-[13px] [&_svg]:shrink-0 [&_svg]:fill-none [&_svg]:stroke-current [&_svg]:stroke-[1.7]'; + export const CHAT_ATTACHMENTS_CLASS = 'mb-[8px] grid gap-[7px]'; export const CHAT_ATTACHMENTS_USER_CLASS = 'mb-0 justify-items-end'; export const CHAT_ATTACHMENT_CLASS =