| 1 | import { app } from "./bridge"; |
| 2 | import { onSessionExportProgress } from "./sessionExportBridge"; |
| 3 | import type { SessionExportHandle, SessionSelector } from "../generated/desktopContract.generated"; |
| 4 | import { t } from "./i18n"; |
| 5 | import { getTranscriptStore } from "./transcriptStore"; |
| 6 | import { sessionObservationDiagnostics } from "./sessionObservationDiagnostics"; |
| 7 | import { sessionPipelineDiagnostics } from "./sessionDiagnostics"; |
| 8 | |
| 9 | import { cancelled, publish, remove } from "./sessionExportProgress"; |
| 10 | function assertActive(id: string) { if (cancelled.has(id)) throw new DOMException("Export cancelled", "AbortError"); } |
| 11 | |
| 12 | export async function* exportChunks(handle: SessionExportHandle): AsyncGenerator<string> { |
| 13 | let offset = 0; |
| 14 | const decoder = new TextDecoder("utf-8", { fatal: true }); |
| 15 | for (;;) { |
| 16 | assertActive(handle.exportId); |
| 17 | const chunk = await app.ReadSessionExportChunk(handle.exportId, offset); |
| 18 | const bytes = Uint8Array.from(atob(chunk.data), value => value.charCodeAt(0)); |
| 19 | yield decoder.decode(bytes, { stream: !chunk.done }); |
| 20 | if (chunk.done) return; |
| 21 | if (chunk.nextOffset <= offset) throw new Error("Export cursor did not advance"); |
| 22 | offset = chunk.nextOffset; |
| 23 | } |
| 24 | } |
| 25 | export async function* exportBlocks(handle: SessionExportHandle): AsyncGenerator<{ kind: string; text: string; label?: string }> { |
| 26 | let pending = ""; |
| 27 | for await (const chunk of exportChunks(handle)) { |
| 28 | pending += chunk; |
| 29 | for (;;) { |
| 30 | const end = pending.indexOf("\n"); if (end < 0) break; |
| 31 | const line = pending.slice(0, end); pending = pending.slice(end + 1); |
| 32 | if (line) yield JSON.parse(line); |
| 33 | } |
| 34 | } |
| 35 | if (pending.trim()) throw new Error("Incomplete export block stream"); |
| 36 | } |
| 37 | |
| 38 | export async function runSessionExport(input: { |
| 39 | selector: SessionSelector; tabId: string; format: string; title: string; remote: boolean; |
| 40 | residentItems: number; runningStream: boolean; unresolvedTools: number; |
| 41 | }): Promise<{ text?: string; files: number; cancelled: boolean }> { |
| 42 | let handle: SessionExportHandle | undefined; |
| 43 | const off = onSessionExportProgress(value => { if (handle?.exportId === value.exportId && !cancelled.has(value.exportId)) publish(value); }); |
| 44 | try { |
| 45 | const route = input.selector.ref ? `session-id:${input.selector.ref.sessionId}` : input.selector.sessionPath ?? ""; |
| 46 | const observation = JSON.stringify({ binding: getTranscriptStore().exportObservation(input.tabId, route), lifecycleDiagnostics: sessionObservationDiagnostics(route, input.tabId), readDiagnostics: sessionPipelineDiagnostics(), capturedAt: new Date().toISOString(), tabId: input.tabId, remote: input.remote, residentItems: input.residentItems, runningStream: input.runningStream, unresolvedTools: input.unresolvedTools }); |
| 47 | handle = await app.BeginSessionExportForTarget(input.selector, input.tabId, input.format, input.title, observation); |
| 48 | if (!handle.exportId) return { files: 0, cancelled: true }; |
| 49 | publish({ exportId: handle.exportId, title: input.title, phase: "preparing", records: 0, pages: 0 }); |
| 50 | let text: string | undefined; |
| 51 | if (input.format === "clipboard") { |
| 52 | const parts: string[] = []; |
| 53 | for await (const part of exportChunks(handle)) parts.push(part); |
| 54 | try { text = parts.join(""); } catch { throw new Error(t("topicBar.exportClipboardTooLarge")); } |
| 55 | } else if (input.format === "pdf" || input.format === "image") { |
| 56 | const { renderSessionExportPages, blobToBase64 } = await import("./sessionExport"); |
| 57 | let index = 0; |
| 58 | for await (const page of renderSessionExportPages(exportBlocks(handle), input.format)) { |
| 59 | assertActive(handle.exportId); |
| 60 | // Bound each RPC payload independently of the encoded page size. |
| 61 | for (let offset = 0; offset < page.blob.size; offset += 1 << 20) { |
| 62 | const end = Math.min(offset + (1 << 20), page.blob.size); |
| 63 | await app.AppendSessionExportPage(handle.exportId, { index, offset, data: await blobToBase64(page.blob.slice(offset, end)), done: end === page.blob.size, width: page.width, height: page.height }); |
| 64 | } |
| 65 | index++; |
| 66 | } |
| 67 | } |
| 68 | assertActive(handle.exportId); |
| 69 | const result = await app.FinishSessionExport(handle.exportId); |
| 70 | return { text, files: (result.paths ?? []).length, cancelled: false }; |
| 71 | } catch (error) { |
| 72 | if (handle?.exportId) await app.CancelSessionExport(handle.exportId).catch(() => {}); |
| 73 | if (handle && cancelled.has(handle.exportId)) return { files: 0, cancelled: true }; |
| 74 | throw error; |
| 75 | } finally { |
| 76 | off(); if (handle?.exportId) { remove(handle.exportId); cancelled.delete(handle.exportId); } |
| 77 | } |
| 78 | } |
| 79 |