// Same-origin client for the Next.js API routes (projects, tasks, messages) + legacy benchmark/model/campaign shims. import type { Artifact, Benchmark, ChatMessage, DatasetSearchResponse, DatasetSearchResult, ModelEntry, ProjectRecord, ResourceKind, ResourceRef, Skill, SyncStatusEntry, TaskRecord, } from "./types"; import { BENCHMARKS, MODELS } from "./data"; // Legacy backend base — some deployments host the agent backend on a separate // origin. New task/project/artifact routes always live same-origin under the // Next.js app; only the legacy catalog endpoints (benchmarks, models, campaigns) // respect this base. const LEGACY_BASE = process.env.NEXT_PUBLIC_API_URL || ""; /** True when running without Tailscale (local dev). In production, Tailscale * Serve injects identity headers at the reverse-proxy layer so the JS side * doesn't need to add anything. In dev mode we pass X-Dev-User so the backend * can still identify the caller. */ const IS_DEV = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); /** Fetch with a 4-second timeout so the UI doesn't hang. */ async function fetchJSON(path: string, init?: RequestInit): Promise { return fetchJSONAt(path, init); } async function fetchJSONAt(path: string, init?: RequestInit, base = ""): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 4000); const devHeaders: Record = IS_DEV ? { "X-Dev-User": "dev@proteinea.local" } : {}; try { const res = await fetch(`${base}${path}`, { ...init, signal: controller.signal, headers: { "Content-Type": "application/json", ...devHeaders, ...init?.headers }, }); if (!res.ok) throw new Error(`API ${res.status}`); return (await res.json()) as T; } finally { clearTimeout(timeout); } } // ---- Projects ---- export async function listProjects(): Promise<{ projects: ProjectRecord[] }> { return fetchJSON<{ projects: ProjectRecord[] }>("/api/projects"); } export async function ensureDefaultProject(): Promise { const { projects } = await listProjects(); if (projects.length === 0) { throw new Error("No projects returned from /api/projects"); } return projects[0]; } // ---- Tasks ---- export async function listTasks(projectId: string): Promise<{ tasks: TaskRecord[] }> { return fetchJSON<{ tasks: TaskRecord[] }>( `/api/projects/${encodeURIComponent(projectId)}/tasks`, ); } export async function createTask( projectId: string, firstMessage: string, ): Promise { const { task } = await fetchJSON<{ task: TaskRecord }>( `/api/projects/${encodeURIComponent(projectId)}/tasks`, { method: "POST", body: JSON.stringify({ firstMessage }), }, ); return task; } export async function getTask(taskId: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 4000); try { const res = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { signal: controller.signal, headers: { "Content-Type": "application/json" }, }); if (res.status === 404) return null; if (!res.ok) throw new Error(`API ${res.status}`); const data = (await res.json()) as { task: TaskRecord }; return data.task; } finally { clearTimeout(timeout); } } export async function appendMessage( taskId: string, message: ChatMessage, ): Promise { const { task } = await fetchJSON<{ task: TaskRecord }>( `/api/tasks/${encodeURIComponent(taskId)}/messages`, { method: "POST", body: JSON.stringify({ message }), }, ); return task; } export async function patchTask( taskId: string, patch: Partial, ): Promise { const body: Record = {}; if (patch.title !== undefined) body.title = patch.title; if (patch.messages !== undefined) body.messages = patch.messages; if (patch.artifacts !== undefined) body.artifacts = patch.artifacts; const { task } = await fetchJSON<{ task: TaskRecord }>( `/api/tasks/${encodeURIComponent(taskId)}`, { method: "PATCH", body: JSON.stringify(body), }, ); return task; } // ---- Skills ---- export async function listSkillsAPI(): Promise<{ skills: Skill[] }> { return fetchJSON<{ skills: Skill[] }>("/api/skills"); } // ---- Jobs ---- export interface JobSubmitResult { campaign_job_id: string; hub_task_execution_id: string; provider: string; hub_response: Record; } export interface BackendArtifact { id: string; hub_output_key: string; phylo_tags?: string[]; scientist_note?: string | null; } export interface JobStatusResult { campaign_job_id: string; campaign_id: string; role: string; submitted_by: string; hub_task_execution_id: string; hub: Record; artifacts: BackendArtifact[]; } export interface CostEstimate { estimated_usd: number; gpu_type: string; minutes_used: number; cost_alert: boolean; } /** Submit a tool run job. FormData must include campaign_id, endpoint_name, * call_params (JSON string), and input_file. */ export async function submitJobAPI( campaignId: string, formData: FormData, ): Promise { formData.set("campaign_id", campaignId); const controller = new AbortController(); // Allow 60s for job submission (file upload may be large). const timeout = setTimeout(() => controller.abort(), 60_000); const devHeaders: Record = IS_DEV ? { "X-Dev-User": "dev@proteinea.local" } : {}; try { const res = await fetch("/api/jobs/submit", { method: "POST", body: formData, signal: controller.signal, headers: devHeaders, // Do NOT set Content-Type — the browser sets the multipart boundary. }); if (!res.ok) throw new Error(`API ${res.status}`); return (await res.json()) as JobSubmitResult; } finally { clearTimeout(timeout); } } /** Poll job status. */ export async function getJobStatusAPI( jobId: string, ): Promise { return fetchJSON(`/api/jobs/${encodeURIComponent(jobId)}`); } /** Pre-submit cost estimate for a tool. */ export async function estimateToolCostAPI( toolName: string, gpuType?: string, ): Promise { return fetchJSON( `/api/tools/${encodeURIComponent(toolName)}/estimate`, { method: "POST", body: JSON.stringify(gpuType ? { gpu_type: gpuType } : {}), }, ); } // ---- Resources ---- export async function listResourcesAPI(params?: { type?: ResourceKind; q?: string; }): Promise<{ resources: ResourceRef[]; counts: Record }> { const qs = new URLSearchParams(); if (params?.type) qs.set("type", params.type); if (params?.q) qs.set("q", params.q); const suffix = qs.toString() ? `?${qs.toString()}` : ""; return fetchJSON<{ resources: ResourceRef[]; counts: Record }>( `/api/resources${suffix}`, ); } // ---- Datasets ---- export async function searchDatasetsAPI( query: Record, ): Promise { return fetchJSON("/api/datasets/search", { method: "POST", body: JSON.stringify(query), }); } export async function searchDatasetSourceAPI( source: string, query: Record, ): Promise { return fetchJSON( `/api/datasets/${encodeURIComponent(source)}/search`, { method: "POST", body: JSON.stringify(query), }, ); } export async function importDatasetEntriesAPI( entries: { source: string; entry_id: string }[], ): Promise<{ artifacts: Artifact[]; errors: Record }> { return fetchJSON<{ artifacts: Artifact[]; errors: Record }>( "/api/datasets/import", { method: "POST", body: JSON.stringify({ entries }), }, ); } export async function getDatasetSyncStatusAPI(): Promise { return fetchJSON("/api/datasets/sync/status"); } // ---- Context Enrichment ---- export interface EnrichmentResult { entities: Record; context: Record; enriched_prompt: string; } export async function enrichContextAPI( message: string, maxResultsPerSource = 5, ): Promise { return fetchJSON("/api/context/enrich", { method: "POST", body: JSON.stringify({ message, max_results_per_source: maxResultsPerSource }), }); } // ---- Legacy catalog endpoints (no server routes yet; fall back to embedded JSON) ---- export async function listBenchmarks(): Promise<{ benchmarks: Benchmark[]; count: number }> { try { return await fetchJSONAt<{ benchmarks: Benchmark[]; count: number }>("/api/benchmarks", undefined, LEGACY_BASE); } catch { return { benchmarks: BENCHMARKS, count: BENCHMARKS.length }; } } export async function listModels(params?: { capability?: string; }): Promise<{ models: ModelEntry[]; count: number }> { try { const qs = params?.capability ? `?capability=${encodeURIComponent(params.capability)}` : ""; return await fetchJSONAt<{ models: ModelEntry[]; count: number }>(`/api/models${qs}`, undefined, LEGACY_BASE); } catch { let models = MODELS; if (params?.capability) { const cap = params.capability; models = models.filter((m) => m.capabilities.includes(cap)); } return { models, count: models.length }; } } interface CampaignEntry { campaign_id: string; conversation_id: string; active_jobs: Record; } export async function listCampaigns(): Promise<{ campaigns: CampaignEntry[]; count: number }> { try { return await fetchJSONAt<{ campaigns: CampaignEntry[]; count: number }>("/api/campaigns", undefined, LEGACY_BASE); } catch { return { campaigns: [], count: 0 }; } } // ---- Phylo Backend (Innovation Hub) ---- const PHYLO_BACKEND = process.env.NEXT_PUBLIC_PHYLO_BACKEND_URL || "http://127.0.0.1:8601"; /** GET /api/campaigns — list all design campaigns from Phylo backend */ export async function listPhyloCampaigns(): Promise { return fetchJSONAt("/api/campaigns", undefined, PHYLO_BACKEND); } export interface PhyloCampaign { id: string; name: string; target: string; modality: string; goal: string; constraints?: Record; created_by: string; created_at?: string; } /** POST /api/campaigns — create a new design campaign */ export async function createPhyloCampaign(params: { name: string; target: string; modality: string; goal: string; constraints?: Record; }): Promise<{ id: string; name: string; target: string; [k: string]: unknown }> { return fetchJSONAt<{ id: string; name: string; target: string }>( "/api/campaigns", { method: "POST", body: JSON.stringify(params) }, PHYLO_BACKEND, ); } /** POST /api/campaigns/{id}/jobs — submit a job (multipart form) */ export async function submitPhyloJob( campaignId: string, formData: FormData, ): Promise<{ campaign_job_id: string; hub_task_execution_id: string; provider: string; hub_response: unknown; }> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 120_000); // jobs can take time try { const res = await fetch( `${PHYLO_BACKEND}/api/campaigns/${encodeURIComponent(campaignId)}/jobs`, { method: "POST", body: formData, signal: controller.signal }, ); if (!res.ok) throw new Error(`Phylo API ${res.status}: ${await res.text()}`); return (await res.json()) as { campaign_job_id: string; hub_task_execution_id: string; provider: string; hub_response: unknown; }; } finally { clearTimeout(timeout); } } /** GET /api/jobs/{id} — poll job status */ export async function getPhyloJobStatus(jobId: string): Promise<{ hub: { status: string; output_data?: unknown; cost_estimate?: number; [k: string]: unknown }; artifacts: { name: string; url: string; mime?: string; bytes?: number }[]; }> { return fetchJSONAt( `/api/jobs/${encodeURIComponent(jobId)}`, undefined, PHYLO_BACKEND, ); } /** GET /api/tools — list all Hub tools */ export async function listPhyloTools(): Promise< { endpoint_name: string; provider: string; [k: string]: unknown }[] > { return fetchJSONAt< { endpoint_name: string; provider: string; [k: string]: unknown }[] >("/api/tools", undefined, PHYLO_BACKEND); } /** POST /api/tools/{name}/estimate — cost estimate for a tool run */ export async function estimateToolCost( endpointName: string, gpuType = "A100", ): Promise<{ estimated_usd: number; gpu_type: string; minutes_used: number; cost_alert?: string; }> { return fetchJSONAt( `/api/tools/${encodeURIComponent(endpointName)}/estimate`, { method: "POST", body: JSON.stringify({ gpu_type: gpuType }) }, PHYLO_BACKEND, ); } // ---- Legacy conversation shims (route into the new task system) ---- /** Create a conversation. Tries the Phylo backend first (creates a task * in the local Next.js system, then uses the task ID for the stream). * If NEXT_PUBLIC_API_URL is set, falls back to legacy backend. */ export async function createConversation(): Promise<{ conversation_id: string }> { // Always create a local task for persistence try { const project = await ensureDefaultProject(); const task = await createTask(project.id, "(new conversation)"); return { conversation_id: task.id }; } catch { // Task system unavailable — generate a temp ID (demo mode will activate) return { conversation_id: `temp-${Date.now()}` }; } } /** Stream URL — points to the Phylo backend's agentic SSE endpoint. * This is where Claude processes the message and calls Hub tools. */ export function getStreamUrl(conversationId: string): string { return `${PHYLO_BACKEND}/api/tasks/${encodeURIComponent(conversationId)}/messages/stream`; } // ---- File Upload ---- export async function uploadFiles( taskId: string, files: File[], note?: string, ): Promise<{ artifacts: Artifact[]; message: ChatMessage }> { const form = new FormData(); for (const file of files) { form.append("files", file); } if (note) form.append("note", note); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 60_000); const devHeaders: Record = IS_DEV ? { "X-Dev-User": "dev@proteinea.local" } : {}; try { const res = await fetch( `/api/tasks/${encodeURIComponent(taskId)}/upload`, { method: "POST", body: form, signal: controller.signal, headers: devHeaders, }, ); if (!res.ok) throw new Error(`API ${res.status}`); return (await res.json()) as { artifacts: Artifact[]; message: ChatMessage }; } finally { clearTimeout(timeout); } } // ---- Knowledge Bases ---- export interface KnowledgeBaseEntry { id: string; name: string; description: string; entryCount: number; source: "embedded" | "dataset" | "custom"; } export async function listKnowledgeBases(): Promise<{ knowledgeBases: KnowledgeBaseEntry[] }> { return fetchJSON<{ knowledgeBases: KnowledgeBaseEntry[] }>("/api/knowledge-bases"); } // ---- Task Skills ---- export async function listTaskSkills(taskId: string): Promise<{ skills: unknown[]; taskId: string }> { return fetchJSON<{ skills: unknown[]; taskId: string }>( `/api/tasks/${encodeURIComponent(taskId)}/skills`, ); } export interface DatasetListEntry { id: string; name: string; source: string; type: "structure" | "sequence" | "gene" | "benchmark" | "mixed"; description: string; entryCount: number; lastUpdated: string | null; sizeBytes: number; } export async function listDatasetsAPI(params?: { q?: string; type?: string; }): Promise<{ datasets: DatasetListEntry[]; count: number }> { const qs = new URLSearchParams(); if (params?.q) qs.set("q", params.q); if (params?.type) qs.set("type", params.type); const suffix = qs.toString() ? `?${qs.toString()}` : ""; return fetchJSON<{ datasets: DatasetListEntry[]; count: number }>( `/api/datasets/list${suffix}`, ); }