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 });
}
}
}
}