Files
learning-platform/src/components/chat/ChatWindow.jsx
RaymondVerhoef 4a8dbee7df feat: phase 1 of AI pipeline hardening — single LLM client + tier-aware models
Implements phase 1 of AI_PIPELINE_HARDENING_PLAN.md. Every Anthropic call
now goes through one module that owns retry, timeout, abort, structured-
output parsing, schema validation, and best-effort call telemetry.

* src/lib/llm.js — single callLLM entry point. Resolves model per tier
  (fast / standard / reasoning) with admin:model legacy fallback for the
  standard tier; 60s default timeout via AbortController; balanced-brace
  JSON extraction; LLMHttpError, LLMTruncatedError, LLMOutputError, and
  LLMValidationError surface clearly distinct failure modes.
* src/lib/llmRetry.js — exponential backoff with full jitter, retries
  only on transient HTTP statuses, honours Retry-After up to 60s, never
  retries on AbortError.
* src/lib/llmSchemas.js — Zod schemas for every structured task plus
  normalizeHandbookResult (collapses legacy "executes" relations into
  the canonical "executed_by" vocabulary).
* src/lib/api.js — thin shim over callLLM so existing callers (extraction
  pipeline, learning, quiz, R42, knowledge graph) keep working unchanged.
* src/lib/__tests__/ — 32 Vitest cases covering parse paths, error
  surfaces, simulation mode, model resolution, and schema validation.
* src/pages/Admin/index.jsx — three model inputs (fast / standard /
  reasoning) replacing the single legacy field; legacy value falls back
  for the standard tier so existing overrides survive.

Adds Zod and Vitest, plus an "npm run test" script.

Also cleans up the pre-existing repo-wide ESLint failures so phase 1's
"npm run lint passes" acceptance criterion can be checked: drops unused
React imports across the JSX tree (React 19 JSX runtime auto-imports),
attaches cause to rethrown errors in the service modules, ignores
pb_migrations in the ESLint config (PocketBase JSVM globals), and
removes one dead handleCreateCustom function in Leren.jsx. A real
behaviour bug surfaced in Testen.jsx — the quiz timer captured a stale
finishQuiz via setInterval closure; now updated via finishQuizRef so the
timer always invokes the latest callback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:50:09 +02:00

132 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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>
);
}