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.
372 lines
16 KiB
TypeScript
372 lines
16 KiB
TypeScript
"use client";
|
|
/* Components screen (Feature 2 - generative UI): author UI widgets (HTML + CSS + props +
|
|
button actions) that an agent can render in chat. Code-first editor (no visual builder,
|
|
per product direction) with a live sandboxed preview. A new component pre-loads with a
|
|
simple 2-column table example. */
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Icon } from "../icons";
|
|
import { Tile } from "../primitives";
|
|
import { VersionHistory } from "../version-history";
|
|
import { ImportExport } from "../import-export";
|
|
import { api, ComponentT } from "@/lib/api";
|
|
import { ComponentRenderer } from "../component-renderer";
|
|
|
|
const DEFAULT_HTML = `<div class="card">
|
|
<div class="title">{{title}}</div>
|
|
<table>
|
|
{{#col1}}<thead><tr><th>{{col1}}</th><th>{{col2}}</th></tr></thead>{{/col1}}
|
|
<tbody>
|
|
{{#rows}}
|
|
<tr><td>{{label}}</td><td>{{value}}</td></tr>
|
|
{{/rows}}
|
|
</tbody>
|
|
</table>
|
|
</div>`;
|
|
|
|
const DEFAULT_CSS = `.card { font-family: system-ui, -apple-system, sans-serif; border: 1px solid #e3e3e8; border-radius: 12px; overflow: hidden; max-width: 420px; background: #fff; color: #1a1a1f; }
|
|
.title { font-weight: 600; font-size: 14px; padding: 10px 14px; border-bottom: 1px solid #e3e3e8; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
th, td { text-align: left; padding: 8px 14px; border-bottom: 1px solid #f0f0f3; }
|
|
th { color: #6b6b76; font-weight: 600; background: #fafafb; }
|
|
tbody tr:last-child td { border-bottom: none; }`;
|
|
|
|
const DEFAULT_PROPS_SCHEMA = {
|
|
type: "object",
|
|
properties: {
|
|
title: { type: "string", description: "Card title" },
|
|
col1: { type: "string", description: "First column header" },
|
|
col2: { type: "string", description: "Second column header" },
|
|
rows: { type: "array", description: "Rows, each an object with label and value" },
|
|
},
|
|
required: ["title"],
|
|
};
|
|
|
|
const DEFAULT_SAMPLE = {
|
|
title: "Weather - London",
|
|
col1: "Day",
|
|
col2: "Forecast",
|
|
rows: [
|
|
{ label: "Mon", value: "Sunny · 24°C" },
|
|
{ label: "Tue", value: "Cloudy · 21°C" },
|
|
{ label: "Wed", value: "Rain · 18°C" },
|
|
],
|
|
};
|
|
|
|
const NEW_COMPONENT = {
|
|
name: "new_component",
|
|
title: "New component",
|
|
description: "A UI component the agent can render for the user.",
|
|
html: DEFAULT_HTML,
|
|
css: DEFAULT_CSS,
|
|
props_schema: DEFAULT_PROPS_SCHEMA,
|
|
sample_props: DEFAULT_SAMPLE,
|
|
actions: [],
|
|
};
|
|
|
|
export function ComponentsScreen({ project, onOpen }: { project: any; onOpen: (c: ComponentT) => void }) {
|
|
const [items, setItems] = useState<ComponentT[]>([]);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
const reload = useCallback(() => {
|
|
if (!project?.id) return;
|
|
setLoaded(false);
|
|
setErr(null);
|
|
api
|
|
.listComponents(project.id)
|
|
.then((c) => {
|
|
setItems(c);
|
|
setLoaded(true);
|
|
})
|
|
.catch((e) => {
|
|
setErr(String(e.message || e));
|
|
setLoaded(true);
|
|
});
|
|
}, [project?.id]);
|
|
useEffect(() => { reload(); }, [reload]);
|
|
|
|
async function create() {
|
|
if (creating) return;
|
|
setCreating(true);
|
|
try {
|
|
const c = await api.createComponent(project.id, NEW_COMPONENT as any);
|
|
onOpen(c);
|
|
} catch (e: any) {
|
|
setErr(String(e.message || e));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="col grow scroll-y" style={{ minHeight: 0 }}>
|
|
<div className="row spread" style={{ padding: "16px 20px", borderBottom: "1px solid var(--line)" }}>
|
|
<div className="row gap2">
|
|
<div>
|
|
<div className="t-display">Components</div>
|
|
<div className="fg-2 t-caption">UI widgets the agent can render in chat - attached to agents like tools.</div>
|
|
</div>
|
|
</div>
|
|
<div className="row gap2">
|
|
<ImportExport project={project} type="component" typeLabel="component" onImported={reload}
|
|
items={items.map((c) => ({ id: c.id, name: c.name, sub: c.title || c.description || undefined }))} />
|
|
<button className="btn btn-primary btn-sm" onClick={create} disabled={creating || !project}>
|
|
<Icon name="plus" size={14} />
|
|
New component
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div style={{ padding: 20 }}>
|
|
{err && <div className="card" style={{ padding: 14, color: "var(--err)", marginBottom: 12 }}>{err}</div>}
|
|
{!loaded && <div className="fg-2 t-body-sm">Loading…</div>}
|
|
{loaded && items.length === 0 && !err && (
|
|
<div className="col center" style={{ minHeight: 220, gap: 8, color: "var(--fg-2)", textAlign: "center" }}>
|
|
<Tile icon="grid" color="var(--accent)" size={40} />
|
|
<div className="t-h3" style={{ color: "var(--fg-1)" }}>No components yet</div>
|
|
<div className="t-caption" style={{ maxWidth: 380 }}>
|
|
Author an HTML/CSS widget - a table, product card, or form - then attach it to an agent. The agent renders it in chat when relevant.
|
|
</div>
|
|
<button className="btn btn-secondary btn-sm" onClick={create} disabled={creating} style={{ marginTop: 6 }}>
|
|
<Icon name="plus" size={14} />
|
|
New component
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 12 }}>
|
|
{items.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
className="card card-hover col"
|
|
style={{ padding: 14, textAlign: "left", alignItems: "stretch", gap: 6 }}
|
|
onClick={() => onOpen(c)}
|
|
>
|
|
<div className="row gap2" style={{ alignItems: "center" }}>
|
|
<Tile icon="grid" color="var(--accent)" size={26} />
|
|
<div className="grow" style={{ minWidth: 0 }}>
|
|
<div className="t-h3 truncate">{c.title || c.name}</div>
|
|
<div className="mono-sm fg-2 truncate">{c.name}</div>
|
|
</div>
|
|
{!c.enabled && (
|
|
<span className="pill pill-muted">
|
|
off
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="t-caption fg-2"
|
|
style={{ overflow: "hidden", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" }}
|
|
>
|
|
{c.description || "-"}
|
|
</div>
|
|
<div className="row gap2" style={{ marginTop: 2 }}>
|
|
<span className="typechip">{c.kind}</span>
|
|
<span className="typechip">v{c.version}</span>
|
|
{Array.isArray(c.actions) && c.actions.length > 0 && (
|
|
<span className="typechip">
|
|
{c.actions.length} action{c.actions.length !== 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function tryParse(text: string): { value: any; error: string | null } {
|
|
if (!text.trim()) return { value: undefined, error: null };
|
|
try {
|
|
return { value: JSON.parse(text), error: null };
|
|
} catch (e: any) {
|
|
return { value: undefined, error: String(e.message || e) };
|
|
}
|
|
}
|
|
|
|
export function ComponentBuilderScreen({
|
|
project,
|
|
componentId,
|
|
onBack,
|
|
}: {
|
|
project: any;
|
|
componentId?: string;
|
|
onBack: () => void;
|
|
}) {
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [name, setName] = useState("new_component");
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [html, setHtml] = useState(DEFAULT_HTML);
|
|
const [css, setCss] = useState(DEFAULT_CSS);
|
|
const [propsText, setPropsText] = useState(JSON.stringify(DEFAULT_PROPS_SCHEMA, null, 2));
|
|
const [sampleText, setSampleText] = useState(JSON.stringify(DEFAULT_SAMPLE, null, 2));
|
|
const [actionsText, setActionsText] = useState("[]");
|
|
const [enabled, setEnabled] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
const [reloadKey, setReloadKey] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!project?.id || !componentId) {
|
|
setLoaded(true);
|
|
return;
|
|
}
|
|
setLoaded(false);
|
|
api
|
|
.getComponent(project.id, componentId)
|
|
.then((c) => {
|
|
setName(c.name);
|
|
setTitle(c.title || "");
|
|
setDescription(c.description || "");
|
|
setHtml(c.html || "");
|
|
setCss(c.css || "");
|
|
setPropsText(JSON.stringify(c.props_schema || {}, null, 2));
|
|
setSampleText(JSON.stringify(c.sample_props || {}, null, 2));
|
|
setActionsText(JSON.stringify(c.actions || [], null, 2));
|
|
setEnabled(c.enabled);
|
|
setLoaded(true);
|
|
})
|
|
.catch(() => setLoaded(true));
|
|
}, [project?.id, componentId, reloadKey]);
|
|
|
|
const sample = useMemo(() => tryParse(sampleText), [sampleText]);
|
|
const actionsParsed = useMemo(() => tryParse(actionsText), [actionsText]);
|
|
const propsParsed = useMemo(() => tryParse(propsText), [propsText]);
|
|
const previewProps = sample.error ? {} : sample.value || {};
|
|
const previewActions = actionsParsed.error ? [] : actionsParsed.value || [];
|
|
|
|
async function save() {
|
|
if (saving) return;
|
|
if (propsParsed.error) return setStatus("Props schema is not valid JSON.");
|
|
if (sample.error) return setStatus("Sample props is not valid JSON.");
|
|
if (actionsParsed.error) return setStatus("Actions is not valid JSON.");
|
|
setSaving(true);
|
|
setStatus(null);
|
|
const body = {
|
|
name: name.trim().replace(/\s+/g, "_") || "component",
|
|
title: title || null,
|
|
description,
|
|
html,
|
|
css,
|
|
props_schema: propsParsed.value || {},
|
|
sample_props: sample.value || {},
|
|
actions: previewActions,
|
|
enabled,
|
|
};
|
|
try {
|
|
if (componentId) await api.updateComponent(project.id, componentId, body);
|
|
else await api.createComponent(project.id, body as any);
|
|
setStatus("Saved.");
|
|
setTimeout(() => setStatus(null), 1600);
|
|
} catch (e: any) {
|
|
setStatus(`Save failed: ${e.message || e}`);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function del() {
|
|
if (!componentId) return;
|
|
if (!window.confirm(`Delete component "${name}"?`)) return;
|
|
await api.deleteComponent(project.id, componentId);
|
|
onBack();
|
|
}
|
|
|
|
const codeStyle: any = { fontFamily: "var(--font-mono)", fontSize: 12, lineHeight: "18px", minHeight: 120, resize: "vertical" };
|
|
const field = (label: string, node: any, help?: string, helpErr?: boolean) => (
|
|
<div style={{ marginBottom: 12 }}>
|
|
<label className="field-label">{label}</label>
|
|
{node}
|
|
{help && <div className="field-help" style={helpErr ? { color: "var(--err)" } : undefined}>{help}</div>}
|
|
</div>
|
|
);
|
|
|
|
if (!loaded) return <div className="col center grow" style={{ color: "var(--fg-2)" }}>Loading…</div>;
|
|
|
|
return (
|
|
<div className="col grow" style={{ minHeight: 0 }}>
|
|
<div className="row spread" style={{ padding: "12px 20px", borderBottom: "1px solid var(--line)", flex: "none" }}>
|
|
<div className="row gap2">
|
|
<button className="iconbtn" onClick={onBack} title="Back to Components">
|
|
<Icon name="chevleft" size={17} />
|
|
</button>
|
|
<div>
|
|
<div className="t-h2">{title || name}</div>
|
|
<div className="mono-sm fg-2">{name}</div>
|
|
</div>
|
|
</div>
|
|
<div className="row gap2" style={{ alignItems: "center" }}>
|
|
{status && (
|
|
<span
|
|
className="t-caption"
|
|
style={{ color: status.includes("fail") || status.includes("not valid") ? "var(--err)" : "var(--ok)" }}
|
|
>
|
|
{status}
|
|
</span>
|
|
)}
|
|
<label className="row gap2" style={{ alignItems: "center", fontSize: 12, color: "var(--fg-1)", cursor: "pointer" }}>
|
|
<span className={"toggle" + (enabled ? " on" : "")} onClick={() => setEnabled((v) => !v)} role="switch" aria-checked={enabled} />
|
|
enabled
|
|
</label>
|
|
{componentId && <VersionHistory entityType="component" entityId={componentId} entityLabel={name} onRestored={() => setReloadKey((k) => k + 1)} />}
|
|
{componentId && (
|
|
<button className="btn btn-danger btn-sm" onClick={del}>
|
|
Delete
|
|
</button>
|
|
)}
|
|
<button className="btn btn-primary btn-sm" onClick={save} disabled={saving}>
|
|
<Icon name={saving ? "refresh" : "check"} size={14} style={saving ? { animation: "spin 1s linear infinite" } : {}} />
|
|
{saving ? "Saving…" : "Save"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
|
|
<div className="scroll-y" style={{ flex: 1, minWidth: 0, padding: 20, borderRight: "1px solid var(--line)" }}>
|
|
{field("Name", <input className="input mono" value={name} onChange={(e) => setName(e.target.value)} placeholder="product_card" />, "Machine name - this is the tool name the agent calls.")}
|
|
{field("Title", <input className="input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Product card" />)}
|
|
{field(
|
|
"Description",
|
|
<textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="When should the agent render this?" style={{ minHeight: 56 }} />,
|
|
"Model-facing: tells the agent when to use this component.",
|
|
)}
|
|
{field(
|
|
"HTML",
|
|
<textarea className="textarea" value={html} onChange={(e) => setHtml(e.target.value)} style={codeStyle} spellCheck={false} />,
|
|
'Mustache: {{prop}} for values, {{#rows}}…{{/rows}} to loop. Buttons: add data-forge-action="id".',
|
|
)}
|
|
{field("CSS", <textarea className="textarea" value={css} onChange={(e) => setCss(e.target.value)} style={codeStyle} spellCheck={false} />)}
|
|
{field(
|
|
"Props schema (JSON)",
|
|
<textarea className="textarea" value={propsText} onChange={(e) => setPropsText(e.target.value)} style={{ ...codeStyle, borderColor: propsParsed.error ? "var(--err)" : undefined }} spellCheck={false} />,
|
|
propsParsed.error ? `Invalid JSON: ${propsParsed.error}` : "JSON Schema for the props the agent supplies.",
|
|
!!propsParsed.error,
|
|
)}
|
|
{field(
|
|
"Sample props (JSON)",
|
|
<textarea className="textarea" value={sampleText} onChange={(e) => setSampleText(e.target.value)} style={{ ...codeStyle, borderColor: sample.error ? "var(--err)" : undefined }} spellCheck={false} />,
|
|
sample.error ? `Invalid JSON: ${sample.error}` : "Drives the live preview.",
|
|
!!sample.error,
|
|
)}
|
|
{field(
|
|
"Actions (JSON)",
|
|
<textarea className="textarea" value={actionsText} onChange={(e) => setActionsText(e.target.value)} style={{ ...codeStyle, minHeight: 80, borderColor: actionsParsed.error ? "var(--err)" : undefined }} spellCheck={false} />,
|
|
actionsParsed.error ? `Invalid JSON: ${actionsParsed.error}` : 'Buttons: [{ "id": "add", "label": "Add", "message": "Add {{props.title}} to cart" }]',
|
|
!!actionsParsed.error,
|
|
)}
|
|
</div>
|
|
<div className="scroll-y" style={{ width: 460, flex: "none", padding: 20, background: "var(--bg-0)" }}>
|
|
<div className="t-micro" style={{ marginBottom: 10 }}>Live preview</div>
|
|
<div className="card" style={{ padding: 14 }}>
|
|
<ComponentRenderer def={{ name, html, css, actions: previewActions }} props={previewProps} onAction={(a, f) => setStatus(`Action "${a}" → ${JSON.stringify(f)}`)} />
|
|
</div>
|
|
<div className="field-help" style={{ marginTop: 10 }}>
|
|
Rendered in a sandboxed iframe with your sample props. Clicking a button shows what it would send back to the agent.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|