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>
132 lines
4.0 KiB
JavaScript
132 lines
4.0 KiB
JavaScript
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>
|
||
);
|
||
}
|