fix(session-export): preserve streamed browser downloads
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user