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.