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.
218 lines
9.4 KiB
TypeScript
218 lines
9.4 KiB
TypeScript
"use client";
|
|
/* Reusable Export / Import controls for the four authorable entity types (tool, workflow,
|
|
component, agent). Export opens a select-all picker and downloads a single-type JSON
|
|
bundle; Import uploads such a bundle and re-creates its items IN THE CURRENT PROJECT
|
|
(new ids, auto-renamed on collision - never overwrites). Dropped into each list screen's
|
|
header; the same bundle format works across projects. */
|
|
import { useRef, useState } from "react";
|
|
import { Icon } from "./icons";
|
|
import { Modal } from "./primitives";
|
|
import { api, ImportReport, PortableType } from "@/lib/api";
|
|
|
|
export interface PortableItem {
|
|
id: string;
|
|
name: string;
|
|
sub?: string; // optional secondary line (e.g. kind / model)
|
|
}
|
|
|
|
function downloadJson(filename: string, data: unknown) {
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function Check({ checked, indeterminate }: { checked: boolean; indeterminate?: boolean }) {
|
|
return (
|
|
<span
|
|
aria-checked={indeterminate ? "mixed" : checked}
|
|
role="checkbox"
|
|
style={{
|
|
width: 16, height: 16, flex: "none", borderRadius: 4, display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
border: "1.5px solid " + (checked || indeterminate ? "var(--accent)" : "var(--line-strong)"),
|
|
background: checked || indeterminate ? "var(--accent)" : "transparent", color: "var(--fg-on-accent)",
|
|
}}
|
|
>
|
|
{indeterminate ? <Icon name="minus" size={11} /> : checked ? <Icon name="check" size={11} /> : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function ImportExport({
|
|
project, type, typeLabel, items, onImported, size = "sm",
|
|
}: {
|
|
project: { id: string; name?: string; slug?: string } | null | undefined;
|
|
type: PortableType;
|
|
typeLabel: string; // singular, lowercase (e.g. "tool")
|
|
items: PortableItem[];
|
|
onImported: () => void;
|
|
size?: "sm" | "md";
|
|
}) {
|
|
const [exportOpen, setExportOpen] = useState(false);
|
|
const [sel, setSel] = useState<Set<string>>(new Set());
|
|
const [downloading, setDownloading] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
const [report, setReport] = useState<ImportReport | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
const btnCls = "btn btn-secondary " + (size === "sm" ? "btn-sm" : "");
|
|
const plural = `${typeLabel}s`;
|
|
|
|
const allSelected = items.length > 0 && sel.size === items.length;
|
|
const someSelected = sel.size > 0 && !allSelected;
|
|
|
|
function openExport() {
|
|
setSel(new Set(items.map((i) => i.id))); // default to "select all"
|
|
setExportOpen(true);
|
|
}
|
|
function toggle(id: string) {
|
|
setSel((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
|
}
|
|
function toggleAll() {
|
|
setSel(allSelected ? new Set() : new Set(items.map((i) => i.id)));
|
|
}
|
|
|
|
async function doDownload() {
|
|
if (!project || sel.size === 0) return;
|
|
setDownloading(true);
|
|
try {
|
|
const ids = items.filter((i) => sel.has(i.id)).map((i) => i.id); // preserve list order
|
|
const bundle = await api.exportBundle(project.id, type, ids);
|
|
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
const base = (project.slug || project.name || "forge").toString().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "forge";
|
|
downloadJson(`${base}-${plural}-${stamp}.json`, bundle);
|
|
setExportOpen(false);
|
|
} catch (e: any) {
|
|
setError(String(e?.message || e));
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
}
|
|
|
|
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = ""; // allow re-selecting the same file
|
|
if (!file || !project) return;
|
|
setImporting(true);
|
|
setError(null);
|
|
setReport(null);
|
|
try {
|
|
const text = await file.text();
|
|
let bundle: unknown;
|
|
try {
|
|
bundle = JSON.parse(text);
|
|
} catch {
|
|
throw new Error("That file isn't valid JSON. Choose a bundle exported from Forge.");
|
|
}
|
|
const r = await api.importBundle(project.id, type, bundle);
|
|
setReport(r);
|
|
onImported();
|
|
} catch (e: any) {
|
|
setError(String(e?.message || e));
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button className={btnCls} onClick={openExport} disabled={!project || items.length === 0} title={items.length === 0 ? `No ${plural} to export` : `Export ${plural} to a file`}>
|
|
<Icon name="download" size={14} />Export
|
|
</button>
|
|
<button className={btnCls} onClick={() => fileRef.current?.click()} disabled={!project || importing} title={`Import ${plural} from a file`}>
|
|
<Icon name={importing ? "refresh" : "upload"} size={14} style={importing ? { animation: "spin 1s linear infinite" } : undefined} />
|
|
{importing ? "Importing…" : "Import"}
|
|
</button>
|
|
<input ref={fileRef} type="file" accept=".json,application/json" style={{ display: "none" }} onChange={onFile} />
|
|
|
|
{/* Export picker: choose which rows go into the bundle (defaults to all). */}
|
|
<Modal
|
|
open={exportOpen}
|
|
onClose={() => setExportOpen(false)}
|
|
title={`Export ${plural}`}
|
|
width={520}
|
|
footer={
|
|
<>
|
|
<button className="btn btn-secondary btn-sm" onClick={() => setExportOpen(false)}>Cancel</button>
|
|
<button className="btn btn-primary btn-sm" onClick={doDownload} disabled={sel.size === 0 || downloading}>
|
|
<Icon name={downloading ? "refresh" : "download"} size={14} style={downloading ? { animation: "spin 1s linear infinite" } : undefined} />
|
|
{downloading ? "Preparing…" : `Download ${sel.size} ${sel.size === 1 ? typeLabel : plural}`}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="col gap2">
|
|
<button className="row gap2" onClick={toggleAll} style={{ alignItems: "center", background: "none", border: "none", cursor: "pointer", padding: "4px 2px", width: "100%", textAlign: "left" }}>
|
|
<Check checked={allSelected} indeterminate={someSelected} />
|
|
<span className="t-body-sm" style={{ fontWeight: 600 }}>Select all</span>
|
|
<span className="fg-2 t-caption" style={{ marginLeft: "auto" }}>{sel.size}/{items.length} selected</span>
|
|
</button>
|
|
<div className="divider" />
|
|
<div className="col" style={{ gap: 1, maxHeight: 380, overflowY: "auto" }}>
|
|
{items.map((it) => {
|
|
const on = sel.has(it.id);
|
|
return (
|
|
<button key={it.id} className="row gap2" onClick={() => toggle(it.id)}
|
|
style={{ alignItems: "center", background: on ? "var(--bg-3)" : "none", border: "none", cursor: "pointer", padding: "8px 8px", borderRadius: 7, width: "100%", textAlign: "left" }}>
|
|
<Check checked={on} />
|
|
<div className="grow" style={{ minWidth: 0 }}>
|
|
<div className="mono-sm truncate" style={{ fontWeight: 600 }}>{it.name}</div>
|
|
{it.sub && <div className="fg-2 t-caption truncate">{it.sub}</div>}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Import result. */}
|
|
<Modal
|
|
open={!!report || !!error}
|
|
onClose={() => { setReport(null); setError(null); }}
|
|
title={error ? "Import failed" : "Import complete"}
|
|
width={520}
|
|
footer={<button className="btn btn-primary btn-sm" onClick={() => { setReport(null); setError(null); }}>Done</button>}
|
|
>
|
|
{error ? (
|
|
<div className="t-body-sm" style={{ color: "var(--err)", overflowWrap: "anywhere" }}>{error}</div>
|
|
) : report ? (
|
|
<div className="col gap3">
|
|
<div className="t-body-sm">
|
|
Imported <b>{report.imported}</b> {report.imported === 1 ? typeLabel : plural}
|
|
{report.skipped > 0 && <> · skipped <b>{report.skipped}</b></>}
|
|
{(report.toolsets_imported ?? 0) > 0 && <> · <b>{report.toolsets_imported}</b> tool set{report.toolsets_imported === 1 ? "" : "s"}</>} into <b>{project?.name}</b>.
|
|
</div>
|
|
{report.items.some((i) => i.renamed) && (
|
|
<div className="card" style={{ padding: 10 }}>
|
|
<div className="t-micro" style={{ marginBottom: 6 }}>Renamed to avoid clashes</div>
|
|
<div className="col gap1">
|
|
{report.items.filter((i) => i.renamed).map((i, n) => (
|
|
<div key={n} className="mono-sm fg-1 truncate">{i.original_name} → {i.name}</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{report.warnings.length > 0 && (
|
|
<div className="card" style={{ padding: 10, borderColor: "var(--warn)" }}>
|
|
<div className="t-micro" style={{ marginBottom: 6, color: "var(--warn)" }}>Heads up</div>
|
|
<div className="col gap1">
|
|
{report.warnings.map((w, n) => (
|
|
<div key={n} className="t-caption fg-1" style={{ overflowWrap: "anywhere" }}>{w}</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|