Files
learning-platform/src/components/chat/useChat.js

230 lines
7.2 KiB
JavaScript

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,
};
}