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') })