Files
paseo/packages/app/src/attachments/service.ts
T
Mohamed BoudraandGitHub bb889dae99 feat: stream files as binary frames over WebSocket (#659)
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.
2026-05-02 14:02:27 +08:00

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 });
}