import { useCallback, useEffect, useRef, useState } from 'react'; import { storage } from '../../lib/storage'; import { callLLM } from '../../lib/llm'; import * as db from '../../lib/db'; import { buildKbContext, validateDelta, deltaKey } from './rag'; import { buildSystemPrompt, PROPOSE_GRAPH_DELTA_TOOL, LOOKUP_TOPIC_TOOL, STRINGS, } from './prompts'; const MAX_HISTORY = 50; const VERBATIM_TURNS = 12; const MAX_LOOKUP_HOPS = 3; const TRUNCATION_NOTICE = '(earlier conversation truncated)'; /** Trim API history to the last N turns and prepend a truncation notice. */ function truncateApiMessages(history) { if (history.length <= VERBATIM_TURNS) return history; const tail = history.slice(-VERBATIM_TURNS); return [{ role: 'assistant', content: TRUNCATION_NOTICE }, ...tail]; } async function resolveLookupTopic(id, allTopics) { const topic = allTopics.find(t => t.id === id); if (!topic) { return { ok: false, payload: `Geen topic gevonden met id "${id}".` }; } const content = await db.getContent(id).catch(() => null); let contentSnippet = null; if (content) { let raw; if (typeof content === 'string') raw = content; else if (content.article) raw = typeof content.article === 'string' ? content.article : JSON.stringify(content.article); else raw = JSON.stringify(content); contentSnippet = raw.replace(/\s+/g, ' ').trim().slice(0, 2400); } return { ok: true, payload: { id: topic.id, label: topic.label, type: topic.type || 'concept', description: topic.description || '', learning_content: contentSnippet, }, }; } /** * Conversation hook for R42. * Owns the message list, persists to chat:thread:{userId}, calls Anthropic * via the shared `callLLM` client (with a `lookup_topic` multi-hop loop and * the `propose_graph_delta` tool), and surfaces validated graph delta * suggestions inline. */ export function useChat({ user, isAdmin }) { const threadKey = user ? `chat:thread:${user.id}` : null; const [messages, setMessages] = useState([]); const [isThinking, setIsThinking] = useState(false); const [errored, setErrored] = useState(false); const seenDeltaKeys = useRef(new Set()); useEffect(() => { if (!user) return; const stored = storage.get(threadKey, null); if (stored && Array.isArray(stored) && stored.length) { setMessages(stored); for (const m of stored) { if (m.suggestion?.key) seenDeltaKeys.current.add(m.suggestion.key); } } else { setMessages([ { id: `m_${Date.now()}`, role: 'assistant', content: STRINGS.greeting(user.name || 'daar'), ts: Date.now(), }, ]); } }, [user, threadKey]); useEffect(() => { if (!threadKey) return; const capped = messages.slice(-MAX_HISTORY); storage.set(threadKey, capped); }, [messages, threadKey]); const updateMessage = useCallback((id, patch) => { setMessages(prev => prev.map(m => (m.id === id ? { ...m, ...patch } : m))); }, []); const send = useCallback(async (text) => { const trimmed = (text || '').trim(); if (!trimmed || !user) return; const userMsg = { id: `m_${Date.now()}_u`, role: 'user', content: trimmed, ts: Date.now(), }; const next = [...messages, userMsg]; setMessages(next); setIsThinking(true); setErrored(false); try { const { context: kbContext, allTopics } = await buildKbContext(trimmed); const systemPrompt = buildSystemPrompt({ userName: user.name || 'daar', isAdmin, kbContext, }); const historyMessages = next .filter(m => m.role === 'user' || m.role === 'assistant') .map(m => ({ role: m.role, content: m.content })); const apiMessages = truncateApiMessages(historyMessages); let textOut = ''; let suggestion = null; let hops = 0; while (true) { const response = await callLLM({ task: 'chat.r42', tier: 'standard', system: systemPrompt, messages: apiMessages, tools: [LOOKUP_TOPIC_TOOL, PROPOSE_GRAPH_DELTA_TOOL], maxTokens: 2048, temperature: 0.3, }); if (response.text) { textOut += (textOut ? '\n' : '') + response.text; } const lookupCalls = response.toolUses.filter(tu => tu.name === LOOKUP_TOPIC_TOOL.name); const deltaCall = response.toolUses.find(tu => tu.name === PROPOSE_GRAPH_DELTA_TOOL.name); if (deltaCall && !suggestion) { const validated = validateDelta(deltaCall.input, allTopics); if (validated) { const key = deltaKey(validated); if (!seenDeltaKeys.current.has(key)) { seenDeltaKeys.current.add(key); suggestion = { ...validated, key, status: 'pending' }; } } } if (lookupCalls.length === 0 || hops >= MAX_LOOKUP_HOPS) break; hops++; const assistantBlocks = []; if (response.text) assistantBlocks.push({ type: 'text', text: response.text }); for (const tu of response.toolUses) { if (!tu.id) continue; assistantBlocks.push({ type: 'tool_use', id: tu.id, name: tu.name, input: tu.input }); } apiMessages.push({ role: 'assistant', content: assistantBlocks }); const toolResults = []; for (const tu of lookupCalls) { if (!tu.id) continue; const topicId = String(tu.input?.id || '').trim(); const resolved = await resolveLookupTopic(topicId, allTopics); toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: typeof resolved.payload === 'string' ? resolved.payload : JSON.stringify(resolved.payload), ...(resolved.ok ? {} : { is_error: true }), }); } apiMessages.push({ role: 'user', content: toolResults }); } const assistantMsg = { id: `m_${Date.now()}_a`, role: 'assistant', content: textOut || (suggestion ? STRINGS.suggestionTitle : STRINGS.errorGeneric), ts: Date.now(), ...(suggestion ? { suggestion } : {}), }; setMessages(prev => [...prev, assistantMsg]); } catch (e) { console.error('[R42] chat error', e); setErrored(true); let errorContent = STRINGS.errorGeneric; const errorMsg = e?.message || ''; if (e?.name === 'LLMTruncatedError') { errorContent = 'Mijn circuits zijn overbelast (Token Limiet bereikt). Kun je je vraag korter of specifieker maken?'; } else if (e?.name === 'LLMValidationError') { errorContent = 'Mijn antwoord was helaas beschadigd of incorrect geformatteerd. Kun je het nog eens proberen?'; } else if (/api key/i.test(errorMsg)) { errorContent = STRINGS.errorNoKey; } setMessages(prev => [ ...prev, { id: `m_${Date.now()}_e`, role: 'error', content: errorContent, ts: Date.now(), }, ]); } finally { setIsThinking(false); } }, [messages, user, isAdmin]); return { messages, isThinking, errored, send, updateMessage, }; }