feat: implement R42 chat infrastructure with Anthropic API integration and custom design system
All checks were successful
On Push to Main / test (push) Successful in 30s
On Push to Main / publish (push) Successful in 57s
On Push to Main / deploy-dev (push) Successful in 1m31s

This commit is contained in:
RaymondVerhoef
2026-05-17 16:48:40 +02:00
parent 43d01dff58
commit 98e32d8ac0
21 changed files with 2631 additions and 6 deletions

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { storage } from '../../lib/storage';
import { anthropicApi } from '../../lib/api';
import { buildKbContext, validateDelta, deltaKey } from './rag';
import { buildSystemPrompt, PROPOSE_GRAPH_DELTA_TOOL, STRINGS } from './prompts';
const MAX_HISTORY = 50;
/**
* Conversation hook for R42.
* Owns the message list, persists to chat:thread:{userId}, calls Anthropic,
* and surfaces validated graph delta suggestions inline.
*
* Note: buildKbContext is async (reads PocketBase), so send() is fully async.
*/
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());
// Load persisted thread + seed greeting
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]);
// Persist on change
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, topics: kbTopics } = await buildKbContext(trimmed);
const systemPrompt = buildSystemPrompt({
userName: user.name || 'daar',
isAdmin,
kbContext,
});
// Strip UI-only fields before sending to Anthropic
const apiMessages = next
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: m.content }));
const response = await anthropicApi.chat(systemPrompt, apiMessages, {
tools: [PROPOSE_GRAPH_DELTA_TOOL],
});
let textOut = '';
let suggestion = null;
for (const block of response.content || []) {
if (block.type === 'text') {
textOut += (textOut ? '\n' : '') + (block.text || '');
} else if (block.type === 'tool_use' && block.name === PROPOSE_GRAPH_DELTA_TOOL.name) {
const validated = validateDelta(block.input, kbTopics);
if (validated) {
const key = deltaKey(validated);
if (!seenDeltaKeys.current.has(key)) {
seenDeltaKeys.current.add(key);
suggestion = { ...validated, key, status: 'pending' };
}
}
}
}
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);
const isKey = /api key/i.test(e?.message || '');
setMessages(prev => [
...prev,
{
id: `m_${Date.now()}_e`,
role: 'error',
content: isKey ? STRINGS.errorNoKey : STRINGS.errorGeneric,
ts: Date.now(),
},
]);
} finally {
setIsThinking(false);
}
}, [messages, user, isAdmin]);
return {
messages,
isThinking,
errored,
send,
updateMessage,
};
}