feat(web): export the session log from the trajectory toolbar

The 导出 button fetches GET /api/session.export and saves the ZIP (root
artifact plus subagent descendants). The plugin exposes exportLog through
the view's inject face, resolves the tab label through the locale service,
and disables the button while in flight; fixture mode answers 404 so the
error bar explains the gap.
This commit is contained in:
_Kerman
2026-08-10 17:47:49 +08:00
parent ded90bffba
commit 9574a99f45
18 changed files with 369 additions and 10 deletions

View File

@@ -61,7 +61,7 @@ describe('tsdown client artifact', () => {
const { handoff, surface } = await loadArtifact()
expect(handoff.id).toBe(PLUGIN_ID)
expect(surface.apply).toBeTypeOf('function')
expect(surface.inject).toEqual(['slots', 'sessionHistory'])
expect(surface.inject).toEqual(['slots', 'sessionHistory', 'locale'])
})
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
@@ -74,8 +74,11 @@ describe('tsdown client artifact', () => {
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
// The plugin reads sessionHistory for its per-session history source;
// slot availability is tracked by slots.inject.
// slot availability is tracked by slots.inject, and the locale plugin
// backs the locale-aware view tab label.
ctx.provide('sessionHistory', {})
const locale = await import('@deepseek-ai/dsh-client-locale/client')
ctx.plugin({ inject: [...locale.inject], apply: locale.apply })
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await()
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])

View File

@@ -0,0 +1,20 @@
// @vitest-environment node
/**
* Session-log export filename derivation. The archive itself is produced and
* streamed by the host (GET /api/session.export); this package only derives
* the download filename and triggers the browser save.
*/
import { describe, expect, it } from 'vitest'
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')
})
it('neutralizes unsafe id characters that could shape the filename', () => {
expect(sessionLogZipFilename('../evil')).toBe('dsh-session-.._evil.zip')
expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip')
})
})

View File

