feat(agent): add authenticated durable uploads to backend and web

This commit is contained in:
2026-10-14 17:25:52 -07:00
parent 4cbab487d0
commit 8b125c13a5
16 changed files with 838 additions and 54 deletions
+36 -8
View File
@@ -9,11 +9,17 @@ import {
import { AGENT_OUTBOX_STORAGE_KEY, createAgentOptimisticOutbox } from "@/lib/agent-optimistic-outbox";
import { createAgentRoomPresenter } from "@/lib/agent-room-presentation";
import { AGENT_ATTACHMENT_DRAFT_KEY, createAgentUploadQueue, type AgentAttachment, type AgentAttachmentCapability } from "@/lib/agent-attachments";
export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" | "offline";
export type AgentRoomHookResult = {
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
sendMessage(content: string): Promise<void>; retryMessage(messageId: string): Promise<void>; markRead(): void; retry(): void;
attachmentCapability?: AgentAttachmentCapability;
uploadAttachment(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
downloadAttachment(file: AgentAttachment, signal?: AbortSignal): Promise<Blob>;
uploadQueue: ReturnType<typeof createAgentUploadQueue>;
sendMessage(content: string, attachments?: AgentAttachment[], onPersisted?: () => void): Promise<void>; retryMessage(messageId: string): Promise<void>; markRead(): void; retry(): void;
};
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
@@ -23,10 +29,24 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
const room = view.room;
const current = useRef(room);
const [connection, setConnection] = useState<AgentRoomConnection>("offline");
const [attachmentCapability, setAttachmentCapability] = useState<AgentAttachmentCapability>();
const [error, setError] = useState<string | null>(null);
const [isLoading, setLoading] = useState(enabled);
const [revision, setRevision] = useState(0);
const client = useRef<AgentRoomClient | null>(null);
const [, renderUploads] = useState(0);
const capabilityRef = useRef(attachmentCapability); capabilityRef.current = enabled ? attachmentCapability : undefined;
const uploadQueue = useRef<ReturnType<typeof createAgentUploadQueue> | null>(null);
if (!uploadQueue.current) uploadQueue.current = createAgentUploadQueue({
capability: () => capabilityRef.current,
upload: (file, id, signal) => { if (!client.current) throw new Error("Agent connection is unavailable."); return client.current.upload(file, id, signal); },
changed: () => renderUploads(n => n + 1),
storage: {
read: () => { try { return JSON.parse(sessionStorage.getItem(AGENT_ATTACHMENT_DRAFT_KEY) || "null"); } catch { return null; } },
write: rows => { try { sessionStorage.setItem(AGENT_ATTACHMENT_DRAFT_KEY, JSON.stringify(rows)); } catch { throw new Error("Could not save attachment retry details. Allow session storage before uploading."); } },
},
});
useEffect(() => () => uploadQueue.current?.dispose(), []);
const mark = useRef<((cursor: string) => void) | null>(null);
const [outboxRevision, setOutboxRevision] = useState(0);
const update = useCallback((change: (state: AgentRoomState) => AgentRoomState) => {
@@ -36,9 +56,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
if (!outbox.current) {
const stored = (key: string) => { try { return JSON.parse(sessionStorage.getItem(key) || "null"); } catch { return null; } };
outbox.current = createAgentOptimisticOutbox({
submit: (content, requestId) => {
submit: (content, requestId, attachments) => {
if (!client.current) throw new Error("Agent connection is unavailable.");
return client.current.submit(content, requestId);
return client.current.submit(content, requestId, attachments);
},
changed: () => setOutboxRevision(value => value + 1),
storage: {
@@ -60,7 +80,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
useEffect(() => {
if (!enabled) {
client.current = null; mark.current = null;
client.current = null; mark.current = null; setAttachmentCapability(undefined);
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
return;
}
@@ -112,7 +132,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
}, controller.signal);
const refreshActivity = async () => {
const descriptor = await api.descriptor(controller.signal);
if (active) presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity });
if (active) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
};
const connect = () => {
@@ -214,9 +234,17 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
// here would incorrectly mark a socket message that has not rendered yet.
mark.current?.(renderedCursor);
}, [enabled, isLoading, renderedCursor]);
const sendMessage = useCallback(async (content: string) => {
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.send(content);
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
}, [enabled]);
const uploadAttachment = useCallback((file: File, uploadId: string, signal: AbortSignal) => {
if (!enabled || !client.current) return Promise.reject(new Error("Agent connection is unavailable."));
return client.current.upload(file, uploadId, signal);
}, [enabled]);
const downloadAttachment = useCallback((file: AgentAttachment, signal?: AbortSignal) => {
if (!enabled || !client.current) return Promise.reject(new Error("Agent connection is unavailable."));
return client.current.download(file, signal);
}, [enabled]);
const retryMessage = useCallback(async (messageId: string) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
@@ -225,5 +253,5 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
const retry = useCallback(() => { setRevision(value => value + 1); }, []);
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
}