import type { AppendMessage, ExternalStoreAdapter } from '@assistant-ui/react'; import { PersonaProtocol, PersonaMessage, PersonaMessageAttachment, TransformMessages, AgentState } from '../types'; import type { PersonaLogger } from '../logging'; import { parseMessages } from '../messages'; import { fileToBase64 } from './utils'; /** * Codifica un testo in base64 (per l'eco locale degli allegati testuali: * un tile con nome e download, invece di far sparire il contenuto). */ function textToBase64(text: string): string { const bytes = new TextEncoder().encode(text); let binary = ''; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); } return btoa(binary); } type StartRunConfig = Parameters>[1]; /** * Creates the onNew handler for new messages */ export function createOnNewHandler( protocols: PersonaProtocol[], setMessages: React.Dispatch>, setAgentState: React.Dispatch>, transformMessages?: TransformMessages, logger?: PersonaLogger, ) { return async (message: AppendMessage) => { const textPart = (message.content as Array<{ type: string; text?: string }>).find((c) => c.type === 'text'); const hasAttachments = message.attachments && message.attachments.length > 0; if (!textPart && !hasAttachments) { throw new Error('Message must contain text or attachments'); } const input = textPart?.type === 'text' ? textPart.text ?? '' : ''; // Check for steer command: /steer if (input.startsWith('/steer ') && !hasAttachments) { const steerText = input.slice(7).trim(); if (steerText) { return steerAgent(protocols, steerText, logger); } } const ready = protocols.some( (protocol) => protocol.getName() !== 'transaction' && protocol.status === 'connected', ); if (!ready) { setMessages((currentConversation) => { const errorMessage: PersonaMessage = { role: 'assistant', type: 'text', text: 'No protocol is connected.', createdAt: new Date(), finishReason: 'stop', }; const newMessages = [...currentConversation, errorMessage]; return transformMessages ? transformMessages(newMessages) : newMessages; }); return; } const protocol = protocols .sort((a, b) => b.getPriority() - a.getPriority()) .find((protocol) => protocol.status === 'connected'); const content: Array = []; // Eco locale: gli allegati inviati restano visibili nel messaggio utente // in chat (image/file part), oltre che essere spediti al backend. const attachments: PersonaMessageAttachment[] = []; if (message.attachments) { for (const attachment of message.attachments) { const contentType = attachment.contentType || 'application/octet-stream'; if (contentType.startsWith('image/') && attachment.file) { // L'adapter immagine ha già letto il data URL: riusiamo il base64 // senza riloggere il File. const imagePart = attachment.content?.find((c) => c.type === 'image'); const base64 = imagePart && 'image' in imagePart ? (imagePart.image.split(',')[1] ?? '') : await fileToBase64(attachment.file); content.push({ role: 'user', image: { contentType, content: base64, }, text: '', type: 'text', createdAt: new Date(), }); attachments.push({ image: { contentType, content: base64 } }); } else if (attachment.content) { // Check for text content (from SimpleTextAttachmentAdapter) const textPart = attachment.content.find((c) => c.type === 'text'); if (textPart && 'text' in textPart) { content.push({ role: 'user', file: { name: attachment.name, contentType, }, text: textPart.text, type: 'text', createdAt: new Date(), }); attachments.push({ file: { name: attachment.name, base64: textToBase64(textPart.text), contentType }, }); } // Check for file content with base64 data (from SimpleFileAttachmentAdapter) const filePart = attachment.content.find((c) => c.type === 'file'); if (filePart && 'data' in filePart) { content.push({ role: 'user', file: { name: attachment.name, base64: (filePart as { data: string }).data, contentType, }, text: '', type: 'text', createdAt: new Date(), }); attachments.push({ file: { name: attachment.name, base64: (filePart as { data: string }).data, contentType }, }); } } else if (attachment.file) { // Fallback: any file with no processed content — read as base64 const base64 = await fileToBase64(attachment.file); content.push({ role: 'user', file: { name: attachment.name, base64, contentType, }, text: '', type: 'text', createdAt: new Date(), }); attachments.push({ file: { name: attachment.name, base64, contentType } }); } } } if (input) { content.push({ role: 'user', text: input, type: 'text', createdAt: new Date(), }); } // Il messaggio utente locale include gli allegati: il testo appare subito // e l'immagine/il file restano visibili nel messaggio inviato. const userMessage: PersonaMessage = { role: 'user', type: 'text', text: input, ...(attachments.length > 0 ? { attachments } : {}), createdAt: new Date(), }; setMessages((currentConversation) => { const newMessages = parseMessages([...currentConversation, userMessage]); return transformMessages ? transformMessages(newMessages) : newMessages; }); setAgentState('running'); logger?.debug('Sending message:', content); await protocol?.sendPacket({ type: 'request', payload: content }); }; } /** * Sends a steer command to all connected protocols. */ async function steerAgent( protocols: PersonaProtocol[], steerText: string, logger?: PersonaLogger, ) { const connectedProtocols = protocols.filter( (p) => p.getName() !== 'transaction' && p.status === 'connected', ); if (connectedProtocols.length === 0) { logger?.warn('No connected protocol available for steer'); return; } logger?.debug('Sending steer command:', steerText); // Send steer to all connected protocols await Promise.allSettled( connectedProtocols.map((protocol) => protocol.sendPacket({ type: 'command', payload: { command: 'steer', arguments: { text: steerText } }, }), ), ); } /** * Creates the onEdit handler for editing messages */ export function createOnEditHandler( protocols: PersonaProtocol[], messages: PersonaMessage[], setMessages: React.Dispatch>, setAgentState: React.Dispatch>, transformMessages?: TransformMessages, logger?: PersonaLogger, ) { return async (message: AppendMessage) => { if (message.content[0]?.type !== 'text') { throw new Error('Only text messages are supported'); } const protocol = protocols .sort((a, b) => b.getPriority() - a.getPriority()) .find((protocol) => protocol.status === 'connected'); if (!protocol) { logger?.debug('No protocol available for edit'); return; } const parentId = message.parentId; if (!parentId) { logger?.debug('No parent ID provided for edit'); return; } const parentIndex = messages.findIndex((m) => m.id === parentId); if (parentIndex === -1) { logger?.debug('Parent message not found:', parentId); return; } const messagesToKeep = messages.slice(0, parentIndex + 1); const editedMessage: PersonaMessage = { role: 'user', text: message.content[0].text, type: 'text', createdAt: new Date(), }; const newMessages = parseMessages([...messagesToKeep, editedMessage]); setMessages(transformMessages ? transformMessages(newMessages) : newMessages); setAgentState('running'); const content: Array = [ { role: 'user', text: message.content[0].text, type: 'text', createdAt: new Date(), }, ]; logger?.debug('Sending edited message:', content); await protocol.sendPacket({ type: 'request', payload: content }); }; } /** * Creates the onReload handler for regenerating messages */ export function createOnReloadHandler( protocols: PersonaProtocol[], messages: PersonaMessage[], setMessages: React.Dispatch>, setAgentState: React.Dispatch>, transformMessages?: TransformMessages, logger?: PersonaLogger, ) { return async (parentId: string | null, config: StartRunConfig) => { const protocol = protocols .sort((a, b) => b.getPriority() - a.getPriority()) .find((protocol) => protocol.status === 'connected'); if (!protocol) { logger?.debug('No protocol available for reload'); return; } const actualParentId = config.parentId ?? parentId; if (!actualParentId) { logger?.debug('No parent ID provided for reload'); return; } const parentIndex = messages.findIndex((msg) => msg.id === actualParentId); if (parentIndex === -1) { logger?.debug('Parent message not found:', actualParentId); return; } const parentMessage = messages[parentIndex]; setMessages((currentMessages) => { const messagesToKeep = currentMessages.slice(0, parentIndex + 1); return transformMessages ? transformMessages(messagesToKeep) : messagesToKeep; }); let userMessage: PersonaMessage | undefined; if (parentMessage.role === 'assistant' || parentMessage.role === 'function') { for (let i = parentIndex - 1; i >= 0; i--) { if (messages[i].role === 'user') { userMessage = messages[i]; break; } } } else if (parentMessage.role === 'user') { userMessage = parentMessage; } if (!userMessage) { logger?.debug('No user message found to regenerate from'); return; } setAgentState('running'); const content: Array = []; // Gli allegati dell'eco locale vengono ripescati: rigenerare un turno // rispedisce anche immagini e file che lo hanno avviato. for (const att of userMessage.attachments ?? []) { if (att.image) { content.push({ role: 'user', image: att.image, text: '', type: 'text', createdAt: new Date(), }); } else if (att.file?.base64) { content.push({ role: 'user', file: att.file, text: '', type: 'text', createdAt: new Date(), }); } } if (userMessage.image) { content.push({ role: 'user', image: userMessage.image, text: '', type: 'text', createdAt: new Date(), }); } content.push({ role: 'user', text: userMessage.text, type: 'text', createdAt: new Date(), }); logger?.debug('Regenerating message with config:', { actualParentId, sourceId: config.sourceId, custom: config.runConfig.custom, }); await protocol.sendPacket({ type: 'request', payload: content }); }; } /** * Quanto si aspetta l'agent_end dopo un cancel prima di sbloccare comunque il * composer. Il server manda l'agent_end quando il turno si è davvero fermato, * che può richiedere il tempo della tool call in corso; se però il messaggio * non arriva (connessione caduta, execution perduta) l'utente resterebbe con * la chat bloccata sullo stato "interruzione in corso". */ const CANCEL_FALLBACK_MS = 20000; /** * Creates the onCancel handler for cancelling agent execution. * Sends the dedicated 'cancel' command to all connected protocols. */ export function createOnCancelHandler( protocols: PersonaProtocol[], setAgentState: React.Dispatch>, logger?: PersonaLogger, ) { return () => { setAgentState('cancelling'); setTimeout(() => { setAgentState((current) => { if (current !== 'cancelling') { return current; } logger?.warn('No agent_end received after cancel: unlocking the composer'); return 'idle'; }); }, CANCEL_FALLBACK_MS); logger?.debug('Sending cancel command to all connected protocols'); // Send the dedicated cancel command to ALL connected protocols protocols.forEach((protocol) => { if (protocol.status === 'connected') { protocol .sendPacket({ type: 'command', payload: { command: 'cancel', arguments: {} }, }) .catch((err) => { logger?.error('Failed to send cancel command:', err); }); } }); return Promise.resolve(); }; }