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:
nihalashetty
2026-07-28 01:49:19 +05:30
commit ae67bff5a3
350 changed files with 58244 additions and 0 deletions
+726
View File
@@ -0,0 +1,726 @@
/* Forge API client. Calls are proxied through Next (/api/forge/* -> backend) so the
app and API share an origin in dev (see next.config.mjs). */
const BASE = "/api/forge";
const DIRECT_API = (process.env.NEXT_PUBLIC_FORGE_API_URL || "").replace(/\/$/, "");
function isLocalWebHost(hostname: string) {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
function sseBase() {
if (DIRECT_API) return DIRECT_API;
if (typeof window !== "undefined" && isLocalWebHost(window.location.hostname)) {
return "http://127.0.0.1:8000";
}
return BASE;
}
function sseUrl(path: string) {
return `${sseBase()}${path}`;
}
export interface Project {
id: string;
name: string;
slug: string;
description?: string | null;
status: string;
config?: Record<string, unknown>;
}
export interface Workflow {
id: string;
project_id: string;
name: string;
description?: string | null;
status: string;
active_version: number;
executable: Record<string, any>;
canvas: Record<string, any>;
}
export interface ValidateResult {
valid: boolean;
errors: { pointer: string; message: string; node_id?: string }[];
}
/** Model catalog served from the backend (GET /v1/models) - the single source of truth for
* every model picker, so the frontend hardcodes no model lists. See forge/model_catalog.py. */
export interface ModelInfo {
id: string;
name: string;
provider: string;
ctx: string;
tools: boolean;
vision: boolean;
}
export interface EmbeddingModelInfo {
id: string;
name: string;
provider: string;
dim: number;
billed: boolean;
default: boolean;
}
export interface RerankerModelInfo {
id: string;
name: string;
note: string;
default: boolean;
}
export interface ModelCatalog {
chat: ModelInfo[];
embedding: EmbeddingModelInfo[];
reranker: RerankerModelInfo[];
}
export interface Tool {
id: string;
project_id: string;
name: string;
kind: string;
enabled: boolean;
auth_provider_id?: string | null;
last_tested?: string | null;
config: Record<string, any>;
}
export interface ToolSet {
id: string;
project_id: string;
name: string;
slug: string;
description: string;
icon?: string | null;
is_default: boolean;
exposed: boolean;
tool_ids: string[];
}
export interface McpToken {
id: string;
name: string;
prefix: string;
project_id?: string | null;
status: string;
created_at?: string | null;
last_used_at?: string | null;
expires_at?: string | null;
token?: string | null;
}
export interface ComponentT {
id: string;
name: string;
title?: string | null;
description: string;
props_schema: Record<string, any>;
html: string;
css: string;
actions: Record<string, any>[];
sample_props: Record<string, any>;
kind: string;
enabled: boolean;
version: number;
}
export interface RedirectInfo {
followed: boolean;
status?: number;
final_status?: number;
requested_url?: string;
final_url?: string;
location?: string | null;
chain?: string[];
note?: string;
}
export interface ToolTestResult {
ok: boolean;
error?: string;
status?: number;
latency_ms?: number;
raw?: any;
projected?: any;
raw_tokens?: number;
projected_tokens?: number;
final_url?: string;
redirect?: RedirectInfo | null;
}
export interface AuthProviderT {
id: string;
project_id: string;
name: string;
kind: string;
credentials_ref?: string | null;
config: Record<string, any>;
}
/** A per-user ("external") credential the current user connects themselves. Minimal, connector-safe
* shape from GET /v1/projects/{id}/connections (no provider config / secret refs). */
export interface MyConnection {
id: string;
name: string;
kind: string;
connected: boolean;
}
export interface Agent {
id: string;
project_id: string;
name: string;
config: Record<string, any>;
created_by?: string | null;
created_by_email?: string | null;
}
export interface KbSource { id: string; project_id: string; kind: string; name: string; folder?: string; uri?: string | null; status: string; chunks: number; embedding_model?: string | null; chunking_strategy?: string | null; chunk_size?: number | null; chunk_overlap?: number | null; }
export interface RechunkSettings { chunking_strategy?: string; chunk_size?: number; chunk_overlap?: number; }
export interface QaPair { id: string; question: string; answer: string; kind: string; tags: string[]; upvotes: number; }
export interface SearchHit { text: string; score: number; source_id?: string; }
// Chunk-map visualizer (POST /knowledge/map): a 2-D (PCA) projection of the stored chunk vectors.
export interface ChunkPoint { id: string; x: number; y: number; source_id?: string | null; chunk_idx?: number | null; parent_id?: string | null; preview: string; retrieved?: number; }
export interface ChunkMapResult { points: ChunkPoint[]; sources: { id: string; name: string }[]; query_point: [number, number] | null; query: string | null; total: number; truncated: boolean; }
export interface ChunkDetail { id: string; text: string; source_id?: string | null; chunk_idx?: number | null; parent_id?: string | null; }
export interface Trace { id: string; run_id: string; workflow_id?: string | null; name: string; status: string; started_at?: string | null; ended_at?: string | null; latency_ms: number; total_tokens: number; total_cost_usd: number; }
export interface Span { id: string; parent_span_id?: string | null; name: string; kind: string; latency_ms: number; input?: any; output?: any; model?: string | null; input_tokens: number; output_tokens: number; cost_usd: number; error?: string | null; }
export interface Conversation { thread_id: string; actor: string; source: string; end_user_id?: string | null; workflow_id?: string | null; turns: number; total_tokens: number; total_cost_usd: number; started_at?: string | null; last_activity?: string | null; status: string; preview: string; }
export interface Turn { trace_id: string; run_id: string; source: string; user_message?: string | null; ai_response?: string | null; status: string; error?: string | null; latency_ms: number; total_tokens: number; total_cost_usd: number; started_at?: string | null; }
export interface ConversationDetail { conversation: Conversation; turns: Turn[]; }
export interface Facets { actors: string[]; sources: string[]; }
export interface Secret { id: string; name: string; kind: string; version: number; }
export interface StatRollup { runs: number; tokens: number; cost_usd: number; avg_latency_ms: number; errors?: number; error_rate?: number; }
export interface ReportRow extends StatRollup { label: string; kind: "workflow" | "assistant" | "other"; }
/* ---- analytics dashboard (time-series + breakdowns over a date range) ---- */
export interface TimeBucket { date: string; runs: number; tokens: number; cost_usd: number; avg_latency_ms: number; errors: number; success: number; }
export interface SourceRollup extends StatRollup { source: string; }
export interface ToolStat { name: string; calls: number; avg_latency_ms: number; errors: number; cost_usd: number; tokens: number; }
export interface ModelStat { model: string; calls: number; tokens: number; cost_usd: number; avg_latency_ms: number; }
export interface LatencyBucket { label: string; count: number; }
export interface AnalyticsRange { days: number; since: string; until: string; bucket: string; }
export interface AnalyticsRecentRun { id: string; workflow: string; project: string; status: string; tokens: number; latency_ms: number; cost_usd: number; started_at: string | null; }
export interface Analytics {
range: AnalyticsRange;
totals: StatRollup;
prev_totals: StatRollup;
timeseries: TimeBucket[];
by_source: SourceRollup[];
by_workflow: ReportRow[];
tools: ToolStat[];
models: ModelStat[];
latency_histogram: LatencyBucket[];
recent: AnalyticsRecentRun[];
}
export interface ProjectStats {
totals: StatRollup;
last_7d: StatRollup;
assistant: StatRollup & { turns: number };
reports: ReportRow[];
}
// Sidebar badge counts (keys match countKey in data.ts PROJECT_NAV). One cheap call
// replaces six full-list fetches that were only ever read for their `.length`.
export interface ProjectCounts {
workflows: number; agents: number; tools: number; components: number; knowledge: number; auth: number; handoffs: number;
}
export interface DashboardStats {
runs_7d: number;
total_runs: number;
success_rate: number;
avg_latency_ms: number;
spend_7d: number;
recent: { id: string; workflow: string; project: string; status: string; tokens: number; latency_ms: number; cost_usd: number; started_at: string | null }[];
projects: Record<string, { workflows: number; tools: number; runs_7d: number }>;
reports: (StatRollup & { project_id: string; project: string; assistant_cost_usd: number; assistant_turns: number })[];
totals: StatRollup;
}
/* ---- import / export (portable single-type bundles) ---- */
export type PortableType = "tool" | "workflow" | "component" | "agent";
const PORTABLE_PLURAL: Record<PortableType, string> = {
tool: "tools", workflow: "workflows", component: "components", agent: "agents",
};
export interface ExportBundle {
format?: string;
type: string;
exported_at?: string;
source?: { project_id?: string; project_name?: string | null };
items: Record<string, any>[];
}
export interface ImportReportItem {
id?: string;
name?: string;
original_name?: string;
renamed?: boolean;
skipped?: boolean;
}
export interface ImportReport {
type: string;
imported: number;
skipped: number;
items: ImportReportItem[];
warnings: string[];
toolsets_imported?: number;
}
export interface NodeType {
type: string;
category: string;
label: string;
description: string;
schema_id: string;
allows_cycle: boolean;
input_ports: { id: string; io_type: string; direction: string }[];
output_ports: { id: string; io_type: string; direction: string }[];
}
/* ---- auth token storage (JWT). Sent as a Bearer header on every request. ---- */
const TOKEN_KEY = "forge_access_token";
const REFRESH_KEY = "forge_refresh_token";
export function getToken(): string | null {
return typeof window !== "undefined" ? window.localStorage.getItem(TOKEN_KEY) : null;
}
export function setTokens(access: string, refresh?: string) {
if (typeof window === "undefined") return;
window.localStorage.setItem(TOKEN_KEY, access);
if (refresh) window.localStorage.setItem(REFRESH_KEY, refresh);
}
export function clearTokens() {
if (typeof window === "undefined") return;
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(REFRESH_KEY);
}
export function authHeader(): Record<string, string> {
const t = getToken();
return t ? { Authorization: `Bearer ${t}` } : {};
}
export const UNAUTHORIZED_EVENT = "forge:unauthorized";
function on401() {
if (typeof window !== "undefined") {
clearTokens();
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT));
}
}
// In-flight GET de-duplication: identical GET requests issued while one is still pending
// share a single promise (and thus one network round-trip). This collapses React
// StrictMode's double-invoked effects in dev AND any accidental concurrent duplicate
// fetches (e.g. sidebar + overview both asking for counts). There is NO time-based cache -
// the entry is dropped the moment the request settles, so data is never served stale.
const _inflight = new Map<string, Promise<any>>();
async function json<T>(path: string, init?: RequestInit): Promise<T> {
const method = (init?.method || "GET").toUpperCase();
const key = method === "GET" ? path : null;
if (key && _inflight.has(key)) return _inflight.get(key) as Promise<T>;
const p = (async () => {
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json", ...authHeader(), ...(init?.headers || {}) },
...init,
});
if (res.status === 401) on401();
if (!res.ok) throw new Error(`${res.status} ${res.statusText} on ${path}`);
return res.json() as Promise<T>;
})();
if (key) {
_inflight.set(key, p);
const done = () => _inflight.delete(key);
p.then(done, done); // clear on settle (both fulfil + reject); never itself rejects
}
return p;
}
/** Fired after any create/delete of a counted resource so the project sidebar can
* refresh its badge counts without a page reload. */
export const COUNTS_CHANGED_EVENT = "forge:counts-changed";
function notifyCounts<T>(p: Promise<T>): Promise<T> {
return p.then((v) => {
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(COUNTS_CHANGED_EVENT));
return v;
});
}
export const api = {
listProjects: () => json<Project[]>("/v1/projects"),
getProject: (id: string) => json<Project>(`/v1/projects/${id}`),
projectCounts: (pid: string) => json<ProjectCounts>(`/v1/projects/${pid}/counts`),
createProject: (body: { name: string; slug?: string; description?: string; config?: Record<string, unknown> }) =>
json<Project>("/v1/projects", { method: "POST", body: JSON.stringify(body) }),
listWorkflows: (pid: string) => json<Workflow[]>(`/v1/projects/${pid}/workflows`),
getWorkflow: (pid: string, wid: string) => json<Workflow>(`/v1/projects/${pid}/workflows/${wid}`),
validateExecutable: (pid: string, executable: Record<string, unknown>) =>
json<ValidateResult>(`/v1/projects/${pid}/workflows/validate`, {
method: "POST",
body: JSON.stringify({ executable }),
}),
createRun: (pid: string, wid: string, input: Record<string, unknown>, threadId?: string, endUser?: Record<string, unknown> | null) =>
json<{ id: string; status: string; thread_id: string }>(
`/v1/projects/${pid}/workflows/${wid}/runs`,
{ method: "POST", body: JSON.stringify({ input, ...(threadId ? { thread_id: threadId } : {}), ...(endUser ? { end_user: endUser } : {}) }) },
),
resumeRun: (pid: string, wid: string, rid: string, value: unknown) =>
json<{ status?: string; messages?: any[]; interrupted?: boolean; error?: string }>(
`/v1/projects/${pid}/workflows/${wid}/runs/${rid}/resume`,
{ method: "POST", body: JSON.stringify({ value }) },
),
createWorkflow: (pid: string, body: { name: string; description?: string; executable?: Record<string, unknown>; canvas?: Record<string, unknown> }) =>
notifyCounts(json<Workflow>(`/v1/projects/${pid}/workflows`, { method: "POST", body: JSON.stringify(body) })),
updateWorkflow: (pid: string, wid: string, body: { name?: string; description?: string }) =>
json<Workflow>(`/v1/projects/${pid}/workflows/${wid}`, { method: "PATCH", body: JSON.stringify(body) }),
saveCanvas: (pid: string, wid: string, canvas: Record<string, unknown>, executable: Record<string, unknown>) =>
json<ValidateResult>(`/v1/projects/${pid}/workflows/${wid}/canvas`, { method: "PUT", body: JSON.stringify({ canvas, executable }) }),
publishWorkflow: (pid: string, wid: string) =>
json<Workflow>(`/v1/projects/${pid}/workflows/${wid}/publish`, { method: "POST" }),
deleteWorkflow: (pid: string, wid: string) =>
notifyCounts(fetch(`${BASE}/v1/projects/${pid}/workflows/${wid}`, { method: "DELETE", headers: authHeader() })),
dashboardStats: () => json<DashboardStats>("/v1/stats/dashboard"),
projectStats: (pid: string) => json<ProjectStats>(`/v1/stats/projects/${pid}`),
projectAnalytics: (pid: string, days = 30) => json<Analytics>(`/v1/stats/projects/${pid}/analytics?days=${days}`),
listAgents: (pid: string) => json<Agent[]>(`/v1/projects/${pid}/agents`),
getAgent: (pid: string, aid: string) => json<Agent>(`/v1/projects/${pid}/agents/${aid}`),
createAgent: (pid: string, body: { name: string; config: Record<string, unknown> }) =>
notifyCounts(json<Agent>(`/v1/projects/${pid}/agents`, { method: "POST", body: JSON.stringify(body) })),
updateAgent: (pid: string, aid: string, body: { name?: string; config?: Record<string, unknown> }) =>
json<Agent>(`/v1/projects/${pid}/agents/${aid}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteAgent: (pid: string, aid: string) =>
notifyCounts(fetch(`${BASE}/v1/projects/${pid}/agents/${aid}`, { method: "DELETE", headers: authHeader() })),
listTools: (pid: string) => json<Tool[]>(`/v1/projects/${pid}/tools`),
getTool: (pid: string, tid: string) => json<Tool>(`/v1/projects/${pid}/tools/${tid}`),
createTool: (pid: string, body: { name: string; kind: string; config: Record<string, unknown>; auth_provider_id?: string }) =>
notifyCounts(json<Tool>(`/v1/projects/${pid}/tools`, { method: "POST", body: JSON.stringify(body) })),
updateTool: (pid: string, tid: string, body: { name?: string; config?: Record<string, unknown>; auth_provider_id?: string | null; enabled?: boolean }) =>
json<Tool>(`/v1/projects/${pid}/tools/${tid}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteTool: (pid: string, tid: string) =>
notifyCounts(fetch(`${BASE}/v1/projects/${pid}/tools/${tid}`, { method: "DELETE", headers: authHeader() })),
testTool: (pid: string, tid: string, args: Record<string, unknown>, context?: Record<string, unknown>) =>
json<ToolTestResult>(`/v1/projects/${pid}/tools/${tid}/test`, {
method: "POST",
body: JSON.stringify({ args, context }),
}),
// tool sets (describable groups of tools; organize the Tools screen + publish over MCP)
listToolSets: (pid: string) => json<ToolSet[]>(`/v1/projects/${pid}/tool-sets`),
createToolSet: (pid: string, body: { name: string; description?: string; icon?: string | null; is_default?: boolean; exposed?: boolean; tool_ids?: string[] }) =>
json<ToolSet>(`/v1/projects/${pid}/tool-sets`, { method: "POST", body: JSON.stringify(body) }),
updateToolSet: (pid: string, sid: string, body: Partial<{ name: string; description: string; icon: string | null; is_default: boolean; exposed: boolean; tool_ids: string[] }>) =>
json<ToolSet>(`/v1/projects/${pid}/tool-sets/${sid}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteToolSet: (pid: string, sid: string) =>
fetch(`${BASE}/v1/projects/${pid}/tool-sets/${sid}`, { method: "DELETE", headers: authHeader() }),
addToolToSet: (pid: string, sid: string, tid: string) =>
fetch(`${BASE}/v1/projects/${pid}/tool-sets/${sid}/tools/${tid}`, { method: "POST", headers: authHeader() }),
removeToolFromSet: (pid: string, sid: string, tid: string) =>
fetch(`${BASE}/v1/projects/${pid}/tool-sets/${sid}/tools/${tid}`, { method: "DELETE", headers: authHeader() }),
// MCP personal access tokens (per-user MCP auth; the plaintext is returned once on create)
listMcpTokens: (pid: string) => json<McpToken[]>(`/v1/projects/${pid}/mcp-tokens`),
createMcpToken: (pid: string, body: { name?: string; ttl_days?: number }) =>
json<McpToken>(`/v1/projects/${pid}/mcp-tokens`, { method: "POST", body: JSON.stringify(body) }),
revokeMcpToken: (pid: string, tid: string) =>
fetch(`${BASE}/v1/projects/${pid}/mcp-tokens/${tid}`, { method: "DELETE", headers: authHeader() }),
// components (Feature 2 - generative UI widgets)
listComponents: (pid: string) => json<ComponentT[]>(`/v1/projects/${pid}/components`),
getComponent: (pid: string, cid: string) => json<ComponentT>(`/v1/projects/${pid}/components/${cid}`),
createComponent: (pid: string, body: Record<string, unknown> & { name: string }) =>
notifyCounts(json<ComponentT>(`/v1/projects/${pid}/components`, { method: "POST", body: JSON.stringify(body) })),
updateComponent: (pid: string, cid: string, body: Record<string, unknown>) =>
json<ComponentT>(`/v1/projects/${pid}/components/${cid}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteComponent: (pid: string, cid: string) =>
notifyCounts(fetch(`${BASE}/v1/projects/${pid}/components/${cid}`, { method: "DELETE", headers: authHeader() })),
// import / export - serialize the selected rows of one type into a downloadable bundle,
// and re-create a bundle's items in a target project (new ids, auto-renamed on collision).
exportBundle: (pid: string, type: PortableType, ids: string[]) =>
json<ExportBundle>(`/v1/projects/${pid}/${PORTABLE_PLURAL[type]}/export`, { method: "POST", body: JSON.stringify({ ids }) }),
importBundle: async (pid: string, type: PortableType, bundle: unknown): Promise<ImportReport> => {
// Direct fetch (not the shared `json` helper) so the backend's error `detail` - e.g. a
// wrong-type file or validation message - surfaces to the user instead of a bare status.
const res = await fetch(`${BASE}/v1/projects/${pid}/${PORTABLE_PLURAL[type]}/import`, {
method: "POST", headers: { "Content-Type": "application/json", ...authHeader() }, body: JSON.stringify(bundle),
});
if (res.status === 401) on401();
if (!res.ok) {
const detail = await res.json().then((d) => d?.detail).catch(() => null);
throw new Error(typeof detail === "string" ? detail : `${res.status} ${res.statusText}`);
}
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(COUNTS_CHANGED_EVENT));
return res.json() as Promise<ImportReport>;
},
listAuthProviders: (pid: string) => json<AuthProviderT[]>(`/v1/projects/${pid}/auth-providers`),
createAuthProvider: (pid: string, body: { name: string; kind: string; config: Record<string, unknown>; credentials_ref?: string }) =>
notifyCounts(json<AuthProviderT>(`/v1/projects/${pid}/auth-providers`, { method: "POST", body: JSON.stringify(body) })),
updateAuthProvider: (pid: string, aid: string, body: { name?: string; kind?: string; config?: Record<string, unknown>; credentials_ref?: string }) =>
json<AuthProviderT>(`/v1/projects/${pid}/auth-providers/${aid}`, { method: "PATCH", body: JSON.stringify(body) }),
testAuthProvider: (pid: string, aid: string, context?: Record<string, unknown>) =>
json<any>(`/v1/projects/${pid}/auth-providers/${aid}/test`, { method: "POST", body: JSON.stringify({ context }) }),
listMcpClients: (pid: string) => json<McpClientT[]>(`/v1/projects/${pid}/mcp-clients`),
createMcpClient: (pid: string, body: { name: string; transport?: string; url?: string; command?: string; args?: any; headers_ref?: string }) =>
json<McpClientT>(`/v1/projects/${pid}/mcp-clients`, { method: "POST", body: JSON.stringify(body) }),
updateMcpClient: (pid: string, cid: string, body: Partial<{ name: string; enabled: boolean; disabled_tools: string[]; url: string; headers_ref: string }>) =>
json<McpClientT>(`/v1/projects/${pid}/mcp-clients/${cid}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteMcpClient: (pid: string, cid: string) =>
json<{ ok: boolean }>(`/v1/projects/${pid}/mcp-clients/${cid}`, { method: "DELETE" }),
discoverMcpTools: (pid: string, cid: string) =>
json<{ ok: boolean; tools?: { name: string; description?: string }[]; error?: string }>(`/v1/projects/${pid}/mcp-clients/${cid}/tools`),
oauthStart: (pid: string, aid: string) =>
json<{ authorize_url: string }>(`/v1/projects/${pid}/auth-providers/${aid}/oauth/start`, { method: "POST" }),
oauthStatus: (pid: string, aid: string) =>
json<{ connected: boolean; expires_at?: number | null; scope?: string | null; has_refresh?: boolean }>(`/v1/projects/${pid}/auth-providers/${aid}/oauth/status`),
deleteAuthProvider: (pid: string, aid: string) =>
notifyCounts(fetch(`${BASE}/v1/projects/${pid}/auth-providers/${aid}`, { method: "DELETE", headers: authHeader() })),
// Per-user ("external") auth via the connector-safe /connections router (NOT the auth-providers
// admin surface). The current user's own downstream credential for a per-user provider, keyed
// server-side by their user id (the same id the MCP PAT resolves to), so a tool acts as them
// without a shared secret. Used by owners (Auth screen) and connectors (their token page) alike.
listMyConnections: (pid: string) => json<MyConnection[]>(`/v1/projects/${pid}/connections`),
getMyConnection: (pid: string, aid: string) =>
json<{ connected: boolean; expires_at?: number | null }>(`/v1/projects/${pid}/connections/${aid}`),
setMyConnection: (pid: string, aid: string, access_token: string) =>
fetch(`${BASE}/v1/projects/${pid}/connections/${aid}`, {
method: "PUT", headers: { "Content-Type": "application/json", ...authHeader() }, body: JSON.stringify({ access_token }),
}),
clearMyConnection: (pid: string, aid: string) =>
fetch(`${BASE}/v1/projects/${pid}/connections/${aid}`, { method: "DELETE", headers: authHeader() }),
// knowledge
listSources: (pid: string) => json<KbSource[]>(`/v1/projects/${pid}/knowledge/sources`),
listFolders: (pid: string) => json<string[]>(`/v1/projects/${pid}/knowledge/folders`),
addSource: (pid: string, body: { kind: string; name: string; folder?: string; uri?: string; text?: string; chunking_strategy?: string }) =>
notifyCounts(json<KbSource>(`/v1/projects/${pid}/knowledge/sources`, { method: "POST", body: JSON.stringify(body) })),
uploadSource: async (pid: string, file: globalThis.File, folder?: string, chunkingStrategy?: string) => {
const fd = new FormData();
fd.append("file", file);
if (folder) fd.append("folder", folder);
if (chunkingStrategy) fd.append("chunking_strategy", chunkingStrategy);
const res = await fetch(`${BASE}/v1/projects/${pid}/knowledge/sources/upload`, { method: "POST", body: fd, headers: authHeader() });
if (!res.ok) {
const detail = await res.json().then((d) => d?.detail).catch(() => null);
throw new Error(detail || `${res.status} ${res.statusText} on upload`);
}
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(COUNTS_CHANGED_EVENT));
return res.json() as Promise<KbSource>;
},
moveSource: (pid: string, sid: string, folder: string) =>
json<KbSource>(`/v1/projects/${pid}/knowledge/sources/${sid}`, { method: "PATCH", body: JSON.stringify({ folder }) }),
reingestSource: (pid: string, sid: string, settings?: RechunkSettings) =>
notifyCounts(json<{ id: string; status: string; chunks: number }>(`/v1/projects/${pid}/knowledge/sources/${sid}/reingest`, { method: "POST", body: JSON.stringify(settings || {}) })),
rechunkSources: (pid: string, source_ids: string[], settings: RechunkSettings) =>
notifyCounts(json<{ id: string; status: string; chunks: number }[]>(`/v1/projects/${pid}/knowledge/sources/rechunk`, { method: "POST", body: JSON.stringify({ source_ids, ...settings }) })),
embeddingHealth: (pid: string) =>
json<{ current_model: string; current_dim: number; sources: number; needs_reembed: boolean; mismatched: { id: string; name: string; embedded_with: string; dim: number }[] }>(`/v1/projects/${pid}/knowledge/health`),
deleteSource: (pid: string, sid: string) => notifyCounts(fetch(`${BASE}/v1/projects/${pid}/knowledge/sources/${sid}`, { method: "DELETE", headers: authHeader() })),
searchKnowledge: (pid: string, query: string, top_k = 5, folders?: string[], hybrid = false, rerank = false) =>
json<SearchHit[]>(`/v1/projects/${pid}/knowledge/search`, { method: "POST", body: JSON.stringify({ query, top_k, hybrid, rerank, ...(folders?.length ? { folders } : {}) }) }),
chunkMap: (pid: string, body: { query?: string; folders?: string[]; source_ids?: string[]; limit?: number; hybrid?: boolean; rerank?: boolean; top_k?: number }) =>
json<ChunkMapResult>(`/v1/projects/${pid}/knowledge/map`, { method: "POST", body: JSON.stringify(body) }),
// Full text of one chunk, fetched on demand for the chunk-map detail panel (the map response
// itself carries only a short preview, so the payload stays lean at large point budgets).
chunkDetail: (pid: string, chunkId: string) =>
json<ChunkDetail>(`/v1/projects/${pid}/knowledge/chunk?chunk_id=${encodeURIComponent(chunkId)}`),
dedupeChunks: (pid: string) =>
json<{ removed: number; groups: number; sources_affected: number; remaining: number }>(`/v1/projects/${pid}/knowledge/dedupe`, { method: "POST" }),
listQa: (pid: string) => json<QaPair[]>(`/v1/projects/${pid}/qa-pairs`),
listQaKinds: (pid: string) => json<string[]>(`/v1/projects/${pid}/qa-pairs/kinds`),
addQa: (pid: string, body: { question: string; answer: string; kind?: string; tags?: string[] }) =>
json<QaPair>(`/v1/projects/${pid}/qa-pairs`, { method: "POST", body: JSON.stringify(body) }),
updateQa: (pid: string, qid: string, body: { question?: string; answer?: string; kind?: string; tags?: string[] }) =>
json<QaPair>(`/v1/projects/${pid}/qa-pairs/${qid}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteQa: (pid: string, qid: string) => fetch(`${BASE}/v1/projects/${pid}/qa-pairs/${qid}`, { method: "DELETE", headers: authHeader() }),
// traces + conversations (Traces view)
listTraces: (pid: string) => json<Trace[]>(`/v1/projects/${pid}/traces`),
getTrace: (pid: string, trid: string) => json<{ trace: Trace; spans: Span[] }>(`/v1/projects/${pid}/traces/${trid}`),
listConversations: (pid: string, opts?: { actor?: string; source?: string; status?: string; search?: string; limit?: number; offset?: number }) => {
const q = new URLSearchParams();
if (opts?.actor) q.set("actor", opts.actor);
if (opts?.source) q.set("source", opts.source);
if (opts?.status) q.set("status", opts.status);
if (opts?.search) q.set("search", opts.search);
if (opts?.limit != null) q.set("limit", String(opts.limit));
if (opts?.offset != null) q.set("offset", String(opts.offset));
const qs = q.toString();
return json<Conversation[]>(`/v1/projects/${pid}/conversations${qs ? `?${qs}` : ""}`);
},
// Re-run a past run with the same input (fresh thread). Used by the Traces "re-run" button.
rerunRun: (pid: string, wid: string, rid: string) =>
json<{ id: string; status: string; thread_id: string }>(
`/v1/projects/${pid}/workflows/${wid}/runs/${rid}/rerun`, { method: "POST" },
),
getConversation: (pid: string, threadId: string) =>
json<ConversationDetail>(`/v1/projects/${pid}/conversations/${encodeURIComponent(threadId)}`),
conversationFacets: (pid: string) => json<Facets>(`/v1/projects/${pid}/conversations/facets`),
purgeConversations: (pid: string, olderThanDays: number) =>
json<{ removed: number }>(`/v1/projects/${pid}/conversations/purge?older_than_days=${olderThanDays}`, { method: "POST" }),
// secrets
listSecrets: (pid: string) => json<Secret[]>(`/v1/projects/${pid}/secrets`),
createSecret: (pid: string, body: { name: string; value: unknown; kind?: string }) =>
json<Secret>(`/v1/projects/${pid}/secrets`, { method: "POST", body: JSON.stringify(body) }),
secretUsage: (pid: string, name: string) =>
json<{ count: number; references: { type: string; label: string }[] }>(`/v1/projects/${pid}/secrets/${encodeURIComponent(name)}/usage`),
deleteSecret: (pid: string, name: string, force = false) =>
fetch(`${BASE}/v1/projects/${pid}/secrets/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE", headers: authHeader() }),
// project
updateProject: (pid: string, body: { name?: string; description?: string; config?: Record<string, unknown> }) =>
json<Project>(`/v1/projects/${pid}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteProject: async (pid: string) => {
const res = await fetch(`${BASE}/v1/projects/${pid}`, { method: "DELETE", headers: authHeader() });
if (!res.ok) throw new Error(`${res.status} ${res.statusText} on /v1/projects/${pid}`);
},
listNodeTypes: () => json<NodeType[]>("/v1/node-types"),
runStreamUrl: (pid: string, wid: string, runId: string) =>
sseUrl(`/v1/projects/${pid}/workflows/${wid}/runs/${runId}/stream`),
assistantStreamUrl: (pid: string) => sseUrl(`/v1/projects/${pid}/assistant/stream`),
assistantResumeUrl: (pid: string) => sseUrl(`/v1/projects/${pid}/assistant/resume`),
// auth + team
register: (email: string, password: string, workspace_name?: string) =>
json<AuthResult>("/v1/auth/register", { method: "POST", body: JSON.stringify({ email, password, workspace_name }) }),
login: (email: string, password: string) =>
json<AuthResult>("/v1/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }),
me: () => json<MeResult>("/v1/auth/me"),
inviteInfo: (token: string) => json<{ email: string; role: string }>(`/v1/auth/invite-info?token=${encodeURIComponent(token)}`),
acceptInvite: (token: string, password: string) =>
json<AuthResult>("/v1/auth/accept-invite", { method: "POST", body: JSON.stringify({ token, password }) }),
listTeam: () => json<TeamMember[]>("/v1/team/members"),
listModels: () => json<ModelCatalog>("/v1/models"),
listPricing: () => json<Record<string, { input_per_1m: number; output_per_1m: number }>>("/v1/pricing"),
setPricing: (model: string, body: { input_per_1m: number; output_per_1m: number }) =>
json<any>(`/v1/pricing/${encodeURIComponent(model)}`, { method: "PUT", body: JSON.stringify(body) }),
// version history (workflow | agent | tool | component | auth_provider | kb_source | project)
listVersions: (entityType: EntityType, entityId: string) =>
json<EntityVersion[]>(`/v1/versions/${entityType}/${entityId}`),
getVersion: (entityType: EntityType, entityId: string, versionNo: number) =>
json<EntityVersion & { snapshot: Record<string, any> }>(`/v1/versions/${entityType}/${entityId}/${versionNo}`),
restoreVersion: (entityType: EntityType, entityId: string, versionNo: number) =>
json<{ ok?: boolean; version_no?: number }>(`/v1/versions/${entityType}/${entityId}/restore`, { method: "POST", body: JSON.stringify({ version_no: versionNo }) }),
// read-only project-wide knowledge activity (added | changed | removed) for the Knowledge History
knowledgeActivity: (projectId: string) =>
json<ActivityEntry[]>(`/v1/versions/project/${projectId}/activity`),
// full project config snapshots (newest first) so Settings > History can diff by section
projectConfigHistory: (projectId: string) =>
json<ProjectVersion[]>(`/v1/versions/project/${projectId}/config-history`),
inviteMember: (body: { email: string; role?: string; password?: string }) =>
json<InviteResult>("/v1/team/members", { method: "POST", body: JSON.stringify(body) }),
updateMember: (uid: string, body: { role?: string; status?: string }) =>
json<TeamMember>(`/v1/team/members/${uid}`, { method: "PATCH", body: JSON.stringify(body) }),
deactivateMember: (uid: string) =>
json<{ ok: boolean }>(`/v1/team/members/${uid}`, { method: "DELETE" }),
// channels
listChannels: (pid: string) => json<Channel[]>(`/v1/projects/${pid}/channels`),
createChannel: (pid: string, body: { type: string; name: string; workflow_id?: string; config?: Record<string, any> }) =>
json<Channel>(`/v1/projects/${pid}/channels`, { method: "POST", body: JSON.stringify(body) }),
updateChannel: (pid: string, cid: string, body: { name?: string; workflow_id?: string; config?: Record<string, any>; enabled?: boolean }) =>
json<Channel>(`/v1/projects/${pid}/channels/${cid}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteChannel: (pid: string, cid: string) =>
json<{ ok: boolean }>(`/v1/projects/${pid}/channels/${cid}`, { method: "DELETE" }),
// triggers
listTriggers: (pid: string) => json<Trigger[]>(`/v1/projects/${pid}/triggers`),
// datasets / eval
listDatasets: (pid: string) => json<Dataset[]>(`/v1/projects/${pid}/datasets`),
createDataset: (pid: string, body: { name: string; workflow_id?: string; score_mode?: string; items?: any[] }) =>
json<Dataset>(`/v1/projects/${pid}/datasets`, { method: "POST", body: JSON.stringify(body) }),
updateDataset: (pid: string, did: string, body: { name: string; workflow_id?: string; score_mode?: string; items?: any[] }) =>
json<Dataset>(`/v1/projects/${pid}/datasets/${did}`, { method: "PATCH", body: JSON.stringify(body) }),
runDataset: (pid: string, did: string) =>
json<EvalRunResult>(`/v1/projects/${pid}/datasets/${did}/run`, { method: "POST" }),
runDatasetStreamUrl: (pid: string, did: string) =>
sseUrl(`/v1/projects/${pid}/datasets/${did}/run/stream`),
deleteDataset: (pid: string, did: string) =>
json<{ ok: boolean }>(`/v1/projects/${pid}/datasets/${did}`, { method: "DELETE" }),
// handoff inbox
listHandoffs: (pid: string, status = "open") => json<Handoff[]>(`/v1/projects/${pid}/handoffs?status=${status}`),
replyHandoff: (pid: string, hid: string, message: string) =>
json<{ ok: boolean }>(`/v1/projects/${pid}/handoffs/${hid}/reply`, { method: "POST", body: JSON.stringify({ message }) }),
// embed (widget)
getEmbed: (pid: string) => json<EmbedSettings>(`/v1/projects/${pid}/embed`),
setEmbed: (pid: string, body: { enabled: boolean; allowed_origins: string[]; workflow_id?: string | null }) =>
json<EmbedSettings>(`/v1/projects/${pid}/embed`, { method: "PUT", body: JSON.stringify(body) }),
};
export interface InviteResult extends TeamMember { email_sent: boolean; invite_url?: string; }
export interface Channel { id: string; type: string; name: string; workflow_id?: string | null; enabled: boolean; config: Record<string, any>; key?: string | null; inbound_url?: string; }
export interface Trigger { id: string; workflow_id: string; node_id: string; kind: string; enabled: boolean; config: Record<string, any>; webhook_url?: string; last_fired_at?: string | null; }
export interface Dataset { id: string; name: string; workflow_id?: string | null; score_mode: string; items: any[]; n_items: number; last_pass_rate?: number | null; }
/** One scored case. `status` is "scored" for a normal pass/fail, else an inconclusive outcome
* ("run_failed" / "unavailable" / "error"). tokens/cost/latency_ms are per-case run metrics
* (absent on old runs). */
export interface EvalResult {
input: string; expected: string; answer: string; passed: boolean; reason?: string | null;
status?: string; score?: number | null; tokens?: number; cost?: number; latency_ms?: number;
}
export interface EvalSummary { total: number; passed: number; pass_rate: number; inconclusive?: number; tokens?: number; cost_usd?: number; eval_run_id?: string | null; }
export interface EvalReport { summary: EvalSummary; results: EvalResult[]; }
/** A dataset run either scores (EvalReport) or fails before scoring (no workflow bound,
* quota exceeded, …) - the backend returns a bare `{error}` for the latter, so callers
* must narrow before reading `summary`/`results`. */
export type EvalRunResult = EvalReport | { error: string };
/** SSE frames from the streaming run endpoint (run/stream). */
export interface EvalStreamStart { total: number; truncated?: boolean; items: { index: number; input: string; expected: string }[]; }
export interface EvalStreamItem extends EvalResult { index: number; }
export interface EvalStreamDone { summary: EvalSummary; results: EvalResult[]; }
export interface Handoff { id: string; run_id: string; workflow_id?: string | null; customer?: string | null; customer_message?: string | null; reason?: string | null; status: string; at?: string | null; }
export interface EmbedSettings { enabled: boolean; allowed_origins: string[]; workflow_id?: string | null; publishable_key?: string | null; embed_src?: string | null; }
export type EntityType = "workflow" | "agent" | "tool" | "component" | "auth_provider" | "kb_source" | "project";
export interface EntityVersion { id: string; version_no: number; label?: string | null; author_email?: string | null; created_at?: string | null; }
// One row of the read-only Knowledge activity feed (a file or Q&A pair added/changed/removed).
export interface ActivityEntry { id: string; entity_type: "kb_source" | "qa_pair"; entity_id: string; action?: string | null; title: string; author_email?: string | null; created_at?: string | null; }
// A full project config snapshot (Settings > History diffs consecutive ones per section).
export interface ProjectVersion { id: string; version_no: number; author_email?: string | null; created_at?: string | null; snapshot: Record<string, any>; }
export interface MeResult { id: string; email: string | null; role: string; tenant_id: string; is_fallback: boolean; }
export interface AuthResult { access_token: string; refresh_token: string; user: { id: string; email: string; role: string }; }
export interface TeamMember { id: string; email: string; role: string; status: string; tenant_id: string; }
export interface McpClientT { id: string; name: string; transport: string; url?: string | null; command?: string | null; args?: any; headers_ref?: string | null; enabled: boolean; disabled_tools?: string[]; }
export interface SSEFrame {
event: string;
data: any;
}
/** Open an SSE stream and invoke `onFrame` per event. Supports GET (default) or POST
* (pass init.method/body) - the backend assistant endpoint streams over POST. */
export async function openSSE(
url: string,
onFrame: (frame: SSEFrame) => void,
init?: RequestInit,
): Promise<void> {
const res = await fetch(url, {
...init,
cache: "no-store",
headers: { Accept: "text/event-stream", "Cache-Control": "no-cache", ...authHeader(), ...(init?.headers || {}) },
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText} on ${url}`);
if (!res.body) throw new Error("No response body for SSE");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line - handle both \n\n and \r\n\r\n.
const chunks = buffer.split(/\r?\n\r?\n/);
buffer = chunks.pop() || "";
for (const chunk of chunks) {
let event = "message";
const dataLines: string[] = [];
for (const line of chunk.split(/\r?\n/)) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
}
if (dataLines.length) {
const raw = dataLines.join("\n");
let data: any = raw;
try {
data = JSON.parse(raw);
} catch {
/* keep raw string */
}
onFrame({ event, data });
}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
/* Assembles an assistant reply into ordered parts (markdown text + inline UI components).
The problem this solves: a component is rendered as a side-effect of a tool call, which the
agent runs BEFORE it writes its prose (the prose is generated in the final turn, after every
tool returns). So if we placed components in frame-arrival order, every widget would jump to
the TOP of the reply, ahead of all text - never in the middle, never at the end.
The fix (how ChatGPT/Claude interleave widgets): the component tool returns a tiny placeholder
marker - [[forge:component:<instance_id>]] - and the model copies it into its reply text at the
exact spot the widget belongs. Here we split the text on those markers and splice each
already-received component instance into its place. The heavy props/markup never enter the
token stream (only the ~10-token marker does); the model gets full control over ordering. */
export interface ComponentInstance {
component_id: string;
instance_id?: string;
name?: string;
version?: number;
props?: Record<string, any>;
actions?: Record<string, any>[];
}
export type Part =
| { kind: "text"; text: string }
| { kind: "component"; inst: ComponentInstance };
// The literal a component tool's ack tells the model to place. Keep in sync with the backend
// (tools/components.py COMPONENT_MARKER). Instance ids are uuid4().hex, but we accept any
// url-safe token so a slightly-mangled id still matches.
const MARKER_PREFIX = "[[forge:component:";
const MARKER_RE = /\[\[forge:component:([A-Za-z0-9_-]+)\]\]/g;
/* While streaming, the closing `]]` of a marker may not have arrived yet (e.g. the buffer ends
with "…[[forge:comp"). Return the index where such a half-typed marker begins so the caller can
hide it until it completes - otherwise it flashes as raw text. -1 when there's no partial tail. */
function trailingPartialMarkerStart(text: string): number {
// 1) the "[[forge:component:" prefix itself is still being typed - longest suffix of `text`
// that equals a non-empty prefix of MARKER_PREFIX.
for (let n = Math.min(MARKER_PREFIX.length, text.length); n > 0; n--) {
if (text.slice(text.length - n) === MARKER_PREFIX.slice(0, n)) return text.length - n;
}
// 2) the prefix is complete and the id is streaming, but the closing `]]` hasn't arrived.
const m = /\[\[forge:component:[A-Za-z0-9_-]*$/.exec(text);
return m ? m.index : -1;
}
/* Split `raw` on component markers and produce ordered parts, resolving each marker to its
component instance. Unknown markers (no matching instance) are dropped from the text. When
not streaming, any rendered component the model never referenced with a marker is appended at
the end (in arrival order) so a widget is never lost - and never jumps ahead of the prose. */
function assembleParts(
raw: string,
instances: Record<string, ComponentInstance>,
order: string[],
streaming: boolean,
): Part[] {
let text = raw || "";
if (streaming) {
const cut = trailingPartialMarkerStart(text);
if (cut >= 0) text = text.slice(0, cut);
}
const parts: Part[] = [];
const used = new Set<string>();
const push = (s: string) => {
if (!s) return;
const last = parts[parts.length - 1];
if (last && last.kind === "text") last.text += s;
else parts.push({ kind: "text", text: s });
};
const re = new RegExp(MARKER_RE.source, "g");
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
push(text.slice(last, m.index));
const inst = instances[m[1]];
// Render each instance at most once (a duplicated marker is ignored); an unknown id is
// simply dropped so a stray/hallucinated marker never shows as literal text.
if (inst && !used.has(m[1])) {
parts.push({ kind: "component", inst });
used.add(m[1]);
}
last = m.index + m[0].length;
}
push(text.slice(last));
if (!streaming) {
for (const id of order) {
if (!used.has(id) && instances[id]) parts.push({ kind: "component", inst: instances[id] });
}
}
// Drop whitespace-only text parts (e.g. the blank line between two adjacent components) - each
// text part renders as its own markdown block, so leading/trailing whitespace is meaningless.
return parts.filter((p) => p.kind === "component" || p.text.trim().length > 0);
}
/* Accumulates a streaming assistant reply: token text plus the component instances the agent
rendered (keyed by instance_id). `parts()` produces the ordered render list at any point.
Shared by every chat surface (Playground, embed widget, workflow test panel) so they stay
consistent. */
export class ReplyAccumulator {
text = "";
private instances: Record<string, ComponentInstance> = {};
private order: string[] = [];
addText(s: string) {
this.text += s;
}
addComponent(inst: ComponentInstance) {
const id = String(inst?.instance_id || `c${this.order.length}`);
if (!(id in this.instances)) this.order.push(id);
this.instances[id] = inst;
}
hasComponents() {
return this.order.length > 0;
}
/** Ordered parts for rendering. `streaming` hides a half-arrived marker and withholds any
component whose marker hasn't streamed in yet (it appears the moment the marker does, in
its proper place). Pass `finalText` to render the reconciled final text instead of the
live token buffer. */
parts(opts?: { streaming?: boolean; finalText?: string }): Part[] {
const text = opts?.finalText ?? this.text;
return assembleParts(text, this.instances, this.order, !!opts?.streaming);
}
/** Reconcile the streamed token buffer with the run's authoritative final answer. Prefer the
streamed text (it spans every turn, in order, and carries the markers); fall back to - or
append - the final answer only when it adds something the stream didn't (a non-LLM node's
output, or an error message). */
resolveText(finalAnswer?: string): string {
const buf = this.text;
const fa = (finalAnswer || "").trim();
if (!fa) return buf;
if (!buf.trim()) return finalAnswer || "";
if (buf.includes(fa)) return buf;
return `${buf}\n\n${finalAnswer}`;
}
}
+393
View File
@@ -0,0 +1,393 @@
/* Forge mock data - generic, plausible SaaS content. Ported from the design handoff.
Screens not yet wired to the live API render from this; the Dashboard merges live
projects from the backend when available (see lib/api.ts). */
export const spark = (n: number, base: number, amp: number): number[] =>
Array.from({ length: n }, (_, i) =>
Math.max(0, Math.round(base + Math.sin(i * 0.8) * amp + (Math.random() - 0.5) * amp * 0.8)),
);
/* Format a USD cost. Cheap models cost fractions of a cent per run, so a flat
$0.00 reads as "broken" even when tracking works - show small amounts with
enough precision (down to a sub-cent floor) so non-zero cost is visible. */
export const fmtUSD = (v: number | null | undefined): string => {
const n = v || 0;
if (n <= 0) return "$0.00";
if (n >= 0.01) return `$${n.toFixed(2)}`;
if (n >= 0.0001) return `$${n.toFixed(4)}`;
return "<$0.0001";
};
// The model catalog now lives on the backend (single source of truth so the picker and cost
// engine can't disagree). Fetch it with `useModels()` from lib/models.ts.
export const NODE_CATALOG = [
{ group: "Flow", color: "var(--io-control)", items: [
{ type: "start", icon: "n_start", label: "Start", desc: "Entry marker" },
{ type: "end", icon: "n_end", label: "End", desc: "Terminal node" },
{ type: "router", icon: "n_router", label: "Router", desc: "Conditional branch" },
{ type: "loop", icon: "n_loop", label: "Loop", desc: "Bounded iteration" },
{ type: "parallel_fanout", icon: "n_fanout", label: "Fan-out", desc: "Map over a list" },
{ type: "join", icon: "n_join", label: "Join", desc: "Wait-for-all / reduce" },
]},
{ group: "Agents", color: "var(--accent)", items: [
{ type: "agent", icon: "n_agent", label: "Agent", desc: "ReAct tool loop" },
{ type: "deep_agent", icon: "n_deepagent", label: "Deep Agent", desc: "Planning + subagents harness" },
]},
{ group: "Model & Tools", color: "var(--io-json)", items: [
{ type: "llm", icon: "n_llm", label: "LLM", desc: "Single model call" },
{ type: "classifier", icon: "n_router", label: "Classifier", desc: "Intent classification" },
{ type: "tool_call", icon: "n_tool", label: "Tool Call", desc: "Run a specific tool" },
{ type: "transform", icon: "n_transform", label: "Transform", desc: "JMESPath data map" },
]},
{ group: "Knowledge", color: "var(--io-vector)", items: [
{ type: "retrieval", icon: "n_retrieval", label: "Retrieval", desc: "RAG + Q&A" },
]},
{ group: "Human", color: "var(--warn)", items: [
{ type: "human_input", icon: "n_human", label: "Human Input", desc: "HITL pause via interrupt" },
]},
{ group: "Integrations", color: "var(--signal)", items: [
{ type: "subworkflow", icon: "n_subworkflow", label: "Subworkflow", desc: "Embed another graph" },
{ type: "webhook_out", icon: "n_webhook", label: "Webhook", desc: "Call external URL" },
{ type: "emit_event", icon: "n_emit", label: "Emit Event", desc: "Push custom SSE frame" },
]},
];
export const NODE_META: Record<string, any> = {};
NODE_CATALOG.forEach((g) => g.items.forEach((it) => (NODE_META[it.type] = { ...it, group: g.group, color: g.color })));
/* The friendly, human-readable name for a node - the SAME string the canvas shows on the node
card (ForgeNode): the operator-set `config.name`, else the node type's catalog label, else the
raw type. Runs, traces and the RUN STEPS list key everything on the node `id` (needed for the
graph and the per-node cost rollup), so the id stays canonical - this is a DISPLAY layer that
makes a bigger workflow readable without losing traceability (the id is still shown alongside). */
export type NodeLabel = { label: string; type: string };
// Build an `id -> {label, type}` map from a workflow definition. Accepts either the executable
// (`{id,type,config}`) or the canvas / React-Flow (`{id, data:{nodeType, config}}`) node shape, so
// callers can pass whichever they have (mirrors WorkflowTestPanel's dual-shape read).
export function buildNodeLabels(workflow: any): Record<string, NodeLabel> {
const nodes = workflow?.executable?.nodes || workflow?.canvas?.nodes || workflow?.nodes || [];
const map: Record<string, NodeLabel> = {};
for (const n of nodes) {
if (!n?.id) continue;
const type = n.type ?? n?.data?.nodeType ?? "";
const name = n?.config?.name ?? n?.data?.config?.name;
map[n.id] = { label: name || NODE_META[type]?.label || type || n.id, type };
}
return map;
}
// The label to show for a run step / span whose canonical name is a node id. Falls back to the
// raw name for spans that aren't graph nodes (e.g. "model · …", "embedding · …", a subagent).
export function nodeLabel(name: string, labels: Record<string, NodeLabel>): string {
return labels[name]?.label || name;
}
/* Hover help for the canvas palette: what each node is for + a tiny concrete example.
Keep these in plain product language - they're the first thing a new user reads. */
export const NODE_HELP: Record<string, { what: string; example: string }> = {
start: {
what: "The entry point. Every run begins here - wire it to your first real step.",
example: "Start → Retrieval → Agent → End",
},
end: {
what: "Marks where the run finishes. A workflow can have several Ends (one per branch).",
example: "FAQ hit → End (answered early), miss → Agent → End",
},
router: {
what: "Branches the flow based on a value already in state - no model call. One labeled connector per case, plus Else. With 'multi' on, a list value (multi-label Classifier) runs EVERY matching branch in parallel. Always set a Default - without one, an unmatched value ends the run silently.",
example: "intent = 'refund' → refund_agent · 'cancel' → retention_agent · Else → general_agent",
},
classifier: {
what: "Calls the model once (structured output) to pick a label from your list and writes it to state (default: intent). Multi-label mode writes EVERY applicable label (a list) - pair with a multi Router so two-part questions reach both specialists. Put a Router after it to branch.",
example: "labels: return_item, cancel, question - “I want my money back” → return_item",
},
agent: {
what: "A model with tools and a system prompt that loops reason → act until it can answer (ReAct). The workhorse node for answering users.",
example: "Support agent with a weather tool + knowledge-base grounding",
},
deep_agent: {
what: "An agent plus planning (write_todos), a virtual filesystem, and subagents - for long, multi-step tasks that need decomposition.",
example: "“Research our top 3 competitors and draft a comparison”",
},
llm: {
what: "One single model call - no tools, no loop. A cheap text step for rewriting, summarizing, or extracting.",
example: "“Summarize the conversation so far into 2 sentences”",
},
transform: {
what: "Reshapes state with a JMESPath expression - pure data, no model. Reads input_key, writes output_key.",
example: "messages[-1].content → question",
},
tool_call: {
what: "Invokes one specific project tool directly with fixed arguments - no model deciding whether to call it. Result lands in a state key.",
example: "Always fetch get_weather before the agent answers",
},
human_input: {
what: "PAUSES the run with a real interrupt until a person approves or rejects in the Playground. Use for irreversible or sensitive steps.",
example: "Agent drafts a refund email → human approves → it goes out",
},
webhook_out: {
what: "Sends data from the run to an external URL (POST/PUT/…) - push results into your own systems.",
example: "POST the final answer to your Slack webhook",
},
emit_event: {
what: "Emits a named custom event into the run's live stream - for UI badges, metrics, or integrations listening to the run.",
example: "Emit 'escalated' when the agent hands off to a human",
},
retrieval: {
what: "Pulls the most relevant knowledge into context for the user's question - place it right before a grounded agent. Toggle DOCUMENTS (RAG over your chunks) and Q&A PAIRS independently: use either or both. Tip: for multi-part questions, give the agent a knowledge_search TOOL instead, so it can search per sub-question.",
example: "KB says “returns within 30 days” → agent answers with that policy",
},
// --- flow ---
loop: {
what: "Repeats a section of the graph until a condition is false or a max-iteration cap is hit. It increments _loop_count and writes _loop = continue/done - wire a Router on _loop and point the 'continue' branch back to this node.",
example: "Refine a draft up to 3 times: loop → agent → loop (until good enough)",
},
parallel_fanout: {
what: "Maps over a list in state: runs a child node ONCE PER ITEM, all in parallel (LangGraph Send). Each child reads its item from the chosen state key. Children write to an add-reducer key so results aggregate.",
example: "over: tickets → run a summarizer per ticket, in parallel",
},
join: {
what: "A convergence point where parallel branches (e.g. a Parallel Fanout's children) meet before the flow continues. Results aggregate via an add-reducer state key.",
example: "Fanout → (summarize each) → Join → final agent composes the digest",
},
subworkflow: {
what: "Runs ANOTHER workflow in this project as a reusable component (shares the messages state). Build a flow once - 'verify identity', 'look up order' - and drop it into many workflows.",
example: "Support flow → Subworkflow: 'verify_identity' → continue",
},
handoff: {
what: "Escalates the conversation to a HUMAN: pauses the run and opens a ticket in the Agent inbox. When an agent replies there, their message becomes the assistant's answer and is delivered over the channel.",
example: "Agent can't resolve → Handoff → a person replies from the inbox",
},
// --- triggers (entry points) ---
webhook_in: {
what: "Starts the workflow when an external system POSTs to this workflow's hook URL (shown on the Triggers screen after publish). Optionally verify an HMAC signature. Map the JSON body to the message with a JMESPath.",
example: "Your app POSTs {text: '…'} → the workflow runs and replies",
},
schedule: {
what: "Runs the workflow on a recurring schedule - every N minutes or a cron expression. Sends a fixed message into the flow each time.",
example: "Every weekday 9am → 'Summarize overnight tickets'",
},
email_in: {
what: "Starts the workflow when an email arrives in the connected mailbox (configure the Email channel under Connect → Channels). Optionally replies to the sender with the answer.",
example: "support@yourco.com receives a question → agent replies by email",
},
app_event: {
what: "Polls an external source on an interval and runs the workflow once PER NEW item (deduped by a key you choose). Turns any API/feed into an event source.",
example: "Poll the issues API every 5 min → triage each new issue",
},
};
export const CAT_BY_TYPE: Record<string, string> = {
start: "control", end: "control", router: "control", loop: "control", parallel_fanout: "control", join: "control",
agent: "agent", deep_agent: "agent",
llm: "json", tool_call: "json", transform: "json", code: "json",
retrieval: "vector",
human_input: "human",
subworkflow: "signal", webhook_out: "signal", emit_event: "signal",
};
export const workflowNodes = [
{ id: "start", type: "start", position: { x: 40, y: 300 }, data: {}, summary: [] as string[] },
{ id: "faq_deflect", type: "retrieval", position: { x: 220, y: 286 }, data: { top_k: 5, include_qa: true }, title: "Knowledge", summary: ["docs top_k 5", "+ Q&A"] },
{ id: "intent_router", type: "router", position: { x: 430, y: 280 }, data: {}, title: "Intent Router", summary: ["expression · state.intent", "billing · technical · default"], cases: ["billing", "technical", "default"] },
{ id: "kb_search", type: "retrieval", position: { x: 700, y: 110 }, data: {}, title: "Help Docs", summary: ["3 sources · top_k 5", "hybrid + rerank"] },
{ id: "billing_agent", type: "agent", position: { x: 690, y: 270 }, data: {}, title: "Billing Agent", summary: ["claude-sonnet-4-6", "2 tools · 4 middleware"], mw: ["summarization", "tool_call_limit", "human_in_the_loop", "pii"] },
{ id: "tech_agent", type: "deep_agent", position: { x: 690, y: 470 }, data: {}, title: "Tech Agent", summary: ["gpt-5.4 · deep agent", "subagents 2 · planning on"], mw: ["summarization", "context_editing"] },
{ id: "approve_refund", type: "human_input", position: { x: 980, y: 270 }, data: {}, title: "Approve Refund", summary: ['"Approve this refund?"', "approve · edit · reject"] },
{ id: "end", type: "end", position: { x: 1210, y: 320 }, data: {}, summary: [] as string[] },
];
export const workflowEdges = [
{ id: "e1", source: "start", target: "faq_deflect", io: "control" },
{ id: "e2", source: "faq_deflect", target: "intent_router", io: "messages" },
{ id: "e3", source: "intent_router", target: "billing_agent", io: "control", label: "billing" },
{ id: "e4", source: "intent_router", target: "tech_agent", io: "control", label: "technical" },
{ id: "e5", source: "kb_search", target: "billing_agent", io: "json" },
{ id: "e6", source: "billing_agent", target: "approve_refund", io: "messages" },
{ id: "e7", source: "approve_refund", target: "end", io: "messages" },
{ id: "e8", source: "tech_agent", target: "end", io: "messages" },
];
export const runOrder = ["start", "faq_deflect", "intent_router", "kb_search", "billing_agent", "approve_refund", "end"];
export const TOOLS = [
{ id: "t_get_order", name: "get_order", kind: "rest_api", auth: "orders_session", enabled: true, tested: "pass", version: 4, desc: "Fetch an order by ID from the commerce API, including line items and totals.", method: "GET", url: "https://api.acme.dev/v2/orders/{order_id}", rawTok: 1240, projTok: 92 },
{ id: "t_get_invoice", name: "get_invoice", kind: "rest_api", auth: "orders_session", enabled: true, tested: "pass", version: 2, desc: "Retrieve a customer invoice and its payment status.", method: "GET", url: "https://api.acme.dev/v2/invoices/{invoice_id}", rawTok: 880, projTok: 64 },
{ id: "t_search_kb", name: "search_catalog", kind: "graphql", auth: null, enabled: true, tested: "pass", version: 1, desc: "Query the product catalog via GraphQL.", method: "POST", url: "https://api.acme.dev/graphql", rawTok: 2100, projTok: 140 },
{ id: "t_refund", name: "submit_refund", kind: "rest_api", auth: "orders_session", enabled: true, tested: "fail", version: 3, desc: "Issue a refund against an order. Requires human approval.", method: "POST", url: "https://api.acme.dev/v2/orders/{order_id}/refunds", rawTok: 320, projTok: 48 },
{ id: "t_geo", name: "geocode_address", kind: "code", auth: null, enabled: true, tested: "untested", version: 1, desc: "Normalize and geocode a postal address using a sandboxed Python function.", rawTok: 0, projTok: 0 },
{ id: "t_jira", name: "create_ticket", kind: "mcp", auth: "jira_oauth", enabled: true, tested: "pass", version: 1, desc: "Create an issue in the project tracker over MCP.", rawTok: 540, projTok: 70 },
{ id: "t_web", name: "web_search", kind: "builtin", auth: null, enabled: false, tested: "untested", version: 1, desc: "Search the public web (Tavily).", rawTok: 0, projTok: 0 },
];
export const AUTH_PROVIDERS = [
{ id: "orders_session", name: "orders_session", kind: "csrf_session", tested: "pass", usedBy: 3, ttl: 1800 },
{ id: "jira_oauth", name: "jira_oauth", kind: "oauth2_client_credentials", tested: "pass", usedBy: 1, ttl: 3600 },
{ id: "stripe_bearer", name: "stripe_bearer", kind: "bearer", tested: "pass", usedBy: 2, ttl: 0 },
{ id: "legacy_basic", name: "legacy_basic", kind: "basic", tested: "untested", usedBy: 0, ttl: 0 },
];
export const AGENTS = [
{ id: "a_billing", name: "billing_agent", flavor: "agent", model: "anthropic:claude-sonnet-4-6", tools: 2, mw: 4, updated: "2h ago" },
{ id: "a_tech", name: "tech_agent", flavor: "deep_agent", model: "openai:gpt-5.4", tools: 5, mw: 3, updated: "1d ago" },
{ id: "a_triage", name: "triage_agent", flavor: "agent", model: "openai:gpt-5.4-mini", tools: 1, mw: 2, updated: "3d ago" },
{ id: "a_research", name: "research_agent", flavor: "deep_agent", model: "google_genai:gemini-3.1-pro-preview", tools: 3, mw: 5, updated: "5d ago" },
];
export const MIDDLEWARE_CATALOG = [
{ cat: "Memory & Context", color: "var(--signal)", items: [
{ type: "summarization", name: "Summarization", desc: "Summarize older messages near a token limit." },
{ type: "context_editing", name: "Context Editing", desc: "Clear old tool outputs past a threshold." },
{ type: "todo", name: "Planning (To-do)", desc: "Add a write_todos planning tool." },
]},
{ cat: "Safety & Guardrails", color: "var(--err)", items: [
{ type: "pii", name: "PII Handling", desc: "Detect & redact/mask/block PII." },
{ type: "guardrail_regex", name: "Regex Guardrail", desc: "Block or flag matched patterns." },
{ type: "openai_moderation", name: "Moderation", desc: "OpenAI moderation on input/output." },
]},
{ cat: "Reliability", color: "var(--info)", items: [
{ type: "tool_retry", name: "Tool Retry", desc: "Retry failed tool calls with backoff." },
{ type: "model_retry", name: "Model Retry", desc: "Retry failed model calls." },
{ type: "model_fallback", name: "Model Fallback", desc: "Failover across providers." },
]},
{ cat: "Cost & Limits", color: "var(--warn)", items: [
{ type: "model_call_limit", name: "Model Call Limit", desc: "Cap model calls per run/thread." },
{ type: "tool_call_limit", name: "Tool Call Limit", desc: "Cap tool calls, global or per-tool." },
{ type: "tenant_budget", name: "Budget Cap", desc: "Stop when cost/tokens exceed a cap." },
{ type: "llm_tool_selector", name: "Tool Selector", desc: "Pre-select relevant tools (saves tokens)." },
]},
{ cat: "Human Oversight", color: "var(--accent)", items: [
{ type: "human_in_the_loop", name: "Human-in-the-loop", desc: "Pause for approval on sensitive tools." },
]},
{ cat: "Provider-specific", color: "var(--io-json)", items: [
{ type: "anthropic_prompt_caching", name: "Prompt Caching", desc: "Cache the system prompt (Anthropic)." },
]},
{ cat: "Advanced", color: "var(--io-vector)", items: [
{ type: "dynamic_model_by_state", name: "Dynamic Model", desc: "Switch model at runtime by state." },
{ type: "tool_filter_by_context", name: "Tool Filter", desc: "Show/hide tools by context/role." },
]},
];
export const MW_META: Record<string, any> = {};
MIDDLEWARE_CATALOG.forEach((c) => c.items.forEach((it) => (MW_META[it.type] = { ...it, cat: c.cat, color: c.color })));
export const AGENT_MW_STACK = [
{ type: "summarization", enabled: true, summary: "Summarize when > 4,000 tok · keep last 20 msgs" },
{ type: "tool_call_limit", enabled: true, summary: "get_order · max 3 calls per run" },
{ type: "human_in_the_loop", enabled: true, summary: "submit_refund → approve · edit · reject" },
{ type: "pii", enabled: false, summary: "email → redact (input)" },
];
export const PROJECTS = [
{ id: "p_support", name: "Customer Support", slug: "customer-support", status: "active", workflows: 4, tools: 7, runs7d: 1840, spark: spark(14, 60, 30), edited: "12m ago" },
{ id: "p_ops", name: "Internal Ops Bot", slug: "internal-ops-bot", status: "active", workflows: 2, tools: 5, runs7d: 620, spark: spark(14, 28, 16), edited: "3h ago" },
{ id: "p_sales", name: "Sales Assistant", slug: "sales-assistant", status: "active", workflows: 3, tools: 9, runs7d: 980, spark: spark(14, 40, 22), edited: "1d ago" },
{ id: "p_research", name: "Research Copilot", slug: "research-copilot", status: "draft", workflows: 1, tools: 3, runs7d: 40, spark: spark(14, 6, 6), edited: "2d ago" },
{ id: "p_data", name: "Data Q&A", slug: "data-qa", status: "active", workflows: 2, tools: 4, runs7d: 410, spark: spark(14, 20, 12), edited: "4d ago" },
{ id: "p_archived", name: "Legacy Triage", slug: "legacy-triage", status: "draft", workflows: 1, tools: 2, runs7d: 0, spark: spark(14, 2, 2), edited: "3w ago" },
];
export const RECENT_RUNS = [
{ id: "r1", project: "Customer Support", workflow: "Support Router", status: "done", dur: "4.2s", tokens: "12.4k", trigger: "email", time: "2m ago" },
{ id: "r2", project: "Sales Assistant", workflow: "Lead Qualifier", status: "done", dur: "2.1s", tokens: "6.1k", trigger: "api", time: "5m ago" },
{ id: "r3", project: "Customer Support", workflow: "Support Router", status: "interrupted", dur: "1.8s", tokens: "3.2k", trigger: "email", time: "8m ago" },
{ id: "r4", project: "Internal Ops Bot", workflow: "PR Summarizer", status: "error", dur: "0.9s", tokens: "1.1k", trigger: "mcp", time: "14m ago" },
{ id: "r5", project: "Data Q&A", workflow: "Metrics Explainer", status: "done", dur: "3.6s", tokens: "9.8k", trigger: "playground", time: "21m ago" },
{ id: "r6", project: "Customer Support", workflow: "Refund Flow", status: "done", dur: "5.5s", tokens: "15.2k", trigger: "email", time: "33m ago" },
];
export const KB_SOURCES = [
{ id: "k1", name: "Help Center (acme.dev/help)", kind: "url", status: "ready", chunks: 482, size: "3.1 MB", model: "text-embedding-3-small", updated: "1h ago" },
{ id: "k2", name: "Billing FAQ.pdf", kind: "file", status: "ready", chunks: 96, size: "740 KB", model: "text-embedding-3-small", updated: "1d ago" },
{ id: "k3", name: "API Reference.pdf", kind: "file", status: "processing", prog: 62, chunks: 210, size: "2.4 MB", model: "text-embedding-3-small", updated: "now" },
{ id: "k4", name: "s3://acme-docs/policies", kind: "s3", status: "ready", chunks: 154, size: "1.2 MB", model: "text-embedding-3-small", updated: "3d ago" },
{ id: "k5", name: "Onboarding notes", kind: "text", status: "error", chunks: 0, size: "12 KB", model: "-", updated: "5d ago" },
];
export const QA_PAIRS = [
{ id: "q1", q: "How do I reset my password?", a: "Go to Settings → Security → Reset password. A link is emailed to you.", kind: "faq", tags: ["account"], upvotes: 42, used: "3m ago" },
{ id: "q2", q: 'Why is my order stuck in "processing"?', a: "Processing clears within 30 min. If longer, the payment hold failed - retry the card.", kind: "error_workaround", tags: ["orders", "billing"], upvotes: 31, used: "18m ago" },
{ id: "q3", q: "Can I change my plan mid-cycle?", a: "Yes. Upgrades are prorated immediately; downgrades apply next cycle.", kind: "faq", tags: ["billing"], upvotes: 27, used: "1h ago" },
{ id: "q4", q: "Error E-4012 on checkout", a: "E-4012 means an expired CSRF token. Refresh the page and retry.", kind: "error_workaround", tags: ["errors"], upvotes: 19, used: "2h ago" },
];
export const SEARCH_HITS = [
{ title: "Billing FAQ.pdf · §3 Refunds", vec: 0.91, fts: 0.74, fused: 0.88, text: "Refunds are issued to the original payment method within 57 business days…" },
{ title: "Help Center · Cancel an order", vec: 0.86, fts: 0.81, fused: 0.85, text: "You can cancel an order before it ships from the Orders page…" },
{ title: "API Reference.pdf · POST /refunds", vec: 0.83, fts: 0.62, fused: 0.79, text: "Creates a refund object. Requires an order in a refundable state…" },
{ title: "Policies · Returns window", vec: 0.71, fts: 0.55, fused: 0.68, text: "Items may be returned within 30 days of delivery for a full refund…" },
];
export const TRACE_RUNS = [
{ id: "tr1", workflow: "Support Router", status: "done", started: "14:22:08", dur: "4.2s", tokens: "12.4k", cost: "$0.038", trigger: "email" },
{ id: "tr2", workflow: "Support Router", status: "interrupted", started: "14:18:51", dur: "1.8s", tokens: "3.2k", cost: "$0.009", trigger: "email" },
{ id: "tr3", workflow: "Refund Flow", status: "done", started: "14:10:33", dur: "5.5s", tokens: "15.2k", cost: "$0.047", trigger: "api" },
{ id: "tr4", workflow: "PR Summarizer", status: "error", started: "13:58:02", dur: "0.9s", tokens: "1.1k", cost: "$0.003", trigger: "mcp" },
{ id: "tr5", workflow: "Metrics Explainer", status: "done", started: "13:44:19", dur: "3.6s", tokens: "9.8k", cost: "$0.030", trigger: "playground" },
];
export const SPANS = [
{ id: "s0", name: "Support Router", kind: "chain", depth: 0, start: 0, dur: 4200, tokens: "12.4k", cost: "$0.038" },
{ id: "s1", name: "faq_deflect", kind: "retriever", depth: 1, start: 40, dur: 210, tokens: "-", cost: "$0.000" },
{ id: "s2", name: "intent_router", kind: "node", depth: 1, start: 260, dur: 60, tokens: "-", cost: "$0.000" },
{ id: "s3", name: "billing_agent", kind: "agent", depth: 1, start: 330, dur: 3600, tokens: "11.9k", cost: "$0.036" },
{ id: "s4", name: "model · claude-sonnet-4-6", kind: "llm", depth: 2, start: 360, dur: 1400, tokens: "4.1k", cost: "$0.013" },
{ id: "s5", name: "tool · get_order", kind: "tool", depth: 2, start: 1780, dur: 320, tokens: "92", cost: "$0.000" },
{ id: "s6", name: "model · claude-sonnet-4-6", kind: "llm", depth: 2, start: 2130, dur: 1700, tokens: "7.7k", cost: "$0.023" },
{ id: "s7", name: "approve_refund", kind: "node", depth: 1, start: 3950, dur: 250, tokens: "-", cost: "$0.000" },
];
export const COST_BY_NODE = [
{ name: "billing_agent", cost: 0.036, color: "var(--accent)" },
{ name: "tech_agent", cost: 0.0, color: "var(--io-json)" },
{ name: "kb_search", cost: 0.001, color: "var(--io-vector)" },
{ name: "router", cost: 0.0005, color: "var(--io-control)" },
];
export const SECRETS = [
{ id: "sec1", name: "orders_api_creds", kind: "csrf_session", version: 3, used: "2m ago" },
{ id: "sec2", name: "openai_key", kind: "api_key", version: 1, used: "1m ago" },
{ id: "sec3", name: "anthropic_key", kind: "api_key", version: 1, used: "1m ago" },
{ id: "sec4", name: "jira_client_secret", kind: "oauth2", version: 2, used: "1h ago" },
{ id: "sec5", name: "stripe_secret", kind: "bearer", version: 1, used: "3h ago" },
];
export const AUDIT = [
{ action: "secret.read", actor: "orders_session", resource: "orders_api_creds", at: "14:22:09" },
{ action: "workflow.publish", actor: "you@acme.dev", resource: "Support Router v7", at: "13:40:11" },
{ action: "tool.test", actor: "you@acme.dev", resource: "submit_refund", at: "13:38:55" },
{ action: "secret.write", actor: "you@acme.dev", resource: "stripe_secret", at: "11:02:30" },
];
// Sectioned nav: top-level leaves (Overview, Settings) plus collapsible groups
// (Build / Deploy / Observe). The sidebar renders a leaf as a button and a group as a
// labeled, collapsible section. `countKey` shows a live badge.
export type NavLeaf = { id: string; label: string; icon: string; help?: string; countKey?: string };
export type NavGroup = { section: string; items: NavLeaf[] };
export type NavEntry = NavLeaf | NavGroup;
export const PROJECT_NAV: NavEntry[] = [
{ id: "overview", label: "Analytics", icon: "layout-dashboard", help: "Observability dashboard - volume, latency, cost, tokens, and per-source/tool breakdowns over time." },
{ section: "Build", items: [
{ id: "playground", label: "Playground", icon: "playground", help: "Chat with a workflow to test it live, with token + cost metering." },
{ id: "workflows", label: "Workflows", icon: "workflow", countKey: "workflows", help: "The visual canvas - wire nodes (agents, tools, routers, triggers) into a graph." },
{ id: "agents", label: "Agents", icon: "bot", countKey: "agents", help: "Reusable agent presets (model + prompt + tools + middleware) to drop into workflows." },
{ id: "tools", label: "Tools", icon: "tools", countKey: "tools", help: "Capabilities an agent can call: REST, GraphQL, Code, SQL, or built-ins." },
{ id: "components", label: "Components", icon: "grid", countKey: "components", help: "User-defined UI widgets (HTML/CSS) an agent can render in chat - tables, cards, forms, actions." },
{ id: "knowledge", label: "Knowledge", icon: "book-open", countKey: "knowledge", help: "Documents + Q&A pairs that ground answers (RAG). Add text, URLs, files, or crawl a site." },
{ id: "auth", label: "Auth Providers", icon: "shield-check", countKey: "auth", help: "Reusable credential strategies (Bearer, API key, OAuth, CSRF) that tools attach to." },
{ id: "mcp", label: "External MCP", icon: "server", help: "Connect external MCP servers (GitHub, Slack, …) and toggle which of their tools agents and workflows can use." },
] },
{ section: "Deploy", items: [
{ id: "channels", label: "Channels", icon: "mail", help: "Deploy a workflow to an email surface." },
{ id: "triggers", label: "Triggers", icon: "bolt", help: "Event-driven entry points - webhook URLs, schedules, and pollers that start runs." },
{ id: "connect", label: "Connect", icon: "plug-zap", help: "Connect this project to external systems: the Run API, integration reference, MCP server, and the embeddable chat widget." },
] },
{ section: "Observe", items: [
{ id: "traces", label: "Traces", icon: "activity", help: "Per-run span waterfall with model calls, tokens, latency, and cost." },
{ id: "datasets", label: "Evaluations", icon: "validate", help: "Test datasets (input + expected) scored against a workflow to catch regressions." },
{ id: "handoff", label: "Agent inbox", icon: "inbox", countKey: "handoffs", help: "Live conversations escalated to a human - reply here to resume the run." },
] },
{ id: "settings", label: "Settings", icon: "settings", help: "Model defaults, provider keys, secrets, team & roles, and the audit log." },
];
export const IO_COLOR: Record<string, string> = {
messages: "var(--io-messages)", text: "var(--io-text)", json: "var(--io-json)", tool: "var(--io-tool)",
embedding: "var(--io-vector)", vector: "var(--io-vector)", any: "var(--io-any)", control: "var(--io-control)",
};
export const KIND_LABEL: Record<string, string> = { rest_api: "REST", graphql: "GraphQL", code: "Code", sql: "SQL", builtin: "Builtin" };
export const KIND_ICON: Record<string, string> = { rest_api: "k_rest", graphql: "k_graphql", code: "k_code", sql: "db", builtin: "k_builtin" };
+191
View File
@@ -0,0 +1,191 @@
/* Canvas (React Flow) <-> executable JSON translation + IOType rules.
Canvas JSON is React-Flow-shaped (UI owns it); executable is the compiler input. */
import type { Edge, Node } from "@xyflow/react";
export interface ForgeNodeData {
nodeType: string;
config: Record<string, any>;
status?: "idle" | "running" | "done" | "error";
[k: string]: any;
}
export type FlowNode = Node<ForgeNodeData>;
export type FlowEdge = Edge;
export const DEFAULT_STATE: Record<string, any> = {
messages: { type: "list[message]", reducer: "add_messages" },
intent: { type: "str", reducer: "last" },
};
/** `any` matches all; `control` only connects to `control`; else exact match. */
export function ioCompatible(a: string, b: string): boolean {
if (a === "control" || b === "control") return a === "control" && b === "control";
if (a === "any" || b === "any") return true;
return a === b;
}
export function newNodeId(type: string, existing: Iterable<string>): string {
const ids = new Set(existing);
let i = 1;
while (ids.has(`${type}_${i}`)) i++;
return `${type}_${i}`;
}
/** Ensure a node's config carries schema-required fields the UI only defaults visually,
* so the saved executable always validates. Agent/deep_agent need an explicit `flavor`
* derived from the node type - a deep_agent must never silently compile as a plain agent. */
export function normalizeNodeConfig(nodeType: string, config: Record<string, any>): Record<string, any> {
if (nodeType === "agent" || nodeType === "deep_agent") {
return { ...config, flavor: config.flavor || nodeType };
}
return config;
}
const ROUTER_CASE_HANDLE = "case:";
function routerCaseFromEdge(node: FlowNode | undefined, edge: FlowEdge): string | undefined {
const rawHandle = edge.sourceHandle ?? (edge as any).source_handle ?? null;
if (typeof rawHandle === "string" && rawHandle.startsWith(ROUTER_CASE_HANDLE)) {
return rawHandle.slice(ROUTER_CASE_HANDLE.length);
}
if (!node || node.data.nodeType !== "router") return undefined;
const cfg = node.data.config || {};
const label = (edge as any).label;
if (label != null && Object.prototype.hasOwnProperty.call(cfg.cases || {}, String(label))) {
return String(label);
}
const caseMatch = Object.entries(cfg.cases || {}).find(([, target]) => target === edge.target);
if (caseMatch) return caseMatch[0];
if (cfg.default === edge.target) return "__default__";
return undefined;
}
function normalizeRouterConfigFromEdges(node: FlowNode, edges: FlowEdge[]): Record<string, any> {
const cfg = { ...(node.data.config || {}) };
if (node.data.nodeType !== "router") return cfg;
const cases: Record<string, string> = { ...(cfg.cases || {}) };
for (const edge of edges) {
if (edge.source !== node.id) continue;
const key = routerCaseFromEdge(node, edge);
if (!key) continue;
if (key === "__default__") cfg.default = edge.target;
else if (Object.prototype.hasOwnProperty.call(cases, key)) cases[key] = edge.target;
}
return { ...cfg, cases };
}
/** State keys each node type writes (from its config), so the workflow state can declare
* them automatically - LangGraph rejects writes to undeclared keys, which would silently
* break any router branching on a classifier label, qa/retrieval route flag, or human
* decision in a canvas-built workflow. */
function nodeWrittenKeys(nodeType: string, c: Record<string, any>): [string, string][] {
switch (nodeType) {
case "classifier": return [[c.output_key || "intent", c.multi_label ? "list[str]" : "str"]];
case "retrieval": return c.route_key ? [[c.route_key, "str"]] : [];
case "human_input": return c.output_key ? [[c.output_key, "str"]] : [];
case "transform": return [[c.output_key || "data", "json"]];
case "tool_call": return c.output_key ? [[c.output_key, "json"]] : [];
case "webhook_out": return [[c.output_key || "webhook_result", "json"]];
default: return [];
}
}
export function canvasToExecutable(
nodes: FlowNode[],
edges: FlowEdge[],
meta: { id: string; version?: number; state?: Record<string, any> },
): Record<string, any> {
// Entry: a Start marker, else a trigger node (webhook/schedule/email/app_event),
// else a node with no incoming edge, else the first node.
const TRIGGERS = new Set(["webhook_in", "schedule", "email_in", "app_event"]);
const hasIncoming = new Set(edges.map((e) => e.target));
const start =
nodes.find((n) => n.data.nodeType === "start") ||
nodes.find((n) => TRIGGERS.has(n.data.nodeType)) ||
nodes.find((n) => !hasIncoming.has(n.id));
const state: Record<string, any> = { ...(meta.state || DEFAULT_STATE) };
for (const n of nodes) {
for (const [key, type] of nodeWrittenKeys(n.data.nodeType, n.data.config || {})) {
if (key && !state[key]) state[key] = { type, reducer: "last" };
}
}
return {
id: meta.id,
version: meta.version || 1,
state,
entry_node: start?.id || nodes[0]?.id || "start",
nodes: nodes.map((n) => ({
id: n.id,
type: n.data.nodeType,
config: normalizeNodeConfig(n.data.nodeType, normalizeRouterConfigFromEdges(n, edges)),
position: { x: Math.round(n.position.x), y: Math.round(n.position.y) },
})),
edges: edges.map((e) => ({
source: e.source,
target: e.target,
source_handle: e.sourceHandle || undefined,
target_handle: e.targetHandle || undefined,
})),
};
}
export function canvasToFlow(canvas: any): { nodes: FlowNode[]; edges: FlowEdge[] } {
const nodes: FlowNode[] = (canvas?.nodes || []).map((n: any) => ({
id: n.id,
type: "forge",
position: n.position || { x: 0, y: 0 },
data: { nodeType: n.data?.nodeType || n.type, config: n.data?.config || {} },
}));
const byId: Record<string, FlowNode> = Object.fromEntries(nodes.map((n) => [n.id, n]));
// Forge nodes use React Flow's default handle (one in/out per node), so edges carry no
// handle id - they attach to the default handle. Any stored handle id is still honored.
const edges: FlowEdge[] = (canvas?.edges || []).map((e: any, i: number) => ({
id: e.id || `e${i}`,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? e.source_handle ?? (
routerCaseFromEdge(byId[e.source], e as FlowEdge)
? `${ROUTER_CASE_HANDLE}${routerCaseFromEdge(byId[e.source], e as FlowEdge)}`
: null
),
targetHandle: e.targetHandle ?? e.target_handle ?? null,
label: e.label,
}));
return { nodes, edges };
}
/** A minimal runnable starter: start -> end. */
export function starterWorkflow(): { canvas: any; nodes: FlowNode[]; edges: FlowEdge[] } {
const nodes: FlowNode[] = [
{ id: "start", type: "forge", position: { x: 80, y: 220 }, data: { nodeType: "start", config: {} } },
{ id: "end", type: "forge", position: { x: 520, y: 220 }, data: { nodeType: "end", config: {} } },
];
const edges: FlowEdge[] = [{ id: "e0", source: "start", target: "end" }];
return { canvas: { nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } }, nodes, edges };
}
const GROUNDING_PROMPT =
"You are the support assistant for this project. Be friendly, natural, and concise. " +
"For greetings, thanks, or small talk (e.g. 'hi', 'thanks'), reply naturally and briefly and invite " +
"the user's question - do NOT refuse these. For questions about this project/product, answer using ONLY " +
"the KNOWLEDGE BASE context provided in the conversation (documents and FAQs); if it doesn't contain the " +
"answer, say you don't have that information and offer to connect them with a human. Never invent facts " +
"or use outside knowledge for such questions. Use the prior conversation turns for context.";
/** A complete grounded support flow: start -> retrieval (RAG over KB + Q&A) -> agent -> end. */
export function groundedWorkflow(model = "openai:gpt-4o-mini"): { canvas: any; executable: Record<string, any> } {
const nodes: FlowNode[] = [
{ id: "start", type: "forge", position: { x: 60, y: 180 }, data: { nodeType: "start", config: {} } },
{ id: "retrieval_1", type: "forge", position: { x: 300, y: 180 }, data: { nodeType: "retrieval", config: { top_k: 4, include_qa: true, announce_empty: true, min_score: 0.18 } } },
{ id: "agent_1", type: "forge", position: { x: 560, y: 180 }, data: { nodeType: "agent", config: { flavor: "agent", name: "support_agent", model, system_prompt: GROUNDING_PROMPT, tools: [], middleware: [] } } },
{ id: "end", type: "forge", position: { x: 820, y: 180 }, data: { nodeType: "end", config: {} } },
];
const edges: FlowEdge[] = [
{ id: "e0", source: "start", target: "retrieval_1" },
{ id: "e1", source: "retrieval_1", target: "agent_1" },
{ id: "e2", source: "agent_1", target: "end" },
];
return { canvas: { nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } }, executable: canvasToExecutable(nodes, edges, { id: "grounded_support" }) };
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
/* Model picker data. The catalog (chat + embedding + reranker) is served by the backend
(GET /v1/models) from its canonical lists, so no model dropdown hardcodes options in the
frontend and the picker can only offer models the backend actually runs (and, for chat,
prices). See forge/model_catalog.py. */
import { useEffect, useState } from "react";
import { api, type EmbeddingModelInfo, type ModelCatalog, type ModelInfo, type RerankerModelInfo } from "./api";
const EMPTY: ModelCatalog = { chat: [], embedding: [], reranker: [] };
// Fetched once, shared across every picker. api.json() also de-dupes concurrent GETs, so even
// a cold cache is a single round-trip.
let _cache: ModelCatalog | null = null;
function useModelCatalog(): ModelCatalog {
const [catalog, setCatalog] = useState<ModelCatalog>(() => _cache ?? EMPTY);
useEffect(() => {
if (_cache) return;
let alive = true;
api
.listModels()
.then((c) => {
_cache = c;
if (alive) setCatalog(c);
})
.catch(() => {
/* leave empty: selects still show the current value + any hardcoded default option */
});
return () => {
alive = false;
};
}, []);
return catalog;
}
export function useModels(): ModelInfo[] {
return useModelCatalog().chat;
}
export function useEmbeddingModels(): EmbeddingModelInfo[] {
return useModelCatalog().embedding;
}
export function useRerankerModels(): RerankerModelInfo[] {
return useModelCatalog().reranker;
}