feat: implement R42 chat infrastructure with Anthropic API integration and custom design system
This commit is contained in:
59
src/components/chat/ChatLauncher.jsx
Normal file
59
src/components/chat/ChatLauncher.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/components/chat/ChatMessage.jsx
Normal file
84
src/components/chat/ChatMessage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/chat/ChatWindow.jsx
Normal file
131
src/components/chat/ChatWindow.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
src/components/chat/chat.css
Normal file
233
src/components/chat/chat.css
Normal 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; }
|
||||
110
src/components/chat/prompts.js
Normal file
110
src/components/chat/prompts.js
Normal 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: '1–2 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
127
src/components/chat/rag.js
Normal 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}`;
|
||||
}
|
||||
137
src/components/chat/useChat.js
Normal file
137
src/components/chat/useChat.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user