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

@@ -2,6 +2,8 @@ import React from 'react'
import { Routes, Route, Navigate, Link } from 'react-router-dom'
import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut } from 'lucide-react'
import { useApp } from './store/AppContext'
import Mark from './components/ui/Mark'
import ChatLauncher from './components/chat/ChatLauncher'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
@@ -29,8 +31,9 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
<div className="min-h-screen bg-bg text-fg font-sans flex flex-col pb-16 md:pb-0">
{/* Top Navigation Bar */}
<nav className="bg-bg-dark text-paper p-4 shadow-soft flex justify-between items-center sticky top-0 z-50">
<Link to="/" className="flex items-center">
<img src="/images/logo-light.png" alt="Respellion Logo" className="h-6 md:h-8 object-contain" />
<Link to="/" className="flex items-center gap-2" aria-label="Respellion home">
<Mark state="idle" size={28} brace="#ECE9E9" letter="#ECE9E9" />
<span className="text-paper font-semibold tracking-tight text-base md:text-lg">respellion</span>
</Link>
{/* Desktop Links */}
@@ -74,6 +77,8 @@ const ProtectedRoute = ({ children, requireAdmin }) => {
<main className="flex-1 w-full max-w-[var(--max)] mx-auto">
{children}
</main>
<ChatLauncher />
</div>
)
}

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
import * as db from '../../lib/db';
import { anthropicApi } from '../../lib/api';
import Button from '../ui/Button';
import SuggestionsQueue from './SuggestionsQueue';
const KnowledgeGraph = () => {
const svgRef = useRef(null);
@@ -19,11 +20,18 @@ const KnowledgeGraph = () => {
const [topics, setTopics] = useState([]);
const [relations, setRelations] = useState([]);
useEffect(() => {
const reloadKb = useCallback(() => {
db.getTopics().then(setTopics);
db.getRelations().then(setRelations);
}, []);
useEffect(() => {
reloadKb();
const handler = () => reloadKb();
window.addEventListener('respellion:kb-updated', handler);
return () => window.removeEventListener('respellion:kb-updated', handler);
}, [reloadKb]);
useEffect(() => {
if (!wrapperRef.current) return;
const { width, height } = wrapperRef.current.getBoundingClientRect();
@@ -290,6 +298,11 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
)}
</div>
{/* R42 chatbot suggestions queue */}
<div className="mb-6 pb-4 border-b border-bg-warm">
<SuggestionsQueue onApplied={reloadKb} />
</div>
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg text-teal">Node Details</h3>
{selectedNode && !isEditing && (

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useState } from 'react';
import { Check, X, Clock, Sparkles } from 'lucide-react';
import { kbStore } from '../../lib/kbStore';
import Button from '../ui/Button';
/**
* Admin sub-panel inside the Knowledge Graph view. Shows pending R42 chatbot
* suggestions with approve / reject controls.
*/
export default function SuggestionsQueue({ onApplied }) {
const [pending, setPending] = useState([]);
const refresh = () => setPending(kbStore.listSuggestions('pending'));
useEffect(() => {
refresh();
const onChange = () => refresh();
window.addEventListener('respellion:kb-updated', onChange);
return () => window.removeEventListener('respellion:kb-updated', onChange);
}, []);
if (pending.length === 0) {
return (
<div className="text-xs text-fg-muted flex items-center gap-2">
<Sparkles size={14} /> Geen openstaande voorstellen van R42.
</div>
);
}
return (
<div className="space-y-3">
<div className="text-xs text-fg-muted uppercase tracking-wider font-mono flex items-center gap-2">
<Sparkles size={14} /> R42-voorstellen ({pending.length})
</div>
{pending.map(s => (
<div key={s.id} className="bg-bg rounded-[var(--r-sm)] border border-bg-warm p-3 text-sm">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-fg-muted flex items-center gap-2">
<Clock size={12} />
{new Date(s.ts).toLocaleString()}
{s.proposedByName && <> · door {s.proposedByName}</>}
</div>
</div>
{s.reason && <p className="mb-2 text-fg">{s.reason}</p>}
{(s.topics?.length > 0 || s.relations?.length > 0) && (
<ul className="text-xs space-y-1 mb-3">
{s.topics?.map(t => (
<li key={`t-${t.id}`}>
<strong>{t.label}</strong>{' '}
<span className="text-fg-muted">({t.type})</span>
{t.description && <> {t.description}</>}
</li>
))}
{s.relations?.map((r, i) => (
<li key={`r-${i}`}>
<code className="text-teal">{r.source}</code>{' → '}
<span className="text-fg-muted">{r.type}</span>{' → '}
<code className="text-teal">{r.target}</code>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<Button
onClick={async () => {
await kbStore.approveSuggestion(s.id);
refresh();
onApplied?.();
}}
className="text-xs py-1 px-3 flex items-center gap-1"
>
<Check size={14} /> Goedkeuren
</Button>
<Button
variant="outline"
onClick={() => {
kbStore.rejectSuggestion(s.id);
refresh();
}}
className="text-xs py-1 px-3 flex items-center gap-1"
>
<X size={14} /> Afwijzen
</Button>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import React, { useEffect, useState } from 'react';
import Mark from '../ui/Mark';
import ChatWindow from './ChatWindow';
import { useApp } from '../../store/AppContext';
import { storage } from '../../lib/storage';
import { STRINGS } from './prompts';
import './chat.css';
const QUIZ_EVENT = 'respellion:quiz-state';
/**
* Global floating launcher. Hidden while a quiz is active (set via
* the `respellion:quiz-state` window event from Testen.jsx) to protect
* quiz integrity.
*/
export default function ChatLauncher() {
const { state } = useApp();
const user = state.currentUser;
const isAdmin = user?.role === 'admin';
const [open, setOpen] = useState(false);
const [quizActive, setQuizActive] = useState(() => {
if (!user) return false;
return storage.get(`quiz:active:${user.id}`, false) === true;
});
useEffect(() => {
if (!user) return;
setQuizActive(storage.get(`quiz:active:${user.id}`, false) === true);
}, [user]);
useEffect(() => {
const handler = (e) => {
setQuizActive(Boolean(e?.detail?.active));
if (e?.detail?.active) setOpen(false);
};
window.addEventListener(QUIZ_EVENT, handler);
return () => window.removeEventListener(QUIZ_EVENT, handler);
}, []);
if (!user || quizActive) return null;
return (
<>
{open && (
<ChatWindow user={user} isAdmin={isAdmin} onClose={() => setOpen(false)} />
)}
<button
type="button"
className={`r42-fab ${open ? 'open' : ''}`}
onClick={() => setOpen(o => !o)}
aria-label={open ? STRINGS.closeAria : STRINGS.openAria}
aria-expanded={open}
>
<Mark state="idle" size={open ? 28 : 36} brace="#ECE9E9" letter="#ECE9E9" />
</button>
</>
);
}

View File

@@ -0,0 +1,84 @@
import React from 'react';
import Mark from '../ui/Mark';
import { STRINGS } from './prompts';
/**
* Renders one message bubble. Assistant messages may include a `suggestion`
* (validated graph delta) — the user confirms or rejects inline.
*/
export default function ChatMessage({ msg, onAcceptSuggestion, onRejectSuggestion }) {
if (msg.role === 'user') {
return (
<div className="r42-msg me">
<div className="bub">{msg.content}</div>
</div>
);
}
if (msg.role === 'error') {
return (
<div className="r42-msg error">
<div className="av-sm">
<Mark state="error" size={20} brace="#ECE9E9" letter="#ECE9E9" />
</div>
<div className="bub">{msg.content}</div>
</div>
);
}
// assistant
const s = msg.suggestion;
return (
<div className="r42-msg">
<div className="av-sm">
<Mark state="idle" size={20} brace="#ECE9E9" letter="#ECE9E9" />
</div>
<div>
<div className="bub">{msg.content}</div>
{s && (
<div className="r42-suggestion" role="group" aria-label="Voorstel kennisgraaf">
<div className="r42-suggestion-title">{STRINGS.suggestionTitle}</div>
{s.reason && <div style={{ marginBottom: 6 }}>{s.reason}</div>}
{(s.topics.length > 0 || s.relations.length > 0) && (
<ul className="r42-suggestion-items">
{s.topics.map(t => (
<li key={`t-${t.id}`}>
<strong>{t.label}</strong>{' '}
<span style={{ opacity: 0.7 }}>({t.type})</span>
</li>
))}
{s.relations.map((r, i) => (
<li key={`r-${i}`}>
<code>{r.source}</code>{' '}
<span style={{ opacity: 0.7 }}>{r.type}</span>{' → '}
<code>{r.target}</code>
</li>
))}
</ul>
)}
{s.status === 'pending' ? (
<div className="r42-suggestion-actions">
<button
type="button"
className="primary"
onClick={() => onAcceptSuggestion(msg.id)}
>
{STRINGS.suggestionAccept}
</button>
<button type="button" onClick={() => onRejectSuggestion(msg.id)}>
{STRINGS.suggestionReject}
</button>
</div>
) : (
<div className="r42-suggestion-status">
{s.status === 'applied' && STRINGS.suggestionAppliedAdmin}
{s.status === 'queued' && STRINGS.suggestionQueuedUser}
{s.status === 'rejected' && STRINGS.suggestionDismissed}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Mark from '../ui/Mark';
import ChatMessage from './ChatMessage';
import { useChat } from './useChat';
import { kbStore } from '../../lib/kbStore';
import { BOT_NAME, STRINGS } from './prompts';
export default function ChatWindow({ user, isAdmin, onClose }) {
const { messages, isThinking, send } = useChat({ user, isAdmin });
const [draft, setDraft] = useState('');
const bodyRef = useRef(null);
const inputRef = useRef(null);
const [decided, setDecided] = useState({}); // { [msgId]: 'applied'|'queued'|'rejected' }
// Scroll to bottom when new messages arrive
useEffect(() => {
if (bodyRef.current) {
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
}
}, [messages, isThinking]);
// Focus the input on open
useEffect(() => {
inputRef.current?.focus();
}, []);
// Close on Escape
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onClose?.();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const handleSubmit = (e) => {
e.preventDefault();
if (!draft.trim() || isThinking) return;
send(draft);
setDraft('');
};
const handleAccept = useCallback(async (msgId) => {
const msg = messages.find(m => m.id === msgId);
if (!msg?.suggestion) return;
if (isAdmin) {
await kbStore.applyDelta(msg.suggestion);
setDecided(prev => ({ ...prev, [msgId]: 'applied' }));
} else {
kbStore.appendSuggestion({
...msg.suggestion,
proposedBy: user?.id,
proposedByName: user?.name,
});
setDecided(prev => ({ ...prev, [msgId]: 'queued' }));
}
}, [messages, isAdmin, user]);
const handleReject = useCallback((msgId) => {
setDecided(prev => ({ ...prev, [msgId]: 'rejected' }));
}, []);
const renderedMessages = messages.map(m => {
if (!m.suggestion) return m;
const status = decided[m.id] || m.suggestion.status || 'pending';
return { ...m, suggestion: { ...m.suggestion, status } };
});
return (
<div
className="r42-window"
role="dialog"
aria-label={`${BOT_NAME} chatbot`}
aria-modal="false"
>
<header className="r42-window-hd">
<div className="av">
<Mark state={isThinking ? 'typing' : 'idle'} size={28} brace="#ECE9E9" letter="#ECE9E9" />
</div>
<div className="r42-window-hd-text">
<div className="r42-window-hd-name">{BOT_NAME}</div>
<div className="r42-window-hd-status"><i /> {STRINGS.status}</div>
</div>
<button
type="button"
className="r42-window-hd-x"
onClick={onClose}
aria-label={STRINGS.closeAria}
>
×
</button>
</header>
<div className="r42-window-body" ref={bodyRef}>
{renderedMessages.map(m => (
<ChatMessage
key={m.id}
msg={m}
onAcceptSuggestion={handleAccept}
onRejectSuggestion={handleReject}
/>
))}
{isThinking && (
<div className="r42-msg">
<div className="av-sm">
<Mark state="typing" size={20} brace="#ECE9E9" letter="#ECE9E9" />
</div>
<div className="bub" style={{ background: 'transparent', border: 'none', padding: '6px 0' }}>
<Mark state="typing" size={28} brace="#1F5560" letter="#1F5560" />
</div>
</div>
)}
</div>
<form className="r42-window-input" onSubmit={handleSubmit}>
<input
ref={inputRef}
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={STRINGS.placeholder}
disabled={isThinking}
aria-label={STRINGS.placeholder}
/>
<button type="submit" disabled={isThinking || !draft.trim()}>
{STRINGS.send}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,233 @@
/* R42 chatbot — FAB + chat window */
.r42-fab {
position: fixed;
right: 24px;
bottom: 80px;
width: 64px;
height: 64px;
border-radius: var(--r-pill);
background: var(--teal-900);
display: grid;
place-items: center;
box-shadow: var(--shadow-pill);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 2px solid var(--cream);
z-index: 60;
padding: 0;
}
.r42-fab:hover { transform: translateY(-2px); }
.r42-fab:focus-visible {
outline: 2px solid var(--purple);
outline-offset: 3px;
}
.r42-fab.open {
bottom: calc(80px + 480px - 56px);
width: 48px;
height: 48px;
}
@media (min-width: 768px) {
.r42-fab { bottom: 24px; }
.r42-fab.open { bottom: calc(24px + 480px - 56px); }
}
.r42-window {
position: fixed;
right: 24px;
bottom: 80px;
width: min(380px, calc(100vw - 32px));
height: min(480px, calc(100vh - 120px));
background: var(--paper);
border-radius: var(--r-md);
border: 1px solid rgba(31, 85, 96, 0.06);
box-shadow: var(--shadow-pill);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 60;
}
@media (min-width: 768px) {
.r42-window { bottom: 24px; }
}
.r42-window-hd {
background: var(--teal-900);
color: var(--cream);
padding: var(--s-3) var(--s-4);
display: flex;
align-items: center;
gap: var(--s-3);
flex-shrink: 0;
}
.r42-window-hd .av {
width: 36px;
height: 36px;
border-radius: var(--r-pill);
background: var(--teal);
display: grid;
place-items: center;
flex-shrink: 0;
}
.r42-window-hd-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.r42-window-hd-name { font: 600 14px/1 var(--font-sans); }
.r42-window-hd-status {
font: 500 var(--t-label)/1 var(--font-mono);
letter-spacing: var(--label-ls);
text-transform: uppercase;
color: rgba(236, 233, 233, 0.6);
display: flex;
align-items: center;
gap: 6px;
}
.r42-window-hd-status i {
width: 5px;
height: 5px;
border-radius: 999px;
background: var(--sage);
}
.r42-window-hd-x {
margin-left: auto;
color: rgba(236, 233, 233, 0.7);
background: transparent;
border: none;
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--r-sm);
}
.r42-window-hd-x:hover { background: rgba(236, 233, 233, 0.1); }
.r42-window-body {
flex: 1;
padding: var(--s-4);
background: var(--cream);
display: flex;
flex-direction: column;
gap: var(--s-3);
overflow-y: auto;
}
.r42-msg { display: flex; gap: var(--s-2); align-items: flex-end; }
.r42-msg .av-sm {
width: 26px;
height: 26px;
border-radius: var(--r-pill);
background: var(--teal-900);
display: grid;
place-items: center;
flex-shrink: 0;
}
.r42-msg .bub {
background: var(--paper);
border-radius: 16px 16px 16px 4px;
padding: 10px 14px;
font: 400 14px/1.45 var(--font-sans);
color: var(--ink);
max-width: 250px;
border: 1px solid rgba(31, 85, 96, 0.06);
word-wrap: break-word;
}
.r42-msg.me { justify-content: flex-end; }
.r42-msg.me .bub {
background: var(--teal);
color: var(--cream);
border: none;
border-radius: 16px 16px 4px 16px;
}
.r42-msg.error .bub {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
}
.r42-suggestion {
margin-top: var(--s-2);
padding: 10px 12px;
background: rgba(92, 29, 216, 0.08);
border: 1px solid rgba(92, 29, 216, 0.2);
border-radius: var(--r-sm);
font: 400 13px/1.4 var(--font-sans);
color: var(--ink);
}
.r42-suggestion-title {
font-weight: 600;
color: var(--purple);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 4px;
}
.r42-suggestion-items {
margin: 6px 0 10px;
padding-left: 16px;
list-style: disc;
}
.r42-suggestion-items li { margin: 2px 0; }
.r42-suggestion-actions {
display: flex;
gap: 6px;
}
.r42-suggestion-actions button {
flex: 1;
padding: 6px 10px;
border-radius: var(--r-pill);
border: 1px solid rgba(92, 29, 216, 0.3);
font: 600 12px/1 var(--font-sans);
cursor: pointer;
background: var(--paper);
color: var(--purple);
transition: background 0.15s ease, color 0.15s ease;
}
.r42-suggestion-actions button.primary {
background: var(--purple);
color: var(--cream);
border-color: var(--purple);
}
.r42-suggestion-actions button.primary:hover { background: var(--purple-700); }
.r42-suggestion-actions button:not(.primary):hover { background: rgba(92, 29, 216, 0.1); }
.r42-suggestion-status {
margin-top: 6px;
font: 500 11px/1 var(--font-mono);
letter-spacing: var(--label-ls);
text-transform: uppercase;
color: var(--fg-muted);
}
.r42-window-input {
padding: var(--s-3) var(--s-4);
background: var(--paper);
border-top: 1px solid rgba(31, 85, 96, 0.06);
display: flex;
align-items: center;
gap: var(--s-2);
flex-shrink: 0;
}
.r42-window-input input {
flex: 1;
background: var(--cream);
border: none;
outline: none;
border-radius: var(--r-pill);
padding: 10px 14px;
font: 400 14px/1 var(--font-sans);
color: var(--ink);
min-width: 0;
}
.r42-window-input input::placeholder { color: rgba(60, 58, 58, 0.4); }
.r42-window-input input:disabled { opacity: 0.5; }
.r42-window-input button {
background: var(--purple);
color: var(--cream);
border: none;
border-radius: var(--r-pill);
padding: 10px 18px;
font: 600 13px/1 var(--font-sans);
cursor: pointer;
transition: background 0.15s ease;
flex-shrink: 0;
}
.r42-window-input button:hover:not(:disabled) { background: var(--purple-700); }
.r42-window-input button:disabled { opacity: 0.4; cursor: not-allowed; }

View File

@@ -0,0 +1,110 @@
/**
* R42 — system prompts, greeting, and the propose_graph_delta tool spec.
* All user-facing strings live here so they can be localised in one place.
*/
export const BOT_NAME = 'R42';
export const STRINGS = {
greeting: (name) =>
`Hoi ${name}, ik ben R42 — vraag iets over je weekonderwerp, of laat me uitleggen wat je in de quiz tegenkwam.`,
placeholder: `Vraag R42 iets…`,
send: 'Stuur',
status: 'online · antwoordt direct',
errorGeneric: 'Sorry, ik kon dat niet beantwoorden. Probeer het nog eens.',
errorNoKey: 'Er is geen API-sleutel ingesteld. Vraag een beheerder om hem toe te voegen in Admin → Settings.',
suggestionTitle: 'Ik hoorde iets nieuws — voeg toe aan de kennisgraaf?',
suggestionAccept: 'Ja, voeg toe',
suggestionReject: 'Nee, sla over',
suggestionAppliedAdmin: 'Toegevoegd aan de graaf.',
suggestionQueuedUser: 'Genoteerd — ik geef het door aan een beheerder.',
suggestionDismissed: 'Oké, niets gedaan.',
closeAria: 'Sluit chatvenster',
openAria: 'Open R42 chatbot',
};
export function buildSystemPrompt({ userName, isAdmin, kbContext }) {
return [
`Je bent R42, de chatbot-avatar van Respellion — een leerplatform voor microlearning, quizzen en kennisontwikkeling.`,
`Antwoord altijd in het Nederlands, kort en zakelijk-vriendelijk. Spreek de gebruiker aan met hun voornaam wanneer dat natuurlijk voelt (${userName}).`,
``,
`JE TAKEN:`,
`1. Leg onderwerpen uit die in de kennisbasis staan.`,
`2. Help de gebruiker quizvragen begrijpen ná afloop (niet tijdens een actieve quiz).`,
`3. Verwijs bij twijfel terug naar het bronmateriaal of zeg eerlijk dat je het niet weet.`,
``,
`JE KENNIS:`,
`Je kennis is beperkt tot de onderstaande Respellion-kennisgraaf. Als een vraag duidelijk buiten dit bereik valt, zeg dat dan eerlijk en stel voor dat de gebruiker de bron toevoegt via Admin → Sources.`,
``,
kbContext,
``,
`KENNISGRAAF VERFIJNEN:`,
`Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`,
``,
`STIJL:`,
`- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`,
`- Geen markdown-headers; gewone Nederlandse tekst.`,
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
isAdmin
? `\nDe gebruiker is beheerder; voorstellen die de tool genereert worden direct toegepast.`
: `\nDe gebruiker is geen beheerder; voorstellen worden in een goedkeuringswachtrij gezet.`,
].join('\n');
}
export const PROPOSE_GRAPH_DELTA_TOOL = {
name: 'propose_graph_delta',
description:
'Stel toevoegingen aan de Respellion-kennisgraaf voor wanneer de gebruiker een topic, proces, rol of relatie noemt die nog niet bestaat. Gebruik dit alleen wanneer je iets nieuws en concreets hoort — niet voor algemene speculatie.',
input_schema: {
type: 'object',
properties: {
reason: {
type: 'string',
description: 'Korte uitleg (1 zin, NL) waarom dit voorstel relevant is.',
},
topics: {
type: 'array',
description: 'Maximaal 3 nieuwe topics.',
items: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'kebab-case identifier, bv. "onboarding-buddy".',
},
label: {
type: 'string',
description: 'Mens-leesbare naam.',
},
type: {
type: 'string',
enum: ['concept', 'role', 'process'],
},
description: {
type: 'string',
description: '12 zinnen Nederlandstalige beschrijving.',
},
},
required: ['id', 'label', 'type', 'description'],
},
},
relations: {
type: 'array',
description: 'Maximaal 5 nieuwe relaties.',
items: {
type: 'object',
properties: {
source: { type: 'string', description: 'topic id' },
target: { type: 'string', description: 'topic id' },
type: {
type: 'string',
enum: ['related_to', 'depends_on', 'part_of', 'executed_by'],
},
},
required: ['source', 'target', 'type'],
},
},
},
required: ['reason'],
},
};

127
src/components/chat/rag.js Normal file
View File

@@ -0,0 +1,127 @@
import * as db from '../../lib/db';
/**
* Build a compact knowledge-base context string to inject into the system prompt.
* Reads topics + relations from PocketBase via db.js.
* Topic-level content is loaded only when a topic id/label appears in the user's message.
*
* Returns { context: string, topics: Array } so callers can reuse the fetched topics
* for validateDelta without a second round-trip.
*/
export async function buildKbContext(userMessage = '') {
const [topics, relations] = await Promise.all([
db.getTopics(),
db.getRelations(),
]);
if (topics.length === 0) {
return {
context: 'KENNISGRAAF: (leeg — er zijn nog geen onderwerpen geëxtraheerd)',
topics: [],
};
}
const topicLines = topics.map(t => {
const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200);
return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`;
});
const relLines = relations.map(r => {
const src = typeof r.source === 'object' ? r.source.id : r.source;
const tgt = typeof r.target === 'object' ? r.target.id : r.target;
return `- ${src} --${r.type}--> ${tgt}`;
});
// Pull deep content for any topic explicitly mentioned in the user message.
const lowered = userMessage.toLowerCase();
const mentionedDeepContent = [];
for (const t of topics) {
const idHit = t.id && lowered.includes(t.id.toLowerCase());
const labelHit = t.label && lowered.includes(t.label.toLowerCase());
if (idHit || labelHit) {
const content = await db.getContent(t.id).catch(() => null);
if (content) {
let raw;
if (typeof content === 'string') raw = content;
else if (content.article) raw = content.article;
else raw = JSON.stringify(content);
const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200);
mentionedDeepContent.push(`### ${t.label}\n${snippet}`);
}
}
}
const context = [
`KENNISGRAAF — TOPICS:`,
topicLines.join('\n'),
``,
`KENNISGRAAF — RELATIES:`,
relLines.length ? relLines.join('\n') : '(geen relaties)',
mentionedDeepContent.length
? `\n\nDIEPERE INHOUD (voor genoemde topics):\n${mentionedDeepContent.join('\n\n')}`
: '',
].join('\n');
return { context, topics };
}
/**
* Validate a delta proposal against the current topic list (already fetched).
* Drops:
* - topics whose id already exists (by id or case-folded label)
* - relations whose source/target isn't in the current graph + this delta
* - self-referencing relations
* Caps to 3 topics + 5 relations.
*
* @param {object} delta - Raw input from the propose_graph_delta tool call
* @param {Array} existingTopics - Topics already fetched from PocketBase
*/
export function validateDelta(delta, existingTopics = []) {
if (!delta || typeof delta !== 'object') return null;
const existingIds = new Set(existingTopics.map(t => t.id));
const existingLabels = new Set(existingTopics.map(t => (t.label || '').toLowerCase()));
const safeTopics = [];
for (const t of Array.isArray(delta.topics) ? delta.topics : []) {
if (!t || typeof t.id !== 'string' || typeof t.label !== 'string') continue;
if (existingIds.has(t.id)) continue;
if (existingLabels.has(t.label.toLowerCase())) continue;
if (!['concept', 'role', 'process'].includes(t.type)) continue;
safeTopics.push({
id: t.id.trim(),
label: t.label.trim(),
type: t.type,
description: (t.description || '').trim(),
});
existingIds.add(t.id);
existingLabels.add(t.label.toLowerCase());
if (safeTopics.length >= 3) break;
}
const knownAfter = new Set([...existingIds, ...safeTopics.map(t => t.id)]);
const safeRelations = [];
for (const r of Array.isArray(delta.relations) ? delta.relations : []) {
if (!r || typeof r.source !== 'string' || typeof r.target !== 'string') continue;
if (r.source === r.target) continue;
if (!knownAfter.has(r.source) || !knownAfter.has(r.target)) continue;
if (!['related_to', 'depends_on', 'part_of', 'executed_by'].includes(r.type)) continue;
safeRelations.push({ source: r.source, target: r.target, type: r.type });
if (safeRelations.length >= 5) break;
}
if (safeTopics.length === 0 && safeRelations.length === 0) return null;
return {
reason: typeof delta.reason === 'string' ? delta.reason : '',
topics: safeTopics,
relations: safeRelations,
};
}
/** Stable key for a delta (used to dedupe within a thread). */
export function deltaKey(delta) {
const t = delta.topics.map(x => x.id).sort().join(',');
const r = delta.relations.map(x => `${x.source}-${x.type}->${x.target}`).sort().join(',');
return `t:${t}|r:${r}`;
}

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

View File

@@ -0,0 +1,71 @@
import React from 'react';
/**
* Respellion brand mark — typeset directly from BallPill Light.
* Three states drive the middle glyph; braces always remain.
* idle → { r }
* typing → { … } three bouncing dots animated in CSS
* error → { ! } with a brief shake
*/
const RESP_PURPLE = '#5C1DD8';
const RESP_TEAL_900 = '#183E46';
const RESP_CREAM = '#ECE9E9';
export default function Mark({
state = 'idle',
size = 160,
theme = 'none',
brace = RESP_PURPLE,
letter = RESP_PURPLE,
showFrame = false,
ariaLabel = 'Respellion mark',
}) {
const bg =
theme === 'dark' ? RESP_TEAL_900 :
theme === 'light' ? RESP_CREAM :
'transparent';
let middle = null;
if (state === 'idle') middle = 'r';
else if (state === 'error') middle = '!';
return (
<svg
viewBox="0 0 100 100"
width={size}
height={size}
role="img"
aria-label={ariaLabel}
className={`r42-mark ${state === 'error' ? 'shake' : ''}`}
>
{showFrame && theme !== 'none' && (
<rect x="2" y="2" width="96" height="96" rx="22" fill={bg} />
)}
<text
x="50"
y="50"
fontFamily="BallPill, ui-monospace, 'JetBrains Mono', monospace"
fontSize="78"
textAnchor="middle"
dominantBaseline="central"
fontWeight="300"
style={{ letterSpacing: '-0.04em', paintOrder: 'stroke fill' }}
>
<tspan fill={brace} stroke={brace} strokeWidth="0.6">{'{'}</tspan>
{middle && <tspan fill={letter} stroke={letter} strokeWidth="0.6">{middle}</tspan>}
{state === 'typing' && <tspan fill={letter}>{' '}</tspan>}
<tspan fill={brace} stroke={brace} strokeWidth="0.6">{'}'}</tspan>
</text>
{state === 'typing' && (
<g>
<circle className="dot" cx="40" cy="50" r="3.8" fill={letter} />
<circle className="dot" cx="50" cy="50" r="3.8" fill={letter} />
<circle className="dot" cx="60" cy="50" r="3.8" fill={letter} />
</g>
)}
</svg>
);
}

View File

@@ -1,6 +1,14 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import "tailwindcss";
@font-face {
font-family: 'BallPill';
font-weight: 300;
font-style: normal;
font-display: swap;
src: url('/fonts/BallPill-light.otf') format('opentype');
}
@theme {
--font-sans: "Space Grotesk", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
@@ -117,3 +125,19 @@ code, .mono { font-family: var(--font-mono); }
.label { font: 500 var(--t-label)/1 var(--font-mono); letter-spacing: var(--label-ls); text-transform: uppercase; }
::selection { background: var(--purple); color: var(--paper); }
/* ─── R42 chatbot mark animations ────────────────────────────── */
@keyframes dotBounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-4px); opacity: 1; }
}
.r42-mark .dot { animation: dotBounce 1.2s ease-in-out infinite; transform-origin: center; }
.r42-mark .dot:nth-child(2) { animation-delay: 0.15s; }
.r42-mark .dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes r42Shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-2px); }
40%, 80% { transform: translateX(2px); }
}
.r42-mark.shake { animation: r42Shake 0.5s ease-in-out; }

View File

@@ -64,6 +64,61 @@ export const anthropicApi = {
}
};
/**
* Multi-turn chat with optional tool use.
* Returns the raw Anthropic response so callers can read both `text` and
* `tool_use` content blocks.
*
* @param {string} systemPrompt
* @param {Array<{role: 'user'|'assistant', content: string}>} messages
* @param {{tools?: Array}} opts
* @returns {Promise<{content: Array, stop_reason: string}>}
*/
anthropicApi.chat = async function chat(systemPrompt, messages, opts = {}) {
const useSimulation = storage.get('admin:use_simulation') === true;
if (useSimulation) {
await new Promise(r => setTimeout(r, 600));
return {
content: [{
type: 'text',
text: 'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.',
}],
stop_reason: 'end_turn',
};
}
const model = storage.get('admin:model') || DEFAULT_MODEL;
const body = {
model,
max_tokens: 1024,
system: systemPrompt,
messages,
};
if (opts.tools && opts.tools.length) body.tools = opts.tools;
const response = await fetch('/api/anthropic/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(`API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errData)}`);
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new Error('Your session has expired. Please refresh the page and log in again.');
}
return await response.json();
};
async function simulateResponse() {
await new Promise(r => setTimeout(r, 2000));
return JSON.stringify({

117
src/lib/kbStore.js Normal file
View File

@@ -0,0 +1,117 @@
import { storage } from './storage';
import * as db from './db';
/**
* Knowledge-base mutations for the R42 chatbot path.
*
* Topics and relations live in PocketBase (via db.js). The suggestions queue
* (pending / approved / rejected) is ephemeral and stored in localStorage
* since there is no PocketBase collection for it.
*
* Every write dispatches `respellion:kb-updated` so the D3 graph and the
* suggestions panel refresh without a page reload.
*/
function dispatchUpdated() {
try {
window.dispatchEvent(new CustomEvent('respellion:kb-updated'));
} catch {
// non-browser environment — no-op
}
}
export const kbStore = {
/**
* Apply a validated delta directly to PocketBase topics + relations.
* Skips topics/relations that already exist.
*/
async applyDelta(delta) {
if (!delta) return { addedTopics: 0, addedRelations: 0 };
const [existingTopics, existingRelations] = await Promise.all([
db.getTopics(),
db.getRelations(),
]);
const existingIds = new Set(existingTopics.map(t => t.id));
const existingLabels = new Set(existingTopics.map(t => (t.label || '').toLowerCase()));
let addedTopics = 0;
for (const t of delta.topics || []) {
if (existingIds.has(t.id) || existingLabels.has((t.label || '').toLowerCase())) continue;
await db.upsertTopic({
id: t.id,
label: t.label,
type: t.type || 'concept',
description: t.description || '',
});
existingIds.add(t.id);
existingLabels.add((t.label || '').toLowerCase());
addedTopics++;
}
let addedRelations = 0;
const allIds = new Set([...existingTopics.map(t => t.id), ...(delta.topics || []).map(t => t.id)]);
for (const r of delta.relations || []) {
if (!allIds.has(r.source) || !allIds.has(r.target) || r.source === r.target) continue;
const dup = existingRelations.some(x => {
const sx = typeof x.source === 'object' ? x.source.id : x.source;
const tx = typeof x.target === 'object' ? x.target.id : x.target;
return sx === r.source && tx === r.target && x.type === r.type;
});
if (dup) continue;
await db.addRelation({ source: r.source, target: r.target, type: r.type });
addedRelations++;
}
dispatchUpdated();
return { addedTopics, addedRelations };
},
/** Queue a suggestion for admin review (stored in localStorage). */
appendSuggestion(suggestion) {
const list = storage.get('kb:suggestions', []) || [];
const entry = {
id: `sug_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
ts: Date.now(),
status: 'pending',
reason: suggestion.reason || '',
proposedBy: suggestion.proposedBy || null,
proposedByName: suggestion.proposedByName || null,
topics: suggestion.topics || [],
relations: suggestion.relations || [],
};
storage.set('kb:suggestions', [...list, entry]);
dispatchUpdated();
return entry;
},
listSuggestions(status) {
const all = storage.get('kb:suggestions', []) || [];
if (!status) return all;
return all.filter(s => s.status === status);
},
async approveSuggestion(id) {
const all = storage.get('kb:suggestions', []) || [];
const target = all.find(s => s.id === id);
if (!target || target.status !== 'pending') return null;
const result = await this.applyDelta(target);
target.status = 'approved';
target.appliedAt = Date.now();
storage.set('kb:suggestions', all);
dispatchUpdated();
return result;
},
rejectSuggestion(id) {
const all = storage.get('kb:suggestions', []) || [];
const target = all.find(s => s.id === id);
if (!target || target.status !== 'pending') return null;
target.status = 'rejected';
target.rejectedAt = Date.now();
storage.set('kb:suggestions', all);
dispatchUpdated();
return target;
},
};

View File

@@ -10,9 +10,21 @@ import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag';
import { useApp } from '../store/AppContext';
import { generateWeeklyQuiz, getCachedQuiz, saveTestResult, getTestResult } from '../lib/testService';
import { storage } from '../lib/storage';
const TIMER_SECONDS = 300; // 5 minutes
function setQuizActive(userId, active) {
if (!userId) return;
if (active) storage.set(`quiz:active:${userId}`, true);
else storage.remove(`quiz:active:${userId}`);
try {
window.dispatchEvent(new CustomEvent('respellion:quiz-state', { detail: { active } }));
} catch {
// ignore
}
}
// ─── Helper: format mm:ss ──────────────────────────────────
function formatTime(sec) {
const m = Math.floor(sec / 60);
@@ -65,6 +77,19 @@ const Testen = () => {
return () => clearInterval(timerRef.current);
}, [phase]);
// ── Clear quiz-active flag whenever we leave the quiz phase or unmount ──
useEffect(() => {
if (phase !== 'quiz' && currentUser) {
setQuizActive(currentUser.id, false);
}
}, [phase, currentUser]);
useEffect(() => {
return () => {
if (currentUser) setQuizActive(currentUser.id, false);
};
}, [currentUser]);
// ── Start quiz ──
const startQuiz = async () => {
setPhase('loading');
@@ -76,6 +101,7 @@ const Testen = () => {
setAnswers({});
setShowFeedback(false);
setTimeLeft(TIMER_SECONDS);
setQuizActive(currentUser.id, true);
setPhase('quiz');
} catch (e) {
setError(e.message);