@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
JavaScript
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