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>
84 lines
2.8 KiB
JavaScript
84 lines
2.8 KiB
JavaScript
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>
|
|
);
|
|
}
|