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