/* 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; } export interface Workflow { id: string; project_id: string; name: string; description?: string | null; status: string; active_version: number; executable: Record; canvas: Record; } 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; } 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; html: string; css: string; actions: Record[]; sample_props: Record; 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; } /** 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; 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; 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 = { 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[]; } 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 { 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>(); async function json(path: string, init?: RequestInit): Promise { const method = (init?.method || "GET").toUpperCase(); const key = method === "GET" ? path : null; if (key && _inflight.has(key)) return _inflight.get(key) as Promise; 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; })(); 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(p: Promise): Promise { return p.then((v) => { if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(COUNTS_CHANGED_EVENT)); return v; }); } export const api = { listProjects: () => json("/v1/projects"), getProject: (id: string) => json(`/v1/projects/${id}`), projectCounts: (pid: string) => json(`/v1/projects/${pid}/counts`), createProject: (body: { name: string; slug?: string; description?: string; config?: Record }) => json("/v1/projects", { method: "POST", body: JSON.stringify(body) }), listWorkflows: (pid: string) => json(`/v1/projects/${pid}/workflows`), getWorkflow: (pid: string, wid: string) => json(`/v1/projects/${pid}/workflows/${wid}`), validateExecutable: (pid: string, executable: Record) => json(`/v1/projects/${pid}/workflows/validate`, { method: "POST", body: JSON.stringify({ executable }), }), createRun: (pid: string, wid: string, input: Record, threadId?: string, endUser?: Record | 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; canvas?: Record }) => notifyCounts(json(`/v1/projects/${pid}/workflows`, { method: "POST", body: JSON.stringify(body) })), updateWorkflow: (pid: string, wid: string, body: { name?: string; description?: string }) => json(`/v1/projects/${pid}/workflows/${wid}`, { method: "PATCH", body: JSON.stringify(body) }), saveCanvas: (pid: string, wid: string, canvas: Record, executable: Record) => json(`/v1/projects/${pid}/workflows/${wid}/canvas`, { method: "PUT", body: JSON.stringify({ canvas, executable }) }), publishWorkflow: (pid: string, wid: string) => json(`/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("/v1/stats/dashboard"), projectStats: (pid: string) => json(`/v1/stats/projects/${pid}`), projectAnalytics: (pid: string, days = 30) => json(`/v1/stats/projects/${pid}/analytics?days=${days}`), listAgents: (pid: string) => json(`/v1/projects/${pid}/agents`), getAgent: (pid: string, aid: string) => json(`/v1/projects/${pid}/agents/${aid}`), createAgent: (pid: string, body: { name: string; config: Record }) => notifyCounts(json(`/v1/projects/${pid}/agents`, { method: "POST", body: JSON.stringify(body) })), updateAgent: (pid: string, aid: string, body: { name?: string; config?: Record }) => json(`/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(`/v1/projects/${pid}/tools`), getTool: (pid: string, tid: string) => json(`/v1/projects/${pid}/tools/${tid}`), createTool: (pid: string, body: { name: string; kind: string; config: Record; auth_provider_id?: string }) => notifyCounts(json(`/v1/projects/${pid}/tools`, { method: "POST", body: JSON.stringify(body) })), updateTool: (pid: string, tid: string, body: { name?: string; config?: Record; auth_provider_id?: string | null; enabled?: boolean }) => json(`/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, context?: Record) => json(`/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(`/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(`/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(`/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(`/v1/projects/${pid}/mcp-tokens`), createMcpToken: (pid: string, body: { name?: string; ttl_days?: number }) => json(`/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(`/v1/projects/${pid}/components`), getComponent: (pid: string, cid: string) => json(`/v1/projects/${pid}/components/${cid}`), createComponent: (pid: string, body: Record & { name: string }) => notifyCounts(json(`/v1/projects/${pid}/components`, { method: "POST", body: JSON.stringify(body) })), updateComponent: (pid: string, cid: string, body: Record) => json(`/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(`/v1/projects/${pid}/${PORTABLE_PLURAL[type]}/export`, { method: "POST", body: JSON.stringify({ ids }) }), importBundle: async (pid: string, type: PortableType, bundle: unknown): Promise => { // 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; }, listAuthProviders: (pid: string) => json(`/v1/projects/${pid}/auth-providers`), createAuthProvider: (pid: string, body: { name: string; kind: string; config: Record; credentials_ref?: string }) => notifyCounts(json(`/v1/projects/${pid}/auth-providers`, { method: "POST", body: JSON.stringify(body) })), updateAuthProvider: (pid: string, aid: string, body: { name?: string; kind?: string; config?: Record; credentials_ref?: string }) => json(`/v1/projects/${pid}/auth-providers/${aid}`, { method: "PATCH", body: JSON.stringify(body) }), testAuthProvider: (pid: string, aid: string, context?: Record) => json(`/v1/projects/${pid}/auth-providers/${aid}/test`, { method: "POST", body: JSON.stringify({ context }) }), listMcpClients: (pid: string) => json(`/v1/projects/${pid}/mcp-clients`), createMcpClient: (pid: string, body: { name: string; transport?: string; url?: string; command?: string; args?: any; headers_ref?: string }) => json(`/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(`/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(`/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(`/v1/projects/${pid}/knowledge/sources`), listFolders: (pid: string) => json(`/v1/projects/${pid}/knowledge/folders`), addSource: (pid: string, body: { kind: string; name: string; folder?: string; uri?: string; text?: string; chunking_strategy?: string }) => notifyCounts(json(`/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; }, moveSource: (pid: string, sid: string, folder: string) => json(`/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(`/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(`/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(`/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(`/v1/projects/${pid}/qa-pairs`), listQaKinds: (pid: string) => json(`/v1/projects/${pid}/qa-pairs/kinds`), addQa: (pid: string, body: { question: string; answer: string; kind?: string; tags?: string[] }) => json(`/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(`/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(`/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(`/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(`/v1/projects/${pid}/conversations/${encodeURIComponent(threadId)}`), conversationFacets: (pid: string) => json(`/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(`/v1/projects/${pid}/secrets`), createSecret: (pid: string, body: { name: string; value: unknown; kind?: string }) => json(`/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 }) => json(`/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("/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("/v1/auth/register", { method: "POST", body: JSON.stringify({ email, password, workspace_name }) }), login: (email: string, password: string) => json("/v1/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }), me: () => json("/v1/auth/me"), inviteInfo: (token: string) => json<{ email: string; role: string }>(`/v1/auth/invite-info?token=${encodeURIComponent(token)}`), acceptInvite: (token: string, password: string) => json("/v1/auth/accept-invite", { method: "POST", body: JSON.stringify({ token, password }) }), listTeam: () => json("/v1/team/members"), listModels: () => json("/v1/models"), listPricing: () => json>("/v1/pricing"), setPricing: (model: string, body: { input_per_1m: number; output_per_1m: number }) => json(`/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(`/v1/versions/${entityType}/${entityId}`), getVersion: (entityType: EntityType, entityId: string, versionNo: number) => json }>(`/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(`/v1/versions/project/${projectId}/activity`), // full project config snapshots (newest first) so Settings > History can diff by section projectConfigHistory: (projectId: string) => json(`/v1/versions/project/${projectId}/config-history`), inviteMember: (body: { email: string; role?: string; password?: string }) => json("/v1/team/members", { method: "POST", body: JSON.stringify(body) }), updateMember: (uid: string, body: { role?: string; status?: string }) => json(`/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(`/v1/projects/${pid}/channels`), createChannel: (pid: string, body: { type: string; name: string; workflow_id?: string; config?: Record }) => json(`/v1/projects/${pid}/channels`, { method: "POST", body: JSON.stringify(body) }), updateChannel: (pid: string, cid: string, body: { name?: string; workflow_id?: string; config?: Record; enabled?: boolean }) => json(`/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(`/v1/projects/${pid}/triggers`), // datasets / eval listDatasets: (pid: string) => json(`/v1/projects/${pid}/datasets`), createDataset: (pid: string, body: { name: string; workflow_id?: string; score_mode?: string; items?: any[] }) => json(`/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(`/v1/projects/${pid}/datasets/${did}`, { method: "PATCH", body: JSON.stringify(body) }), runDataset: (pid: string, did: string) => json(`/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(`/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(`/v1/projects/${pid}/embed`), setEmbed: (pid: string, body: { enabled: boolean; allowed_origins: string[]; workflow_id?: string | null }) => json(`/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; key?: string | null; inbound_url?: string; } export interface Trigger { id: string; workflow_id: string; node_id: string; kind: string; enabled: boolean; config: Record; 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; } 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 { 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 }); } } } }