proteinea / src /lib /api.ts
Mahmoud Eljendy
feat: Antibody Studio — AI-native antibody design workspace by Proteinea
30cc31a
// 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<T>(path: string, init?: RequestInit): Promise<T> {
return fetchJSONAt(path, init);
}
async function fetchJSONAt<T>(path: string, init?: RequestInit, base = ""): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
const devHeaders: Record<string, string> = 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<ProjectRecord> {
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<TaskRecord> {
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<TaskRecord | null> {
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<TaskRecord> {
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<TaskRecord>,
): Promise<TaskRecord> {
const body: Record<string, unknown> = {};
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<string, unknown>;
}
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<string, unknown>;
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<JobSubmitResult> {
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<string, string> = 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<JobStatusResult> {
return fetchJSON<JobStatusResult>(`/api/jobs/${encodeURIComponent(jobId)}`);
}
/** Pre-submit cost estimate for a tool. */
export async function estimateToolCostAPI(
toolName: string,
gpuType?: string,
): Promise<CostEstimate> {
return fetchJSON<CostEstimate>(
`/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<string, number> }> {
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<string, number> }>(
`/api/resources${suffix}`,
);
}
// ---- Datasets ----
export async function searchDatasetsAPI(
query: Record<string, unknown>,
): Promise<DatasetSearchResponse> {
return fetchJSON<DatasetSearchResponse>("/api/datasets/search", {
method: "POST",
body: JSON.stringify(query),
});
}
export async function searchDatasetSourceAPI(
source: string,
query: Record<string, unknown>,
): Promise<DatasetSearchResult[]> {
return fetchJSON<DatasetSearchResult[]>(
`/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<string, string> }> {
return fetchJSON<{ artifacts: Artifact[]; errors: Record<string, string> }>(
"/api/datasets/import",
{
method: "POST",
body: JSON.stringify({ entries }),
},
);
}
export async function getDatasetSyncStatusAPI(): Promise<SyncStatusEntry[]> {
return fetchJSON<SyncStatusEntry[]>("/api/datasets/sync/status");
}
// ---- Context Enrichment ----
export interface EnrichmentResult {
entities: Record<string, string[]>;
context: Record<string, unknown>;
enriched_prompt: string;
}
export async function enrichContextAPI(
message: string,
maxResultsPerSource = 5,
): Promise<EnrichmentResult> {
return fetchJSON<EnrichmentResult>("/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<string, string>;
}
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<PhyloCampaign[]> {
return fetchJSONAt<PhyloCampaign[]>("/api/campaigns", undefined, PHYLO_BACKEND);
}
export interface PhyloCampaign {
id: string;
name: string;
target: string;
modality: string;
goal: string;
constraints?: Record<string, unknown>;
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<string, unknown>;
}): 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<string, string> = 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}`,
);
}