UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

217 lines • 8.77 kB
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AgentBackendUnsupportedError } from '../backend.js'; import { useAgentConfig } from '../config/use-agent-config.js'; import { useAgentBackend } from '../provider.js'; import { acceptsFile, dragHasFiles, filesFromClipboard } from './files.js'; const DEFAULT_REJECTION = { type: (file) => `${file.name} is not a supported file type`, size: (file) => `${file.name} is too large`, count: () => 'Too many files', }; let sequence = 0; /** * Staged attachments for a composer, headless: validation (`accept`, `maxBytes`, `maxFiles`), * one upload per file with progress and cancel, retry, previews, and the event handlers for a file * input, a drop zone and paste — no markup, no styles. Send `refs` with the * message, then `clear()`: * * ```ts * await chat.sendMessage({ text }, { body: { attachments: files.refs } }); * files.clear(); * ``` */ export function useAttachments(options = {}) { const backend = useAgentBackend(options.backend); // The server's limits are the defaults, so a composer never repeats (and drifts from) them. const { config } = useAgentConfig({ backend }); const limits = config?.attachments; const accept = options.accept ?? limits?.allowedContentTypes; const maxBytes = options.maxBytes ?? limits?.maxBytes; const maxFiles = options.maxFiles ?? limits?.maxPerMessage; const effective = { ...options, backend, ...(accept !== undefined ? { accept } : {}), ...(maxBytes !== undefined ? { maxBytes } : {}), ...(maxFiles !== undefined ? { maxFiles } : {}), }; const latest = useRef(effective); latest.current = effective; const [items, setItems] = useState([]); const itemsRef = useRef(items); itemsRef.current = items; const controllers = useRef(new Map()); const [dragDepth, setDragDepth] = useState(0); const patch = useCallback((id, change) => { setItems((current) => current.map((item) => (item.id === id ? { ...item, ...change } : item))); }, []); const start = useCallback((item) => { const { upload, backend } = latest.current; const run = upload ?? (backend.uploadAttachment !== undefined ? backend.uploadAttachment.bind(backend) : undefined); if (run === undefined) { patch(item.id, { status: 'error', error: new AgentBackendUnsupportedError('uploadAttachment').message, }); return; } const controller = new AbortController(); controllers.current.set(item.id, controller); run(item.file, { signal: controller.signal, onProgress: (fraction) => patch(item.id, { progress: Math.max(0, Math.min(1, fraction)) }), }).then((attachment) => { if (controller.signal.aborted) return; controllers.current.delete(item.id); patch(item.id, { status: 'ready', progress: 1, attachment }); }, (error) => { if (controller.signal.aborted) return; controllers.current.delete(item.id); patch(item.id, { status: 'error', error: error instanceof Error ? error.message : String(error), }); }); }, [patch]); const add = useCallback((files) => { if (files == null) return; const { accept, maxBytes, maxFiles, describeRejection } = latest.current; const describe = (reason, file) => describeRejection?.(reason, file) ?? DEFAULT_REJECTION[reason](file); let room = maxFiles === undefined ? Number.POSITIVE_INFINITY : maxFiles - itemsRef.current.filter((item) => item.status !== 'rejected').length; const staged = []; for (const file of Array.from(files)) { sequence += 1; const base = { id: `att-${sequence}`, file, name: file.name, size: file.size, type: file.type, progress: 0, }; const reason = !acceptsFile(accept, file) ? 'type' : maxBytes !== undefined && file.size > maxBytes ? 'size' : room <= 0 ? 'count' : undefined; if (reason !== undefined) { staged.push({ ...base, status: 'rejected', error: describe(reason, file) }); continue; } room -= 1; const previewUrl = file.type.startsWith('image/') && typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : undefined; staged.push({ ...base, status: 'uploading', ...(previewUrl ? { previewUrl } : {}) }); } if (staged.length === 0) return; setItems((current) => [...current, ...staged]); for (const item of staged) { if (item.status === 'uploading') start(item); } }, [start]); const release = useCallback((item) => { if (item === undefined) return; controllers.current.get(item.id)?.abort(); controllers.current.delete(item.id); if (item.previewUrl !== undefined && typeof URL.revokeObjectURL === 'function') { URL.revokeObjectURL(item.previewUrl); } }, []); const remove = useCallback((id) => { release(itemsRef.current.find((item) => item.id === id)); setItems((current) => current.filter((item) => item.id !== id)); }, [release]); const clear = useCallback(() => { for (const item of itemsRef.current) release(item); setItems([]); }, [release]); const retry = useCallback((id) => { const item = itemsRef.current.find((candidate) => candidate.id === id); if (item === undefined || item.status !== 'error') return; const { error: _previous, ...rest } = item; const again = { ...rest, status: 'uploading', progress: 0 }; setItems((current) => current.map((candidate) => (candidate.id === id ? again : candidate))); start(again); }, [start]); // Unmount: cancel what is in flight and free the previews. useEffect(() => () => { for (const item of itemsRef.current) release(item); }, [release]); const onPaste = useCallback((event) => { const files = filesFromClipboard(event.clipboardData); if (files.length === 0) return; event.preventDefault(); add(files); }, [add]); const dropZoneProps = useMemo(() => ({ onDragEnter: (event) => { if (!dragHasFiles(event.dataTransfer)) return; event.preventDefault(); setDragDepth((depth) => depth + 1); }, onDragOver: (event) => { // Without this the browser never fires `drop` — it opens the file instead. if (dragHasFiles(event.dataTransfer)) event.preventDefault(); }, onDragLeave: (event) => { if (!dragHasFiles(event.dataTransfer)) return; setDragDepth((depth) => Math.max(0, depth - 1)); }, onDrop: (event) => { if (!dragHasFiles(event.dataTransfer)) return; event.preventDefault(); setDragDepth(0); add(event.dataTransfer?.files); }, }), [add]); const inputProps = useMemo(() => ({ type: 'file', accept: typeof accept === 'string' ? accept : accept?.join(','), multiple: maxFiles === undefined || maxFiles > 1, onChange: (event) => { const input = event.currentTarget; add(input.files); // Let the same file be picked again after a remove. input.value = ''; }, }), [accept, maxFiles, add]); return useMemo(() => { const ready = items.filter((item) => item.status === 'ready' && item.attachment !== undefined); return { items, add, remove, retry, clear, isUploading: items.some((item) => item.status === 'uploading'), refs: ready.map((item) => ({ mediaId: item.attachment.mediaId })), inputProps, dropZoneProps, isDragging: dragDepth > 0, onPaste, }; }, [items, add, remove, retry, clear, inputProps, dropZoneProps, dragDepth, onPaste]); } //# sourceMappingURL=use-attachments.js.map