diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 7e282a1bd7..7c30c1b7db 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -39,6 +39,14 @@ interface RetryCountdown { seconds: number } +function failureMessage( + message: string, + code: unknown, + t: ChatViewSlotProps['t'], +): string { + return code === 'AUTH' ? t('message.failure.auth') : message +} + function ModelRetryItem({ node, active, t }: { node: ModelRetryNode active: boolean @@ -98,7 +106,7 @@ function ModelRetryItem({ node, active, t }: {
{t('message.retry.failure')} - {node.failure.message} + {failureMessage(node.failure.message, node.failure.code, t)}
@@ -115,7 +123,7 @@ function TurnErrorItem({ node, t }: {
{t('message.turnError')} - {node.message} + {failureMessage(node.message, node.code, t)}
{node.code !== undefined && {node.code}} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index cee7c5c9dc..f4b0695f0b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -3,7 +3,7 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TurnErrorNode } from '../contract/snapshot.ts' -import { displayFailureMessage } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { displayFailure } from '@deepseek-ai/dsh-client-ui-conversation/client' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { @@ -32,11 +32,12 @@ function lastStep(context: ConversationNodeContext): number { function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined { if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined const failure = match.event.data.reason.error + const display = displayFailure(failure) return { seq: match.event.seq, time: match.event.time, - message: displayFailureMessage(failure), - code: failure.code, + message: display.message, + ...(display.code === undefined ? {} : { code: display.code }), } } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 9c82da7564..1816fae928 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -43,7 +43,7 @@ export type { export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts' export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts' export { - contextForm, contextProvenance, displayFailureMessage, emptyAssistantBlock, isTokenDelta, + contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta, } from '@deepseek-ai/dsh-client-ui-conversation/client' /** Public merge surface for Chat renderer payloads contributed by other plugins. */ diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 7297dd7208..9f6a18fd7b 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -66,6 +66,7 @@ export const zh = { 'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s', 'message.retry.delay': '重试延迟:', 'message.retry.failure': '失败原因:', + 'message.failure.auth': 'API 密钥无效', 'message.turnError': '本轮运行失败', 'message.maxTokens': '已达到输出 token 上限', 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', @@ -151,6 +152,7 @@ export const en = { 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', 'message.retry.delay': 'Retry delay: ', 'message.retry.failure': 'Failure reason: ', + 'message.failure.auth': 'API key is invalid', 'message.turnError': 'This turn failed', 'message.maxTokens': 'Output token limit reached', 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.', diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 1e72de9fc9..30f25e2277 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -625,7 +625,7 @@ describe('ChatView', () => { const view = render() const statuses = view.getAllByRole('status') expect(statuses.map(status => status.textContent)).toEqual([ - '本轮运行失败API key is invalidAUTH', + '本轮运行失败API 密钥无效AUTH', '本轮运行失败plugin exploded', ]) }) diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index b715814bcc..a92e284ca8 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -176,7 +176,9 @@ export interface TurnErrorNode { time: number turn: number step: number + /** Sanitized provider message; empty when a known code owns localized copy. */ message: string + /** Stable provider failure code, when recorded. */ code?: string } diff --git a/packages/client/ui-conversation/src/client/contract/request-inspection.ts b/packages/client/ui-conversation/src/client/contract/request-inspection.ts index 8c631fa346..773b131ae2 100644 --- a/packages/client/ui-conversation/src/client/contract/request-inspection.ts +++ b/packages/client/ui-conversation/src/client/contract/request-inspection.ts @@ -37,6 +37,8 @@ interface RequestViewBase { completedAt: number | null status: 'running' | 'complete' | 'error' error?: string + /** Stable provider code for localized presentation of known failures. */ + errorCode?: string provenance?: AssistantProvenanceView requestConfig?: AssistantRequestConfig usage?: unknown diff --git a/packages/client/ui-conversation/src/client/conversation/failure-display.ts b/packages/client/ui-conversation/src/client/conversation/failure-display.ts index 88531f0857..85fdf89896 100644 --- a/packages/client/ui-conversation/src/client/conversation/failure-display.ts +++ b/packages/client/ui-conversation/src/client/conversation/failure-display.ts @@ -1,13 +1,25 @@ +/** Display-safe failure fields retained by locale-independent projections. */ +export interface DisplayFailure { + /** Stable provider failure code used for localized known-error copy. */ + code?: string + /** Sanitized provider message; empty when the code owns the display copy. */ + message: string +} + /** - * Convert a durable failure into copy that is safe to expose in the GUI. + * Convert a durable failure into locale-independent fields safe for GUI projections. * @param failure - Failure value preserved by the session event. - * @returns Display-safe copy for client projections. + * @returns Sanitized message and optional stable provider code. */ -export function displayFailureMessage(failure: unknown): string { - if (failure === null || typeof failure !== 'object') return String(failure) +export function displayFailure(failure: unknown): DisplayFailure { + if (failure === null || typeof failure !== 'object') return { message: String(failure) } const record = failure as { code?: unknown; message?: unknown } + const code = typeof record.code === 'string' ? record.code : undefined // Provider AUTH messages may echo a masked or partially preserved credential. // Keep the raw diagnostic in the session log, but never project it into UI state. - if (record.code === 'AUTH') return 'API key is invalid' - return typeof record.message === 'string' ? record.message : JSON.stringify(failure) + if (code === 'AUTH') return { code, message: '' } + return { + ...(code === undefined ? {} : { code }), + message: typeof record.message === 'string' ? record.message : JSON.stringify(failure), + } } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 59af961a7e..33474321f1 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -48,7 +48,8 @@ export type { AssistantStepMetadata } from './conversation/assistant-timing.ts' export { assistantStepKey, indexAssistantStepTiming, isTokenDelta, settledAssistantTiming, } from './conversation/assistant-timing.ts' -export { displayFailureMessage } from './conversation/failure-display.ts' +export { displayFailure } from './conversation/failure-display.ts' +export type { DisplayFailure } from './conversation/failure-display.ts' export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts' export { ConversationNodeAssembler } from './conversation/assembler.ts' diff --git a/packages/client/ui-conversation/tests/failure-display.client.spec.ts b/packages/client/ui-conversation/tests/failure-display.client.spec.ts new file mode 100644 index 0000000000..0db2ae6834 --- /dev/null +++ b/packages/client/ui-conversation/tests/failure-display.client.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { displayFailure } from '../src/client/conversation/failure-display.ts' + +describe('displayFailure', () => { + it('keeps ordinary diagnostics and stable provider codes', () => { + expect(displayFailure(null)).toEqual({ message: 'null' }) + expect(displayFailure('disconnected')).toEqual({ message: 'disconnected' }) + expect(displayFailure({ code: 'RATE_LIMIT', message: 'try later' })).toEqual({ + code: 'RATE_LIMIT', + message: 'try later', + }) + expect(displayFailure({ detail: 'unknown' })).toEqual({ + message: '{"detail":"unknown"}', + }) + }) + + it('removes the provider message when AUTH owns localized display copy', () => { + expect(displayFailure({ code: 'AUTH', message: 'credential sk-secret failed' })).toEqual({ + code: 'AUTH', + message: '', + }) + }) +}) diff --git a/packages/client/ui-message-feedback/src/client/controller.ts b/packages/client/ui-message-feedback/src/client/controller.ts index 29bbfede8d..4acc79db0a 100644 --- a/packages/client/ui-message-feedback/src/client/controller.ts +++ b/packages/client/ui-message-feedback/src/client/controller.ts @@ -306,7 +306,7 @@ export class MessageFeedbackController implements HostObservable { expect(controller.getSnapshot().status).not.toBe('error') }) - it('describes a non-Error list rejection with a stable message', async () => { - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test. + it('preserves a non-Error list rejection as a diagnostic string', async () => { const { remote } = fakeRemote({ list: () => Promise.reject('socket string') }) const controller = new MessageFeedbackController(remote, SESSION) expect(await controller.ensure()).toEqual({ ok: false, - error: { code: 'transport', message: 'message feedback list failed' }, + error: { code: 'transport', message: 'socket string' }, }) }) - it('describes a non-Error mutation rejection with a stable message', async () => { - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test. + it('preserves a non-Error mutation rejection as a diagnostic string', async () => { const { remote } = fakeRemote({ put: () => Promise.reject('nope') }) const controller = new MessageFeedbackController(remote, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: false, - error: { code: 'transport', message: 'message feedback mutation failed' }, + error: { code: 'transport', message: 'nope' }, }) }) diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index b9afb33960..8399f3681e 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -397,7 +397,6 @@ export function JsonTree({ expandTopLevel = true, labels, }: JsonTreeProps) { - const copyLabels = labels const rootEntries = entriesOf(data) const firstExpandableIndex = rootEntries.findIndex(([, value]) => ( isExpandableValue(value) && entriesOf(value).length > 0 @@ -526,10 +525,10 @@ export function JsonTree({ const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value' const copyTitle = copyState === 'copied' - ? copyLabels.copied + ? labels.copied : copyState === 'failed' - ? copyLabels.copyFailed - : copyTargetIsObject ? copyLabels.copyPrettyJson : copyLabels.copyValue + ? labels.copyFailed + : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue return (
void copy(defaultCopyMode)} onContextMenu={(event) => { event.preventDefault() @@ -626,7 +625,7 @@ export function JsonTree({ : } )} - items={copyTargetIsObject ? objectCopyMenuItems(copyLabels) : valueCopyMenuItems(copyLabels)} + items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} onSelect={(id) => { void copy(id as 'json' | 'path' | 'prettyJson' | 'value') copyMenuOpenRef.current = false diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 30859bc911..96b3213a04 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -1,4 +1,4 @@ -import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, @@ -101,6 +101,11 @@ export function ToolRow({ inspect, }: ToolRowProps) { const [expanded, setExpanded] = useState(false) + const terminalLabels = useMemo(() => terminalBlockLabels(t), [t]) + const diffLabels = useMemo(() => diffBlockLabels(t), [t]) + const readLabels = useMemo(() => readBlockLabels(t), [t]) + const searchLabels = useMemo(() => searchBlockLabels(t), [t]) + const webLabels = useMemo(() => webBlockLabels(t), [t]) const terminalBody = terminal ?? null const diffBody = diff ?? null const readBody = read ?? null @@ -179,20 +184,20 @@ export function ToolRow({ ) : diffBody !== null - ? + ? : readBody !== null - ? + ? : searchBody !== null ? ( <> @@ -204,7 +209,7 @@ export function ToolRow({ ) : webBody !== null - ? + ? : ( <> {variant === 'code' && body !== null && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 61424bbf02..b3f5f3415e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -187,9 +187,7 @@ interface ToolCallTextParts { } interface SelectedRequest { - turn: number | null - group: string - seq?: number + identity: string } interface DetailsResizeDrag { @@ -425,14 +423,13 @@ export interface TrajectoryTableProps { /** Request-inspector fields shared by ordinary generation and compaction. */ interface TrajectoryRequestNumberBase { - /** Request anchor event sequence; absent for the currently streaming ordinary request. */ - seq?: number group: string number: number status?: 'complete' | 'running' | 'error' startedAt?: number completedAt?: number | null error?: string + errorCode?: string retry?: number maxRetries?: number retryDelayMs?: number @@ -448,11 +445,15 @@ interface TrajectoryRequestNumberBase { export type TrajectoryRequestNumber = TrajectoryRequestNumberBase & ( | { purpose?: 'assistant' + /** Request anchor event sequence; absent for the currently streaming request. */ + seq?: number turn: number step: number } | { purpose: 'compaction' + /** Request anchor event sequence and stable compaction identity. */ + seq: number turn: number | null step: 0 } @@ -523,6 +524,12 @@ function requestKey(turn: number | null, group: string): string { return `${turn}\u0000${group}` } +function requestIdentity(request: TrajectoryRequestNumber): string { + return request.purpose === 'compaction' + ? `compaction\u0000${request.seq}` + : `assistant\u0000${request.turn}\u0000${request.step}` +} + function indexRequestBoundaries( records: readonly TableRecord[], requestGroups: ReadonlySet, @@ -647,19 +654,23 @@ function assistantToolCalls( return calls } -function summarizeAssistantTools(records: readonly TableRecord[]): string { +function summarizeAssistantTools( + records: readonly TableRecord[], + t: TrajectoryTranslate, +): string { const names = [...new Set(records.map((record) => { const separator = record.cell.text.indexOf(' · ') return separator === -1 ? record.cell.text : record.cell.text.slice(0, separator) }).filter(name => name !== ''))] const count = records.length - const summary = `${count} tool ${count === 1 ? 'call' : 'calls'}` + const summary = t(count === 1 ? 'summary.toolCalls.one' : 'summary.toolCalls.other', { count }) return names.length > 0 ? `${summary} · ${names.join(', ')}` : summary } function collapseAssistantRecords( records: readonly TableRecord[], collapsedAssistants: ReadonlySet, + t: TrajectoryTranslate, ): TableRecord[] { const out: TableRecord[] = [] for (let i = 0; i < records.length; i++) { @@ -688,7 +699,7 @@ function collapseAssistantRecords( groupStart: false, turnStart: false, turnEnd: last?.turnEnd ?? false, - collapsedSummary: summarizeAssistantTools(calls), + collapsedSummary: summarizeAssistantTools(calls, t), collapsedSummaryKind: 'assistant', }) i += calls.length @@ -712,6 +723,15 @@ function statusLabel(state: RecordState, t: TrajectoryTranslate): string { return t('status.completed') } +function requestErrorMessage( + request: Pick, + t: TrajectoryTranslate, +): string | undefined { + if (request.errorCode === 'AUTH') return t('details.failure.auth') + if (request.error === COMPACTION_INTERRUPTED_ERROR) return t('layout.compactionInterrupted') + return request.error +} + function TokenRows({ cell, t }: { cell: TrajectoryCellProps; t: TrajectoryTranslate }) { const content = cell.output !== undefined && cell.think !== undefined ? Math.max(0, cell.output - cell.think) @@ -1083,10 +1103,11 @@ function MarkdownFragment({ preview: boolean t: TrajectoryTranslate }) { + const labels = useMemo(() => markdownLabels(t), [t]) if (rendered) { return (
- +
) } @@ -1849,7 +1870,7 @@ export function TrajectoryTable({ : collapseTurnRecords(allRecords, collapsedTurns, requestGroups, t) return collapsedAssistants.size === 0 ? turnRecords - : collapseAssistantRecords(turnRecords, collapsedAssistants) + : collapseAssistantRecords(turnRecords, collapsedAssistants, t) }, [allRecords, collapsedAssistants, collapsedTurns, requestGroups, searchMatchIndexes, t]) const projectedVirtualRows = useMemo( () => groupTrajectoryVirtualRows(records), @@ -1932,28 +1953,25 @@ export function TrajectoryTable({ : undefined const promptSelected = selectedPrompt !== undefined const selectedState = selected === undefined ? undefined : stateOf(selected) - const selectedRequestRecordTemplates = useMemo(() => selectedRequest === null + const selectedRequestInfo = selectedRequest === null + ? undefined + : sessionRequestNumbers?.find(request => + requestIdentity(request) === selectedRequest.identity) + const selectedRequestRecordTemplates = useMemo(() => selectedRequestInfo === undefined ? [] : allRecords.filter(record => - record.turn === selectedRequest.turn - && record.group === selectedRequest.group, - ), [allRecords, selectedRequest]) + record.turn === selectedRequestInfo.turn + && record.group === selectedRequestInfo.group, + ), [allRecords, selectedRequestInfo]) const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord) const selectedRequestAssistant = selectedRequestRecords.find( record => record.cell.kind === 'message', ) const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0] - const selectedRequestNumber = selectedRequest === null + const selectedRequestNumber = selectedRequestInfo?.number + const selectedRequestState: RecordState | undefined = selectedRequestInfo === undefined ? undefined - : requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group)) - const selectedRequestInfo = selectedRequest === null - ? undefined - : sessionRequestNumbers?.find(request => selectedRequest.seq === undefined - ? request.turn === selectedRequest.turn && request.group === selectedRequest.group - : request.seq === selectedRequest.seq) - const selectedRequestState: RecordState | undefined = selectedRequest === null - ? undefined - : selectedRequestInfo?.status + : selectedRequestInfo.status ?? (selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null ? 'running' : selectedRequestAssistant === undefined @@ -1996,11 +2014,11 @@ export function TrajectoryTable({ const selectedRequestCumulativeUsage = selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage const selectedRequestOptions = selectedRequestInfo?.requestConfig - const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn - const activeSection = selectedRequest === null + const activeTurn = selectedRequestInfo === undefined ? selected?.turn : selectedRequestInfo.turn + const activeSection = selectedRequestInfo === undefined ? selected?.section : selectedRequestRecords[0]?.section - const selectedTabs = selectedRequest !== null + const selectedTabs = selectedRequestInfo !== undefined ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined) : selected === undefined ? [] : detailTabs(selected) const selectedParents: ParentRecords = selected === undefined @@ -2015,15 +2033,9 @@ export function TrajectoryTable({ ? undefined : sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest) const selectedAssistantRequestTarget: SelectedRequest | undefined = - selected !== undefined && selectedAssistantRequest !== undefined - ? { - turn: selected.turn, - group: selected.group, - ...(selectedAssistantRequestInfo?.seq === undefined - ? {} - : { seq: selectedAssistantRequestInfo.seq }), - } - : undefined + selectedAssistantRequestInfo === undefined + ? undefined + : { identity: requestIdentity(selectedAssistantRequestInfo) } const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined || selectedParents.message !== undefined || selectedParents.tool !== undefined @@ -2385,9 +2397,8 @@ export function TrajectoryTable({ : t(requestInfo?.purpose === 'compaction' ? 'request.labelCompaction' : 'request.label', { request }) - const requestSelected = request !== undefined - && selectedRequest?.turn === record.turn - && selectedRequest.group === record.group + const requestSelected = requestInfo !== undefined + && selectedRequest?.identity === requestIdentity(requestInfo) const sectionActive = record.turn === null ? activeSection === record.section : activeTurn === record.turn @@ -2489,11 +2500,9 @@ export function TrajectoryTable({ style={requestBoundaryStyle} onClick={(event) => { event.stopPropagation() - selectRequest({ - turn: record.turn, - group: record.group, - ...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }), - }) + if (requestInfo !== undefined) { + selectRequest({ identity: requestIdentity(requestInfo) }) + } }} onDoubleClick={(event) => { event.stopPropagation() }} /> @@ -2625,7 +2634,7 @@ export function TrajectoryTable({
- {(selectedRequest !== null + {(selectedRequestInfo !== undefined || promptSelected || (selected !== undefined && selectedState !== undefined)) && (