Spaces:
Running on Zero
Running on Zero
File size: 7,184 Bytes
5f43c7d | 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 | import React, { useRef, useState, useEffect } from "react";
import { Send, Sparkles, CornerDownRight, Loader2, ShieldCheck } from "lucide-react";
import { C, FD, FM } from "../theme.js";
import { askTrace } from "../useAnalysis.js";
// "Ask the trace" — a chat bound to ONE session. The local server does
// deterministic retrieval over the engine output, the LOCAL model writes the
// answer (grounded, suggest-never-assert, cites turns). Every answer can open
// the cited turn in the middle pane (Mode B). Nothing leaves the machine.
const SUGGESTIONS = [
"Why was this session so expensive?",
"What drove turn 9?",
"Were there any errors or retries?",
"Where did the agent act on its own?",
];
export default function ChatPanel({ sessionPath, llama, onFocusTurn, sessionLabel }) {
const [msgs, setMsgs] = useState([]);
const [q, setQ] = useState("");
const [busy, setBusy] = useState(false);
const scroller = useRef(null);
useEffect(() => {
// reset the conversation when the session changes — answers are session-bound
setMsgs([]);
}, [sessionPath]);
useEffect(() => {
if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight;
}, [msgs, busy]);
async function ask(text) {
const question = (text ?? q).trim();
if (!question || busy) return;
setQ("");
setMsgs((m) => [...m, { role: "user", text: question }]);
setBusy(true);
try {
const res = await askTrace(question, sessionPath);
setMsgs((m) => [...m, { role: "assistant", ...res }]);
if (res.focusTurn != null) onFocusTurn(res.focusTurn, res.focusTool ?? null);
} catch (e) {
setMsgs((m) => [...m, { role: "assistant", answer: `Couldn't answer: ${String(e)}`, citedTurns: [], error: true }]);
} finally {
setBusy(false);
}
}
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* header */}
<div style={{ padding: "13px 15px 11px", borderBottom: `1px solid ${C.borderSoft}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Sparkles size={15} color={C.orange} />
<span style={{ fontFamily: FD, fontWeight: 700, fontSize: 14 }}>Ask <span style={{ color: C.orange }}>Her</span></span>
<div style={{ flex: 1 }} />
<span style={{ fontFamily: FM, fontSize: 9, color: llama ? C.cyan : C.muted, border: `1px solid ${llama ? C.cyan : C.border}`, borderRadius: 5, padding: "1px 6px", display: "flex", alignItems: "center", gap: 3 }}>
<ShieldCheck size={10} /> {llama ? "LOCAL MODEL" : "MODEL OFF"}
</span>
</div>
<div style={{ fontFamily: FM, fontSize: 10, color: C.muted, marginTop: 6, lineHeight: 1.5 }}>
Her reads ONLY this session's trace · cites turns · suggests, never asserts
</div>
</div>
{/* messages */}
<div ref={scroller} style={{ flex: 1, overflowY: "auto", padding: "12px 13px" }}>
{msgs.length === 0 && (
<div>
<div style={{ fontFamily: FM, fontSize: 11, color: C.muted, marginBottom: 9 }}>Try asking:</div>
{SUGGESTIONS.map((s) => (
<div key={s} className="row lift" onClick={() => ask(s)} style={{ cursor: "pointer", fontFamily: FM, fontSize: 12, color: C.text2, border: `1px solid ${C.borderSoft}`, borderRadius: 8, padding: "9px 11px", marginBottom: 7, display: "flex", gap: 7, alignItems: "center" }}>
<CornerDownRight size={12} color={C.orange} /> {s}
</div>
))}
</div>
)}
{msgs.map((m, i) =>
m.role === "user" ? (
<div key={i} style={{ display: "flex", justifyContent: "flex-end", marginBottom: 10 }}>
<div style={{ maxWidth: "88%", background: C.orangeMut, border: `1px solid ${C.orangeBd}`, borderRadius: "10px 10px 2px 10px", padding: "8px 11px", fontSize: 12.5, color: C.text, lineHeight: 1.5 }}>{m.text}</div>
</div>
) : (
<div key={i} style={{ marginBottom: 14 }}>
<div style={{ display: "flex", gap: 7, alignItems: "center", marginBottom: 5 }}>
<Sparkles size={11} color={m.error ? C.red : C.orange} />
<span style={{ fontFamily: FM, fontSize: 9, letterSpacing: 0.4, color: C.muted }}>
{m.error ? "ERROR" : `GENERATED${m.model ? " · " + m.model.split("/").pop().slice(0, 22) : ""} · reads the trace`}
</span>
</div>
<div style={{ fontSize: 13, color: C.text2, lineHeight: 1.6, background: `linear-gradient(135deg,${C.card},${C.panel})`, border: `1px solid ${C.borderSoft}`, borderRadius: "2px 10px 10px 10px", padding: "10px 12px" }}>
{m.answer}
{(() => {
const chips = (m.citations && m.citations.length)
? m.citations
: (m.citedTurns || []).map((tn) => ({ turn: tn, tool: null, label: `turn ${tn}` }));
return chips.length > 0 ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
{chips.map((c) => (
<span key={`${c.turn}-${c.tool}`} className="lift" onClick={() => onFocusTurn(c.turn, c.tool)} title={c.tool != null ? "opens the turn and highlights this tool" : "opens this turn"} style={{ cursor: "pointer", fontFamily: FM, fontSize: 10.5, color: C.orange, background: C.black, border: `1px solid ${C.orangeBd}`, borderRadius: 6, padding: "2px 8px", display: "inline-flex", alignItems: "center", gap: 4 }}>
{c.label} →
</span>
))}
</div>
) : null;
})()}
</div>
</div>
)
)}
{busy && (
<div style={{ display: "flex", gap: 7, alignItems: "center", color: C.muted, fontFamily: FM, fontSize: 11 }}>
<Loader2 size={13} className="spin" /> retrieving from the trace + asking the local model…
</div>
)}
</div>
{/* input */}
<div style={{ padding: "10px 12px", borderTop: `1px solid ${C.borderSoft}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, background: C.black, border: `1px solid ${C.borderSoft}`, borderRadius: 9, padding: "7px 9px 7px 12px" }}>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && ask()}
placeholder="why did this happen?"
style={{ flex: 1, background: "transparent", border: "none", outline: "none", color: C.text, fontFamily: FM, fontSize: 12.5 }}
/>
<div onClick={() => ask()} className="lift" style={{ cursor: busy ? "default" : "pointer", width: 30, height: 30, borderRadius: 7, background: q.trim() && !busy ? C.orange : C.elevated, display: "flex", alignItems: "center", justifyContent: "center" }}>
<Send size={14} color={q.trim() && !busy ? "#fff" : C.muted} />
</div>
</div>
</div>
</div>
);
}
|