import { useState } from 'preact/hooks'; import { askAgent, explainPanelQuestion } from '../api.js'; import { renderWithNums } from '../numText.jsx'; import { useExplain } from './ExplainButton.jsx'; // The live LangGraph router emits route "REFUSE" (agent/graph.py's REFUSE // constant); the offline keyword fallback router emits "refusal" (see // app/api/main.py). Both mean the same thing to the UI — match either, // case-insensitively, rather than assume one contract spelling. export const isRefusalRoute = (route) => /^refus(e|al)$/i.test(route || ''); // The REASONED route (agent/graph.py's REASONED constant): a cited, // number-disciplined LLM interpretation of a conceptually relevant // question no tool computes — grounded, never a fresh engine number. // Prefer the response's own `mode === 'reasoned'` field (docs/ // api_contract.md §POST /api/agent/ask) when it is present; this route // check is the fallback for callers that only have `route`. export const isReasonedRoute = (route) => /^reasoned$/i.test(route || ''); // agent/graph.py's REASONED_PREFIX — every reasoned answer's wire text // starts with this machine-readable marker (belt-and-suspenders alongside // `mode === 'reasoned'`). The badge below already labels the bubble, so // strip the marker from the DISPLAYED text rather than show it twice. const REASONED_PREFIX_RE = /^\[REASONED\s*—\s*interpretation, not engine output\]\s*/; /** Shared agent-message bubble — used by ChatPanel's own log AND by * MiniChatDock's expanded log, so the two chat surfaces render identically. */ export function AgentMessage({ msg }) { const isRefusal = msg.mode ? msg.mode === 'refusal' : isRefusalRoute(msg.route); const isReasoned = msg.mode ? msg.mode === 'reasoned' : isReasonedRoute(msg.route); if (isRefusal) { return (
Outside validated scope

{msg.answer}

); } if (isReasoned) { const text = (msg.answer || '').replace(REASONED_PREFIX_RE, ''); return (
Reasoned — interpretation, not engine output

{renderWithNums(text)}

); } return (

{renderWithNums(msg.answer)}

{msg.route && (
via ⚙ {msg.route}
)}
); } /** * Ask-the-copilot chat. Two skins from the same component: * - mode="dock": compact, used inside the collapsed mini-chat dock. * - mode="full": Copilot tab, with suggestion chips. * `contextLabel` (e.g. the current tab name) is prefixed into the question * sent to the agent so the router has situational context; it is NOT * invented data, just a routing hint, and is shown to the user before send. */ export default function ChatPanel({ mode = 'full', contextLabel, suggestions, onResult }) { const [messages, setMessages] = useState([]); const [question, setQuestion] = useState(''); const [busy, setBusy] = useState(false); // Every panel heading carries the AI-explain affordance — including this // one (operator request #3); here it recaps the session's own state // rather than an exhibit payload. const { button: explainBtn, strip: explainStrip } = useExplain({ label: 'Ask the copilot', buildQuestion: mode === 'full' ? () => explainPanelQuestion({ panelId: 'copilot_chat', title: 'Ask the copilot', recap: messages.length ? `${messages.filter((m) => m.role === 'user').length} question(s) asked this session; last route: ${ messages.filter((m) => m.role === 'agent').at(-1)?.route ?? 'none yet' }.` : 'no questions asked yet this session — the validated routes are six numeric/documentation tools plus a reasoned-interpretation pass for conceptually relevant questions with no fixed tool.', }) : undefined, }); const send = async (q) => { const trimmed = q.trim(); if (!trimmed || busy) return; setQuestion(''); setMessages((m) => [...m, { role: 'user', text: trimmed }]); setBusy(true); try { const wire = contextLabel ? `[${contextLabel}] ${trimmed}` : trimmed; const res = await askAgent(wire); setMessages((m) => [...m, { role: 'agent', ...res }]); onResult?.({ question: trimmed, ...res, at: Date.now() }); } catch (err) { setMessages((m) => [ ...m, { role: 'error', text: `Request failed: ${err.message}` }, ]); } finally { setBusy(false); } }; const submit = (e) => { e.preventDefault(); send(question); }; const defaultChips = suggestions || [ 'What is the reported allowance under the downside scenario?', 'Write pandas to find the 10 largest-balance Stage 3 loans', 'What does the double-trigger LTV x UER coefficient mean?', ]; return (
{mode === 'full' && ( <>

Ask the copilot

{explainBtn}

Narrated, data-grounded answers — every number traces to an engine tool call; out-of-scope questions are refused by design.

{explainStrip} )}
{messages.length === 0 && (
{mode === 'full' ? (
{defaultChips.map((c) => ( ))}
) : ( <>Ask about {contextLabel ? contextLabel.toLowerCase() : 'this book'}… )}
)} {messages.map((msg, i) => msg.role === 'user' ? (

{msg.text}

) : msg.role === 'error' ? (

{msg.text}

) : ( ), )} {busy && (
THINKING
)}
setQuestion(e.currentTarget.value)} disabled={busy} />
); }