feat(session-export): add command and Header action
This commit is contained in:
114 files changed
+1533
-368
No files matched your search
@@ -0,0 +1,49 @@
|
||||
import type { ObservableSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionExportDownloadState } from './controller.ts'
|
||||
import { NS } from './locales.ts'
|
||||
|
||||
/** Browser operations and state injected into the Session Header contribution. */
|
||||
export interface SessionExportDialogInjected {
|
||||
hooks: { sessionExport: ObservableSnapshot<SessionExportDownloadState> }
|
||||
request: (sessionId: SessionId) => Promise<void>
|
||||
dismiss: (sessionId: SessionId) => void
|
||||
}
|
||||
|
||||
export type SessionExportDialogProps =
|
||||
PropsRuntime<'conversation.session.header.actions'>
|
||||
& PropsLocale<typeof NS>
|
||||
& InjectFace<SessionExportDialogInjected>
|
||||
|
||||
/**
|
||||
* Modal shared by the Session Header button and this browser's `/export` command.
|
||||
* @param props - Session runtime, bound controller state, actions, and localized copy.
|
||||
* @returns the modal portal contribution.
|
||||
*/
|
||||
export function SessionExportDialog({
|
||||
sessionId, useSessionExport, dismiss, t,
|
||||
}: SessionExportDialogProps) {
|
||||
const entry = useSessionExport(state => state.bySession[String(sessionId)])
|
||||
|
||||
const status = entry?.status
|
||||
const open = entry?.open === true
|
||||
const error = status === 'error' ? entry?.error || t('dialog.commandFailed') : null
|
||||
const title = status === 'downloading'
|
||||
? t('dialog.preparingTitle')
|
||||
: status === 'success' ? t('dialog.successTitle') : t('dialog.errorTitle')
|
||||
const description = status === 'downloading'
|
||||
? t('dialog.preparingDescription')
|
||||
: status === 'success' ? t('dialog.successDescription') : error ?? t('dialog.commandFailed')
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => { dismiss(sessionId) }}
|
||||
title={title}
|
||||
description={description}
|
||||
closeLabel={t('dialog.close')}
|
||||
footer={<Button variant="primary" onClick={() => { dismiss(sessionId) }}>{t('dialog.close')}</Button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* The 111 px design width is a floor so translated labels do not clip. */
|
||||
.sessionLogButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 111px;
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 18px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: transparent;
|
||||
font-family: var(--dsw-font-family);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sessionLogButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.sessionLogButton:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.sessionLogButton span,
|
||||
.sessionLogButton svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sessionLogButton span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { SessionExportDialog, type SessionExportDialogProps } from './Dialog.tsx'
|
||||
import css from './HeaderAction.module.css'
|
||||
|
||||
/**
|
||||
* Render the Session Header export capsule and its shared result dialog.
|
||||
* @param props - Session runtime, download controller, and localized dialog copy.
|
||||
* @returns the persistent Header action and Session-scoped dialog.
|
||||
*/
|
||||
export function SessionExportHeader(props: SessionExportDialogProps): ReactNode {
|
||||
const { sessionId, useSessionExport, request } = props
|
||||
const entry = useSessionExport(state => state.bySession[String(sessionId)])
|
||||
const busy = entry?.status === 'downloading'
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.sessionLogButton}
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
onClick={() => { void request(sessionId) }}
|
||||
>
|
||||
<span>Session log</span>
|
||||
<IconDownloadOutline16 size={12} />
|
||||
</button>
|
||||
<SessionExportDialog {...props} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/** Browser download state shared by the Session Header button and `/export`. */
|
||||
|
||||
import { createSnapshotStore, type SessionId, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Download phases presented by the shared modal. */
|
||||
export type SessionExportDownloadStatus = 'downloading' | 'success' | 'error'
|
||||
|
||||
/** One Session's current download-dialog state. */
|
||||
export interface SessionExportDownloadEntry {
|
||||
readonly open: boolean
|
||||
readonly status: SessionExportDownloadStatus
|
||||
readonly error: string | null
|
||||
}
|
||||
|
||||
/** Download states keyed by the Session whose Header owns the dialog. */
|
||||
export interface SessionExportDownloadState {
|
||||
bySession: Record<string, SessionExportDownloadEntry | undefined>
|
||||
}
|
||||
|
||||
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
|
||||
type Save = (blob: Blob, filename: string) => void
|
||||
|
||||
const INITIAL: SessionExportDownloadState = { bySession: {} }
|
||||
|
||||
/**
|
||||
* Collapse an untrusted Session id into the filename convention owned by the host endpoint.
|
||||
* @param sessionId - Session whose archive is downloaded.
|
||||
* @returns one safe browser download filename.
|
||||
*/
|
||||
export function sessionLogZipFilename(sessionId: SessionId): string {
|
||||
return `dsh-session-${String(sessionId).replace(/[^A-Za-z0-9_-]/g, '_')}.zip`
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser save without copying the response blob.
|
||||
* @param blob - complete ZIP response body.
|
||||
* @param filename - browser download filename.
|
||||
*/
|
||||
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()
|
||||
setTimeout(() => { URL.revokeObjectURL(url) }, 0)
|
||||
}
|
||||
|
||||
/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */
|
||||
function hostBase(): string {
|
||||
const origin = (globalThis as { location?: { origin?: string } }).location?.origin
|
||||
return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal'
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** Owns one in-flight browser download per Session and publishes modal state. */
|
||||
export class SessionExportDownloadController {
|
||||
/** uSES-safe state source shared by every Session-scoped modal contribution. */
|
||||
readonly store: SnapshotStore<SessionExportDownloadState> = createSnapshotStore(INITIAL)
|
||||
|
||||
private readonly active = new Map<SessionId, { readonly abort: AbortController; readonly done: Promise<void> }>()
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param fetcher - HTTP carrier used to read the host-streamed ZIP.
|
||||
* @param save - browser save operation.
|
||||
*/
|
||||
constructor(
|
||||
private readonly fetcher: Fetch = (input, init) => fetch(input, init),
|
||||
private readonly save: Save = downloadBlob,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Download one Session tree; concurrent gestures for the same Session share one operation.
|
||||
* @param sessionId - root Session whose ZIP includes descendants and attachments.
|
||||
* @returns after the browser save starts, an error state is published, or a late post-disposal request is ignored.
|
||||
*/
|
||||
download(sessionId: SessionId): Promise<void> {
|
||||
const existing = this.active.get(sessionId)
|
||||
if (existing !== undefined) return existing.done
|
||||
if (this.disposed) return Promise.resolve()
|
||||
const abort = new AbortController()
|
||||
const done = this.run(sessionId, abort.signal).finally(() => {
|
||||
this.active.delete(sessionId)
|
||||
})
|
||||
this.active.set(sessionId, { abort, done })
|
||||
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.
|
||||
*/
|
||||
dismiss(sessionId: SessionId): void {
|
||||
const current = this.store.getSnapshot().bySession[String(sessionId)]
|
||||
if (current === undefined || !current.open) return
|
||||
this.publish(sessionId, { ...current, open: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort active fetches and reach quiescence.
|
||||
* @returns after every active operation settles.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
const active = [...this.active.values()]
|
||||
for (const operation of active) operation.abort.abort()
|
||||
await Promise.allSettled(active.map(operation => operation.done))
|
||||
}
|
||||
|
||||
private async run(sessionId: SessionId, signal: AbortSignal): Promise<void> {
|
||||
this.publish(sessionId, { open: true, status: 'downloading', error: null })
|
||||
try {
|
||||
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 })
|
||||
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))
|
||||
const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true
|
||||
this.publish(sessionId, { open, status: 'success', error: null })
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) return
|
||||
const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true
|
||||
this.publish(sessionId, { open, status: 'error', error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
private publish(sessionId: SessionId, entry: SessionExportDownloadEntry): void {
|
||||
this.store.update((state) => {
|
||||
state.bySession = { ...state.bySession, [String(sessionId)]: entry }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Browser plugin owning Session export download state and its shared modal. */
|
||||
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SessionExportDownloadController } from './controller.ts'
|
||||
import type { SessionExportDialogInjected } from './Dialog.tsx'
|
||||
import { SessionExportHeader } from './HeaderAction.tsx'
|
||||
import { en, NS, zh, type SessionExportKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
sessionExport: SessionExportDownloadController
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
'session-export': SessionExportKey
|
||||
}
|
||||
}
|
||||
|
||||
export type { SessionExportDownloadEntry, SessionExportDownloadState } from './controller.ts'
|
||||
|
||||
export const inject = ['slots', 'locale']
|
||||
|
||||
/**
|
||||
* Provide the download controller and mount its modal into the Session Header.
|
||||
* @param ctx - browser context carrying slots and locale services.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const controller = new SessionExportDownloadController()
|
||||
ctx.provide('sessionExport', controller)
|
||||
ctx.effect(() => async () => { await controller.dispose() }, 'session-export: browser download lifecycle')
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'session-export: browser dictionaries')
|
||||
ctx.on('command/executed', (sessionId, commandName, result) => {
|
||||
if (commandName === 'export' && result.kind === 'success') void controller.download(sessionId)
|
||||
})
|
||||
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
||||
name: 'conversation.session.header.utilities',
|
||||
id: 'session-export',
|
||||
locale: NS,
|
||||
inject: (): SessionExportDialogInjected => ({
|
||||
hooks: { sessionExport: controller.store },
|
||||
request: (sessionId: SessionId) => controller.download(sessionId),
|
||||
dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) },
|
||||
}),
|
||||
}, SessionExportHeader))
|
||||
}
|
||||
|
||||
export type { SessionExportDialogInjected, SessionExportDialogProps } from './Dialog.tsx'
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Locale namespace owned by Session export browser feedback. */
|
||||
export const NS = 'session-export'
|
||||
|
||||
/** Simplified-Chinese Session export strings. */
|
||||
export const zh = {
|
||||
'dialog.preparingTitle': '正在导出 Session',
|
||||
'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。',
|
||||
'dialog.successTitle': 'Session 导出已开始下载',
|
||||
'dialog.successDescription': '浏览器正在下载 Session ZIP 文件。',
|
||||
'dialog.errorTitle': 'Session 导出失败',
|
||||
'dialog.close': '关闭',
|
||||
'dialog.commandFailed': '无法启动 Session 导出。',
|
||||
} as const
|
||||
|
||||
/** English Session export strings. */
|
||||
export const en: Record<keyof typeof zh, string> = {
|
||||
'dialog.preparingTitle': 'Exporting Session',
|
||||
'dialog.preparingDescription': 'Preparing a ZIP containing this Session, its sub-Sessions, and attachments.',
|
||||
'dialog.successTitle': 'Session download started',
|
||||
'dialog.successDescription': 'The browser is downloading the Session ZIP.',
|
||||
'dialog.errorTitle': 'Session export failed',
|
||||
'dialog.close': 'Close',
|
||||
'dialog.commandFailed': 'Could not start the Session export.',
|
||||
}
|
||||
|
||||
/** Stable locale keys consumed by the shared modal. */
|
||||
export type SessionExportKey = keyof typeof zh
|
||||
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Web Session-log download command over the host endpoint owned by ApiProxy. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
|
||||
export const name = 'session-export'
|
||||
export const inject = ['commands']
|
||||
|
||||
const REQUESTED: CommandResult = {
|
||||
kind: 'success',
|
||||
text: 'Session log download requested.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Web-only `/export` command that the browser download plugin observes.
|
||||
* @param ctx - Host context carrying the human-command registry.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.commands.register({
|
||||
name: 'export',
|
||||
description: 'Download this Session log as a ZIP archive',
|
||||
handler: invocation => Promise.resolve(invocation.rawInput.trim() === ''
|
||||
? REQUESTED
|
||||
: { kind: 'error', text: 'The Web /export command does not accept a path.' }),
|
||||
}), 'session-export: command')
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Package invariant companion for `@deepseek-ai/dsh-session-export`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-export'
|
||||
|
||||
export const name = 'session-export-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: the command registry owns lifecycle pairing and ApiProxy owns ZIP integrity. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Host context carrying the invariant registry.
|
||||
* @returns the registration disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user