fix(session-export): preserve streamed browser downloads

This commit is contained in:
NI0317
2026-08-12 18:44:04 +08:00
parent 8c7dab8755
commit 785f41daed
35 changed files with 226 additions and 136 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/session-export/README.md
README.md: 3df11c1132715dc590d50b53588f8875015f237b
README.zh.md: 008cf433df1104c9f7e45cd04c0e0f256e3682fb
README.md: 4abee799c51d50342421ff594e88b03a11e785fa
README.zh.md: 9b3ab129c132f797bbede8d8676bc8af954c1f90

View File

@@ -11,7 +11,7 @@ Web Session-log download control over the host-streamed ZIP endpoint owned by `d
| `/export` | Record a human-command lifecycle; the submitting browser receives the local execution acknowledgment and downloads `GET /api/session.export?sessionId=<id>&includeDescendants=true`. |
| `/export <path>` | Return an error. Browser downloads choose their destination through the browser's ordinary download behavior. |
The command is mounted only by the Web bundle. The local `command/executed` acknowledgment triggers the slash download only after a successful `/export` result in the browser that submitted it; other tabs still render the durable command row without repeating the browser side effect. The Header button calls the same controller directly, so both entry paths share in-flight collapsing, cancellation on plugin disposal, HTTP error handling, browser save behavior, and the same Modal.
The command is mounted only by the Web bundle. The local `command/executed` acknowledgment triggers the slash download only after a successful `/export` result in the browser that submitted it; other tabs still render the durable command row without repeating the browser side effect. The Header button calls the same controller directly. Both entry paths issue a `HEAD` preflight, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript; they share in-flight collapsing, cancellation of the preflight on plugin disposal, preparation-error handling, browser save behavior, and the same Modal.
The Host download endpoint flushes a live root Session before `readRaw`, so a slash-triggered ZIP includes the `command/run` and `command/done` pair whose acknowledgment started the download. Cold persisted Sessions require no flush.
@@ -46,3 +46,4 @@ None. The log-only command lifecycle and browser download do not change the deri
- The download endpoint requires a persistence backend with a per-Session raw artifact. The shipped JSONL backend supports plaintext and zstd artifacts; SQLite export is not included in this change.
- This is a browser download, not a Host-path writer. The browser chooses the local destination; no Host path or native folder action is returned.
- The preflight reports failures found before ZIP streaming starts. A descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the modal.

View File

@@ -11,7 +11,7 @@ Web Session 日志下载控制,使用 `dsh-host-apiproxy` 拥有的 Host 流
| `/export` | 记录一组用户命令生命周期;提交命令的浏览器收到本地执行确认后,下载 `GET /api/session.export?sessionId=<id>&includeDescendants=true`。 |
| `/export <path>` | 返回错误。浏览器下载通过浏览器的普通下载行为选择目标位置。 |
该命令只由 Web bundle 挂载。只有 `/export` 返回成功时,本地 `command/executed` 确认才会在提交命令的浏览器中触发斜杠下载其他标签页仍会渲染持久命令行但不会重复执行浏览器副作用。Header 按钮直接调用同一个控制器,因此两种入口共用并发折叠、插件释放时取消、HTTP 错误处理、浏览器保存行为和同一个 Modal。
该命令只由 Web bundle 挂载。只有 `/export` 返回成功时,本地 `command/executed` 确认才会在提交命令的浏览器中触发斜杠下载其他标签页仍会渲染持久命令行但不会重复执行浏览器副作用。Header 按钮直接调用同一个控制器两种入口都会先发出 `HEAD` 预检,再把 GET URL 交给浏览器下载管理器JavaScript 不会缓冲 ZIP它们共用并发折叠、插件释放时取消预检、准备阶段错误处理、浏览器保存行为和同一个 Modal。
Host 下载端点会在 `readRaw` 前 flush 活动的根 Session因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run``command/done` 事件对。冷持久化 Session 不需要 flush。
@@ -46,3 +46,4 @@ Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-co
- 下载端点要求持久化后端具有逐 Session 原始工件。随附 JSONL 后端支持明文和 zstd 工件;本次改动不包含 SQLite 导出。
- 这是浏览器下载,不是 Host 路径写入。目标位置由浏览器选择,不会返回 Host 路径或原生文件夹操作。
- 预检只报告 ZIP 开始流式传输前发现的失败。浏览器接受 GET 后发生的子 Session 或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。

View File

