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

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