230 lines
7.2 KiB
JavaScript
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,
|
|
};
|
|
}
|