@@ -12,7 +12,7 @@ export interface SessionExportDialogInjected {
}
export type SessionExportDialogProps =
PropsRuntime<'conversation.session.header.actions'>
PropsRuntime<'conversation.session.header.utilities'>
& PropsLocale<typeof NS>
& InjectFace<SessionExportDialogInjected>

View File

@@ -1,4 +1,3 @@
/* The 111 px design width is a floor so translated labels do not clip. */
.sessionLogButton {
display: inline-flex;
align-items: center;

View File

@@ -18,7 +18,7 @@ export interface SessionExportDownloadState {
}
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
type Save = (blob: Blob, filename: string) => void
type Save = (url: string, filename: string) => void
const INITIAL: SessionExportDownloadState = { bySession: {} }
@@ -32,17 +32,15 @@ export function sessionLogZipFilename(sessionId: SessionId): string {
}
/**
* Trigger a browser save without copying the response blob.
* @param blob - complete ZIP response body.
* Hand a Host download URL to the browser download manager.
* @param url - same-origin Host download URL.
* @param filename - browser download filename.
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
export function downloadUrl(url: string, filename: string): void {
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
setTimeout(() => { URL.revokeObjectURL(url) }, 0)
}
/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */
@@ -69,7 +67,7 @@ export class SessionExportDownloadController {
*/
constructor(
private readonly fetcher: Fetch = (input, init) => fetch(input, init),
private readonly save: Save = downloadBlob,
private readonly save: Save = downloadUrl,
) {}
/**
@@ -89,15 +87,6 @@ export class SessionExportDownloadController {
return done
}
/**
* Present a command failure without issuing an HTTP request.
* @param sessionId - Session whose modal reports the failure.
* @param error - stable command failure text.
*/
fail(sessionId: SessionId, error: string): void {
this.publish(sessionId, { open: true, status: 'error', error })
}
/**
* Close one Session's dialog without cancelling an in-flight browser download.
* @param sessionId - Session whose modal closes.
@@ -125,12 +114,12 @@ export class SessionExportDownloadController {
const url = new URL('/api/session.export', hostBase())
url.searchParams.set('sessionId', sessionId)
url.searchParams.set('includeDescendants', 'true')
const response = await this.fetcher(url, { signal })
const response = await this.fetcher(url, { method: 'HEAD', signal })
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
}
this.save(await response.blob(), sessionLogZipFilename(sessionId))
this.save(url.toString(), sessionLogZipFilename(sessionId))
const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true
this.publish(sessionId, { open, status: 'success', error: null })
} catch (error: unknown) {

View File

@@ -43,12 +43,10 @@ describe('session-export browser plugin', () => {
expect(entry?.component).toBe(SessionExportHeader)
expect(entry?.options).toMatchObject({ id: 'session-export' })
const injected = (entry?.inject as unknown as () => import('../src/client/Dialog.tsx').SessionExportDialogInjected)()
b.ctx.sessionExport.fail(SID, 'failed')
await injected.request(SID)
expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error')
injected.dismiss(SID)
expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.open).toBe(false)
await injected.request(SID)
expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error')
await b.fiber.dispose()
expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0)

View File

@@ -2,7 +2,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
downloadBlob, SessionExportDownloadController, sessionLogZipFilename,
downloadUrl, SessionExportDownloadController, sessionLogZipFilename,
} from '../src/client/controller.ts'
const SID = 'session-export-controller' as SessionId
@@ -25,9 +25,12 @@ describe('SessionExportDownloadController', () => {
expect(url.pathname).toBe('/api/session.export')
expect(url.searchParams.get('sessionId')).toBe(SID)
expect(url.searchParams.get('includeDescendants')).toBe('true')
expect(init.method).toBe('HEAD')
expect(init.signal).toBeInstanceOf(AbortSignal)
expect(save).toHaveBeenCalledWith(expect.any(Object), 'dsh-session-session-export-controller.zip')
expect((save.mock.calls[0]?.[0] as Blob).size).toBe(3)
expect(save).toHaveBeenCalledWith(
url.toString(),
'dsh-session-session-export-controller.zip',
)
expect(controller.store.getSnapshot().bySession[SID]).toEqual({
open: true, status: 'success', error: null,
})
@@ -50,7 +53,7 @@ describe('SessionExportDownloadController', () => {
controller.dismiss(SID)
})
it('publishes HTTP, transport, and command failures without leaking rejections', async () => {
it('publishes HTTP and transport failures without leaking rejections', async () => {
const http = new SessionExportDownloadController(
async () => new Response('backend unavailable', { status: 500 }), vi.fn(),
)
@@ -65,8 +68,6 @@ describe('SessionExportDownloadController', () => {
await transport.download(SID)
expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('offline')
transport.fail(SID, 'command failed')
expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('command failed')
transport.dismiss('absent' as SessionId)
const emptyDetail = new SessionExportDownloadController(
@@ -102,14 +103,14 @@ describe('SessionExportDownloadController', () => {
vi.stubGlobal('location', { origin: 'null' })
const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip'))
vi.stubGlobal('fetch', fetcher)
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:default')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const controller = new SessionExportDownloadController()
await controller.download(SID)
expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal')
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'HEAD' })
expect(click).toHaveBeenCalledOnce()
})
it('defaults dialog openness when state is externally cleared before settlement', async () => {
@@ -132,19 +133,14 @@ describe('SessionExportDownloadController', () => {
})
describe('browser download helpers', () => {
it('sanitizes the archive filename and revokes the object URL after the click', () => {
vi.useFakeTimers()
const create = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:session')
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
it('sanitizes the archive filename and hands the URL to a download anchor', () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip')
downloadBlob(new Blob(['zip']), 'archive.zip')
expect(create).toHaveBeenCalledOnce()
downloadUrl('http://host/api/session.export?sessionId=a', 'archive.zip')
expect(click).toHaveBeenCalledOnce()
expect(revoke).not.toHaveBeenCalled()
vi.runAllTimers()
expect(revoke).toHaveBeenCalledWith('blob:session')
vi.useRealTimers()
const anchor = click.mock.instances[0] as HTMLAnchorElement
expect(anchor.href).toBe('http://host/api/session.export?sessionId=a')
expect(anchor.download).toBe('archive.zip')
})
})

View File

@@ -33,7 +33,11 @@ afterEach(cleanup)
describe('SessionExportDialog', () => {
it('shows a controller failure and closes it without reading Session history', async () => {
const b = bench()
act(() => { b.controller.fail(SID, 'toolbar failed') })
act(() => {
b.controller.store.set({
bySession: { [SID]: { open: true, status: 'error', error: 'toolbar failed' } },
})
})
const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' })
expect(dialog.textContent).toContain('toolbar failed')
const close = b.view.getAllByRole('button', { name: 'Close' })[0]
@@ -57,7 +61,11 @@ describe('SessionExportDialog', () => {
it('uses fallback copy when a failure has no detail', async () => {
const b = bench()
act(() => { b.controller.fail(SID, '') })
act(() => {
b.controller.store.set({
bySession: { [SID]: { open: true, status: 'error', error: '' } },
})
})
const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' })
expect(dialog.textContent).toContain('Could not start the Session export.')
const close = b.view.getAllByRole('button', { name: 'Close' }).at(-1)

View File

@@ -54,7 +54,8 @@ describe('session-export real Loader composition', () => {
})
await context.loader.await()
const session = context.sessions.create(SessionId('loader-session-export'), { meta: { createdAt: 1 } })
const session = (context.get('sessions') as unknown as SessionStore)
.create(SessionId('loader-session-export'), { meta: { createdAt: 1 } })
const agent = { session, status: 'idle', options: {} } as unknown as Agent
expect(context.commands.list(agent)).toContainEqual({
name: 'export', description: 'Download this Session log as a ZIP archive',

View File

@@ -1,22 +0,0 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"include": [
"src/client",
"src/css-modules.d.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../interaction/commands" },
{ "path": "../../client/locale" },
{ "path": "../../client/runtime" },
{ "path": "../../client/ui-command" },
{ "path": "../../client/ui-conversation" },
{ "path": "../../client/ui-primitives" },
{ "path": "../../client/ui-slots" }
]
}

View File

@@ -1,17 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
},
"files": [
"src/index.ts",
"src/invariant.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../interaction/commands" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -1,7 +1,21 @@
{
"files": [],
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{ "path": "./tsconfig.host.json" },
{ "path": "./tsconfig.client.json" }
{ "path": "../../../vendor/cordis" },
{ "path": "../../interaction/commands" },
{ "path": "../../client/locale" },
{ "path": "../../client/runtime" },
{ "path": "../../client/ui-command" },
{ "path": "../../client/ui-conversation" },
{ "path": "../../client/ui-primitives" },
{ "path": "../../client/ui-slots" },
{ "path": "../../support/invariants" }
]
}