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:
@@ -164,6 +164,46 @@
|
||||
font: 14px/14px var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.export {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.export:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.export:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.export:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.exportIcon {
|
||||
flex: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.25;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex: 0 1 164px;
|
||||
|
||||
@@ -24,6 +24,12 @@ export interface TrajectoryToolbarProps {
|
||||
searchQuery: string
|
||||
/** Update the live ledger search query. */
|
||||
onSearchQueryChange: (query: string) => void
|
||||
/** Whether the session-log export is in flight. */
|
||||
exporting: boolean
|
||||
/** Trigger the session-log export download. */
|
||||
onExport: () => void
|
||||
/** Export failure message, shown while set; null while idle or successful. */
|
||||
exportError: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,6 +48,9 @@ export function TrajectoryToolbar({
|
||||
onToggleAllAssistants,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
exporting,
|
||||
onExport,
|
||||
exportError,
|
||||
}: TrajectoryToolbarProps) {
|
||||
return (
|
||||
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
|
||||
@@ -105,6 +114,20 @@ export function TrajectoryToolbar({
|
||||
</span>
|
||||
Calls
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.export}
|
||||
aria-label="导出会话日志"
|
||||
title={exportError ?? (exporting ? '导出中…' : '导出会话日志(ZIP,含子代理)')}
|
||||
disabled={exporting}
|
||||
onClick={onExport}
|
||||
>
|
||||
<svg className={css.exportIcon} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<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>
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.search}>
|
||||
<IconSearchOutline16 size={11} className={css.searchIcon} />
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface TrajectoryViewInjected {
|
||||
loadHistoryTail: (signal: AbortSignal) => Promise<void>
|
||||
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
|
||||
setActualDuration: (actualDuration: boolean) => void
|
||||
/** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */
|
||||
exportLog: () => Promise<void>
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
@@ -184,7 +186,7 @@ function mergeSearchMatches(
|
||||
}
|
||||
|
||||
export function TrajectoryView({
|
||||
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration,
|
||||
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, exportLog,
|
||||
inspect, onInspectDone,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
|
||||
@@ -197,6 +199,8 @@ export function TrajectoryView({
|
||||
const actualDuration = useDuration(value => value)
|
||||
const [actualTime, setActualTime] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
|
||||
readonly index: number
|
||||
@@ -524,6 +528,19 @@ export function TrajectoryView({
|
||||
: Promise.resolve(false)
|
||||
}, [loadOlderHistory])
|
||||
|
||||
const onExport = useCallback(() => {
|
||||
if (exporting) return
|
||||
setExporting(true)
|
||||
setExportError(null)
|
||||
void exportLog().then(
|
||||
() => { setExporting(false) },
|
||||
(error: unknown) => {
|
||||
setExportError(error instanceof Error ? error.message : String(error))
|
||||
setExporting(false)
|
||||
},
|
||||
)
|
||||
}, [exportLog, exporting])
|
||||
|
||||
return (
|
||||
<div className={css.root} data-conversation-composer-overlay="">
|
||||
<TrajectoryToolbar
|
||||
@@ -543,7 +560,15 @@ export function TrajectoryView({
|
||||
onToggleAllAssistants={toggleAllAssistants}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
exporting={exporting}
|
||||
onExport={onExport}
|
||||
exportError={exportError}
|
||||
/>
|
||||
{exportError !== null && (
|
||||
<div className={css.exportError} role="alert">
|
||||
{exportError}
|
||||
</div>
|
||||
)}
|
||||
<TrajectoryTimeline
|
||||
turns={timelineTurns}
|
||||
mode={timelineMode}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Session log export: browser download of the host-streamed ZIP. The archive
|
||||
* itself is produced and streamed by the host (GET /api/session.export); this
|
||||
* module only derives the download filename and triggers the browser save.
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collapse an untrusted session id into one safe path/filename segment.
|
||||
* @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, '_')
|
||||
}
|
||||
|
||||
/**
|
||||
* The export archive filename for one session (same convention the host's
|
||||
* Content-Disposition uses).
|
||||
* @param sessionId - the root session id.
|
||||
* @returns the download filename.
|
||||
*/
|
||||
export function sessionLogZipFilename(sessionId: string): string {
|
||||
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of raw bytes.
|
||||
* @param bytes - the file content.
|
||||
* @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 }))
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -4,14 +4,18 @@
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
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'
|
||||
|
||||
/** Required services: the conversation view slot and independent history source. */
|
||||
export const inject = ['slots', 'sessionHistory']
|
||||
/** Required services: the conversation view slot, the independent history source, and the locale service. */
|
||||
export const inject = ['slots', 'sessionHistory', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
@@ -19,12 +23,17 @@ export const inject = ['slots', 'sessionHistory']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-trajectory: dictionaries')
|
||||
// Registration-time text (the view tab label) reads through the bound
|
||||
// translate as a thunk, so it follows the active locale without
|
||||
// re-registration.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const duration = createTrajectoryDurationStore()
|
||||
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'trajectory',
|
||||
order: 10,
|
||||
label: 'Trajectory',
|
||||
label: () => t('view.trajectory'),
|
||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||
const history = ctx.sessionHistory.source(sessionId)
|
||||
return {
|
||||
@@ -32,6 +41,24 @@ export function apply(ctx: Context): void {
|
||||
loadHistoryTail: signal => history.loadTail(signal),
|
||||
loadOlderHistory: signal => history.loadOlder(signal),
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
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)
|
||||
url.searchParams.set('sessionId', sessionId)
|
||||
url.searchParams.set('includeDescendants', 'true')
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
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',
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
}, TrajectoryView))
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** `trajectory` namespace dictionaries (the view tab label). */
|
||||
|
||||
/** 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'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The trajectory view tab label. */
|
||||
'trajectory': TrajectoryKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh: Record<TrajectoryKey, string> = {
|
||||
'view.trajectory': '轨迹',
|
||||
}
|
||||
|
||||
/** English dictionary. */
|
||||
export const en: Record<TrajectoryKey, string> = {
|
||||
'view.trajectory': 'Trajectory',
|
||||
}
|
||||
@@ -13,6 +13,18 @@
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.exportError {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
color: var(--dsw-alias-label-danger, var(--dsw-alias-label-primary));
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ledger {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
|
||||
Reference in New Issue
Block a user