feat(app): store assistant image previews as assets

This commit is contained in:
Mohamed Boudra
2026-04-22 22:28:56 +07:00
parent 434b28fc8e
commit 4140e640fe
15 changed files with 476 additions and 38 deletions
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
const fileSystemMock = vi.hoisted(() => ({
getInfoAsync: vi.fn(async (uri: string) => {
if (uri.endsWith("/preview-assets/")) {
return { exists: true, isDirectory: true };
}
return { exists: true, isDirectory: false, size: 4 };
}),
makeDirectoryAsync: vi.fn(async () => {}),
writeAsStringAsync: vi.fn(async () => {}),
copyAsync: vi.fn(async () => {}),
readAsStringAsync: vi.fn(async () => "AAECAw=="),
deleteAsync: vi.fn(async () => {}),
readDirectoryAsync: vi.fn(async () => []),
}));
vi.mock("expo-file-system/legacy", () => ({
cacheDirectory: "file:///cache/",
EncodingType: { Base64: "base64" },
getInfoAsync: fileSystemMock.getInfoAsync,
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
copyAsync: fileSystemMock.copyAsync,
readAsStringAsync: fileSystemMock.readAsStringAsync,
deleteAsync: fileSystemMock.deleteAsync,
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
}));
describe("local file attachment store", () => {
beforeEach(() => {
fileSystemMock.getInfoAsync.mockClear();
fileSystemMock.makeDirectoryAsync.mockClear();
fileSystemMock.writeAsStringAsync.mockClear();
fileSystemMock.copyAsync.mockClear();
fileSystemMock.readAsStringAsync.mockClear();
fileSystemMock.deleteAsync.mockClear();
fileSystemMock.readDirectoryAsync.mockClear();
});
it("writes raw base64 sources directly to the managed file path", async () => {
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
});
const attachment = await store.save({
id: "preview_8_test",
mimeType: "image/png",
fileName: "result.png",
source: { kind: "base64", base64: "AAECAw==" },
});
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
"file:///cache/preview-assets/preview_8_test.png",
"AAECAw==",
{ encoding: "base64" },
);
expect(attachment).toMatchObject({
id: "preview_8_test",
mimeType: "image/png",
storageType: "native-file",
storageKey: "/cache/preview-assets/preview_8_test.png",
fileName: "result.png",
byteSize: 4,
});
});
});
@@ -72,6 +72,13 @@ async function writeFromSource(input: {
return;
}
if (input.source.kind === "base64") {
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
encoding: FileSystem.EncodingType.Base64,
});
return;
}
const base64 = await blobToBase64(input.source.blob);
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
import { __setAttachmentStoreForTests } from "./store";
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
return {
id: input.id ?? "att_1",
mimeType: input.mimeType ?? "image/png",
storageType: input.storageType ?? "web-indexeddb",
storageKey: input.storageKey ?? "att_1",
fileName: input.fileName,
byteSize: input.byteSize,
createdAt: input.createdAt ?? 1700000000000,
};
}
function createRecordingStore(): AttachmentStore & {
savedSources: SaveAttachmentInput[];
releasedUrls: string[];
} {
const savedSources: SaveAttachmentInput[] = [];
const releasedUrls: string[] = [];
return {
storageType: "web-indexeddb",
savedSources,
releasedUrls,
async save(input) {
savedSources.push(input);
return createAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
byteSize: 4,
});
},
async encodeBase64({ attachment }) {
return `${attachment.id}:base64`;
},
async resolvePreviewUrl({ attachment }) {
return `blob:${attachment.id}`;
},
async releasePreviewUrl({ url }) {
releasedUrls.push(url);
},
async delete() {},
async garbageCollect() {},
};
}
describe("attachment service", () => {
afterEach(() => {
__setAttachmentStoreForTests(null);
});
it("persists raw base64 without requiring a data URL wrapper", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const attachment = await persistAttachmentFromBase64({
id: "att_base64",
base64: "AAECAw==",
mimeType: "image/png",
fileName: "image.png",
});
expect(attachment).toEqual({
id: "att_base64",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "att_1",
fileName: "image.png",
byteSize: 4,
createdAt: 1700000000000,
});
expect(store.savedSources).toEqual([
{
id: "att_base64",
mimeType: "image/png",
fileName: "image.png",
source: { kind: "base64", base64: "AAECAw==" },
},
]);
});
it("keeps provider send output byte-compatible", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const attachment = createAttachment({ id: "att_send", mimeType: "image/jpeg" });
await expect(encodeAttachmentsForSend([attachment])).resolves.toEqual([
{ data: "att_send:base64", mimeType: "image/jpeg" },
]);
});
});
+15
View File
@@ -31,6 +31,21 @@ export async function persistAttachmentFromDataUrl(input: {
});
}
export async function persistAttachmentFromBase64(input: {
base64: 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: "base64", base64: input.base64 },
});
}
export async function persistAttachmentFromFileUri(input: {
uri: string;
mimeType?: string;
+1
View File
@@ -23,6 +23,7 @@ export type ComposerAttachment =
| { kind: "github_pr"; item: GitHubSearchItem };
export type AttachmentDataSource =
| { kind: "base64"; base64: string }
| { kind: "blob"; blob: Blob }
| { kind: "data_url"; dataUrl: string }
| { kind: "file_uri"; uri: string };
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { pathToFileUri } from "./utils";
import { createImageSourceCacheKey, parseDataUrl, parseImageDataUrl, pathToFileUri } from "./utils";
describe("pathToFileUri", () => {
it("converts POSIX absolute paths to file URIs", () => {
@@ -22,3 +22,35 @@ describe("pathToFileUri", () => {
expect(pathToFileUri("relative/path")).toBe("relative/path");
});
});
describe("parseDataUrl", () => {
it("accepts base64 data URLs with media-type parameters", () => {
expect(parseDataUrl("data:image/png;charset=utf-8;name=preview;base64,AAECAw==")).toEqual({
mimeType: "image/png",
base64: "AAECAw==",
});
});
it("rejects non-base64 data URLs", () => {
expect(() => parseDataUrl("data:image/png,not-base64")).toThrow(
"Attachment data URL is not base64 encoded.",
);
});
});
describe("parseImageDataUrl", () => {
it("returns a compact cache key for image data URLs", () => {
const dataUrl = `data:image/png;base64,${"a".repeat(512)}`;
expect(parseImageDataUrl(dataUrl)).toMatchObject({
mimeType: "image/png",
base64: "a".repeat(512),
});
expect(createImageSourceCacheKey(dataUrl)).toMatch(/^data-image:image\/png:512:/);
expect(createImageSourceCacheKey(dataUrl)).not.toContain("a".repeat(128));
});
it("ignores non-image data URLs", () => {
expect(parseImageDataUrl("data:text/plain;base64,SGVsbG8=")).toBeNull();
});
});
+63 -2
View File
@@ -14,11 +14,17 @@ export function normalizeMimeType(input: string | undefined | null): string {
}
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl);
const match = /^data:([^,]*),([\s\S]+)$/i.exec(dataUrl.trim());
if (!match) {
throw new Error("Malformed data URL for attachment.");
}
const [, mimeTypeRaw, base64] = match;
const metadata = match[1] ?? "";
const base64 = match[2]?.replace(/\s/g, "");
const [mimeTypeRaw, ...parameters] = metadata.split(";").map((part) => part.trim());
const isBase64 = parameters.some((part) => part.toLowerCase() === "base64");
if (!isBase64) {
throw new Error("Attachment data URL is not base64 encoded.");
}
if (!base64) {
throw new Error("Attachment data URL is missing base64 payload.");
}
@@ -28,6 +34,61 @@ export function parseDataUrl(dataUrl: string): { mimeType: string; base64: strin
};
}
function hashString(value: string): string {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
export function parseImageDataUrl(
uri: string,
): { mimeType: string; base64: string; cacheKey: string } | null {
if (!uri.trim().toLowerCase().startsWith("data:image/")) {
return null;
}
try {
const parsed = parseDataUrl(uri);
if (!parsed.mimeType.toLowerCase().startsWith("image/")) {
return null;
}
return {
...parsed,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(parsed.base64)}`,
};
} catch {
return null;
}
}
export function createImageSourceCacheKey(source: string): string {
return parseImageDataUrl(source)?.cacheKey ?? source;
}
export function getFileNameFromPath(path: string | null | undefined): string | null {
const trimmed = path?.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "");
const fileName = normalized.split("/").pop()?.trim();
return fileName || null;
}
export function createPreviewAttachmentId(input: {
base64: string;
mimeType: string;
path?: string | null;
}): string {
const path = input.path?.trim() ?? "";
const hash = hashString(`${input.mimeType}\0${path}\0${input.base64}`);
return `preview_${input.base64.length}_${hash}`;
}
export async function blobToBase64(blob: Blob): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -74,6 +74,15 @@ function runTx<T>(
});
}
function base64ToBlob(input: { base64: string; mimeType: string }): Blob {
const binary = atob(input.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: input.mimeType });
}
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
const source = input.source;
if (source.kind === "blob") {
@@ -96,6 +105,14 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
};
}
if (source.kind === "base64") {
const mimeType = normalizeMimeType(input.mimeType);
return {
blob: base64ToBlob({ base64: source.base64, mimeType }),
mimeType,
};
}
const response = await fetch(source.uri);
const blob = await response.blob();
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);