Spaces:
Running on Zero
Running on Zero
| 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> | |
| ); | |
| } | |