Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion frontend-enterprise/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
100 changes: 94 additions & 6 deletions frontend-enterprise/src/pages/DistillPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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);
}
},
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
},
Expand All @@ -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 {
Expand Down Expand Up @@ -1241,15 +1256,15 @@ 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;
}
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);
}
}
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -2149,6 +2201,42 @@ export default function DistillPage({ active = true, searchParamsOverride, curre
)}
</div>
)}
{item.role === 'assistant' && item.failure && (
<div className={CHAT_FAILURE_CLASS}>
<button
type="button"
className={CHAT_FAILURE_BUTTON_CLASS}
aria-expanded={Boolean(item.failureOpen)}
onClick={() => toggleFailureDetails(item.id)}
>
<span className={CHAT_FAILURE_ICON_CLASS}>
<CloseCircleOutlined />
</span>
<span className={CHAT_FAILURE_SUMMARY_CLASS}>{item.failure.summary}</span>
<span className={CHAT_FAILURE_STAGE_CLASS}>{item.failure.stage}</span>
{item.failureOpen ? <DownOutlined /> : <RightOutlined />}
</button>
{item.failureOpen && (
<div className={CHAT_FAILURE_DETAILS_CLASS}>
<div className={CHAT_FAILURE_META_CLASS}>
<span className={CHAT_FAILURE_LABEL_CLASS}>阶段</span>
<span className={CHAT_FAILURE_VALUE_CLASS}>{item.failure.stage}</span>
<span className={CHAT_FAILURE_LABEL_CLASS}>错误码</span>
<span className={CHAT_FAILURE_VALUE_CLASS}>{item.failure.code || '未提供'}</span>
</div>
<pre className={CHAT_FAILURE_RAW_CLASS}>{item.failure.detail}</pre>
<button
type="button"
className={CHAT_FAILURE_COPY_CLASS}
onClick={() => void copyFailureDetails(item.failure!)}
>
<CopyGlyph />
复制错误详情
</button>
</div>
)}
</div>
)}
{item.role === 'user' && item.attachments && item.attachments.length > 0 && (
<div className={cn(CHAT_ATTACHMENTS_CLASS, CHAT_ATTACHMENTS_USER_CLASS)}>
{item.attachments.map((attachment) => (
Expand Down
29 changes: 29 additions & 0 deletions frontend-enterprise/src/pages/distillFailure.test.ts
Original file line number Diff line number Diff line change
@@ -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: '改写失败',
});
});
});
44 changes: 44 additions & 0 deletions frontend-enterprise/src/pages/distillFailure.ts
Original file line number Diff line number Diff line change
@@ -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];
}
21 changes: 21 additions & 0 deletions frontend-enterprise/src/pages/distillPageStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down