Spaces:
Sleeping
Sleeping
| /** | |
| * useConversationManager.ts — S-CM1 | |
| * | |
| * Estratto da MainWorkspace.tsx (S761-CM1). | |
| * Gestisce il ciclo vita della conversazione: sendMessage, streaming, handlers. | |
| * | |
| * Riduzione MainWorkspace: ~1267 → ~720 righe (-43%). | |
| * Invariante PR2: solo frontend, zero side-effect backend. | |
| */ | |
| import { useState, useRef, useCallback, useEffect } from "react"; | |
| import type * as React from "react"; | |
| import { toast } from "sonner"; | |
| import type { ConfirmRequest } from "@/lib/agentLoop"; | |
| import type { Suggestion } from "@/lib/agent/SmartSuggestions"; | |
| import type { PreviewRuntimeError } from "@/components/PreviewSandbox"; | |
| import { | |
| getMessages, getConversations, saveConversations, saveMessages, | |
| createConversation, updateConversationTitle, touchConversation, | |
| type Message, | |
| } from "@/lib/storage"; | |
| import { | |
| getAnyToken, buildMessages, buildRawMessages, guessCategory, | |
| } from "@/lib/messageBuilders"; | |
| import { streamWithFallback } from "@/lib/providerChain"; | |
| import { getCurrentSegment } from "@/lib/topicBoundary"; | |
| import { buildFileContext } from "@/lib/fileReader"; | |
| import { exportAsJSON, importFromJSON } from "@/lib/export"; | |
| import { saveSession } from "@/lib/sessionMemory"; | |
| import { classifyTask, getTopInterpretations } from "@/lib/taskClassifier"; | |
| import { detectAmbiguity } from "@/lib/ambiguityDetector"; | |
| import { useUIStore } from "@/store/uiStore"; | |
| import { useConversationStore } from "@/store/conversationStore"; | |
| import { useWorkspaceStore } from "@/store/workspaceStore"; | |
| import { useChatStore } from "@/store/chatStore"; | |
| import { useTaskStore, RESUME_WINDOW_MS } from "@/store/taskStore"; | |
| import { useSlashCommands } from "@/hooks/useSlashCommands"; | |
| // S439: lazy singleton — identico al pattern MainWorkspace (module-level cache) | |
| let _agentLoopPromise: Promise<typeof import("@/lib/agentLoop")> | null = null; | |
| const _loadAgentLoop = (): Promise<typeof import("@/lib/agentLoop")> => | |
| (_agentLoopPromise ??= import("@/lib/agentLoop")); | |
| export interface ConversationManagerResult { | |
| /** S713: streaming content separato — aggiorna solo il componente streaming */ | |
| streamingContent: string; | |
| streamingMsgIdRef: React.MutableRefObject<string | null>; | |
| /** S296: SmartSuggestions chips */ | |
| suggestions: Suggestion[]; | |
| setSuggestions: React.Dispatch<React.SetStateAction<Suggestion[]>>; | |
| /** S352: resume task interrotto */ | |
| resumeTask: { id: string; goal: string } | null; | |
| setResumeTask: React.Dispatch<React.SetStateAction<{ id: string; goal: string } | null>>; | |
| /** InlineConfirmation */ | |
| pendingConfirm: ConfirmRequest | null; | |
| setPendingConfirm: React.Dispatch<React.SetStateAction<ConfirmRequest | null>>; | |
| confirmResolverRef: React.MutableRefObject<((v: boolean) => void) | null>; | |
| /** Refs esposti per il render di MainWorkspace */ | |
| abortRef: React.MutableRefObject<AbortController | null>; | |
| importFileRef: React.MutableRefObject<HTMLInputElement | null>; | |
| /** Handlers */ | |
| sendMessage: (text: string, prefixContext?: string, opts?: { resumeTaskId?: string }) => Promise<void>; | |
| handleRetry: () => void; | |
| handleAutoFix: (error: string, code: string, filename: string) => void; | |
| handleExport: () => void; | |
| handleImport: () => void; | |
| handleImportFile: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>; | |
| handlePreviewRuntimeError: (errors: PreviewRuntimeError[]) => void; | |
| handlePreviewVerified: () => void; | |
| handleRunRequest: (code: string, filename: string) => void; | |
| executeSlashCommand: ReturnType<typeof useSlashCommands>; | |
| } | |
| export function useConversationManager(): ConversationManagerResult { | |
| // ── Streaming display state (S713) ──────────────────────────────────────── | |
| const [_streamContent, _setStreamContent] = useState(""); | |
| const _streamMsgIdRef = useRef<string | null>(null); | |
| // ── UI state ─────────────────────────────────────────────────────────────── | |
| const [suggestions, setSuggestions] = useState<Suggestion[]>([]); | |
| const [resumeTask, setResumeTask] = useState<{ id: string; goal: string } | null>(null); | |
| // ── InlineConfirmation ───────────────────────────────────────────────────── | |
| const [pendingConfirm, setPendingConfirm] = useState<ConfirmRequest | null>(null); | |
| const confirmResolverRef = useRef<((v: boolean) => void) | null>(null); | |
| // ── Streaming refs ───────────────────────────────────────────────────────── | |
| const abortRef = useRef<AbortController | null>(null); | |
| const importFileRef = useRef<HTMLInputElement | null>(null); | |
| const streamingConvIdRef = useRef<string | null>(null); | |
| const streamingMsgIdRef = useRef<string | null>(null); | |
| const hasFirstChunkRef = useRef(false); | |
| const agentLiveStatusRef = useRef(""); | |
| const _lastSaveRef = useRef(0); | |
| const _sendMsgRef = useRef<((text: string, prefix?: string) => Promise<void>) | null>(null); | |
| const _previewAutoRepairFired = useRef(false); | |
| const _isSafariAbortRef = useRef<boolean>(false); | |
| const _pendingSteerRef = useRef<string | null>(null); // V7-5 | |
| // ── Store subscriptions ──────────────────────────────────────────────────── | |
| const isStreaming = useChatStore(s => s.isStreaming); | |
| const setIsStreaming = useChatStore(s => s.setIsStreaming); | |
| const setAgentMode = useChatStore(s => s.setAgentMode); | |
| const setThinkMode = useChatStore(s => s.setThinkMode); | |
| const setPendingDisambig = useChatStore(s => s.setPendingDisambig); | |
| const setAgentLiveStatus = useChatStore(s => s.setAgentLiveStatus); | |
| const setInput = useChatStore(s => s.setInput); | |
| const setAttachedFiles = useChatStore(s => s.setAttachedFiles); | |
| const activeId = useConversationStore(s => s.activeId); | |
| const messages = useConversationStore(s => s.messages); | |
| const setMessages = useConversationStore(s => s.setMessages); | |
| const setConversations = useConversationStore(s => s.setConversations); | |
| const setActiveId = useConversationStore(s => s.setActiveId); | |
| const previewHtml = useWorkspaceStore(s => s.previewHtml); | |
| const setRunCode = useWorkspaceStore(s => s.setRunCode); | |
| const setTab = useUIStore(s => s.setActiveTab); | |
| const addAlert = useUIStore(s => s.addAlert); | |
| // ── _persistMsgs — stable callback ──────────────────────────────────────── | |
| const _persistMsgs = useCallback( | |
| (id: string, msgs: Message[], opts?: { skipLocalStorage?: boolean }) => | |
| saveMessages(id, msgs, opts), | |
| [], | |
| ); | |
| // ── executeSlashCommand ──────────────────────────────────────────────────── | |
| const executeSlashCommand = useSlashCommands({ | |
| setAgentMode, | |
| persistMsgs: _persistMsgs, | |
| sendMsgRef: _sendMsgRef, | |
| }); | |
| // ── sendMessage ──────────────────────────────────────────────────────────── | |
| const sendMessage = useCallback(async ( | |
| text: string, | |
| prefixContext?: string, | |
| opts?: { resumeTaskId?: string }, | |
| ) => { | |
| const _chat = useChatStore.getState(); | |
| const _conv = useConversationStore.getState(); | |
| const _ws = useWorkspaceStore.getState(); | |
| const fileCtx = buildFileContext(_chat.attachedFiles); | |
| // V7-5: prepend pending steer hint se non streaming | |
| const _sh = _pendingSteerRef.current; | |
| if (_sh) _pendingSteerRef.current = null; | |
| const userText = (prefixContext ? prefixContext + "\n\n" : "") + | |
| (_sh ? `[Contesto: ${_sh}]\n` : "") + text.trim() + fileCtx; | |
| if (!userText) return; | |
| // S400-MANUS: live guidance — inietta istruzione nel loop in esecuzione | |
| if (_chat.isStreaming) { | |
| if (_chat.agentMode && text.trim()) { | |
| const _cid = _conv.activeId; | |
| if (_cid) { | |
| const _guidanceMsg: Message = { | |
| id: crypto.randomUUID(), role: "user", | |
| content: text.trim(), createdAt: Date.now(), isGuidance: true, | |
| }; | |
| const _withGuidance = [..._conv.messages, _guidanceMsg]; | |
| setMessages(_withGuidance); | |
| _persistMsgs(_cid, _withGuidance); | |
| import("@/lib/liveGuidanceStore").then(m => m.liveGuidanceStore.push(text.trim())).catch(() => {}); | |
| setInput(""); setAttachedFiles([]); | |
| } | |
| } | |
| return; | |
| } | |
| const _isPending = !!_chat.pendingDisambig; | |
| const _disambigOrig = _chat.pendingDisambig?.originalQuery ?? ""; | |
| if (_isPending) setPendingDisambig(null); | |
| setInput(""); setAttachedFiles([]); | |
| let convId = _conv.activeId; | |
| let currentConvs = _conv.conversations; | |
| if (!convId) { | |
| const conv = createConversation(); | |
| currentConvs = [conv, ..._conv.conversations]; | |
| setConversations(currentConvs); | |
| saveConversations(currentConvs); | |
| setActiveId(conv.id); | |
| convId = conv.id; | |
| } | |
| const userMsg: Message = { id: crypto.randomUUID(), role: "user", content: userText, createdAt: Date.now() }; | |
| const currentMsgs = _conv.activeId === convId ? _conv.messages : getMessages(convId); | |
| const withUser = [...currentMsgs, userMsg]; | |
| // S97-Bug2: topic boundary cut | |
| const _prevSeg = getCurrentSegment(); | |
| const _newCat = guessCategory(userText); | |
| const INCOMPAT = new Set(["realtime:coding","coding:realtime","news:coding","coding:news"]); | |
| const _isNewTopic = _prevSeg && INCOMPAT.has(`${_prevSeg.category}:${_newCat}`); | |
| let msgsForLLM = _isNewTopic ? [userMsg] : withUser; | |
| if (_isPending && _disambigOrig) { | |
| const enriched = `[Query originale]: ${_disambigOrig}\n\n[Chiarimento]: ${userText}`; | |
| msgsForLLM = msgsForLLM.map(m => m.id === userMsg.id ? { ...m, content: enriched } : m); | |
| } | |
| setMessages(withUser); | |
| _persistMsgs(convId, withUser); | |
| // S264: smart title sincrono | |
| if (currentMsgs.length === 0) { | |
| const _FILLER = /^(ciao|hey|hei|salve|ok|okay|allora|quindi|comunque|per favore|per piacere|grazie|perfavore|puoi|potresti|riesci|dimmi|spiegami|vorrei|voglio|ho bisogno|aiutami|aiuto|please|can you|could you|tell me|explain|help me|i need|i want|what is|what are|how do|how to|come|cosa|chi|quando|dove|perch[eé]|qual[ei]|in che|in modo|fammi|fai|crea|scrivi|genera|fatti|devo|mi serv[ei]|ho un|ho una|[ée] possibile|sai|hai|conosci|sai dirmi|c['']s*[eè]|come funziona|come si|mi puoi|mi potresti)\s+/i; | |
| const _raw = text.trim().replace(/^[\s\u2022\u2019""''«»]+/, ''); | |
| const _clean = _raw.replace(_FILLER, '').trim(); | |
| const _src = (_clean.length > 4 ? _clean : _raw); | |
| const _words = _src.split(/\s+/).filter(w => w.length > 1).slice(0, 6).join(' '); | |
| const _smartTitle = _words.length > 0 | |
| ? (_words.charAt(0).toUpperCase() + _words.slice(1)).slice(0, 54) | |
| : 'Nuova conversazione'; | |
| const updated = updateConversationTitle(currentConvs, convId, _smartTitle); | |
| setConversations(updated); saveConversations(updated); | |
| } | |
| // S435: Auto-detection modalità | |
| const _AUTO_AGENT_TYPES = new Set([ | |
| "full_app","architecture","search_and_report","code_generation", | |
| "debug","refactor","data_analysis","realtime_query", | |
| ]); | |
| const _AUTO_THINK_TYPES = new Set(["explanation","math_calculation"]); | |
| const _isAutoMode = !_chat.agentMode && !_chat.thinkMode; | |
| let _effectiveAgentMode = _chat.agentMode; | |
| let _effectiveThinkMode = _chat.thinkMode; | |
| if (_isAutoMode) { | |
| const _autoCls = classifyTask(text); | |
| _effectiveAgentMode = _autoCls.complexity >= 6 || _autoCls.needsPlan || | |
| _AUTO_AGENT_TYPES.has(_autoCls.type); | |
| _effectiveThinkMode = !_effectiveAgentMode && ( | |
| _AUTO_THINK_TYPES.has(_autoCls.type) || _autoCls.complexity >= 7 | |
| ); | |
| if (_effectiveAgentMode) setAgentMode(true); | |
| else if (_effectiveThinkMode) setThinkMode(true); | |
| } | |
| // S99: Ambiguity Detector | |
| if (_effectiveAgentMode && !_isPending) { | |
| const _topInterps = getTopInterpretations(text, 3); | |
| const _primaryCls = classifyTask(text); | |
| const _amb = detectAmbiguity(text, _primaryCls, _topInterps); | |
| if (_amb.isAmbiguous && _amb.question) { | |
| const qaMsg: Message = { id: crypto.randomUUID(), role: "assistant", content: `🤔 ${_amb.question}`, createdAt: Date.now() }; | |
| const withQA = [...withUser, qaMsg]; | |
| setMessages(withQA); _persistMsgs(convId, withQA); | |
| setPendingDisambig({ originalQuery: text }); | |
| return; | |
| } | |
| } | |
| const assistantMsg: Message = { id: crypto.randomUUID(), role: "assistant", content: "…", createdAt: Date.now() }; | |
| setMessages([...withUser, assistantMsg]); | |
| setIsStreaming(true); | |
| setTab("chat"); | |
| streamingConvIdRef.current = convId; | |
| streamingMsgIdRef.current = assistantMsg.id; | |
| _streamMsgIdRef.current = assistantMsg.id; | |
| hasFirstChunkRef.current = false; | |
| const abort = new AbortController(); | |
| abortRef.current = abort; | |
| let accumulated = ""; | |
| let rafId: number | null = null; | |
| let _lastPersistedLen = 0; | |
| const PERSIST_CHUNK = 500; | |
| let _taskId: string | null = null; | |
| const flushChunk = () => { | |
| rafId = null; | |
| if (streamingConvIdRef.current !== convId) return; | |
| _setStreamContent(accumulated); | |
| if (accumulated.length - _lastPersistedLen >= PERSIST_CHUNK) { | |
| _lastPersistedLen = accumulated.length; | |
| _persistMsgs(convId, withUser.concat({ ...assistantMsg, content: accumulated }), { skipLocalStorage: true }); | |
| } | |
| }; | |
| try { | |
| const _onChunk = (chunk: string) => { | |
| accumulated += chunk; | |
| if (!hasFirstChunkRef.current) { | |
| hasFirstChunkRef.current = true; | |
| const ws = "Scrittura risposta in tempo reale..."; | |
| agentLiveStatusRef.current = ws; | |
| setAgentLiveStatus(ws); | |
| } | |
| if (rafId !== null) return; | |
| rafId = requestAnimationFrame(flushChunk); | |
| }; | |
| const _onAgentStatus = (t: string) => { | |
| agentLiveStatusRef.current = t; | |
| setAgentLiveStatus(t); | |
| if (streamingMsgIdRef.current) | |
| setMessages(prev => prev.map(m => m.id === streamingMsgIdRef.current ? { ...m, agentStatus: t } : m)); | |
| }; | |
| if (_effectiveAgentMode) { | |
| const ts0 = useTaskStore.getState(); | |
| // P4-1: se VITE_BACKEND_URL è configurato e il backend è healthy → SSE Railway | |
| const _backendUrl = (import.meta.env.VITE_BACKEND_URL ?? "").replace(/\/$/, ""); | |
| const { isBackendHealthy: _isBH } = await import("@/lib/backendClient"); | |
| const _useBackendSSE = !!_backendUrl && _isBH() && !opts?.resumeTaskId; | |
| if (_useBackendSSE) { | |
| // ── P4-1: Backend SSE path ──────────────────────────────────────── | |
| const { startAgentTaskSSE } = await import("@/lib/agentSSE"); | |
| const _sseCtx = buildRawMessages(msgsForLLM, _ws.openFile, _ws.settings) as unknown as import('@/lib/apiTypes').ApiMsg[]; | |
| // P17-F3-FIX: userText include fileCtx con visionDescription — il backend SSE | |
| // riceve la descrizione AI delle immagini allegate (buildFileContext già formatta | |
| // visionDescription/visionTags/visionTextInImage come testo strutturato). | |
| // Prima: text (grezzo) → backend cieco sulle immagini. Ora: userText → contesto pieno. | |
| const sseHandle = startAgentTaskSSE(userText, _sseCtx, 8); | |
| // P16-F1: S399-GUARD ha rifiutato il task (SSE già attivo) — fallback locale | |
| if (sseHandle.rejected) { | |
| /* [UCM] SSE rejected fallback */ | |
| // Re-route verso il path locale senza SSE (agentLoop in-browser) | |
| throw new Error("SSE_REJECTED"); | |
| } | |
| _taskId = sseHandle.taskId; | |
| // Quando l'utente clicca "Stop" → cancella anche il task SSE | |
| abort.signal.addEventListener("abort", () => sseHandle.cancel(), { once: true }); | |
| // Token streaming dal backend → stessa pipeline UI del local agent | |
| const _sseOnChunk = (e: Event) => { | |
| const d = (e as CustomEvent<{ taskId: string; token: string; text: string }>).detail; | |
| if (d.taskId !== sseHandle.taskId) return; | |
| _onChunk(d.token); | |
| }; | |
| window.addEventListener("agent:stream-chunk", _sseOnChunk); | |
| const _sseMsgId = streamingMsgIdRef.current; | |
| const { eventRuntime: _er } = await import("@/core/events"); | |
| try { | |
| await new Promise<void>((resolve, reject) => { | |
| if (abort.signal.aborted) { resolve(); return; } | |
| let _resolved = false; | |
| const _done = (err?: Error) => { | |
| if (_resolved) return; | |
| _resolved = true; | |
| _u1?.(); _u2?.(); _u3?.(); | |
| _unsubStore(); | |
| if (err) reject(err); | |
| else resolve(); | |
| }; | |
| // Stop utente → risolvi (non è un errore) | |
| abort.signal.addEventListener("abort", () => _done(), { once: true }); | |
| let _u1: (() => void) | undefined; | |
| let _u2: (() => void) | undefined; | |
| let _u3: (() => void) | undefined; | |
| _u1 = _er.on("task_complete", (ev) => { | |
| if (ev.taskId !== sseHandle.taskId) return; | |
| _done(); | |
| }); | |
| _u2 = _er.on("task_error", (ev) => { | |
| if (ev.taskId !== sseHandle.taskId) return; | |
| _done(abort.signal.aborted ? undefined : new Error(ev.message)); | |
| }); | |
| _u3 = _er.on("task_cancel", (ev) => { | |
| if (ev.taskId !== sseHandle.taskId) return; | |
| _done(); | |
| }); | |
| // Fallback: osserva taskStore per stati terminali (USR timeout, network drop) | |
| const _unsubStore = useTaskStore.subscribe((state) => { | |
| const _t = state.tasks.find(t => t.id === sseHandle.taskId); | |
| if (!_t) return; | |
| const _TERMINAL = ["SUCCESS", "ERROR", "TIMEOUT", "CANCELLED"]; | |
| if (!_TERMINAL.includes(_t.status)) return; | |
| const _isErr = _t.status === "ERROR" || _t.status === "TIMEOUT"; | |
| _done(_isErr ? new Error(_t.error ?? "Task terminato con errore") : undefined); | |
| }); | |
| }); | |
| } finally { | |
| window.removeEventListener("agent:stream-chunk", _sseOnChunk); | |
| } | |
| // Converti TaskAction[] → AgentStep[] e salva nel messaggio per display post-stream | |
| if (_sseMsgId) { | |
| const _ft = useTaskStore.getState().tasks.find(t => t.id === sseHandle.taskId); | |
| if (_ft?.actions.length) { | |
| const _steps = _ft.actions | |
| .filter(a => a.type !== "plan") | |
| .map(a => ({ | |
| tool: a.toolName ?? String(a.type), | |
| status: (a.done ? "done" : "running") as "done" | "running" | "error", | |
| output: a.detail, | |
| explanation: a.explanation, | |
| timestamp: a.at, | |
| })); | |
| if (_steps.length > 0) { | |
| setMessages(prev => (prev.map(m => m.id === _sseMsgId ? { ...m, steps: _steps } : m) as Message[])); | |
| } | |
| } | |
| } | |
| // taskStore già aggiornato a SUCCESS da agentSSEDispatch | |
| } else { | |
| // ── Local agent path (fallback: backend non disponibile / resume task) ── | |
| if (opts?.resumeTaskId) { | |
| _taskId = opts.resumeTaskId; | |
| ts0.retryTask(_taskId); | |
| ts0.updateStatus(_taskId, "RUNNING"); | |
| } else { | |
| _taskId = ts0.startTask(text); | |
| ts0.updateStatus(_taskId, "RUNNING"); | |
| } | |
| const { runAgentLoop } = await _loadAgentLoop(); | |
| await runAgentLoop( | |
| "", | |
| buildRawMessages(msgsForLLM, _ws.openFile, _ws.settings) as Array<{ role: string; content: string }>, | |
| { model: _effectiveThinkMode ? "deepseek-ai/DeepSeek-R1" : _ws.settings.model, temperature: _ws.settings.temperature, maxTokens: _ws.settings.maxTokens, keepThink: _effectiveThinkMode, taskId: _taskId!, conversationId: convId }, | |
| { | |
| onChunk: _onChunk, | |
| onStatus: _onAgentStatus, | |
| onNeedConfirm: (req) => new Promise<boolean>((resolve) => { | |
| confirmResolverRef.current = resolve; | |
| setPendingConfirm(req); | |
| }), | |
| onSteps: (steps) => { | |
| if (!streamingMsgIdRef.current) return; | |
| setMessages(prev => prev.map(m => m.id === streamingMsgIdRef.current ? { ...m, steps } : m)); | |
| }, | |
| onSuggestions: (sugs) => setSuggestions(sugs), | |
| }, | |
| abort.signal, | |
| ); | |
| useTaskStore.getState().updateStatus(_taskId, "SUCCESS"); | |
| } | |
| } else { | |
| await streamWithFallback( | |
| getAnyToken() || "", | |
| buildMessages(msgsForLLM, _ws.openFile, _ws.settings), | |
| _onChunk, | |
| abort.signal, | |
| { model: _effectiveThinkMode ? "deepseek-ai/DeepSeek-R1" : _ws.settings.model, temperature: _ws.settings.temperature, maxTokens: _ws.settings.maxTokens, keepThink: _effectiveThinkMode }, | |
| ); | |
| } | |
| if (_isAutoMode) { setAgentMode(false); setThinkMode(false); } | |
| if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } | |
| if (streamingConvIdRef.current === convId) { | |
| const cleanContent = accumulated.replace(/\u200B/g, ""); | |
| setMessages(prev => { | |
| const final = prev.map(m => m.id === assistantMsg.id ? { ...m, content: cleanContent } : m); | |
| _persistMsgs(convId, final); | |
| return final; | |
| }); | |
| _setStreamContent(""); _streamMsgIdRef.current = null; | |
| const touched = touchConversation(currentConvs, convId); | |
| setConversations(touched); saveConversations(touched); | |
| // S264: async AI title — dopo il primo scambio | |
| if (withUser.length === 1) { | |
| void (async () => { | |
| try { | |
| const _token = getAnyToken(); | |
| if (!_token) return; | |
| let _aiTitle = ''; | |
| await streamWithFallback( | |
| _token, | |
| [ | |
| { role: 'system', content: 'Sei un assistente che genera titoli brevi. Rispondi SOLO con il titolo, niente altro. Massimo 5 parole, niente punteggiatura finale.' }, | |
| { role: 'user', content: `Genera un titolo brevissimo (3-5 parole, in italiano) per una conversazione che inizia con: "${text.slice(0, 200)}"` }, | |
| ], | |
| (chunk) => { _aiTitle += chunk; }, | |
| new AbortController().signal, | |
| { model: 'llama-3.1-8b-instant', maxTokens: 20, temperature: 0.3 }, | |
| ); | |
| const _t = _aiTitle.replace(/^["']|["']$|^Titolo:\s*/gi, '').trim().slice(0, 54); | |
| if (_t.length >= 4) { | |
| const _convs = useConversationStore.getState().conversations; | |
| const _upd = updateConversationTitle(_convs, convId, _t.charAt(0).toUpperCase() + _t.slice(1)); | |
| setConversations(_upd); saveConversations(_upd); | |
| } | |
| } catch { /* non-fatal */ } | |
| })(); | |
| } | |
| } | |
| } catch (err: unknown) { | |
| if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } | |
| if ((err as Error)?.name === "AbortError") { | |
| const partial = accumulated.replace(/\u200B/g, "").trim(); | |
| const stopped = withUser.concat({ ...assistantMsg, content: partial || "_(risposta interrotta)_" }); | |
| setMessages(stopped); | |
| if (streamingConvIdRef.current === convId) _persistMsgs(convId, stopped); | |
| _setStreamContent(""); _streamMsgIdRef.current = null; | |
| return; | |
| } | |
| const rawMsg = (err as Error).message ?? ""; | |
| if (_taskId) useTaskStore.getState().updateStatus(_taskId, "ERROR", rawMsg || "Errore sconosciuto"); | |
| const friendlyMsg = rawMsg.includes("All providers exhausted") || rawMsg.includes("503") | |
| ? "⚠️ Tutti i provider AI sono temporaneamente esauriti. Riprova tra qualche minuto, oppure apri le **Impostazioni** per configurare un'API key alternativa." | |
| : rawMsg.includes("401") || rawMsg.includes("403") | |
| ? "🔑 API key non valida o scaduta. Apri le **Impostazioni** per aggiornare la chiave." | |
| : rawMsg.includes("429") | |
| ? "⏱️ Rate limit raggiunto. Attendi qualche secondo e riprova, oppure apri le **Impostazioni** per aggiungere un'API key alternativa." | |
| : `Errore: ${rawMsg}`; | |
| const errMsg = withUser.concat({ ...assistantMsg, content: friendlyMsg, error: true }); | |
| setMessages(errMsg); _persistMsgs(convId, errMsg); | |
| } finally { | |
| import("@/lib/liveGuidanceStore").then(m => m.liveGuidanceStore.clear()).catch(() => {}); | |
| setIsStreaming(false); | |
| if (typeof requestIdleCallback !== "undefined") { | |
| requestIdleCallback(() => { | |
| import("@/lib/sessionAnalyzer").then(m => m.runSessionAnalysis()).catch(() => {}); | |
| }, { timeout: 10_000 }); | |
| } | |
| abortRef.current = null; | |
| streamingConvIdRef.current = null; | |
| streamingMsgIdRef.current = null; | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [_persistMsgs, setInput, setAttachedFiles, setPendingDisambig, | |
| setConversations, setActiveId, setMessages, setIsStreaming, setTab, setAgentLiveStatus, | |
| setSuggestions, setPendingConfirm, setAgentMode, setThinkMode]); | |
| // ── sendMessage ref sync ────────────────────────────────────────────────── | |
| useEffect(() => { _sendMsgRef.current = sendMessage; }, [sendMessage]); | |
| // ── agente-ai:send listener (GAP-C) ────────────────────────────────────── | |
| useEffect(() => { | |
| const _h = (e: Event) => { | |
| const text = (e as CustomEvent<{ text: string }>).detail?.text; | |
| if (text) { setSuggestions([]); void sendMessage(text); } | |
| }; | |
| window.addEventListener("agente-ai:send", _h); | |
| return () => window.removeEventListener("agente-ai:send", _h); | |
| }, [sendMessage]); | |
| // ── V7-5: agente:steer listener — soft-steer hint ────────────────────────── | |
| useEffect(() => { | |
| const _steerH = (e: Event) => { | |
| const d = (e as CustomEvent<{ tool: string; hint: string }>).detail; | |
| if (!d?.hint) return; | |
| if (useChatStore.getState().isStreaming) { | |
| import("@/lib/liveGuidanceStore") | |
| .then(m => m.liveGuidanceStore.push(`[STEER ${d.tool}] ${d.hint}`)) | |
| .catch(() => {}); | |
| } else { | |
| _pendingSteerRef.current = d.hint; | |
| } | |
| }; | |
| window.addEventListener("agente:steer", _steerH); | |
| return () => window.removeEventListener("agente:steer", _steerH); | |
| }, []); // eslint-disable-line react-hooks/exhaustive-deps | |
| // ── Session save debounced 5min ─────────────────────────────────────────── | |
| useEffect(() => { | |
| if (!isStreaming && messages.length > 2) { | |
| const now = Date.now(); | |
| if (now - _lastSaveRef.current > 300_000) { | |
| _lastSaveRef.current = now; | |
| saveSession(messages.map(m => ({ role: m.role, content: m.content }))); | |
| } | |
| const { streamingTaskId } = useChatStore.getState(); | |
| if (!streamingTaskId) useChatStore.getState().clearStreaming(); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [isStreaming]); | |
| // ── Auto-save ogni 30s durante streaming (S258) ─────────────────────────── | |
| useEffect(() => { | |
| if (!isStreaming) return; | |
| const id = activeId; | |
| if (!id) return; | |
| const interval = setInterval(() => { | |
| const { messages: liveMessages, persistMsgs: _p } = useConversationStore.getState(); | |
| if (liveMessages.length > 0) _p(id, liveMessages); | |
| }, 30_000); | |
| return () => clearInterval(interval); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [isStreaming, activeId]); | |
| // ── Agent live status cycling ───────────────────────────────────────────── | |
| useEffect(() => { | |
| if (!isStreaming) { agentLiveStatusRef.current = ""; setAgentLiveStatus(""); return; } | |
| hasFirstChunkRef.current = false; | |
| const push = (text: string) => { | |
| if (hasFirstChunkRef.current) return; | |
| agentLiveStatusRef.current = text; | |
| setAgentLiveStatus(text); | |
| if (streamingMsgIdRef.current) | |
| setMessages(prev => prev.map(m => m.id === streamingMsgIdRef.current ? { ...m, agentStatus: text } : m)); | |
| }; | |
| const timers = [ | |
| setTimeout(() => push("Comprendo la richiesta..."), 0), | |
| setTimeout(() => push("Pianificazione risposta..."), 1400), | |
| setTimeout(() => push("Elaborazione contesto..."), 3800), | |
| setTimeout(() => push("Strutturazione output..."), 8000), | |
| setTimeout(() => push("Generazione risposta..."), 15000), | |
| ]; | |
| return () => timers.forEach(clearTimeout); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [isStreaming]); | |
| // ── Hydrate messages on activeId change ────────────────────────────────── | |
| useEffect(() => { | |
| if (activeId) setMessages(getMessages(activeId)); | |
| else setMessages([]); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [activeId]); | |
| // ── S352/S621: Banner "riprendi task interrotto" ────────────────────────── | |
| useEffect(() => { | |
| const tasks = useTaskStore.getState().tasks; | |
| const interrupted = tasks.find(t => | |
| (t.status === "RUNNING" || t.status === "QUEUED") && | |
| Date.now() - t.startedAt < RESUME_WINDOW_MS | |
| ); | |
| if (interrupted) setResumeTask({ id: interrupted.id, goal: interrupted.goal.slice(0, 80) }); | |
| }, []); // eslint-disable-line react-hooks/exhaustive-deps | |
| // ── S697: Safari stream abort-on-hide ──────────────────────────────────── | |
| useEffect(() => { | |
| const onStreamBg = () => { | |
| _isSafariAbortRef.current = true; | |
| setTimeout(() => { | |
| if (_isSafariAbortRef.current && abortRef.current) abortRef.current.abort(); | |
| }, 8000); | |
| }; | |
| const onStreamFg = () => { _isSafariAbortRef.current = false; }; | |
| window.addEventListener('safari:stream-background', onStreamBg); | |
| window.addEventListener('safari:stream-foreground', onStreamFg); | |
| return () => { | |
| window.removeEventListener('safari:stream-background', onStreamBg); | |
| window.removeEventListener('safari:stream-foreground', onStreamFg); | |
| }; | |
| }, []); | |
| // ── S402: reset repair guard su nuova versione HTML ─────────────────────── | |
| useEffect(() => { | |
| _previewAutoRepairFired.current = false; | |
| }, [previewHtml?.content]); | |
| // ── OPFS fallback toast ─────────────────────────────────────────────────── | |
| useEffect(() => { | |
| const handler = (e: Event) => { | |
| const path = (e as CustomEvent<{ path: string }>).detail?.path ?? ""; | |
| toast.warning( | |
| `File salvato in memoria alternativa${path ? ` (${path.split("/").pop()})` : ""} — OPFS non disponibile su questo browser.`, | |
| { duration: 6000 }, | |
| ); | |
| }; | |
| window.addEventListener("vfs:opfs-fallback", handler); | |
| return () => window.removeEventListener("vfs:opfs-fallback", handler); | |
| }, []); | |
| // ── handleRetry ────────────────────────────────────────────────────────── | |
| const handleRetry = useCallback(() => { | |
| const msgs = useConversationStore.getState().messages; | |
| const lastIdx = msgs.length - 1; | |
| if (lastIdx < 1) return; | |
| const prev = msgs[lastIdx - 1]; | |
| if (prev?.role === "user") { | |
| setMessages(m => m.slice(0, lastIdx - 1)); | |
| void sendMessage(prev.content); | |
| } | |
| }, [sendMessage, setMessages]); | |
| // ── handleAutoFix ──────────────────────────────────────────────────────── | |
| const handleAutoFix = useCallback((error: string, code: string, filename: string) => | |
| void sendMessage( | |
| `Fix questo errore e restituisci il file completo corretto.\n\n**File:** \`${filename}\`\n**Errore:**\n\`\`\`\n${error}\n\`\`\`\n**Codice:**\n\`\`\`\n${code}\n\`\`\`` | |
| ), [sendMessage]); | |
| // ── handleExport ───────────────────────────────────────────────────────── | |
| const handleExport = useCallback(() => { | |
| const { conversations, activeId: aid, messages: msgs } = useConversationStore.getState(); | |
| const conv = conversations.find(c => c.id === aid); | |
| if (!conv) return; | |
| exportAsJSON(conv, msgs); | |
| }, []); | |
| // ── handleImport ───────────────────────────────────────────────────────── | |
| const handleImport = useCallback(() => { importFileRef.current?.click(); }, []); | |
| // ── handleImportFile ───────────────────────────────────────────────────── | |
| const handleImportFile = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| e.target.value = ""; | |
| try { | |
| const { conv, messages: msgs } = await importFromJSON(file); | |
| const convs = [conv, ...getConversations()]; | |
| saveConversations(convs); saveMessages(conv.id, msgs); | |
| setConversations(convs); setActiveId(conv.id); setMessages(msgs); | |
| try { sessionStorage.setItem("agente_active_id", conv.id); } catch { /**/ } | |
| } catch (err) { | |
| addAlert({ severity: "error", title: "Import fallito", message: String(err instanceof Error ? err.message : err), source: "import" }); | |
| } | |
| }, [setConversations, setActiveId, setMessages, addAlert]); | |
| // ── handlePreviewRuntimeError (S402) ───────────────────────────────────── | |
| const handlePreviewRuntimeError = useCallback((errors: PreviewRuntimeError[]) => { | |
| if (_previewAutoRepairFired.current) return; | |
| const _cs = useChatStore.getState(); | |
| if (_cs.isStreaming) return; | |
| _previewAutoRepairFired.current = true; | |
| const _pHtml = useWorkspaceStore.getState().previewHtml; | |
| if (_pHtml?.name) { | |
| const _firstErr = errors[0]?.message ?? "errore runtime"; | |
| import("@/lib/projectStateGraph").then(m => m.markFailed(_pHtml.name!, _firstErr)).catch(() => {}); | |
| } | |
| const errorLines = errors.slice(0, 3) | |
| .map((e, i) => `${i + 1}. ${e.message}${e.line ? ` (riga ${e.line})` : ""}`) | |
| .join("\n"); | |
| void sendMessage( | |
| `🔴 **Errore runtime rilevato nel preview HTML** (auto-repair S402)\n\n\`\`\`\n${errorLines}\n\`\`\`\n\nCorreggi il file HTML/JS nel VFS e riscrivilo completo. Non spiegare — scrivi direttamente il codice corretto.`, | |
| ); | |
| }, [sendMessage]); | |
| // ── handlePreviewVerified (S403) ───────────────────────────────────────── | |
| const handlePreviewVerified = useCallback(() => { | |
| const _pHtml = useWorkspaceStore.getState().previewHtml; | |
| if (_pHtml?.name) | |
| import("@/lib/projectStateGraph").then(m => m.markVerified(_pHtml.name!)).catch(() => {}); | |
| }, []); | |
| // ── handleRunRequest ────────────────────────────────────────────────────── | |
| const handleRunRequest = useCallback((code: string, filename: string) => { | |
| setRunCode({ code, filename }); | |
| setTab("chat"); | |
| const ext = filename.split(".").pop()?.toLowerCase() ?? ""; | |
| if (ext === "py") { | |
| void sendMessage(`Esegui questo script Python:\n\`\`\`python\n${code}\n\`\`\``); | |
| } else if (ext === "js" || ext === "ts" || ext === "mjs") { | |
| void sendMessage(`Esegui questo codice:\n\`\`\`${ext}\n${code}\n\`\`\``); | |
| } else { | |
| setTab("run"); | |
| } | |
| }, [sendMessage, setRunCode, setTab]); | |
| return { | |
| streamingContent: _streamContent, | |
| streamingMsgIdRef: _streamMsgIdRef, | |
| suggestions, setSuggestions, | |
| resumeTask, setResumeTask, | |
| pendingConfirm, setPendingConfirm, | |
| confirmResolverRef, | |
| abortRef, importFileRef, | |
| sendMessage, handleRetry, handleAutoFix, | |
| handleExport, handleImport, handleImportFile, | |
| handlePreviewRuntimeError, handlePreviewVerified, | |
| handleRunRequest, executeSlashCommand, | |
| }; | |
| } | |