| 1 | import type { AppBindings } from "./bridge"; |
| 2 | import type { StructuredInvocationSubmit } from "./invocationDisplay"; |
| 3 | import type { ComposerTarget } from "../generated/desktopContract.generated"; |
| 4 | |
| 5 | const pendingImageSubmissions = new Map<string, { fingerprint: string; id: string }>(); |
| 6 | |
| 7 | export function imageSubmissionIdentity(draft: string, fingerprint: string): string { |
| 8 | const previous = pendingImageSubmissions.get(draft); |
| 9 | if (previous?.fingerprint === fingerprint) return previous.id; |
| 10 | const id = `image-${crypto.randomUUID()}`; |
| 11 | pendingImageSubmissions.set(draft, { fingerprint, id }); |
| 12 | return id; |
| 13 | } |
| 14 | |
| 15 | export function settleImageSubmission(draft: string, id?: string): void { |
| 16 | if (pendingImageSubmissions.get(draft)?.id === id) pendingImageSubmissions.delete(draft); |
| 17 | } |
| 18 | |
| 19 | export async function prepareImageSubmission( |
| 20 | app: AppBindings, |
| 21 | target: ComposerTarget, |
| 22 | draftKey: string, |
| 23 | fingerprint: string, |
| 24 | attachments: Array<{ draftId?: string; clientAttachmentId?: string }>, |
| 25 | structured: StructuredInvocationSubmit | undefined, |
| 26 | display: string, |
| 27 | input: string, |
| 28 | ) { |
| 29 | const token = await captureImageTarget(app, target); |
| 30 | const submissionId = imageSubmissionIdentity(draftKey, fingerprint); |
| 31 | return { |
| 32 | token, |
| 33 | submissionId, |
| 34 | structured: { |
| 35 | display: structured?.display ?? display, |
| 36 | input: structured?.input ?? input, |
| 37 | invocations: structured?.invocations ?? [], |
| 38 | attachmentTarget: token, |
| 39 | attachmentSubmissionId: submissionId, |
| 40 | attachments: attachments.map((item, index) => ({ |
| 41 | clientAttachmentId: item.clientAttachmentId || `image-${index + 1}`, |
| 42 | draftId: item.draftId, |
| 43 | })), |
| 44 | } satisfies StructuredInvocationSubmit, |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | export function submitAttachmentTurn(app: AppBindings, submissionId: string, structured: StructuredInvocationSubmit, original: string, initialGoal?: { goal: string; toolApprovalMode?: string }) { |
| 49 | if (!app.StartTurnForAttachmentTarget || !structured.attachmentTarget) throw new Error("unsupported: attachments-v2"); |
| 50 | return app.StartTurnForAttachmentTarget(structured.attachmentTarget, submissionId, { |
| 51 | input: structured.input, display: structured.display, original, |
| 52 | goal: initialGoal?.goal, toolApprovalMode: initialGoal?.toolApprovalMode, |
| 53 | invocations: structured.invocations, attachments: structured.attachments ?? [], |
| 54 | }); |
| 55 | } |
| 56 | |
| 57 | export function readFileAsDataURL(file: File): Promise<string> { |
| 58 | return new Promise((resolve, reject) => { |
| 59 | const reader = new FileReader(); |
| 60 | reader.onload = () => resolve(String(reader.result)); |
| 61 | reader.onerror = () => reject(reader.error); |
| 62 | reader.readAsDataURL(file); |
| 63 | }); |
| 64 | } |
| 65 | |
| 66 | export async function captureImageTarget(app: AppBindings, target: ComposerTarget): Promise<string> { |
| 67 | return captureAttachmentTarget(app, target, target.kind === "draft" |
| 68 | ? ["StageImageForTarget", "AttachmentDataURLForTarget"] |
| 69 | : ["StageImageForTarget", "ReadDraftImageForTarget"]); |
| 70 | } |
| 71 | |
| 72 | export async function captureAttachmentTarget( |
| 73 | app: AppBindings, |
| 74 | target: ComposerTarget, |
| 75 | required: ReadonlyArray<keyof AppBindings>, |
| 76 | ): Promise<string> { |
| 77 | if (!app.CaptureAttachmentTarget || required.some((name) => typeof app[name] !== "function")) { |
| 78 | throw new Error("unsupported: attachments-v2"); |
| 79 | } |
| 80 | const captured = await app.CaptureAttachmentTarget(target); |
| 81 | if (!captured.capabilities.includes("attachments-v2")) throw new Error("unsupported: attachments-v2"); |
| 82 | return captured.token; |
| 83 | } |
| 84 | |
| 85 | export async function stageImageFile(app: AppBindings, target: string, draftKey: string, file: File) { |
| 86 | const dataURL = await readFileAsDataURL(file); |
| 87 | const staged = await app.StageImageForTarget!(target, `${draftKey}:${file.name}:${file.lastModified}`, file.name, file.type, dataURL); |
| 88 | const path = staged.draftId ? `draft:${staged.draftId}` : staged.path; |
| 89 | if (!path) throw new Error("attachment staging returned no source"); |
| 90 | const previewUrl = staged.draftId |
| 91 | ? await app.ReadDraftImageForTarget!(target, staged.draftId) |
| 92 | : await app.AttachmentDataURLForTarget!(target, path); |
| 93 | return { path, previewUrl, displayName: file.name, draftId: staged.draftId || undefined, file }; |
| 94 | } |
| 95 |