diff --git a/.changeset/web-session-export.md b/.changeset/web-session-export.md new file mode 100644 index 0000000000..b704c67166 --- /dev/null +++ b/.changeset/web-session-export.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +web: Add session diagnostic export to download a session and bounded metadata-only troubleshooting logs as a ZIP. Run `/export` or pick Export session from a session's more menu. Web downloads are limited to 64 MiB. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index b5167633fe..8e2e336348 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -470,6 +470,9 @@ function handleCommand(cmd: string): void { case '/fork': void client.forkSession(); break; + case '/export': + void client.exportSession(); + break; case '/undo': void client.undo(); break; @@ -663,6 +666,7 @@ function openPr(url: string): void { @rename="(id, title) => client.renameSession(id, title)" @archive="(id) => client.archiveSession(id)" @fork="(id) => client.forkSession(id)" + @export="(id) => client.exportSession(id)" @rename-workspace="(id, name) => client.renameWorkspace(id, name)" @delete-workspace="(id) => client.deleteWorkspace(id)" @reorder-workspaces="client.reorderWorkspaces($event)" @@ -767,6 +771,7 @@ function openPr(url: string): void { @rename-session="(id, title) => client.renameSession(id, title)" @fork-session="(id) => client.forkSession(id)" @archive-session="(id) => client.archiveSession(id)" + @export-session="(id) => client.exportSession(id)" @compact="client.compact()" @pick-model="openModelPicker()" @select-model="handleComposerSelectModel($event)" diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 4a9e5e10e0..5ff57e741a 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -3,6 +3,7 @@ import type { KimiApiConfig } from '../config'; import { buildRestUrl, buildWsUrl } from '../config'; +import { traceKeyEvent } from '../../debug/trace'; import type { AppConfig, AppGoal, @@ -87,6 +88,53 @@ import type { } from './wire'; import { DaemonEventSocket } from './ws'; +function safeExportFileName(contentDisposition: string | undefined, fallback: string): string { + if (contentDisposition === undefined) return fallback; + let candidate: string | undefined; + const encoded = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(contentDisposition)?.[1]?.trim(); + if (encoded !== undefined) { + try { + candidate = decodeURIComponent(encoded.replaceAll(/^"|"$/g, '')); + } catch { + return fallback; + } + } else { + candidate = + /filename\s*=\s*"([^"]*)"/i.exec(contentDisposition)?.[1] ?? + /filename\s*=\s*([^;]+)/i.exec(contentDisposition)?.[1]?.trim(); + } + if ( + candidate === undefined || + candidate.length === 0 || + candidate.length > 200 || + candidate === '.' || + candidate === '..' || + /[\u0000-\u001F\u007F/\\]/.test(candidate) || + !candidate.toLowerCase().endsWith('.zip') + ) { + return fallback; + } + return candidate; +} + +function errorTraceMetadata(err: unknown): Record { + if (typeof err !== 'object' || err === null) return { errorName: typeof err }; + const value = err as { + name?: unknown; + code?: unknown; + requestId?: unknown; + phase?: unknown; + status?: unknown; + }; + return { + errorName: typeof value.name === 'string' ? value.name : 'Error', + errorCode: typeof value.code === 'number' ? value.code : undefined, + requestId: typeof value.requestId === 'string' ? value.requestId : undefined, + phase: typeof value.phase === 'string' ? value.phase : undefined, + httpStatus: typeof value.status === 'number' ? value.status : undefined, + }; +} + // --------------------------------------------------------------------------- // Wire response shapes for endpoints not in shared wire.ts // --------------------------------------------------------------------------- @@ -481,36 +529,74 @@ export class DaemonKimiWebApi implements KimiWebApi { * Rebuild flow: getSessionSnapshot() → seedSnapshot() → subscribe(cursor). */ async getSessionSnapshot(sessionId: string): Promise { - const data = await this.http.get( - `/sessions/${encodeURIComponent(sessionId)}/snapshot`, + const startedAt = Date.now(); + traceKeyEvent('session:snapshot:start', { sessionId }); + try { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/snapshot`, + ); + const snapshot: AppSessionSnapshot = { + asOfSeq: data.as_of_seq, + epoch: data.epoch, + session: toAppSession(data.session), + // Snapshot messages are already chronological ascending. + messages: data.messages.items.map(toAppMessage), + hasMoreMessages: data.messages.has_more, + inFlightTurn: + data.in_flight_turn === null + ? null + : { + turnId: data.in_flight_turn.turn_id, + assistantText: data.in_flight_turn.assistant_text, + thinkingText: data.in_flight_turn.thinking_text, + runningTools: data.in_flight_turn.running_tools.map((t) => ({ + toolCallId: t.tool_call_id, + name: t.name, + args: t.args, + description: t.description, + lastProgress: t.last_progress, + })), + promptId: data.in_flight_turn.current_prompt_id, + }, + pendingApprovals: data.pending_approvals.map(toAppApprovalRequest), + pendingQuestions: data.pending_questions.map(toAppQuestionRequest), + // Older servers omit the roster entirely; treat as an empty roster. + subagents: (data.subagents ?? []).map(toAppTask), + }; + traceKeyEvent('session:snapshot:accepted', { + sessionId, + status: snapshot.session.status, + seq: snapshot.asOfSeq, + messageCount: snapshot.messages.length, + durationMs: Date.now() - startedAt, + }); + return snapshot; + } catch (error) { + traceKeyEvent('session:snapshot:failed', { + sessionId, + status: 'failed', + durationMs: Date.now() - startedAt, + ...errorTraceMetadata(error), + }); + throw error; + } + } + + async exportSession( + sessionId: string, + webLog?: string, + ): Promise<{ blob: Blob; fileName: string }> { + const webLogBytes = webLog === undefined ? 0 : new TextEncoder().encode(webLog).byteLength; + const webLogEntries = webLog === undefined || webLog.length === 0 ? 0 : webLog.split('\n').length; + const result = await this.http.postZip( + `/sessions/${encodeURIComponent(sessionId)}/export`, + { web_log: webLog }, + { web_log_bytes: webLogBytes, web_log_entries: webLogEntries }, ); + const fallback = `${sessionId}.zip`; return { - asOfSeq: data.as_of_seq, - epoch: data.epoch, - session: toAppSession(data.session), - // Snapshot messages are already chronological ascending. - messages: data.messages.items.map(toAppMessage), - hasMoreMessages: data.messages.has_more, - inFlightTurn: - data.in_flight_turn === null - ? null - : { - turnId: data.in_flight_turn.turn_id, - assistantText: data.in_flight_turn.assistant_text, - thinkingText: data.in_flight_turn.thinking_text, - runningTools: data.in_flight_turn.running_tools.map((t) => ({ - toolCallId: t.tool_call_id, - name: t.name, - args: t.args, - description: t.description, - lastProgress: t.last_progress, - })), - promptId: data.in_flight_turn.current_prompt_id, - }, - pendingApprovals: data.pending_approvals.map(toAppApprovalRequest), - pendingQuestions: data.pending_questions.map(toAppQuestionRequest), - // Older servers omit the roster entirely; treat as an empty roster. - subagents: (data.subagents ?? []).map(toAppTask), + blob: result.blob, + fileName: safeExportFileName(result.contentDisposition, fallback), }; } @@ -522,15 +608,39 @@ export class DaemonKimiWebApi implements KimiWebApi { sessionId: string, input: PromptSubmission, ): Promise { - const data = await this.http.post( - `/sessions/${encodeURIComponent(sessionId)}/prompts`, - toWirePromptSubmission(input), - ); - return { - promptId: data.prompt_id, - userMessageId: data.user_message_id, - status: data.status, - }; + const startedAt = Date.now(); + traceKeyEvent('prompt:start', { + sessionId, + contentCount: input.content.length, + mediaCount: input.content.filter((part) => + part.type === 'image' || part.type === 'video' || part.type === 'file' + ).length, + }); + try { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts`, + toWirePromptSubmission(input), + ); + traceKeyEvent('prompt:accepted', { + sessionId, + promptId: data.prompt_id, + status: data.status, + durationMs: Date.now() - startedAt, + }); + return { + promptId: data.prompt_id, + userMessageId: data.user_message_id, + status: data.status, + }; + } catch (error) { + traceKeyEvent('prompt:failed', { + sessionId, + status: 'failed', + durationMs: Date.now() - startedAt, + ...errorTraceMetadata(error), + }); + throw error; + } } // POST /sessions/{id}/prompts:steer — steer daemon-queued prompts into the diff --git a/apps/kimi-web/src/api/daemon/http.ts b/apps/kimi-web/src/api/daemon/http.ts index 495f8422b0..140ca1deb3 100644 --- a/apps/kimi-web/src/api/daemon/http.ts +++ b/apps/kimi-web/src/api/daemon/http.ts @@ -12,6 +12,7 @@ import type { WireEnvelope } from './wire'; composer's in-flight flag with them. Generous enough for slow endpoints; streaming runs over the WS, not these REST calls. */ const REQUEST_TIMEOUT_MS = 30_000; +const EXPORT_TIMEOUT_MS = 5 * 60_000; const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; const BODY_PREVIEW_LIMIT = 500; @@ -27,9 +28,9 @@ export interface DaemonHttpClientIdentity { } /** AbortSignal.timeout with a fallback for older environments (jsdom). */ -function timeoutSignal(): AbortSignal | undefined { +function timeoutSignal(timeoutMs = REQUEST_TIMEOUT_MS): AbortSignal | undefined { try { - return AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return AbortSignal.timeout(timeoutMs); } catch { return undefined; } @@ -178,6 +179,161 @@ export class DaemonHttpClient { return this.request('POST', path, body, undefined, opts?.allowCodes); } + /** POST JSON and receive a raw ZIP. The request trace accepts a separate + * metadata-only body so large/sensitive export logs never enter the trace. */ + async postZip( + path: string, + body: unknown, + traceBody: Record, + ): Promise<{ blob: Blob; contentDisposition?: string }> { + const method = 'POST'; + const url = buildRestUrl(this.origin, path); + const requestId = createRequestId(); + const headers: Record = { + 'X-Request-Id': requestId, + 'Content-Type': 'application/json; charset=utf-8', + }; + this.addClientHeaders(headers); + const startedAt = Date.now(); + traceRestRequest({ method, path, url, requestId, body: traceBody }); + + let response: Response; + try { + response = await fetch(url, { + method, + headers, + body: JSON.stringify(body), + signal: timeoutSignal(EXPORT_TIMEOUT_MS), + }); + } catch (error) { + traceRestFailure({ + method, + path, + requestId, + phase: 'fetch', + durationMs: Date.now() - startedAt, + error, + }); + throw new DaemonNetworkError({ + message: `Network error calling ${method} ${path}`, + cause: error, + method, + path, + url, + requestId, + phase: 'fetch', + timeoutMs: EXPORT_TIMEOUT_MS, + timestamp: Date.now(), + durationMs: Date.now() - startedAt, + }); + } + + const contentType = response.headers.get('content-type') ?? undefined; + const mediaType = contentType?.split(';', 1)[0]?.trim().toLowerCase(); + if (!response.ok || mediaType !== 'application/zip') { + let envelope: WireEnvelope | undefined; + try { + envelope = (await response.clone().json()) as WireEnvelope; + } catch { + // A non-JSON response is diagnosed below without consuming the body. + } + this.checkAuthRequired(response, envelope?.code ?? 0); + if (!response.ok || (envelope !== undefined && envelope.code !== 0)) { + const code = envelope?.code ?? response.status; + const msg = envelope?.msg ?? response.statusText; + traceRestResponse({ + method, + path, + requestId, + status: response.status, + durationMs: Date.now() - startedAt, + code, + msg, + envelopeRequestId: envelope?.request_id, + }); + throw new DaemonApiError({ + code, + msg, + requestId: envelope?.request_id ?? requestId, + details: envelope?.details, + timestamp: Date.now(), + durationMs: Date.now() - startedAt, + }); + } + + const diagnosticResponse = response.clone(); + const error = new TypeError(`Expected application/zip, received ${contentType ?? 'no content type'}`); + traceRestFailure({ + method, + path, + requestId, + phase: 'parse', + durationMs: Date.now() - startedAt, + status: response.status, + error, + }); + throw new DaemonNetworkError({ + message: `Invalid ZIP response from ${method} ${path}`, + cause: error, + method, + path, + url, + requestId, + phase: 'parse', + timeoutMs: EXPORT_TIMEOUT_MS, + status: response.status, + statusText: response.statusText, + contentType, + bodyPreview: await readResponsePreview(diagnosticResponse), + timestamp: Date.now(), + durationMs: Date.now() - startedAt, + }); + } + + let blob: Blob; + try { + blob = await response.blob(); + } catch (error) { + traceRestFailure({ + method, + path, + requestId, + phase: 'parse', + durationMs: Date.now() - startedAt, + status: response.status, + error, + }); + throw new DaemonNetworkError({ + message: `Failed to read ZIP response from ${method} ${path}`, + cause: error, + method, + path, + url, + requestId, + phase: 'parse', + timeoutMs: EXPORT_TIMEOUT_MS, + status: response.status, + statusText: response.statusText, + contentType, + timestamp: Date.now(), + durationMs: Date.now() - startedAt, + }); + } + traceRestResponse({ + method, + path, + requestId, + status: response.status, + durationMs: Date.now() - startedAt, + code: 0, + msg: '', + }); + return { + blob, + contentDisposition: response.headers.get('content-disposition') ?? undefined, + }; + } + /** Send multipart/form-data (FormData). Does NOT set Content-Type — browser sets it with boundary. */ async postForm(path: string, formData: FormData): Promise { const url = buildRestUrl(this.origin, path); diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index def845bca2..cbcde2d07b 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -674,6 +674,8 @@ export interface KimiWebApi { listMessages(sessionId: string, input?: PageRequest & { role?: AppMessageRole }): Promise>; /** v2 initial sync: atomic session state + `asOfSeq` watermark + epoch. */ getSessionSnapshot(sessionId: string): Promise; + /** Export the session archive, optionally including the bounded Web JSONL log. */ + exportSession(sessionId: string, webLog?: string): Promise<{ blob: Blob; fileName: string }>; submitPrompt(sessionId: string, input: PromptSubmission): Promise; /** Steer daemon-queued prompts into the active turn (TUI ctrl+s). */ steerPrompts(sessionId: string, promptIds: string[]): Promise<{ steered: boolean; promptIds: string[] }>; diff --git a/apps/kimi-web/src/components/SessionRow.vue b/apps/kimi-web/src/components/SessionRow.vue index 30158be01c..bc3b55cf3d 100644 --- a/apps/kimi-web/src/components/SessionRow.vue +++ b/apps/kimi-web/src/components/SessionRow.vue @@ -37,6 +37,7 @@ const emit = defineEmits<{ rename: [id: string, title: string]; archive: [id: string]; fork: [id: string]; + export: [id: string]; }>(); // Full, absolute timestamp shown on hover (the row's `time` is a short relative @@ -161,6 +162,12 @@ function forkRow(): void { emit('fork', props.session.id); } +// Export this session as a ZIP +function exportRow(): void { + closeMenu(); + emit('export', props.session.id); +} + // Archive confirm — modal, consistent with remove-workspace. async function startArchive(): Promise { closeMenu(); @@ -264,6 +271,7 @@ defineExpose({ closeMenu }); + {{ copyFailed ? t('sidebar.copyFailed') @@ -273,9 +281,22 @@ defineExpose({ closeMenu }); }} - {{ t('sidebar.rename') }} - {{ t('sidebar.fork') }} - {{ t('sidebar.archive') }} + + + {{ t('sidebar.rename') }} + + + + {{ t('sidebar.fork') }} + + + + {{ t('sidebar.export') }} + + + + {{ t('sidebar.archive') }} + diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index ffd07ed102..b7e6089abf 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -111,6 +111,7 @@ const emit = defineEmits<{ rename: [id: string, title: string]; archive: [id: string]; fork: [id: string]; + export: [id: string]; renameWorkspace: [id: string, name: string]; deleteWorkspace: [id: string]; reorderWorkspaces: [ids: string[]]; @@ -792,6 +793,7 @@ onBeforeUnmount(() => { @rename-session="(id, title) => emit('rename', id, title)" @archive-session="(id) => emit('archive', id)" @fork-session="(id) => emit('fork', id)" + @export-session="(id) => emit('export', id)" @load-more="onLoadMore" @toggle-expand="toggleExpand" @confirm-rename="confirmRenameWorkspace" diff --git a/apps/kimi-web/src/components/WorkspaceGroup.vue b/apps/kimi-web/src/components/WorkspaceGroup.vue index 4fd870a791..7692fa4c41 100644 --- a/apps/kimi-web/src/components/WorkspaceGroup.vue +++ b/apps/kimi-web/src/components/WorkspaceGroup.vue @@ -42,6 +42,7 @@ const emit = defineEmits<{ renameSession: [id: string, title: string]; archiveSession: [id: string]; forkSession: [id: string]; + exportSession: [id: string]; loadMore: [workspaceId: string]; toggleExpand: [workspaceId: string]; confirmRename: []; @@ -183,6 +184,7 @@ function onHeaderDragStart(event: DragEvent): void { @rename="(id, title) => emit('renameSession', id, title)" @archive="emit('archiveSession', $event)" @fork="emit('forkSession', $event)" + @export="emit('exportSession', $event)" />