@@ -0,0 +1,55 @@
// @vitest-environment jsdom
/** Trajectory toolbar export button: click dispatch, in-flight disable, and error surfacing. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
function baseProps(overrides: Partial<TrajectoryToolbarProps> = {}): TrajectoryToolbarProps {
return {
actualDuration: false,
onActualDurationChange: vi.fn(),
actualTime: false,
onActualTimeChange: vi.fn(),
allTurnsCollapsed: false,
onToggleAllTurns: vi.fn(),
allAssistantsCollapsed: false,
onToggleAllAssistants: vi.fn(),
searchQuery: '',
onSearchQueryChange: vi.fn(),
exporting: false,
onExport: vi.fn(),
exportError: null,
...overrides,
}
}
describe('TrajectoryToolbar export', () => {
it('renders the export button and dispatches the export callback on click', () => {
const onExport = vi.fn()
render(<TrajectoryToolbar {...baseProps({ onExport })} />)
const button = screen.getByRole('button', { name: '导出会话日志' })
fireEvent.click(button)
expect(onExport).toHaveBeenCalledTimes(1)
})
it('disables the button while an export is in flight and blocks dispatch', () => {
const onExport = vi.fn()
render(<TrajectoryToolbar {...baseProps({ exporting: true, onExport })} />)
const button = screen.getByRole('button', { name: '导出会话日志' }) as HTMLButtonElement
expect(button.disabled).toBe(true)
fireEvent.click(button)
expect(onExport).not.toHaveBeenCalled()
})
it('surfaces an export failure as the button title', () => {
render(<TrajectoryToolbar {...baseProps({ exportError: '导出失败:internal boom' })} />)
const button = screen.getByRole('button', { name: '导出会话日志' })
expect(button.title).toBe('导出失败:internal boom')
})
})

View File

@@ -27,6 +27,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
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 { 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'
@@ -111,6 +112,12 @@ function standaloneDuration(): Pick<
}
}
function standaloneExport(
onExport: () => Promise<void> = vi.fn(() => Promise.resolve()),
): Pick<ComponentProps<typeof TrajectoryView>, 'exportLog'> {
return { exportLog: onExport }
}
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
nodes, pending: [], partial: null,
@@ -168,6 +175,8 @@ async function bench(snapshot = historySnapshot(NODES)) {
slots.register(
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
ctx.provide('sessionHistory', { source: () => history })
// The locale plugin backs the locale-aware view tab label ('locale' in inject).
ctx.plugin({ inject: [...localeInject], apply: localeApply })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory }
@@ -218,6 +227,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
loadHistoryTail: trajectory.loadHistoryTail,
loadOlderHistory: trajectory.loadOlderHistory,
setActualDuration: trajectory.setActualDuration,
exportLog: trajectory.exportLog,
useHistory: bindSnapshotSelector(trajectory.hooks.history),
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
}
@@ -331,6 +341,17 @@ describe('tab switching in ConversationRoot', () => {
expect(signal?.aborted).toBe(true)
})
it('labels the trajectory tab in the active locale', async () => {
const b = await bench()
const labelOf = () => tabsOf(b.slots).find(tab => tab.id === 'trajectory')?.label
expect(labelOf()).toBe('Trajectory')
const locale = b.ctx.get('locale') as { setLocale(id: string): void }
locale.setLocale('zh')
expect(labelOf()).toBe('轨迹')
locale.setLocale('en')
expect(labelOf()).toBe('Trajectory')
})
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
const b = await bench()
mount(b.slots)
@@ -1066,6 +1087,7 @@ describe('timeline projection', () => {
...standaloneProps([]),
...standaloneHistory(historySnapshot([])),
...standaloneDuration(),
...standaloneExport(),
},
))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
@@ -1073,6 +1095,55 @@ describe('timeline projection', () => {
})
})
describe('session log export', () => {
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(URL, 'createObjectURL')
Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click')
})
it('downloads the host-streamed ZIP with descendants on click', async () => {
// exportLog always fetches a URL instance, so the mock's shape stays narrow.
const fetchMock = vi.fn(async (input: URL) => {
expect(input.pathname).toBe('/api/session.export')
expect(input.searchParams.get('sessionId')).toBe(SID)
expect(input.searchParams.get('includeDescendants')).toBe('true')
return new Response('zip-bytes')
})
vi.stubGlobal('fetch', fetchMock)
const createObjectURL = vi.fn(() => 'blob:export')
URL.createObjectURL = createObjectURL
const clickAnchor = vi.fn()
HTMLAnchorElement.prototype.click = clickAnchor
const b = await bench(historySnapshot(NODES))
mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
fireEvent.click(screen.getByRole('button', { name: '导出会话日志' }))
await vi.waitFor(() => {
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()
})
expect(clickAnchor).toHaveBeenCalled()
})
it('surfaces the download failure in the visible alert bar', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 })))
const b = await bench(historySnapshot(NODES))
mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
fireEvent.click(screen.getByRole('button', { name: '导出会话日志' }))
await vi.waitFor(() => {
const alert = screen.queryByRole('alert')
expect(alert).not.toBeNull()
expect(alert!.textContent).toContain('HTTP 404')
})
})
})
describe('TrajectoryView branches', () => {
it('persists the duration preference through the runtime snapshot-store seam', () => {
const firstDuration = createTrajectoryDurationStore()
@@ -1083,6 +1154,7 @@ describe('TrajectoryView branches', () => {
const first = render(
<TrajectoryView
{...commonProps}
{...standaloneExport()}
useDuration={bindSnapshotSelector(firstDuration)}
setActualDuration={(value) => { firstDuration.set(value) }}
/>,
@@ -1098,6 +1170,7 @@ describe('TrajectoryView branches', () => {
render(
<TrajectoryView
{...commonProps}
{...standaloneExport()}
useDuration={bindSnapshotSelector(restoredDuration)}
setActualDuration={(value) => { restoredDuration.set(value) }}
/>,
@@ -1162,6 +1235,7 @@ describe('TrajectoryView branches', () => {
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
{...standaloneExport()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
@@ -1196,6 +1270,7 @@ describe('TrajectoryView branches', () => {
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
{...standaloneExport()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
@@ -1225,6 +1300,7 @@ describe('TrajectoryView branches', () => {
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
{...standaloneExport()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
@@ -1274,6 +1350,7 @@ describe('TrajectoryView branches', () => {
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
{...standaloneExport()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}