refactor(app): introduce attachment storage layer and draft lifecycle management
Replace inline data-URL image handling with a persistent attachment storage service, add draft lifecycle states (active/abandoned/sent) with generation tracking, and update fetchAgent to return project placement alongside agent snapshots.
This commit is contained in:
@@ -47,20 +47,32 @@ export function applyFetchedAgentDirectory(input: {
|
||||
serverId: string;
|
||||
entries: FetchAgentsEntry[];
|
||||
}): { agents: Map<string, Agent> } {
|
||||
const { agents, pendingPermissions } = buildAgentDirectoryState(input);
|
||||
const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input);
|
||||
|
||||
const store = useSessionStore.getState();
|
||||
|
||||
store.setAgents(input.serverId, agents);
|
||||
store.setAgents(input.serverId, (prev) => {
|
||||
const merged = new Map(prev);
|
||||
for (const [id, agent] of fetchedAgents) {
|
||||
merged.set(id, agent);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
const lastActivityByAgentId = new Map<string, Date>();
|
||||
for (const agent of agents.values()) {
|
||||
for (const agent of fetchedAgents.values()) {
|
||||
lastActivityByAgentId.set(agent.id, agent.lastActivityAt);
|
||||
}
|
||||
store.setAgentLastActivityBatch(lastActivityByAgentId);
|
||||
|
||||
store.setPendingPermissions(input.serverId, pendingPermissions);
|
||||
store.setPendingPermissions(input.serverId, (prev) => {
|
||||
const merged = new Map(prev);
|
||||
for (const [key, entry] of pendingPermissions) {
|
||||
merged.set(key, entry);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
store.setInitializingAgents(input.serverId, new Map());
|
||||
store.setHasHydratedAgents(input.serverId, true);
|
||||
return { agents };
|
||||
return { agents: fetchedAgents };
|
||||
}
|
||||
|
||||
@@ -1,68 +1,10 @@
|
||||
import { Platform } from "react-native";
|
||||
import { File } from "expo-file-system";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { encodeAttachmentsForSend } from "@/attachments/service";
|
||||
|
||||
type ImageInput = { uri: string; mimeType?: string };
|
||||
type ImageInput = AttachmentMetadata;
|
||||
|
||||
export async function encodeImages(
|
||||
images?: ImageInput[]
|
||||
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
||||
if (!images || images.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const encodedImages = await Promise.all(
|
||||
images.map(async ({ uri, mimeType }) => {
|
||||
try {
|
||||
const data = await (async () => {
|
||||
if (Platform.OS === "web") {
|
||||
if (uri.startsWith("data:")) {
|
||||
const [, base64] = uri.split(",", 2);
|
||||
if (!base64) {
|
||||
throw new Error("Malformed data URI for image.");
|
||||
}
|
||||
return base64;
|
||||
}
|
||||
const response = await fetch(uri);
|
||||
const blob = await response.blob();
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== "string") {
|
||||
reject(new Error("Unexpected FileReader result type."));
|
||||
return;
|
||||
}
|
||||
const [, resultBase64] = reader.result.split(",", 2);
|
||||
if (!resultBase64) {
|
||||
reject(new Error("Failed to read image data as base64."));
|
||||
return;
|
||||
}
|
||||
resolve(resultBase64);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(reader.error ?? new Error("Failed to read image data."));
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
return base64;
|
||||
}
|
||||
|
||||
const file = new File(uri);
|
||||
return await file.base64();
|
||||
})();
|
||||
|
||||
return {
|
||||
data,
|
||||
mimeType: mimeType ?? "image/jpeg",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[encodeImages] Failed to convert image:", error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const validImages = encodedImages.filter(
|
||||
(entry): entry is { data: string; mimeType: string } => entry !== null
|
||||
);
|
||||
return validImages.length > 0 ? validImages : undefined;
|
||||
return await encodeAttachmentsForSend(images);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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;
|
||||
@@ -16,6 +18,52 @@ function createClipboardItem(params: {
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
byteSize = input.source.uri.length;
|
||||
}
|
||||
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", {
|
||||
@@ -55,7 +103,7 @@ describe("collectImageFilesFromClipboardData", () => {
|
||||
});
|
||||
|
||||
describe("filesToImageAttachments", () => {
|
||||
it("converts files into data URI image attachments and keeps order", async () => {
|
||||
it("persists files as attachment metadata in order", async () => {
|
||||
const pngFile = new File([new Uint8Array([0, 1, 2, 3])], "first.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
@@ -67,13 +115,34 @@ describe("filesToImageAttachments", () => {
|
||||
|
||||
expect(attachments).toEqual([
|
||||
{
|
||||
uri: "data:image/png;base64,AAECAw==",
|
||||
id: "att-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "att-1",
|
||||
fileName: "first.png",
|
||||
byteSize: 4,
|
||||
createdAt: 1700000000001,
|
||||
},
|
||||
{
|
||||
uri: "data:image/jpeg;base64,BAUGBw==",
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { persistAttachmentFromBlob } from "@/attachments/service";
|
||||
|
||||
export interface ClipboardItemLike {
|
||||
kind?: string;
|
||||
type?: string;
|
||||
@@ -8,23 +11,7 @@ export interface ClipboardDataLike {
|
||||
items?: ArrayLike<ClipboardItemLike> | null;
|
||||
}
|
||||
|
||||
export interface ImageAttachmentFromFile {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
function toBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const chunkSize = 0x8000;
|
||||
let binary = "";
|
||||
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
const chunk = bytes.subarray(index, index + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
export type ImageAttachmentFromFile = AttachmentMetadata;
|
||||
|
||||
export function collectImageFilesFromClipboardData(
|
||||
clipboardData?: ClipboardDataLike | null
|
||||
@@ -54,14 +41,25 @@ export function collectImageFilesFromClipboardData(
|
||||
export async function filesToImageAttachments(
|
||||
files: readonly File[]
|
||||
): Promise<ImageAttachmentFromFile[]> {
|
||||
return Promise.all(
|
||||
const attachments = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const mimeType = file.type || "image/jpeg";
|
||||
const base64 = toBase64(await file.arrayBuffer());
|
||||
return {
|
||||
uri: `data:${mimeType};base64,${base64}`,
|
||||
mimeType,
|
||||
};
|
||||
try {
|
||||
return await persistAttachmentFromBlob({
|
||||
blob: file,
|
||||
mimeType: file.type || "image/jpeg",
|
||||
fileName: file.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[attachments] Failed to persist file attachment", {
|
||||
fileName: file.name,
|
||||
error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return attachments.filter(
|
||||
(entry): entry is ImageAttachmentFromFile => entry !== null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mergePendingCreateImages } from "./pending-create-images";
|
||||
function userMessage(params: {
|
||||
id: string;
|
||||
text: string;
|
||||
images?: Array<{ uri: string; mimeType: string }>;
|
||||
images?: Array<{ id: string; storageType: "native-file"; storageKey: string; mimeType: string; createdAt: number }>;
|
||||
}): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
@@ -16,25 +16,35 @@ function userMessage(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildImage(id: string) {
|
||||
return [
|
||||
{
|
||||
id,
|
||||
storageType: "native-file" as const,
|
||||
storageKey: `/tmp/${id}.jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("mergePendingCreateImages", () => {
|
||||
it("returns same reference when pending images are absent", () => {
|
||||
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
messageId: "msg-1",
|
||||
text: "hello",
|
||||
clientMessageId: "msg-1",
|
||||
images: [],
|
||||
});
|
||||
expect(result).toBe(streamItems);
|
||||
});
|
||||
|
||||
it("merges images by messageId when the matched message has none", () => {
|
||||
it("merges images by clientMessageId when matched message has none", () => {
|
||||
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
|
||||
const images = [{ uri: "file:///tmp/image-1.jpg", mimeType: "image/jpeg" }];
|
||||
const images = buildImage("image-1");
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
messageId: "msg-1",
|
||||
text: "hello",
|
||||
clientMessageId: "msg-1",
|
||||
images,
|
||||
});
|
||||
|
||||
@@ -47,34 +57,27 @@ describe("mergePendingCreateImages", () => {
|
||||
expect(updated.images).toEqual(images);
|
||||
});
|
||||
|
||||
it("falls back to text matching when messageId does not match", () => {
|
||||
it("does not merge when clientMessageId does not match", () => {
|
||||
const streamItems = [userMessage({ id: "msg-1", text: "same text" })];
|
||||
const images = [{ uri: "file:///tmp/image-2.jpg", mimeType: "image/jpeg" }];
|
||||
const images = buildImage("image-2");
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
messageId: "missing-id",
|
||||
text: "same text",
|
||||
clientMessageId: "missing-id",
|
||||
images,
|
||||
});
|
||||
|
||||
const updated = result[0];
|
||||
expect(updated?.kind).toBe("user_message");
|
||||
if (updated?.kind !== "user_message") {
|
||||
throw new Error("Expected user_message item");
|
||||
}
|
||||
expect(updated.images).toEqual(images);
|
||||
expect(result).toBe(streamItems);
|
||||
});
|
||||
|
||||
it("does not overwrite existing user message images", () => {
|
||||
const existingImages = [{ uri: "file:///tmp/existing.jpg", mimeType: "image/jpeg" }];
|
||||
const existingImages = buildImage("existing");
|
||||
const streamItems = [
|
||||
userMessage({ id: "msg-1", text: "hello", images: existingImages }),
|
||||
];
|
||||
const result = mergePendingCreateImages({
|
||||
streamItems,
|
||||
messageId: "msg-1",
|
||||
text: "hello",
|
||||
images: [{ uri: "file:///tmp/new.jpg", mimeType: "image/jpeg" }],
|
||||
clientMessageId: "msg-1",
|
||||
images: buildImage("new"),
|
||||
});
|
||||
|
||||
expect(result).toBe(streamItems);
|
||||
|
||||
@@ -2,15 +2,13 @@ import type { StreamItem, UserMessageImageAttachment } from "@/types/stream";
|
||||
|
||||
type MergePendingCreateImagesParams = {
|
||||
streamItems: StreamItem[];
|
||||
messageId: string;
|
||||
text: string;
|
||||
clientMessageId: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
};
|
||||
|
||||
export function mergePendingCreateImages({
|
||||
streamItems,
|
||||
messageId,
|
||||
text,
|
||||
clientMessageId,
|
||||
images,
|
||||
}: MergePendingCreateImagesParams): StreamItem[] {
|
||||
if (!images || images.length === 0) {
|
||||
@@ -18,8 +16,7 @@ export function mergePendingCreateImages({
|
||||
}
|
||||
|
||||
const targetIndex = streamItems.findIndex(
|
||||
(item) =>
|
||||
item.kind === "user_message" && (item.id === messageId || item.text === text)
|
||||
(item) => item.kind === "user_message" && item.id === clientMessageId
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
return streamItems;
|
||||
|
||||
Reference in New Issue
Block a user