UNPKG

@adonis-agora/agent

Version:

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

595 lines • 25.9 kB
import { useCallback, useEffect, useRef, useState } from 'react'; import { readUiActionText, uiActionSummary } from '../../../genui/actions.js'; import { attachmentFile } from '../attachments/files.js'; import { requireBackendMethod } from '../backend.js'; import { shareStructure } from '../genui/share-structure.js'; import { useAgentBackend } from '../provider.js'; import { buildTranscriptBlocks, describeTimestamp, describeUsage, extractMessageText, } from './model.js'; import { useStickToBottom } from './use-stick-to-bottom.js'; /** Most-recent messages mounted up front; older ones wait behind `window.loadEarlier`. */ const DEFAULT_VISIBLE_COUNT = 50; const DEFAULT_LOAD_EARLIER_STEP = 50; const DEFAULT_COPY_RESET_MS = 1500; /** * The model behind a chat transcript: messages normalized into items with their parts grouped into * runs, per-message derived values (copyable text, usage labels, relative time), each action as a * state machine rather than a button, list windowing, and stick-to-bottom. It renders nothing and * names no class — `MessageList`/`MessageItem` are one renderer over it, and an app with its own * design system writes another without reimplementing any of this. */ export function useChatTranscript(options) { const { messages, status, initialVisibleCount = DEFAULT_VISIBLE_COUNT, followUps, stickToBottomThreshold, } = options; const latest = useRef(options); latest.current = options; const [visibleCount, setVisibleCount] = useState(initialVisibleCount); const [stopRequested, setStopRequested] = useState(false); // A reconnecting stream is still the turn in flight, and its message is still being written. const isStreaming = status === 'streaming' || status === 'reconnecting'; const isBusy = status === 'submitted' || isStreaming; const isEmpty = messages.length === 0; // A settled turn clears a pending stop, so the next turn's button starts from rest. useEffect(() => { if (!isBusy) { setStopRequested(false); } }, [isBusy]); const lastMessage = messages.at(-1); const lastAssistant = findLastAssistant(messages); const streamingMessageId = isStreaming ? (lastAssistant?.id ?? null) : null; const visibleStart = Math.max(0, messages.length - visibleCount); const items = useTranscriptItems({ messages, visibleStart, streamingMessageId, options, }); const stop = useCallback(() => { const current = latest.current; if (current.status !== 'submitted' && current.status !== 'streaming' && current.status !== 'reconnecting') { return; } setStopRequested(true); void current.onStop?.(); }, []); const loadEarlier = useCallback(() => setVisibleCount((count) => count + (latest.current.loadEarlierStep ?? DEFAULT_LOAD_EARLIER_STEP)), []); const showAll = useCallback(() => setVisibleCount(latest.current.messages.length), []); const scroll = useStickToBottom({ contentKey: contentSignature(messages), ...(stickToBottomThreshold !== undefined ? { threshold: stickToBottomThreshold } : {}), }); const showTypingIndicator = isBusy && lastMessage?.role !== 'assistant'; const queued = useQueuedItems(options.queue ?? null, options.readOnly === true); return { items, status, isEmpty, isBusy, isStreaming, showEmptyState: isEmpty && status === 'ready', showTypingIndicator, showFollowUps: status === 'ready' && !showTypingIndicator && lastMessage?.role === 'assistant' && !!followUps && followUps.length > 0, lastAssistantId: lastAssistant?.id ?? null, window: { visibleCount, hiddenCount: visibleStart, canLoadEarlier: visibleStart > 0, loadEarlier, showAll, }, scroll, stop: { available: isBusy && options.readOnly !== true && options.onStop !== undefined, isStopping: stopRequested, stop, }, queued, queuePaused: options.queue?.paused ?? null, }; } /** The queue as transcript items. `remove` is cached per id, like every other item action. */ function useQueuedItems(queue, readOnly) { const latestQueue = useRef(queue); latestQueue.current = queue; const removers = useRef(new Map()); const removerFor = useCallback((id) => { let remover = removers.current.get(id); if (remover === undefined) { remover = () => { void latestQueue.current?.remove?.(id); }; removers.current.set(id, remover); } return remover; }, []); if (queue === null || queue === undefined) { return []; } return queue.items.map((message, position) => { const state = message.state === 'sending' ? 'sending' : queue.paused !== null ? 'paused' : 'queued'; return { id: message.id, role: 'user', text: message.text, files: (message.files ?? message.attachments.map(attachmentFile)).map((file) => ({ ...file, isImage: file.kind === 'image', })), state, position, isNext: position === 0, interrupt: message.interrupt, remove: { available: !readOnly && state !== 'sending' && queue.remove !== undefined, run: removerFor(message.id), }, }; }); } /** * The single-message half of {@link useChatTranscript}, for a renderer that owns the list itself. * `regeneratable` applies directly here: one assistant message is trivially the last one. */ export function useTranscriptItem(options) { const { message, isStreaming = false } = options; const messagesRef = useRef([message]); // A fresh array each render would re-key nothing but does churn the items memo; the identity is // kept stable while the message itself is unchanged. if (messagesRef.current[0] !== message) { messagesRef.current = [message]; } const items = useTranscriptItems({ messages: messagesRef.current, visibleStart: 0, streamingMessageId: isStreaming ? message.id : null, options, }); // One message in, exactly one item out. return items[0]; } /** * All per-message UI state lives here, keyed by message id, because the number of messages is not * known at render time — a hook per item would break the rules of hooks the moment a turn arrives. * Each item's callbacks are cached per id so a controlled `<textarea>`'s ref identity survives every * keystroke; without that, React detaches and re-attaches the node on each render and the focus * effect drags the caret back to the end mid-edit. */ function useTranscriptItems({ messages, visibleStart, streamingMessageId, options, }) { const [drafts, setDrafts] = useState(() => new Map()); const [copied, setCopied] = useState(() => new Set()); const [openReasoning, setOpenReasoning] = useState(() => new Map()); // A question's picks, under `toolCallId|questionId`. Presence IS "the user touched this // question" — an absent entry is what makes the submission omit it and take the server's default. const [picks, setPicks] = useState(() => new Map()); // Which decision each parked call is sending, not merely that one is: the two affordances on a // call are never in flight together, and only the pressed one should report progress. const [settling, setSettling] = useState(() => new Map()); const [settleErrors, setSettleErrors] = useState(() => new Map()); const backend = useAgentBackend(); const handlers = settleHandlers(options, backend); const latest = useRef({ options, handlers, drafts, picks, messages }); latest.current = { options, handlers, drafts, picks, messages }; const copyTimers = useRef(new Map()); useEffect(() => () => { for (const timer of copyTimers.current.values()) { clearTimeout(timer); } copyTimers.current.clear(); }, []); const callbackCache = useRef(new Map()); const reasoningToggleCache = useRef(new Map()); const uiBlocks = useRef(new Map()); const toggleReasoning = useCallback((key, open) => { setOpenReasoning((current) => { const next = new Map(current); next.set(key, open ?? !(current.get(key) ?? false)); return next; }); }, []); const stableToggle = useCallback((key) => { let cached = reasoningToggleCache.current.get(key); if (!cached) { cached = (open) => toggleReasoning(key, open); reasoningToggleCache.current.set(key, cached); } return cached; }, [toggleReasoning]); const pick = useCallback((toolCallId, questionId, values) => { setPicks((current) => new Map(current).set(pickKey(toolCallId, questionId), values)); }, []); /** * Run one decision against the server. A rejection has to land somewhere the user can see: the * call stays parked either way, and a form that silently does nothing on submit is * indistinguishable from a broken button. */ const settle = useCallback((toolCallId, action, send) => { setSettleErrors((current) => { if (!current.has(toolCallId)) { return current; } const next = new Map(current); next.delete(toolCallId); return next; }); setSettling((current) => new Map(current).set(toolCallId, action)); const failed = (error) => { setSettling((current) => { const next = new Map(current); next.delete(toolCallId); return next; }); setSettleErrors((current) => new Map(current).set(toolCallId, { message: error instanceof Error ? error.message : 'Could not settle this', code: settleErrorCode(error), })); }; // Sent in the click's own tick, not a microtask later — a synchronous throw and a rejected // promise are the same failure to the person looking at the form. try { void Promise.resolve(send()).catch(failed); } catch (error) { failed(error); } }, []); const answer = useCallback((toolCallId) => { const current = latest.current; const onAnswer = current.handlers.onAnswer; if (!onAnswer) { return; } settle(toolCallId, 'answer', () => onAnswer({ toolCallId, answers: answersFor(current.picks, toolCallId) })); }, [settle]); const skip = useCallback((toolCallId) => { const onSkip = latest.current.handlers.onSkip; if (onSkip) { settle(toolCallId, 'skip', () => onSkip({ toolCallId })); } }, [settle]); const approve = useCallback((toolCallId, options, target) => { const onApprove = latest.current.handlers.onApprove; if (onApprove) { settle(toolCallId, 'approve', () => onApprove({ toolCallId, ...(target === undefined ? {} : { target }), ...(options?.remember === true ? { remember: true } : {}), })); } }, [settle]); const reject = useCallback((toolCallId, target) => { const onReject = latest.current.handlers.onReject; if (onReject) { settle(toolCallId, 'reject', () => onReject({ toolCallId, ...(target === undefined ? {} : { target }) })); } }, [settle]); const callbacksFor = useCallback((id) => { const cached = callbackCache.current.get(id); if (cached) { return cached; } const textFor = () => extractMessageText(latest.current.messages.find((message) => message.id === id)?.parts); const created = { copy: () => { // A UI action copies as what its chip says, not as the JSON block the model reads. const raw = textFor(); const message = latest.current.messages.find((candidate) => candidate.id === id); const action = message?.role === 'user' ? readUiActionText(raw) : null; const text = action !== null ? uiActionSummary(action) : raw; if (!text) { return; } const write = latest.current.options.writeClipboard ?? ((value) => navigator.clipboard.writeText(value)); void Promise.resolve() .then(() => write(text)) .then(() => { setCopied((current) => new Set(current).add(id)); const existing = copyTimers.current.get(id); if (existing) { clearTimeout(existing); } copyTimers.current.set(id, setTimeout(() => { copyTimers.current.delete(id); setCopied((current) => { const next = new Set(current); next.delete(id); return next; }); }, latest.current.options.copyResetMs ?? DEFAULT_COPY_RESET_MS)); }) .catch(() => { // Clipboard can be unavailable (insecure context / denied permission) — fail quietly. }); }, startEdit: () => { const text = textFor(); setDrafts((current) => new Map(current).set(id, text)); }, cancelEdit: () => { setDrafts((current) => { const next = new Map(current); next.delete(id); return next; }); }, setDraft: (value) => { setDrafts((current) => new Map(current).set(id, value)); }, save: () => { const next = (latest.current.drafts.get(id) ?? '').trim(); if (!next) { return; } created.cancelEdit(); void latest.current.options.onEditSubmit?.({ messageId: id, text: next }); }, fork: () => { void latest.current.options.onFork?.({ messageId: id }); }, regenerate: () => { void latest.current.options.onRegenerate?.({ messageId: id }); }, textareaRef: (element) => { if (!element) { return; } element.focus(); element.setSelectionRange(element.value.length, element.value.length); }, onTextareaChange: (event) => { created.setDraft(event.target.value); }, onTextareaKeyDown: (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); created.save(); } if (event.key === 'Escape') { event.preventDefault(); created.cancelEdit(); } }, }; callbackCache.current.set(id, created); return created; }, []); const lastAssistantId = findLastAssistant(messages)?.id ?? null; const items = []; // Pushed components of the last render, by block key: an unchanged one keeps its object. const previousUi = uiBlocks.current; const keptUi = new Map(); const keepUiIdentity = (blocks) => blocks.map((block) => { if (block.kind !== 'ui') return block; const kept = shareStructure(previousUi.get(block.key), block); keptUi.set(block.key, kept); return kept; }); for (let index = visibleStart; index < messages.length; index++) { const message = messages[index]; if (!message) { continue; } const callbacks = callbacksFor(message.id); const draft = drafts.get(message.id); const isEditing = draft !== undefined; const text = extractMessageText(message.parts); const usage = (options.getUsage ?? usageFromMetadata)(message); const isUser = message.role === 'user'; const uiAction = isUser ? readUiActionText(text) : null; const isAssistant = message.role === 'assistant'; const isLastAssistant = isAssistant && message.id === lastAssistantId; items.push({ id: message.id, message, role: message.role, index, isUser, isAssistant, isLastAssistant, isStreaming: message.id === streamingMessageId, uiAction, blocks: keepUiIdentity(buildTranscriptBlocks(message, { isReasoningOpen: (key, isStreamingRun) => openReasoning.get(key) ?? isStreamingRun, toggleReasoning: (key, open) => stableToggle(key)(open), ...(options.sources !== undefined ? { sources: options.sources } : {}), ...(options.toolCatalog !== undefined ? { toolCatalog: options.toolCatalog } : {}), ...(options.hideRetriedFailures !== undefined ? { hideRetriedFailures: options.hideRetriedFailures } : {}), // Read-only still lifts question sets into their block, so the outcome shows. ...(handlers.onAnswer !== undefined || (options.readOnly === true && options.onAnswer !== null) ? { elicitation: { picked: (toolCallId, questionId) => picks.get(pickKey(toolCallId, questionId)), pick, canAnswer: handlers.onAnswer !== undefined, canSkip: handlers.onSkip !== undefined, answer, skip, submitting: (toolCallId) => settling.get(toolCallId) ?? null, errorOf: (toolCallId) => settleErrors.get(toolCallId)?.message ?? null, errorCodeOf: (toolCallId) => settleErrors.get(toolCallId)?.code ?? null, }, } : {}), ...(handlers.onApprove !== undefined || handlers.onReject !== undefined ? { approval: { canApprove: handlers.onApprove !== undefined, canReject: handlers.onReject !== undefined, approve, reject, submitting: (toolCallId) => settling.get(toolCallId) ?? null, errorOf: (toolCallId) => settleErrors.get(toolCallId)?.message ?? null, errorCodeOf: (toolCallId) => settleErrors.get(toolCallId)?.code ?? null, }, } : {}), })), text, usage: usage ? describeUsage(usage) : null, timestamp: describeTimestamp((options.getCreatedAt ?? createdAtFromMetadata)(message)), copy: { available: text.length > 0, copied: copied.has(message.id), copy: callbacks.copy, }, edit: { available: isUser && options.readOnly !== true && options.editable === true && options.onEditSubmit !== undefined, isEditing, draft: draft ?? text, canSave: (draft ?? '').trim().length > 0, start: callbacks.startEdit, cancel: callbacks.cancelEdit, setDraft: callbacks.setDraft, save: callbacks.save, getTextareaProps: () => ({ ref: callbacks.textareaRef, value: draft ?? text, onChange: callbacks.onTextareaChange, onKeyDown: callbacks.onTextareaKeyDown, }), }, fork: { available: options.readOnly !== true && options.onFork !== undefined, run: callbacks.fork, }, regenerate: { available: isLastAssistant && options.readOnly !== true && options.regeneratable === true && options.onRegenerate !== undefined, run: callbacks.regenerate, }, }); } uiBlocks.current = keptUi; return items; } /** * The handler for each human decision: the caller's, else the backend's own call — so a parked * approval or question set is never a card nobody can act on. `null` opts a decision out. */ function settleHandlers(options, backend) { const pick = (own, fallback) => options.readOnly === true || own === null ? undefined : (own ?? fallback); return { onAnswer: pick(options.onAnswer, backend.answerToolCall ? async (input) => { await backend.answerToolCall?.(input); } : undefined), onSkip: pick(options.onSkip, backend.skipToolCall ? async (input) => { await backend.skipToolCall?.(input); } : undefined), onApprove: pick(options.onApprove, backend.approveToolCall || backend.approveActionProposal ? async (input) => { if (input.target?.kind === 'proposal') { if (input.target.threadId === undefined) throw new Error('A proposal decision requires its thread scope'); await requireBackendMethod(backend, 'approveActionProposal')({ threadId: input.target.threadId, proposalId: input.target.proposalId, ...(input.remember === undefined ? {} : { remember: input.remember }), }); } else await requireBackendMethod(backend, 'approveToolCall')(input); } : undefined), onReject: pick(options.onReject, backend.rejectToolCall || backend.rejectActionProposal ? async (input) => { if (input.target?.kind === 'proposal') { if (input.target.threadId === undefined) throw new Error('A proposal decision requires its thread scope'); await requireBackendMethod(backend, 'rejectActionProposal')({ threadId: input.target.threadId, proposalId: input.target.proposalId, ...(input.reason === undefined ? {} : { reason: input.reason }), }); } else await requireBackendMethod(backend, 'rejectToolCall')(input); } : undefined), }; } function metadataOf(message) { const metadata = message.metadata; return metadata !== null && typeof metadata === 'object' ? metadata : {}; } /** The default `getUsage`: what the library stamps on `metadata.usage`. */ function usageFromMetadata(message) { const usage = metadataOf(message).usage; if (usage === undefined) return null; return { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, costUsd: usage.costUsd ?? null, }; } /** The default `getCreatedAt`: what the library stamps on `metadata.createdAt`. */ function createdAtFromMetadata(message) { return metadataOf(message).createdAt ?? null; } /** `|` never appears in either half: a tool-call id and a question id are both opaque tokens. */ function pickKey(toolCallId, questionId) { return `${toolCallId}|${questionId}`; } /** The picks belonging to one question set, as the answers a submission carries. */ function answersFor(picks, toolCallId) { const prefix = `${toolCallId}|`; const answers = {}; for (const [key, values] of picks) { if (key.startsWith(prefix)) { answers[key.slice(prefix.length)] = values; } } return answers; } function findLastAssistant(messages) { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (message?.role === 'assistant') { return message; } } return undefined; } /** * Changes as the transcript grows, including WITHIN the message being streamed — the array is * mutated in place while tokens arrive, so its length alone never moves during a turn. */ function contentSignature(messages) { const last = messages.at(-1); if (!last) { return '0'; } let length = 0; for (const part of last.parts ?? []) { if ('text' in part && typeof part.text === 'string') { length += part.text.length; } } return `${messages.length}|${last.id}|${last.parts?.length ?? 0}|${length}`; } /** The server's machine-readable reason for refusing a decision (`run_not_active`, …), if any. */ function settleErrorCode(error) { const code = error?.code; return typeof code === 'string' && code !== '' ? code : null; } //# sourceMappingURL=use-chat-transcript.js.map