ae67bff5a3
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
419 lines
23 KiB
TypeScript
419 lines
23 KiB
TypeScript
"use client";
|
||
/* Playground - chat that runs a real workflow over SSE with token-by-token streaming. */
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { Icon } from "../icons";
|
||
import { Tile } from "../primitives";
|
||
import { api, openSSE, Workflow, ComponentT } from "@/lib/api";
|
||
import { fmtUSD, buildNodeLabels, nodeLabel } from "@/lib/data";
|
||
import { Markdown } from "../markdown";
|
||
import { ComponentRenderer } from "../component-renderer";
|
||
import { ReplyAccumulator, type Part, type ComponentInstance } from "@/lib/chat-parts";
|
||
import Mustache from "mustache";
|
||
|
||
interface ChatMsg { role: "user" | "assistant"; content?: string; parts?: Part[] }
|
||
interface Step { node: string }
|
||
interface Activity { id: string; kind: string; name: string; done: boolean; error?: boolean }
|
||
|
||
export function PlaygroundScreen({ project }: { project: any }) {
|
||
const [wf, setWf] = useState<Workflow | null>(null);
|
||
const [wfs, setWfs] = useState<Workflow[]>([]);
|
||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||
const [input, setInput] = useState("");
|
||
const [msgs, setMsgs] = useState<ChatMsg[]>([]);
|
||
const [streaming, setStreaming] = useState("");
|
||
const [steps, setSteps] = useState<Step[]>([]);
|
||
const [activity, setActivity] = useState<Activity[]>([]);
|
||
const [running, setRunning] = useState(false);
|
||
const [meter, setMeter] = useState<{ tokens: number; cost: number } | null>(null);
|
||
const [pendingInterrupt, setPendingInterrupt] = useState<{ runId: string; payload: any } | null>(null);
|
||
const [resuming, setResuming] = useState(false);
|
||
const [compDefs, setCompDefs] = useState<Record<string, ComponentT>>({});
|
||
const [liveParts, setLiveParts] = useState<Part[]>([]); // in-flight assistant reply parts, rendered live (audit H3)
|
||
// Resolve node ids (what the run stream emits) to the friendly names shown on the canvas, so the
|
||
// Run steps read like the graph the operator built rather than raw ids.
|
||
const nodeLabels = useMemo(() => buildNodeLabels(wf), [wf]);
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
// One backend thread per chat session: the checkpointer holds the conversation, so
|
||
// each turn sends ONLY the new message (no full-transcript replay).
|
||
const threadRef = useRef<string | null>(null);
|
||
// Aborts the in-flight SSE stream when the user hits Stop.
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!project?.id) return;
|
||
threadRef.current = null;
|
||
setWf(null); setLoadErr(null); setMsgs([]); setSteps([]); setMeter(null);
|
||
api.listComponents(project.id).then((cs) => setCompDefs(Object.fromEntries(cs.map((c) => [c.id, c])))).catch(() => {});
|
||
api.listWorkflows(project.id)
|
||
.then((ws) => {
|
||
setWfs(ws);
|
||
const active = ws.find((w) => w.status === "active") || ws[0] || null;
|
||
setWf(active);
|
||
if (!active) setLoadErr("No workflows in this project yet. Create one in Workflows, or ask the Forge Assistant to build one.");
|
||
})
|
||
.catch((e) => setLoadErr(String(e.message || e)));
|
||
}, [project?.id]);
|
||
|
||
useEffect(() => { scrollRef.current?.scrollTo({ top: 1e9, behavior: "smooth" }); }, [msgs, streaming, steps, liveParts]);
|
||
|
||
async function send(textArg?: string) {
|
||
const text = (typeof textArg === "string" ? textArg : input).trim();
|
||
if (!text || !wf || running) return;
|
||
if (typeof textArg !== "string") setInput("");
|
||
setMsgs((m) => [...m, { role: "user", content: text }]);
|
||
setStreaming(""); setSteps([]); setActivity([]); setMeter(null); setRunning(true); setLiveParts([]);
|
||
let finalAnswer = "";
|
||
// The reply is an ordered list of parts (text + components). Components are positioned by the
|
||
// [[forge:component:ID]] markers the agent writes into its text - NOT by the order their
|
||
// frames arrive - so a widget lands in its natural place instead of always at the top.
|
||
const acc = new ReplyAccumulator();
|
||
try {
|
||
// The thread's checkpointer holds prior turns, so send only the new message when a
|
||
// thread exists; the first turn establishes the thread. The run always acts as the logged-in
|
||
// operator (the backend sets end_user from the session), so per-user auth providers resolve
|
||
// this user's own connected credential — exactly like the Workflow test panel.
|
||
const run = await api.createRun(
|
||
project.id, wf.id,
|
||
{ messages: [{ role: "user", content: text }] },
|
||
threadRef.current || undefined,
|
||
);
|
||
threadRef.current = run.thread_id;
|
||
let interrupted = false;
|
||
const url = api.runStreamUrl(project.id, wf.id, run.id);
|
||
const controller = new AbortController();
|
||
abortRef.current = controller;
|
||
await openSSE(url, (f) => {
|
||
if (f.event === "messages" && f.data?.content) {
|
||
acc.addText(f.data.content);
|
||
setStreaming(acc.text);
|
||
setLiveParts(acc.parts({ streaming: true }));
|
||
} else if ((f.event === "node_start" || f.event === "updates") && f.data) {
|
||
const node = f.event === "node_start" ? f.data.node : Object.keys(f.data || {})[0];
|
||
if (node) setSteps((s) => (s.some((x) => x.node === node) ? s : [...s, { node }]));
|
||
} else if (f.event === "activity" && f.data?.id) {
|
||
const a = f.data;
|
||
if (a.phase === "start") {
|
||
setActivity((xs) => xs.some((x) => x.id === a.id) ? xs : [...xs, { id: a.id, kind: a.kind, name: a.name, done: false }]);
|
||
} else if (a.phase === "end") {
|
||
setActivity((xs) => xs.map((x) => (x.id === a.id ? { ...x, done: true, error: !!a.error } : x)));
|
||
}
|
||
} else if (f.event === "custom" && f.data?.channel === "component" && f.data?.payload) {
|
||
acc.addComponent(f.data.payload as ComponentInstance);
|
||
setLiveParts(acc.parts({ streaming: true }));
|
||
} else if (f.event === "done") {
|
||
finalAnswer = f.data?.answer || "";
|
||
setMeter({ tokens: f.data?.total_tokens ?? 0, cost: f.data?.total_cost_usd ?? 0 });
|
||
} else if (f.event === "interrupt") {
|
||
interrupted = true;
|
||
setPendingInterrupt({ runId: run.id, payload: f.data });
|
||
} else if (f.event === "error") {
|
||
finalAnswer = `⚠ ${f.data?.message || "run failed"}`;
|
||
}
|
||
}, { signal: controller.signal });
|
||
if (interrupted) {
|
||
// Preserve anything streamed before the pause (audit M4) - mirror the finalize commit.
|
||
if (acc.hasComponents() || acc.text.trim()) {
|
||
setMsgs((m) => [...m, acc.hasComponents()
|
||
? { role: "assistant", parts: acc.parts() }
|
||
: { role: "assistant", content: acc.text }]);
|
||
}
|
||
setStreaming(""); setRunning(false); setLiveParts([]);
|
||
return; // approval card takes over
|
||
}
|
||
} catch (e: any) {
|
||
// A user-initiated Stop aborts the fetch - commit whatever streamed, no error banner.
|
||
if (e?.name !== "AbortError") finalAnswer = `⚠ ${e.message || e}`;
|
||
} finally {
|
||
abortRef.current = null;
|
||
setStreaming("");
|
||
setRunning(false);
|
||
setLiveParts([]);
|
||
}
|
||
// resolveText reconciles the streamed buffer with the authoritative final answer / error
|
||
// (covers non-LLM nodes that don't stream tokens) without dropping it (audit H2).
|
||
const finalText = acc.resolveText(finalAnswer);
|
||
if (acc.hasComponents()) {
|
||
setMsgs((m) => [...m, { role: "assistant", parts: acc.parts({ finalText }) }]);
|
||
} else {
|
||
setMsgs((m) => [...m, { role: "assistant", content: finalText || "(no output)" }]);
|
||
}
|
||
}
|
||
|
||
/** Pull the human-facing prompt + decision options out of the interrupt payload.
|
||
Handles both shapes: the human_input node ({prompt, allowed_decisions}) and
|
||
HumanInTheLoopMiddleware (action requests; resume wants {decisions:[{type}]}). */
|
||
function parseInterrupt(payload: any): { prompt: string; decisions: string[]; middleware: boolean } {
|
||
const flat = (x: any): any[] => (Array.isArray(x) ? x.flatMap(flat) : [x]);
|
||
const items = flat(payload).filter(Boolean);
|
||
const values = items.map((i) => (i && typeof i === "object" && "value" in i ? i.value : i));
|
||
for (const v of values) {
|
||
if (v && typeof v === "object" && v.prompt) {
|
||
return { prompt: String(v.prompt), decisions: v.allowed_decisions || ["approve", "reject"], middleware: false };
|
||
}
|
||
if (v && typeof v === "object" && (v.action_requests || v.action_request || v.action)) {
|
||
const reqs = v.action_requests || [v.action_request || v];
|
||
const desc = reqs.map((r: any) => r.description || `${r.action || r.name || "tool"}(${JSON.stringify(r.args || {}).slice(0, 80)})`).join("; ");
|
||
return { prompt: `Approve tool call: ${desc}`, decisions: ["approve", "reject"], middleware: true };
|
||
}
|
||
}
|
||
return { prompt: "This run paused for your approval.", decisions: ["approve", "reject"], middleware: false };
|
||
}
|
||
|
||
async function resume(decision: string) {
|
||
if (!pendingInterrupt || !wf || resuming) return;
|
||
const { middleware } = parseInterrupt(pendingInterrupt.payload);
|
||
setResuming(true);
|
||
try {
|
||
const value = middleware ? { decisions: [{ type: decision }] } : decision;
|
||
const res = await api.resumeRun(project.id, wf.id, pendingInterrupt.runId, value);
|
||
const msgsOut = res.messages || [];
|
||
const last = [...msgsOut].reverse().find((m: any) => (m.type === "ai" || m.role === "assistant") && m.content);
|
||
const content = last ? (typeof last.content === "string" ? last.content : JSON.stringify(last.content)) : (res.error || "(resumed)");
|
||
setMsgs((m) => [...m, { role: "assistant", content: res.interrupted ? content + "\n⏸ paused again for another approval - check Traces." : content }]);
|
||
} catch (e: any) {
|
||
setMsgs((m) => [...m, { role: "assistant", content: `⚠ resume failed: ${e.message || e}` }]);
|
||
} finally {
|
||
setPendingInterrupt(null);
|
||
setResuming(false);
|
||
}
|
||
}
|
||
|
||
// Abort the in-flight SSE run. The stream reader rejects with AbortError, which send()
|
||
// treats as a graceful stop (partial output is committed, no error banner).
|
||
function stop() {
|
||
abortRef.current?.abort();
|
||
}
|
||
// Reset clears the UI AND starts a fresh conversation thread, so the next message has no
|
||
// server-side history (nulling threadRef) - not just a visual clear.
|
||
function reset() {
|
||
threadRef.current = null;
|
||
setMsgs([]); setSteps([]); setMeter(null); setStreaming(""); setLiveParts([]); setPendingInterrupt(null);
|
||
}
|
||
|
||
function handleComponentAction(inst: ComponentInstance, action: string, fields: Record<string, string>) {
|
||
const def = (inst.actions || []).find((a: any) => a.id === action) || {};
|
||
let msg = (def as any).message || (def as any).label || action;
|
||
try { msg = Mustache.render(String(msg), { props: inst.props || {}, fields, action }); } catch {}
|
||
if (msg) send(msg);
|
||
}
|
||
|
||
const samples = ["How do I reset my password?", "What can you help me with?"];
|
||
|
||
return (
|
||
<div className="col" style={{ flex: 1, minHeight: 0 }}>
|
||
{/* header */}
|
||
<div className="row spread" style={{ padding: "12px 20px", borderBottom: "1px solid var(--line)", flex: "none" }}>
|
||
<div className="row gap2">
|
||
<div>
|
||
<div className="t-display">Playground</div>
|
||
{wfs.length > 1 ? (
|
||
<select
|
||
className="select" disabled={running}
|
||
value={wf?.id || ""}
|
||
style={{ marginTop: 2, height: 24, fontSize: 12, padding: "0 6px", maxWidth: 280 }}
|
||
onChange={(e) => {
|
||
const next = wfs.find((w) => w.id === e.target.value) || null;
|
||
threadRef.current = null;
|
||
setWf(next); setMsgs([]); setSteps([]); setMeter(null); setStreaming("");
|
||
}}>
|
||
{wfs.map((w) => (
|
||
<option key={w.id} value={w.id}>{w.name}{w.status === "active" ? " · active" : " · draft"}</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<div className="fg-2 t-caption mono">{wf ? wf.name : "loading…"}{running && " · running"}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="row gap2">
|
||
{meter && (
|
||
<span className="chip chip-mono"><Icon name="bolt" size={13} />{meter.tokens} tok · {fmtUSD(meter.cost)}</span>
|
||
)}
|
||
<span className="chip chip-mono"><Icon name="knowledge" size={12} />grounded</span>
|
||
<button className="btn btn-ghost btn-sm" onClick={reset} disabled={running}>
|
||
<Icon name="refresh" size={14} />Reset
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* alignItems:stretch - the global .row centers children, which stops the chat column
|
||
from filling the height: it then sizes to content, overflows the viewport, and the
|
||
scroll area never scrolls. Stretch restores the fixed-height column + inner scroll. */}
|
||
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
|
||
<div className="col grow" style={{ minWidth: 0, minHeight: 0, borderRight: "1px solid var(--line)" }}>
|
||
<div ref={scrollRef} className="scroll-y" style={{ flex: 1, minHeight: 0, padding: "20px 0" }}>
|
||
{/* minHeight:100% + justify-end pins a short conversation to the bottom (chat-style);
|
||
once it outgrows the viewport it scrolls normally. */}
|
||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "0 24px", width: "100%", minHeight: "100%", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
|
||
{loadErr && <div className="card" style={{ padding: 14, color: "var(--err)", marginBottom: 12 }}>{loadErr}</div>}
|
||
{msgs.length === 0 && !running && !loadErr && (
|
||
<div className="col center" style={{ minHeight: 300, gap: 10, color: "var(--fg-2)", textAlign: "center", margin: "auto 0" }}>
|
||
<Tile icon="sparkles" color="var(--accent)" size={44} glow />
|
||
<div className="t-h2" style={{ color: "var(--fg-1)" }}>Run “{wf?.name || "your workflow"}” live</div>
|
||
<div className="t-caption">Answers are grounded in this project’s knowledge base & Q&A - it streams token by token.</div>
|
||
<div className="row gap2 wrap center" style={{ maxWidth: 460, marginTop: 6 }}>
|
||
{samples.map((s) => (
|
||
<button key={s} className="chip" style={{ cursor: "pointer" }} onClick={() => setInput(s)}>{s}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="col gap4">
|
||
{msgs.map((m, i) => (
|
||
<MessageBlock key={i} role={m.role} content={m.content} parts={m.parts} compDefs={compDefs} onAction={handleComponentAction} />
|
||
))}
|
||
{(running || liveParts.length > 0) && (
|
||
<MessageBlock role="assistant" parts={liveParts} streaming compDefs={compDefs} onAction={handleComponentAction} />
|
||
)}
|
||
{pendingInterrupt && (() => {
|
||
const info = parseInterrupt(pendingInterrupt.payload);
|
||
return (
|
||
<div className="card fade-up" style={{ padding: 14, borderLeft: "3px solid var(--warn)" }}>
|
||
<div className="row gap2" style={{ marginBottom: 6 }}>
|
||
<Icon name="user" size={15} style={{ color: "var(--warn)" }} />
|
||
<span className="t-h3">Approval required</span>
|
||
</div>
|
||
<div className="t-body-sm fg-1" style={{ marginBottom: 10, whiteSpace: "pre-wrap" }}>{info.prompt}</div>
|
||
<div className="row gap2">
|
||
{info.decisions.map((d) => (
|
||
<button key={d}
|
||
className={d === "approve" ? "btn btn-primary btn-sm" : "btn btn-secondary btn-sm"}
|
||
disabled={resuming} onClick={() => resume(d)}>
|
||
{resuming ? "…" : d}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* composer */}
|
||
<div style={{ padding: "14px 24px", borderTop: "1px solid var(--line)", flex: "none" }}>
|
||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||
<div className="row gap2" style={{ background: "var(--bg-1)", border: "1px solid var(--line-strong)", borderRadius: 12, padding: "7px 7px 7px 14px", boxShadow: "var(--sh-1)" }}>
|
||
<input value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()}
|
||
placeholder="Message the workflow…" disabled={!wf || running}
|
||
style={{ flex: 1, minWidth: 0, border: "none", background: "none", outline: "none", fontSize: 14, color: "var(--fg-0)", fontFamily: "var(--font-ui)" }} />
|
||
{running ? (
|
||
<button className="btn btn-secondary" onClick={stop} title="Stop the run">
|
||
<Icon name="stop" size={15} />Stop
|
||
</button>
|
||
) : (
|
||
<button className="btn btn-primary" onClick={() => send()} disabled={!wf}>
|
||
<Icon name="play" size={15} />Run
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="fg-2 t-caption" style={{ textAlign: "center", marginTop: 7 }}>Runs against the active workflow · interrupts surface for approval</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Steps column */}
|
||
<div style={{ width: 280, flex: "none", background: "var(--bg-1)", minHeight: 0 }} className="scroll-y">
|
||
<div className="t-micro" style={{ padding: "14px 16px 8px" }}>Run steps</div>
|
||
<div className="col" style={{ padding: "0 12px 12px" }}>
|
||
{steps.length === 0 && <div className="fg-2 t-caption" style={{ padding: "4px 8px" }}>Nodes light up as the graph executes.</div>}
|
||
{steps.map((s, i) => (
|
||
<div key={i} className="row gap2 fade-in" style={{ padding: "8px 8px", borderRadius: 7 }}>
|
||
<div style={{ width: 18, height: 18, borderRadius: "50%", background: "var(--ok-bg)", color: "var(--ok)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}><Icon name="check" size={12} /></div>
|
||
<div className="grow" style={{ minWidth: 0 }}>
|
||
<div className="t-body-sm truncate" style={{ color: "var(--fg-1)" }}>{nodeLabel(s.node, nodeLabels)}</div>
|
||
{nodeLabels[s.node] && nodeLabels[s.node].label !== s.node && (
|
||
<div className="t-caption fg-2 mono truncate">{s.node}</div>
|
||
)}
|
||
</div>
|
||
<span className="t-caption fg-2">{i + 1}</span>
|
||
</div>
|
||
))}
|
||
{running && (
|
||
<div className="row gap2" style={{ padding: "8px", color: "var(--accent)" }}>
|
||
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)", animation: "pulse 1s infinite" }} />
|
||
<span className="t-caption">streaming…</span>
|
||
</div>
|
||
)}
|
||
{/* Live agent activity: sub-agent dispatches (prominent) and tool calls (dimmed), so a
|
||
deep_agent's routing is visible as it happens - not just after the run in Traces. */}
|
||
{activity.length > 0 && (
|
||
<>
|
||
<div className="t-micro" style={{ padding: "12px 4px 6px" }}>Agent activity</div>
|
||
{activity.map((a) => {
|
||
const isSub = a.kind === "subagent";
|
||
return (
|
||
<div key={a.id} className="row gap2 fade-in" style={{ padding: "6px 8px", borderRadius: 7 }}>
|
||
<div style={{ width: 16, height: 16, display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
|
||
{a.error ? <Icon name="x" size={12} style={{ color: "var(--err)" }} />
|
||
: a.done ? <Icon name="check" size={12} style={{ color: isSub ? "var(--accent)" : "var(--ok)" }} />
|
||
: <div style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)", animation: "pulse 1s infinite" }} />}
|
||
</div>
|
||
<Icon name={isSub ? "layers" : "sliders"} size={13} style={{ color: isSub ? "var(--accent)" : "var(--fg-2)", flex: "none" }} />
|
||
<span className="mono-sm grow truncate" style={{ color: isSub ? "var(--fg-0)" : "var(--fg-2)", fontWeight: isSub ? 600 : 400 }} title={a.name}>{a.name}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* One chat turn. User turns keep the colored bubble. An assistant turn is ONE flowing reply
|
||
under a single avatar - bare markdown text and inline components in order, with NO bubble
|
||
chrome - so a rendered component reads as part of the reply, not a detached card below it
|
||
(audit Priority C). Missing component defs degrade to a visible notice (audit M5). */
|
||
function MessageBlock({ role, content, streaming, parts, compDefs, onAction }: {
|
||
role: "user" | "assistant";
|
||
content?: string;
|
||
streaming?: boolean;
|
||
parts?: Part[];
|
||
compDefs: Record<string, ComponentT>;
|
||
onAction: (inst: ComponentInstance, action: string, fields: Record<string, string>) => void;
|
||
}) {
|
||
const user = role === "user";
|
||
if (user) {
|
||
return (
|
||
<div className="row" style={{ gap: 9, alignItems: "flex-start", flexDirection: "row-reverse" }}>
|
||
<Tile icon="user" color="var(--fg-2)" size={28} />
|
||
<div style={{ maxWidth: 560, padding: "10px 13px", borderRadius: 12, borderTopRightRadius: 3, fontSize: 14, lineHeight: "21px", whiteSpace: "pre-wrap", overflowWrap: "anywhere", wordBreak: "break-word", background: "var(--accent)", color: "var(--fg-on-accent)" }}>
|
||
{content}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
const renderComp = (inst: ComponentInstance, key: number | string) => {
|
||
const def = compDefs[inst.component_id];
|
||
if (!def) {
|
||
return (
|
||
<div key={key} className="card" style={{ padding: "8px 11px", fontSize: 12.5, color: "var(--fg-2)" }}>
|
||
Component “{inst.name || inst.component_id}” is unavailable.
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<ComponentRenderer key={key} def={{ id: def.id, name: def.name, html: def.html, css: def.css, actions: inst.actions || def.actions }} props={inst.props || {}} onAction={(a, f) => onAction(inst, a, f)} />
|
||
);
|
||
};
|
||
const list: Part[] = parts && parts.length ? parts : (content && content.trim() ? [{ kind: "text", text: content }] : []);
|
||
let lastText = -1;
|
||
for (let k = list.length - 1; k >= 0; k--) { if (list[k].kind === "text") { lastText = k; break; } }
|
||
return (
|
||
<div className="row" style={{ gap: 9, alignItems: "flex-start" }}>
|
||
<Tile icon="sparkles" color="var(--accent)" size={28} />
|
||
<div className="col gap2" style={{ minWidth: 0, maxWidth: 620, flex: 1 }}>
|
||
{list.map((p, j) => (p.kind === "text" ? (
|
||
<div key={j} style={{ fontSize: 14, lineHeight: "21px", color: "var(--fg-0)", overflowWrap: "anywhere" }}>
|
||
<Markdown>{p.text}</Markdown>
|
||
{streaming && j === lastText && <span style={{ display: "inline-block", width: 7, height: 14, background: "var(--accent)", verticalAlign: "-2px", animation: "blink 1s steps(1) infinite" }} />}
|
||
</div>
|
||
) : renderComp(p.inst, j)))}
|
||
{streaming && lastText === -1 && <span className="fg-2" style={{ fontSize: 14 }}>…</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|