Spaces:
Sleeping
Sleeping
File size: 7,221 Bytes
379bf78 20abf65 379bf78 8f1c2fc 20abf65 8f1c2fc 544bcbe 20abf65 544bcbe 8f1c2fc 379bf78 544bcbe 379bf78 20abf65 8f1c2fc 379bf78 20abf65 379bf78 8f1c2fc 379bf78 20abf65 544bcbe 20abf65 8f1c2fc 379bf78 8f1c2fc 379bf78 8f1c2fc 379bf78 8f1c2fc 379bf78 8f1c2fc 379bf78 8f1c2fc 20abf65 8f1c2fc 20abf65 8f1c2fc 379bf78 8f1c2fc 379bf78 20abf65 379bf78 8f1c2fc 379bf78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | 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 (
<div class="msg msg-refusal">
<span class="refusal-tag">Outside validated scope</span>
<p>{msg.answer}</p>
</div>
);
}
if (isReasoned) {
const text = (msg.answer || '').replace(REASONED_PREFIX_RE, '');
return (
<div class="msg msg-agent msg-reasoned">
<span class="reasoned-tag">Reasoned — interpretation, not engine output</span>
<p>{renderWithNums(text)}</p>
</div>
);
}
return (
<div class="msg msg-agent">
<p>{renderWithNums(msg.answer)}</p>
{msg.route && (
<div class="citations">
<span class="citations-label">via</span>
<code class="citation">⚙ {msg.route}</code>
</div>
)}
</div>
);
}
/**
* 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 (
<section class={`panel chat-panel chat-panel-${mode}`}>
{mode === 'full' && (
<>
<div class="panel-head">
<h2>Ask the copilot</h2>
{explainBtn}
</div>
<p class="panel-sub">
Narrated, data-grounded answers — every number traces to an engine
tool call; out-of-scope questions are refused by design.
</p>
{explainStrip}
</>
)}
<div class="chat-log">
{messages.length === 0 && (
<div class="empty-note">
{mode === 'full' ? (
<div class="chip-row">
{defaultChips.map((c) => (
<button type="button" class="chip" key={c} onClick={() => send(c)}>
{c}
</button>
))}
</div>
) : (
<>Ask about {contextLabel ? contextLabel.toLowerCase() : 'this book'}…</>
)}
</div>
)}
{messages.map((msg, i) =>
msg.role === 'user' ? (
<div class="msg msg-user" key={i}>
<p>{msg.text}</p>
</div>
) : msg.role === 'error' ? (
<div class="msg msg-error" key={i}>
<p>{msg.text}</p>
</div>
) : (
<AgentMessage msg={msg} key={i} />
),
)}
{busy && (
<div class="explain-status">
<span class="status-dot status-dot-warn pulse" /> THINKING
</div>
)}
</div>
<form class="chat-form" onSubmit={submit}>
<input
type="text"
value={question}
placeholder={
contextLabel ? `Ask about ${contextLabel}…` : 'Ask about ECL, staging, scenarios…'
}
onInput={(e) => setQuestion(e.currentTarget.value)}
disabled={busy}
/>
<button class="btn" type="submit" disabled={busy || !question.trim()}>
Ask
</button>
</form>
</section>
);
}
|