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.
147 lines
4.0 KiB
TypeScript
147 lines
4.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
collectImageFilesFromClipboardData,
|
|
filesToImageAttachments,
|
|
} from "./image-attachments-from-files";
|
|
import { __setAttachmentStoreForTests } from "@/attachments/store";
|
|
import type { AttachmentStore } from "@/attachments/types";
|
|
|
|
function createClipboardItem(params: { kind: string; type: string; file?: File | null }) {
|
|
return {
|
|
kind: params.kind,
|
|
type: params.type,
|
|
getAsFile: () => params.file ?? null,
|
|
};
|
|
}
|
|
|
|
function createTestStore(): AttachmentStore {
|
|
let sequence = 0;
|
|
return {
|
|
storageType: "web-indexeddb",
|
|
async save(input) {
|
|
sequence += 1;
|
|
const id = input.id ?? `att-${sequence}`;
|
|
const mimeType = input.mimeType ?? "image/jpeg";
|
|
const fileName = input.fileName ?? null;
|
|
let byteSize = 0;
|
|
if (input.source.kind === "blob") {
|
|
byteSize = input.source.blob.size;
|
|
} else if (input.source.kind === "data_url") {
|
|
byteSize = input.source.dataUrl.length;
|
|
} else if (input.source.kind === "file_uri") {
|
|
byteSize = input.source.uri.length;
|
|
} else {
|
|
byteSize = input.source.bytes.byteLength;
|
|
}
|
|
return {
|
|
id,
|
|
mimeType,
|
|
storageType: "web-indexeddb",
|
|
storageKey: id,
|
|
fileName,
|
|
byteSize,
|
|
createdAt: 1700000000000 + sequence,
|
|
};
|
|
},
|
|
async encodeBase64() {
|
|
throw new Error("not used in this test");
|
|
},
|
|
async resolvePreviewUrl() {
|
|
throw new Error("not used in this test");
|
|
},
|
|
async delete() {},
|
|
async garbageCollect() {},
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
__setAttachmentStoreForTests(createTestStore());
|
|
});
|
|
|
|
afterEach(() => {
|
|
__setAttachmentStoreForTests(null);
|
|
});
|
|
|
|
describe("collectImageFilesFromClipboardData", () => {
|
|
it("returns only image files from clipboard items", () => {
|
|
const imagePng = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
|
|
type: "image/png",
|
|
});
|
|
const textFile = new File(["not image"], "notes.txt", {
|
|
type: "text/plain",
|
|
});
|
|
|
|
const files = collectImageFilesFromClipboardData({
|
|
items: [
|
|
createClipboardItem({ kind: "string", type: "text/plain" }),
|
|
createClipboardItem({
|
|
kind: "file",
|
|
type: "text/plain",
|
|
file: textFile,
|
|
}),
|
|
createClipboardItem({
|
|
kind: "file",
|
|
type: "image/png",
|
|
file: imagePng,
|
|
}),
|
|
createClipboardItem({
|
|
kind: "file",
|
|
type: "image/jpeg",
|
|
file: null,
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(files).toEqual([imagePng]);
|
|
});
|
|
|
|
it("returns an empty array when clipboard data is missing", () => {
|
|
expect(collectImageFilesFromClipboardData(undefined)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("filesToImageAttachments", () => {
|
|
it("persists files as attachment metadata in order", async () => {
|
|
const pngFile = new File([new Uint8Array([0, 1, 2, 3])], "first.png", {
|
|
type: "image/png",
|
|
});
|
|
const typeLessFile = new File([new Uint8Array([4, 5, 6, 7])], "second", {
|
|
type: "",
|
|
});
|
|
|
|
const attachments = await filesToImageAttachments([pngFile, typeLessFile]);
|
|
|
|
expect(attachments).toEqual([
|
|
{
|
|
id: "att-1",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "att-1",
|
|
fileName: "first.png",
|
|
byteSize: 4,
|
|
createdAt: 1700000000001,
|
|
},
|
|
{
|
|
id: "att-2",
|
|
mimeType: "image/jpeg",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "att-2",
|
|
fileName: "second",
|
|
byteSize: 4,
|
|
createdAt: 1700000000002,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("handles large files without creating data URLs", async () => {
|
|
const large = new File([new Uint8Array(4 * 1024 * 1024)], "large.png", {
|
|
type: "image/png",
|
|
});
|
|
|
|
const [attachment] = await filesToImageAttachments([large]);
|
|
|
|
expect(attachment?.storageType).toBe("web-indexeddb");
|
|
expect(attachment?.byteSize).toBe(4 * 1024 * 1024);
|
|
});
|
|
});
|