From 53d9810461017799a85704642fdcf7f86d6aadbc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:50:20 +0800 Subject: [PATCH] fix(web): localize the trajectory toolbar and simplify the download Toolbar strings route through the locale dictionary's standard t seat (the export button no longer mixes languages in the English golden), exportLog passes the response blob straight to the browser save instead of copying it three times, the client id sanitizer rejects dot segments like the host one, and the fixture stub comment no longer misattributes the 404. --- .../client/connection/src/client/fixture.ts | 6 +- .../src/client/TrajectoryToolbar.tsx | 37 +++++++----- .../src/client/TrajectoryView.tsx | 7 ++- .../ui-trajectory/src/client/export-log.ts | 16 ++--- .../client/ui-trajectory/src/client/index.ts | 16 ++--- .../ui-trajectory/src/client/locales.ts | 58 ++++++++++++++++++- .../ui-trajectory/tests/export-log.spec.ts | 8 ++- .../ui-trajectory/tests/toolbar.spec.tsx | 6 ++ .../client/ui-trajectory/tests/views.spec.tsx | 30 ++++++---- 9 files changed, 130 insertions(+), 54 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 503cc8cc6b..08837db9c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2834,9 +2834,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) return Promise.resolve({ accepted: true }) }, - // The host-only streaming download has no in-memory counterpart: fixture - // mode answers 404 so the export button's error bar explains the gap - // instead of hanging. + // Satisfies the ApiProxy contract type only: the browser export button + // fetches GET /api/session.export directly (window.fetch), so this stub is + // never reached through the fixture's dispatch. downloads: { sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), }, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx index fd05582b4b..2ff7a6092c 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -1,6 +1,8 @@ /** Trajectory toolbar: timeline and ledger fold controls. */ +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { NS } from './locales.ts' import css from './TrajectoryToolbar.module.css' export interface TrajectoryToolbarProps { @@ -30,6 +32,8 @@ export interface TrajectoryToolbarProps { onExport: () => void /** Export failure message, shown while set; null while idle or successful. */ exportError: string | null + /** Translate a toolbar dictionary key. */ + t: TranslateNS } /** @@ -51,17 +55,18 @@ export function TrajectoryToolbar({ exporting, onExport, exportError, + t, }: TrajectoryToolbarProps) { return ( -
+
@@ -134,8 +139,8 @@ export function TrajectoryToolbar({ { onSearchQueryChange(event.currentTarget.value) }} /> diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 9d182b379b..2046a9813c 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, SessionHistoryFace, SnapshotStore, @@ -187,8 +187,8 @@ function mergeSearchMatches( export function TrajectoryView({ useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, exportLog, - inspect, onInspectDone, -}: ConvViewProps & InjectFace) { + inspect, onInspectDone, t, +}: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_RECORD_IDS) @@ -563,6 +563,7 @@ export function TrajectoryView({ exporting={exporting} onExport={onExport} exportError={exportError} + t={t} /> {exportError !== null && (
diff --git a/packages/client/ui-trajectory/src/client/export-log.ts b/packages/client/ui-trajectory/src/client/export-log.ts index 4a5ccd8bd1..ba3d1ca8ff 100644 --- a/packages/client/ui-trajectory/src/client/export-log.ts +++ b/packages/client/ui-trajectory/src/client/export-log.ts @@ -7,11 +7,13 @@ /** * Collapse an untrusted session id into one safe path/filename segment. + * Distinct ids may collapse onto one segment (impossible for the host-minted + * UUIDs, so no uniqueness suffix is kept). * @param id - the raw session id. * @returns a filesystem-safe single segment. */ function safeSessionIdSegment(id: string): string { - return id.replace(/[^A-Za-z0-9._-]/g, '_') + return id.replace(/[^A-Za-z0-9_-]/g, '_') } /** @@ -25,16 +27,16 @@ export function sessionLogZipFilename(sessionId: string): string { } /** - * Trigger a browser download of raw bytes. - * @param bytes - the file content. + * Trigger a browser download of a blob response. + * @param blob - the response body to save (passed straight through, no copy). * @param filename - the download filename. - * @param type - the MIME type. */ -export function downloadBytes(bytes: Uint8Array, filename: string, type: string): void { - const url = URL.createObjectURL(new Blob([bytes], { type })) +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() - URL.revokeObjectURL(url) + // Revoke one tick later: some browsers read the blob URL after click(). + setTimeout(() => URL.revokeObjectURL(url), 0) } diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index adfb20fb02..a325f31410 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' import { en, NS, zh } from './locales.ts' -import { downloadBytes, sessionLogZipFilename } from './export-log.ts' +import { downloadBlob, sessionLogZipFilename } from './export-log.ts' /** Required services: the conversation view slot, the independent history source, and the locale service. */ export const inject = ['slots', 'sessionHistory', 'locale'] @@ -33,6 +33,7 @@ export function apply(ctx: Context): void { name: 'conversation.view', id: 'trajectory', order: 10, + locale: NS, label: () => t('view.trajectory'), inject: (sessionId: SessionId): TrajectoryViewInjected => { const history = ctx.sessionHistory.source(sessionId) @@ -44,7 +45,11 @@ export function apply(ctx: Context): void { exportLog: async () => { // The host streams the ZIP (root + descendant artifacts verbatim) // from GET /api/session.export; the browser downloads the response. - const url = new URL('/api/session.export', window.location.origin) + // A null origin (no-location Node contexts) falls back like the + // carrier's resolveBase so the URL stays valid. + const loc = (globalThis as { location?: { origin?: string } }).location + const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal' + const url = new URL('/api/session.export', origin) url.searchParams.set('sessionId', sessionId) url.searchParams.set('includeDescendants', 'true') const response = await fetch(url) @@ -52,12 +57,7 @@ export function apply(ctx: Context): void { const detail = await response.text().catch(() => '') throw new Error(`导出失败:HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) } - const blob = await response.blob() - downloadBytes( - new Uint8Array(await blob.arrayBuffer()), - sessionLogZipFilename(sessionId), - 'application/zip', - ) + downloadBlob(await response.blob(), sessionLogZipFilename(sessionId)) }, } }, diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index f5660dfddf..aba527dd2b 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -1,14 +1,32 @@ -/** `trajectory` namespace dictionaries (the view tab label). */ +/** `trajectory` namespace dictionaries (view tab label + toolbar strings). */ /** Dictionary namespace owned by this plugin. */ export const NS = 'trajectory' /** The trajectory dictionary key set (the source of truth for both locales). */ -export type TrajectoryKey = 'view.trajectory' +export type TrajectoryKey = + | 'view.trajectory' + | 'toolbar.aria' + | 'toolbar.duration' + | 'toolbar.useActualDuration' + | 'toolbar.useEqualWidth' + | 'toolbar.actualTime' + | 'toolbar.turns' + | 'toolbar.expandTurns' + | 'toolbar.collapseTurns' + | 'toolbar.calls' + | 'toolbar.expandCalls' + | 'toolbar.collapseCalls' + | 'toolbar.export' + | 'toolbar.exportAria' + | 'toolbar.exporting' + | 'toolbar.exportTitle' + | 'toolbar.search' + | 'toolbar.searchPlaceholder' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** The trajectory view tab label. */ + /** The trajectory view tab label and toolbar strings. */ 'trajectory': TrajectoryKey } } @@ -16,9 +34,43 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh: Record = { 'view.trajectory': '轨迹', + 'toolbar.aria': '轨迹工具栏', + 'toolbar.duration': '时长', + 'toolbar.useActualDuration': '使用实际时长', + 'toolbar.useEqualWidth': '使用等宽时长', + 'toolbar.actualTime': '实际时间', + 'toolbar.turns': '轮次', + 'toolbar.expandTurns': '展开轮次', + 'toolbar.collapseTurns': '折叠轮次', + 'toolbar.calls': '调用', + 'toolbar.expandCalls': '展开调用', + 'toolbar.collapseCalls': '折叠调用', + 'toolbar.export': '导出', + 'toolbar.exportAria': '导出会话日志', + 'toolbar.exporting': '导出中…', + 'toolbar.exportTitle': '导出会话日志(ZIP,含子代理)', + 'toolbar.search': '搜索轨迹', + 'toolbar.searchPlaceholder': '搜索', } /** English dictionary. */ export const en: Record = { 'view.trajectory': 'Trajectory', + 'toolbar.aria': 'Trajectory toolbar', + 'toolbar.duration': 'Duration', + 'toolbar.useActualDuration': 'Use actual duration', + 'toolbar.useEqualWidth': 'Use equal-width operations', + 'toolbar.actualTime': 'Actual time', + 'toolbar.turns': 'Turns', + 'toolbar.expandTurns': 'Expand turns', + 'toolbar.collapseTurns': 'Collapse turns', + 'toolbar.calls': 'Calls', + 'toolbar.expandCalls': 'Expand calls', + 'toolbar.collapseCalls': 'Collapse calls', + 'toolbar.export': 'Export', + 'toolbar.exportAria': 'Export session log', + 'toolbar.exporting': 'Exporting…', + 'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)', + 'toolbar.search': 'Search trajectory', + 'toolbar.searchPlaceholder': 'Search', } diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.spec.ts index e693ded89b..ba7f739573 100644 --- a/packages/client/ui-trajectory/tests/export-log.spec.ts +++ b/packages/client/ui-trajectory/tests/export-log.spec.ts @@ -10,11 +10,15 @@ import { sessionLogZipFilename } from '../src/client/export-log.ts' describe('sessionLogZipFilename', () => { it('keeps safe session ids verbatim', () => { - expect(sessionLogZipFilename('session-abc_1.2')).toBe('dsh-session-session-abc_1.2.zip') + expect(sessionLogZipFilename('session-abc_1-2')).toBe('dsh-session-session-abc_1-2.zip') }) it('neutralizes unsafe id characters that could shape the filename', () => { - expect(sessionLogZipFilename('../evil')).toBe('dsh-session-.._evil.zip') + expect(sessionLogZipFilename('../evil')).toBe('dsh-session-___evil.zip') expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip') }) + + it('strips dots so a dot-only id cannot shape a dot segment', () => { + expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip') + }) }) diff --git a/packages/client/ui-trajectory/tests/toolbar.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.spec.tsx index b3e2a4c014..820af51ec7 100644 --- a/packages/client/ui-trajectory/tests/toolbar.spec.tsx +++ b/packages/client/ui-trajectory/tests/toolbar.spec.tsx @@ -3,7 +3,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx' +import { zh, type TrajectoryKey } from '../src/client/locales.ts' + +/** Test translator pinned to the Simplified Chinese dictionary. */ +const zhT = (key: LocaleKeysOf<'trajectory'>): string => zh[key as TrajectoryKey] ?? key afterEach(() => { cleanup() @@ -25,6 +30,7 @@ function baseProps(overrides: Partial = {}): TrajectoryT exporting: false, onExport: vi.fn(), exportError: null, + t: zhT, ...overrides, } } diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 9c52417b61..e76cc0f41b 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -28,6 +28,8 @@ import { import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' +import { zh, type TrajectoryKey } from '../src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' @@ -142,14 +144,18 @@ function emptyWorkspaces() { } /** Standalone view props: the session-scope standard kit the outlet would bake. */ -function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { +function standaloneProps( + nodes: ConversationSnapshot['nodes'], +): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } { return { sessionId: SID, useSession: fakeSession(nodes).useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined) as never, - } as unknown as ConvViewProps + // The locale seat the outlet would inject for the declared namespace. + t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key, + } as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } } /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */ @@ -230,6 +236,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES exportLog: trajectory.exportLog, useHistory: bindSnapshotSelector(trajectory.hooks.history), useDuration: bindSnapshotSelector(trajectory.hooks.duration), + t: (key: TrajectoryKey) => zh[key], } })() : injected @@ -324,12 +331,12 @@ describe('tab switching in ConversationRoot', () => { expect(screen.queryByText(/turns ·/)).toBeNull() expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) expect(screen.queryByRole('columnheader')).toBeNull() - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) + fireEvent.click(screen.getByRole('button', { name: '折叠轮次' })) expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) + fireEvent.click(screen.getByRole('button', { name: '展开轮次' })) expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() await vi.waitFor(() => { @@ -540,13 +547,13 @@ describe('tab switching in ConversationRoot', () => { const b = await bench(historySnapshot([])) mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByText('No timing data')).toBeTruthy() expect(screen.getByRole('button', { - name: 'Collapse turns', + name: '折叠轮次', }).disabled).toBe(false) expect(screen.getByRole('button', { - name: 'Collapse calls', + name: '折叠调用', }).disabled).toBe(false) expect(screen.queryByRole('row')).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull() @@ -1090,7 +1097,7 @@ describe('timeline projection', () => { ...standaloneExport(), }, )) - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.queryByRole('row')).toBeNull() }) }) @@ -1123,7 +1130,6 @@ describe('session log export', () => { expect(fetchMock).toHaveBeenCalledOnce() }) // The blob download lands a few microtasks after the fetch settles. - // The blob download lands a few microtasks after the fetch settles. await vi.waitFor(() => { expect(createObjectURL).toHaveBeenCalled() }) @@ -1159,7 +1165,7 @@ describe('TrajectoryView branches', () => { setActualDuration={(value) => { firstDuration.set(value) }} />, ) - const duration = screen.getByRole('button', { name: 'Use actual duration' }) + const duration = screen.getByRole('button', { name: '使用实际时长' }) expect(duration.getAttribute('aria-pressed')).toBe('false') fireEvent.click(duration) @@ -1175,7 +1181,7 @@ describe('TrajectoryView branches', () => { setActualDuration={(value) => { restoredDuration.set(value) }} />, ) - expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed')) + expect(screen.getByRole('button', { name: '使用实际时长' }).getAttribute('aria-pressed')) .toBe('true') })