feat(agent): add authenticated durable uploads to backend and web
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user