Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-session-export.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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)"
Expand Down
184 changes: 147 additions & 37 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import type { KimiApiConfig } from '../config';
import { buildRestUrl, buildWsUrl } from '../config';
import { traceKeyEvent } from '../../debug/trace';
import type {
AppConfig,
AppGoal,
Expand Down Expand Up @@ -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<string, string | number | undefined> {
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -481,36 +529,74 @@ export class DaemonKimiWebApi implements KimiWebApi {
* Rebuild flow: getSessionSnapshot() → seedSnapshot() → subscribe(cursor).
*/
async getSessionSnapshot(sessionId: string): Promise<AppSessionSnapshot> {
const data = await this.http.get<WireSessionSnapshot>(
`/sessions/${encodeURIComponent(sessionId)}/snapshot`,
const startedAt = Date.now();
traceKeyEvent('session:snapshot:start', { sessionId });
try {
const data = await this.http.get<WireSessionSnapshot>(
`/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),
};
}

Expand All @@ -522,15 +608,39 @@ export class DaemonKimiWebApi implements KimiWebApi {
sessionId: string,
input: PromptSubmission,
): Promise<PromptSubmitResult> {
const data = await this.http.post<WirePromptSubmitResult>(
`/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<WirePromptSubmitResult>(
`/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
Expand Down
Loading
Loading