feat: deep-agent canvas, live observability, and multi-environment tooling
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.
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user