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.
This commit is contained in:
_Kerman
2026-08-10 19:50:20 +08:00
parent beb1d0601f
commit 53d9810461
9 changed files with 130 additions and 54 deletions
@@ -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<typeof NS>
}
/**
@@ -51,17 +55,18 @@ export function TrajectoryToolbar({
exporting,
onExport,
exportError,
t,
}: TrajectoryToolbarProps) {
return (
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
<div className={css.root} role="toolbar" aria-label={t('toolbar.aria')}>
<div className={css.inner}>
<div className={css.actions}>
<button
type="button"
className={css.toggle}
aria-label="Use actual duration"
aria-label={t('toolbar.useActualDuration')}
aria-pressed={actualDuration}
title={actualDuration ? 'Use equal-width operations' : 'Use actual duration'}
title={actualDuration ? t('toolbar.useEqualWidth') : t('toolbar.useActualDuration')}
onClick={() => { onActualDurationChange(!actualDuration) }}
>
<svg
@@ -73,7 +78,7 @@ export function TrajectoryToolbar({
<circle cx="8" cy="8" r="5.25" />
<path d="M8 4.75V8l2.25 1.5" />
</svg>
Duration
{t('toolbar.duration')}
</button>
<button
type="button"
@@ -83,7 +88,7 @@ export function TrajectoryToolbar({
hidden
onClick={() => { onActualTimeChange(!actualTime) }}
>
<span>Actual time</span>
<span>{t('toolbar.actualTime')}</span>
<span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true">
<span className={css.controlThumb} />
</span>
@@ -91,34 +96,34 @@ export function TrajectoryToolbar({
<button
type="button"
className={css.action}
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
aria-label={allTurnsCollapsed ? t('toolbar.expandTurns') : t('toolbar.collapseTurns')}
aria-pressed={allTurnsCollapsed}
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
title={allTurnsCollapsed ? t('toolbar.expandTurns') : t('toolbar.collapseTurns')}
onClick={onToggleAllTurns}
>
<span className={css.actionIcon} aria-hidden="true">
{allTurnsCollapsed ? '⊞' : '⊟'}
</span>
Turns
{t('toolbar.turns')}
</button>
<button
type="button"
className={css.action}
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
aria-label={allAssistantsCollapsed ? t('toolbar.expandCalls') : t('toolbar.collapseCalls')}
aria-pressed={allAssistantsCollapsed}
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
title={allAssistantsCollapsed ? t('toolbar.expandCalls') : t('toolbar.collapseCalls')}
onClick={onToggleAllAssistants}
>
<span className={css.actionIcon} aria-hidden="true">
{allAssistantsCollapsed ? '⊞' : '⊟'}
</span>
Calls
{t('toolbar.calls')}
</button>
<button
type="button"
className={css.export}
aria-label="导出会话日志"
title={exportError ?? (exporting ? '导出中…' : '导出会话日志(ZIP,含子代理)')}
aria-label={t('toolbar.exportAria')}
title={exportError ?? (exporting ? t('toolbar.exporting') : t('toolbar.exportTitle'))}
disabled={exporting}
onClick={onExport}
>
@@ -126,7 +131,7 @@ export function TrajectoryToolbar({
<path d="M8 3v7m0 0 3-3m-3 3L5 7" />
<path d="M3 11.5V13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1.5" />
</svg>
导出
{t('toolbar.export')}
</button>
</div>
<div className={css.search}>
@@ -134,8 +139,8 @@ export function TrajectoryToolbar({
<input
type="search"
className={css.searchInput}
aria-label="Search trajectory"
placeholder="Search"
aria-label={t('toolbar.search')}
placeholder={t('toolbar.searchPlaceholder')}
value={searchQuery}
onChange={(event) => { onSearchQueryChange(event.currentTarget.value) }}
/>
@@ -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<TrajectoryViewInjected>) {
inspect, onInspectDone, t,
}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
@@ -563,6 +563,7 @@ export function TrajectoryView({
exporting={exporting}
onExport={onExport}
exportError={exportError}
t={t}
/>
{exportError !== null && (
<div className={css.exportError} role="alert">
@@ -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<ArrayBuffer>, 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)
}
@@ -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))
},
}
},
@@ -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<TrajectoryKey, string> = {
'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<TrajectoryKey, string> = {
'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',
}