Replace base64-in-JSON file transfers with binary WebSocket frames to
unblock the JS thread when assistant markdown messages contain images.
Receive cost (JSON.parse over multi-MB base64) and persist cost (base64
re-encode to disk) both removed.
- Split daemon-client `exploreFileSystem` into `listDirectory` + `readFile`;
`readFile` returns `{ bytes, mime, size, modifiedAt }`. Encoding stays
private to the seam.
- New `packages/server/src/shared/binary-frames/` module hosts terminal
frames (moved from `terminal-stream-protocol.ts`) and new file-transfer
opcodes (`FileBegin` / `FileChunk` / `FileEnd`) with request-id correlation.
- Add optional `acceptBinary` to `FileExplorerRequestSchema`. New daemons
emit binary frames when set; legacy JSON path remains for old clients.
Compat fallback lives only inside `readFile`.
- New `persistAttachmentFromBytes` writes bytes directly: native via
`expo-file-system` `File.write(Uint8Array)`, web via Blob -> IndexedDB,
desktop via new `write_attachment_bytes` IPC. `persistAttachmentFromBase64`
removed.
139 lines
3.7 KiB
TypeScript
139 lines
3.7 KiB
TypeScript
import type { AttachmentMetadata } from "@/attachments/types";
|
|
import { getAttachmentStore } from "@/attachments/store";
|
|
|
|
export async function persistAttachmentFromBlob(input: {
|
|
blob: Blob;
|
|
mimeType?: string;
|
|
fileName?: string | null;
|
|
id?: string;
|
|
}): Promise<AttachmentMetadata> {
|
|
const store = await getAttachmentStore();
|
|
return await store.save({
|
|
id: input.id,
|
|
mimeType: input.mimeType,
|
|
fileName: input.fileName,
|
|
source: { kind: "blob", blob: input.blob },
|
|
});
|
|
}
|
|
|
|
export async function persistAttachmentFromDataUrl(input: {
|
|
dataUrl: string;
|
|
mimeType?: string;
|
|
fileName?: string | null;
|
|
id?: string;
|
|
}): Promise<AttachmentMetadata> {
|
|
const store = await getAttachmentStore();
|
|
return await store.save({
|
|
id: input.id,
|
|
mimeType: input.mimeType,
|
|
fileName: input.fileName,
|
|
source: { kind: "data_url", dataUrl: input.dataUrl },
|
|
});
|
|
}
|
|
|
|
export async function persistAttachmentFromBytes(input: {
|
|
bytes: Uint8Array;
|
|
mimeType?: string;
|
|
fileName?: string | null;
|
|
id?: string;
|
|
}): Promise<AttachmentMetadata> {
|
|
const store = await getAttachmentStore();
|
|
return await store.save({
|
|
id: input.id,
|
|
mimeType: input.mimeType,
|
|
fileName: input.fileName,
|
|
source: { kind: "bytes", bytes: input.bytes },
|
|
});
|
|
}
|
|
|
|
export async function persistAttachmentFromFileUri(input: {
|
|
uri: string;
|
|
mimeType?: string;
|
|
fileName?: string | null;
|
|
id?: string;
|
|
}): Promise<AttachmentMetadata> {
|
|
const store = await getAttachmentStore();
|
|
return await store.save({
|
|
id: input.id,
|
|
mimeType: input.mimeType,
|
|
fileName: input.fileName,
|
|
source: { kind: "file_uri", uri: input.uri },
|
|
});
|
|
}
|
|
|
|
export async function encodeAttachmentsForSend(
|
|
attachments: readonly AttachmentMetadata[] | undefined,
|
|
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
|
if (!attachments || attachments.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const store = await getAttachmentStore();
|
|
const encoded = await Promise.all(
|
|
attachments.map(async (attachment) => {
|
|
try {
|
|
const data = await store.encodeBase64({ attachment });
|
|
return {
|
|
data,
|
|
mimeType: attachment.mimeType,
|
|
};
|
|
} catch (error) {
|
|
console.error("[attachments] Failed to encode attachment for send", {
|
|
id: attachment.id,
|
|
error,
|
|
});
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
|
|
const valid = encoded.filter(
|
|
(entry): entry is { data: string; mimeType: string } => entry !== null,
|
|
);
|
|
return valid.length > 0 ? valid : undefined;
|
|
}
|
|
|
|
export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
|
|
const store = await getAttachmentStore();
|
|
return await store.resolvePreviewUrl({ attachment });
|
|
}
|
|
|
|
export async function releaseAttachmentPreviewUrl(input: {
|
|
attachment: AttachmentMetadata;
|
|
url: string;
|
|
}): Promise<void> {
|
|
const store = await getAttachmentStore();
|
|
if (!store.releasePreviewUrl) {
|
|
return;
|
|
}
|
|
await store.releasePreviewUrl({ attachment: input.attachment, url: input.url });
|
|
}
|
|
|
|
export async function deleteAttachments(
|
|
attachments: readonly AttachmentMetadata[] | undefined,
|
|
): Promise<void> {
|
|
if (!attachments || attachments.length === 0) {
|
|
return;
|
|
}
|
|
const store = await getAttachmentStore();
|
|
await Promise.all(
|
|
attachments.map(async (attachment) => {
|
|
try {
|
|
await store.delete({ attachment });
|
|
} catch (error) {
|
|
console.warn("[attachments] Failed to delete attachment", {
|
|
id: attachment.id,
|
|
error,
|
|
});
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function garbageCollectAttachments(input: {
|
|
referencedIds: ReadonlySet<string>;
|
|
}): Promise<void> {
|
|
const store = await getAttachmentStore();
|
|
await store.garbageCollect({ referencedIds: input.referencedIds });
|
|
}
|