proteinea / src /lib /demo-engine.ts
Mahmoud Eljendy
feat: Antibody Studio — AI-native antibody design workspace by Proteinea
30cc31a
/**
* Demo conversation engine with Plan Mode and Action Mode.
*
* Plan Mode: Deep structured interview for drug hunters — asks the critical
* questions that determine whether a molecule will succeed in the clinic.
*
* Action Mode: AI takes the lead — minimal questions, proactive exploration,
* autonomous pipeline decisions.
*/
import { BENCHMARKS, MODELS, TARGETS } from "./data";
import type { StreamEvent, PlanProposal, TraceEntry, Artifact, Citation } from "./types";
import {
createPhyloCampaign,
submitPhyloJob,
getPhyloJobStatus,
estimateToolCost,
} from "./api";
import { resolveToolEndpoint } from "./tool-mapping";
export type AgentMode = "plan" | "action";
type PlanState =
| "greeting"
| "ask_target"
| "ask_indication"
| "ask_mechanism"
| "ask_format"
| "ask_epitope"
| "ask_affinity"
| "ask_species"
| "ask_developability"
| "ask_route"
| "ask_competition"
| "ask_seed"
| "ask_scale"
| "show_plan"
| "confirmed"
| "free_chat";
type ActionState = "greeting" | "ask_target" | "running" | "free_chat";
interface DemoConversation {
id: string;
mode: AgentMode;
planState: PlanState;
actionState: ActionState;
target: string;
indication: string;
mechanism: string;
format: string;
epitope: string;
affinity: string;
species: string;
developability: string;
route: string;
competition: string;
seed: string;
scale: string;
messageCount: number;
}
let _conv: DemoConversation | null = null;
/** Optional scientist-provided input file (PDB/FASTA). Set from the UI. */
let _inputFile: File | null = null;
/** Set the scientist-provided input file for use in executePlan. */
export function demoSetInputFile(file: File | null): void {
_inputFile = file;
}
export function demoCreateConversation(): string {
const id = "demo-" + Math.random().toString(36).slice(2, 10);
_conv = {
id,
mode: "plan",
planState: "greeting",
actionState: "greeting",
target: "", indication: "", mechanism: "", format: "", epitope: "",
affinity: "", species: "", developability: "", route: "", competition: "",
seed: "", scale: "",
messageCount: 0,
};
return id;
}
export function demoSetMode(mode: AgentMode): void {
if (_conv) _conv.mode = mode;
}
export function demoGetMode(): AgentMode {
return _conv?.mode ?? "plan";
}
export function demoGetDesignBrief(): Record<string, string> {
if (!_conv) return {};
const b: Record<string, string> = {};
if (_conv.target) b["Target"] = _conv.target;
if (_conv.indication) b["Indication"] = _conv.indication;
if (_conv.mechanism) b["Mechanism"] = _conv.mechanism;
if (_conv.format) b["Format"] = _conv.format;
if (_conv.epitope) b["Epitope"] = _conv.epitope;
if (_conv.affinity) b["Affinity"] = _conv.affinity;
if (_conv.species) b["Species"] = _conv.species;
if (_conv.developability) b["Developability"] = _conv.developability;
if (_conv.route) b["Route"] = _conv.route;
if (_conv.competition) b["Competition"] = _conv.competition;
if (_conv.seed) b["Starting point"] = _conv.seed;
if (_conv.scale) b["Designs"] = _conv.scale;
return b;
}
export async function demoSendMessage(
message: string,
onEvent: (ev: StreamEvent) => void
): Promise<void> {
if (!_conv) return;
_conv.messageCount++;
const msg = message.toLowerCase().trim();
// Explore queries work in any mode/state
if (isExploreQuery(msg)) {
await handleExploreQuery(msg, onEvent);
return;
}
if (_conv.mode === "plan") {
await handlePlanMode(msg, onEvent);
} else {
await handleActionMode(msg, onEvent);
}
}
// ============================================================
// PLAN MODE — Deep drug design interview
// ============================================================
async function handlePlanMode(msg: string, onEvent: (ev: StreamEvent) => void) {
switch (_conv!.planState) {
case "greeting": {
const target = detectTarget(msg);
if (target) {
_conv!.target = target;
await emitToolCall("lookup_target", { target_name: target }, getTargetData(target), onEvent);
await streamText(
`**${target}** — ${getTargetNotes(target)}.\n\nWhat therapeutic indication are you pursuing?\n\n` +
`- **Oncology** (e.g., solid tumors, hematologic malignancies)\n` +
`- **Autoimmune / Inflammation**\n` +
`- **Infectious disease**\n` +
`- **Other**`,
onEvent
);
_conv!.planState = "ask_indication";
} else {
await streamText(
`Welcome to **Plan Mode**. I will walk you through a structured design brief to make sure we capture everything that matters for your molecule.\n\n` +
`What is your **target protein**?`,
onEvent
);
_conv!.planState = "ask_target";
}
break;
}
case "ask_target": {
const target = detectTarget(msg) || msg.replace(/[^a-zA-Z0-9\-\s]/g, "").trim().toUpperCase();
_conv!.target = target;
const known = TARGETS.find((t) => t.name.toLowerCase() === target.toLowerCase());
if (known) {
await emitToolCall("lookup_target", { target_name: target }, getTargetData(target), onEvent);
await streamText(
`**${target}** — ${known.notes}\n\nWhat therapeutic indication?\n\n- **Oncology**\n- **Autoimmune / Inflammation**\n- **Infectious disease**\n- **Other**`,
onEvent
);
} else {
await streamText(
`Noted: **${target}**. No benchmark data yet, but we can design against it.\n\nWhat therapeutic indication?\n\n- **Oncology**\n- **Autoimmune / Inflammation**\n- **Infectious disease**\n- **Other**`,
onEvent
);
}
_conv!.planState = "ask_indication";
break;
}
case "ask_indication": {
_conv!.indication = extractChoice(msg, ["oncology", "autoimmune", "inflammation", "infectious", "other"]) || msg.slice(0, 60);
await streamText(
`What is the desired **mechanism of action**?\n\n` +
`- **Blocking / Neutralization** — prevent ligand-receptor interaction\n` +
`- **Receptor agonism** — activate signaling\n` +
`- **Internalization / ADC** — deliver payload into cell\n` +
`- **Cell depletion** — ADCC/CDC to kill target-expressing cells\n` +
`- **Not sure** — I can advise based on target biology`,
onEvent
);
_conv!.planState = "ask_mechanism";
break;
}
case "ask_mechanism": {
_conv!.mechanism = extractChoice(msg, ["blocking", "neutralization", "agonism", "internalization", "adc", "depletion", "adcc", "cdc"]) || msg.slice(0, 60);
const needsFc = ["depletion", "adcc", "cdc", "internalization", "adc"].some((k) => _conv!.mechanism.includes(k));
await streamText(
`What **antibody format**?${needsFc ? " (Your mechanism likely needs Fc effector functions.)" : ""}\n\n` +
`- **VHH** (nanobody) — 15 kDa, great tissue penetration, no Fc\n` +
`- **scFv** — 27 kDa, no Fc\n` +
`- **mAb (IgG1)** — full Fc, enables ADCC/CDC\n` +
`- **mAb (IgG4)** — Fc-silent, blocking only\n` +
`- **Bispecific** — dual-target engagement`,
onEvent
);
_conv!.planState = "ask_format";
break;
}
case "ask_format": {
if (msg.includes("vhh") || msg.includes("nanobody")) _conv!.format = "VHH";
else if (msg.includes("scfv")) _conv!.format = "scFv";
else if (msg.includes("igg1") || msg.includes("mab")) _conv!.format = "mAb (IgG1)";
else if (msg.includes("igg4")) _conv!.format = "mAb (IgG4)";
else if (msg.includes("bispecific")) _conv!.format = "Bispecific";
else _conv!.format = msg.slice(0, 20);
await streamText(
`Do you have a **preferred epitope** or binding site on ${_conv!.target}?\n\n` +
`- **Orthosteric** — ligand binding site\n` +
`- **Allosteric** — away from active site\n` +
`- **Specific residues** — describe them\n` +
`- **No preference** — let the model explore`,
onEvent
);
_conv!.planState = "ask_epitope";
break;
}
case "ask_epitope": {
_conv!.epitope = msg.includes("no preference") || msg.includes("no pref") || msg.includes("explore") ? "no preference" : msg.slice(0, 80);
await streamText(
`What **affinity** are you targeting?\n\n` +
`- **< 1 nM** — ultra-high (typical for therapeutic mAbs)\n` +
`- **1-10 nM** — high (standard for most programs)\n` +
`- **10-100 nM** — moderate (acceptable for some mechanisms)\n` +
`- **Any binder first** — optimize affinity later`,
onEvent
);
_conv!.planState = "ask_affinity";
break;
}
case "ask_affinity": {
_conv!.affinity = extractChoice(msg, ["< 1", "1-10", "10-100", "any binder"]) || msg.slice(0, 30);
await streamText(
`What **species cross-reactivity** do you need?\n\n` +
`- **Human only** — sufficient for early discovery\n` +
`- **Human + cynomolgus** — needed for tox studies\n` +
`- **Human + cyno + mouse** — enables mouse efficacy models\n` +
`- **Not sure**`,
onEvent
);
_conv!.planState = "ask_species";
break;
}
case "ask_species": {
_conv!.species = extractChoice(msg, ["human only", "cyno", "mouse", "not sure"]) || msg.slice(0, 40);
await streamText(
`**Developability** priorities? Select the most critical:\n\n` +
`- **Low aggregation** — Agmata score < 0.3\n` +
`- **High expression** — > 1 g/L in CHO\n` +
`- **Thermostability** — Tm > 70C\n` +
`- **Low immunogenicity** — high humanness score\n` +
`- **Balanced** — optimize all equally`,
onEvent
);
_conv!.planState = "ask_developability";
break;
}
case "ask_developability": {
_conv!.developability = extractChoice(msg, ["aggregation", "expression", "thermostab", "immunogenicity", "balanced"]) || msg.slice(0, 40);
await streamText(
`Intended **route of administration**?\n\n` +
`- **IV** (intravenous) — standard for oncology\n` +
`- **SC** (subcutaneous) — preferred for chronic dosing\n` +
`- **Intrathecal** — CNS delivery\n` +
`- **Inhaled** — respiratory targets\n` +
`- **Not decided**`,
onEvent
);
_conv!.planState = "ask_route";
break;
}
case "ask_route": {
_conv!.route = extractChoice(msg, ["iv", "sc", "intrathecal", "inhaled", "not decided"]) || msg.slice(0, 30);
await streamText(
`Are there **existing antibodies** against ${_conv!.target} you want to benchmark against or differentiate from?\n\n` +
`- Name specific antibodies (e.g., trastuzumab, pembrolizumab)\n` +
`- **None known**\n` +
`- **Yes, but undisclosed**`,
onEvent
);
_conv!.planState = "ask_competition";
break;
}
case "ask_competition": {
_conv!.competition = msg.includes("none") ? "none known" : msg.slice(0, 80);
await streamText(
`Starting point?\n\n` +
`- **De novo** — design from scratch against the target structure\n` +
`- **Seed sequence** — optimize an existing antibody\n` +
`- **Humanize** — humanize a non-human lead`,
onEvent
);
_conv!.planState = "ask_seed";
break;
}
case "ask_seed": {
_conv!.seed = msg.includes("novo") || msg.includes("scratch") ? "de novo" : msg.includes("humanize") ? "humanization" : "seed optimization";
await streamText(
`How many designs to generate?\n\n- **10** — quick pilot\n- **50** — standard campaign\n- **100+** — comprehensive`,
onEvent
);
_conv!.planState = "ask_scale";
break;
}
case "ask_scale": {
const match = msg.match(/(\d+)/);
_conv!.scale = match ? match[1] : "50";
await showDesignBrief(onEvent);
_conv!.planState = "show_plan";
break;
}
case "show_plan": {
if (msg.includes("run") || msg.includes("go") || msg.includes("yes") || msg.includes("ready") || msg.includes("confirm") || msg.includes("start")) {
await executePlan(onEvent);
_conv!.planState = "free_chat";
} else {
await streamText("What would you like to adjust in the design brief?", onEvent);
}
break;
}
case "confirmed":
case "free_chat": {
await handleFreeChat(msg, onEvent);
break;
}
}
onEvent({ type: "done" });
}
async function showDesignBrief(onEvent: (ev: StreamEvent) => void) {
// KB lookups — simulate best-practice retrieval before planning
await emitKBLookup("antibody design best practices", onEvent);
await emitKBLookup("benchmark catalog", onEvent);
await emitKBLookup("target lookup", onEvent);
await emitTrace({ kind: "thought", label: "Synthesizing design brief from interview answers" }, onEvent);
await emitToolCall("browse_benchmarks", {}, { count: BENCHMARKS.length }, onEvent);
await emitToolCall("browse_models", { capability: "full_design" }, { count: MODELS.filter((m) => m.capabilities.includes("full_design")).length }, onEvent);
const pipeline = _conv!.seed === "de novo"
? "RFdiffusion → ProteinMPNN → RF2 (filter) → Chai-1 (score)"
: "ProteinMPNN (CDR redesign) → RF2 → Chai-1";
await streamText(
`Here is your **Design Brief**:\n\n` +
`| Parameter | Value |\n` +
`|-----------|-------|\n` +
`| Target | **${_conv!.target}** |\n` +
`| Indication | ${_conv!.indication} |\n` +
`| Mechanism | ${_conv!.mechanism} |\n` +
`| Format | **${_conv!.format}** |\n` +
`| Epitope | ${_conv!.epitope} |\n` +
`| Affinity goal | ${_conv!.affinity} |\n` +
`| Species cross-rx | ${_conv!.species} |\n` +
`| Developability | ${_conv!.developability} |\n` +
`| Route | ${_conv!.route} |\n` +
`| Competition | ${_conv!.competition} |\n` +
`| Starting point | ${_conv!.seed} |\n` +
`| Designs | **${_conv!.scale}** |\n` +
`| Pipeline | ${pipeline} |\n` +
`| Scoring | 3-model consensus (Chai-1 + AF2 + Protenix) |\n\n` +
`Ready to launch this campaign, or want to adjust anything?`,
onEvent
);
// Emit structured plan proposal right before the execute/chat fork
onEvent({ type: "plan", plan: buildPlanProposal() });
}
async function executePlan(onEvent: (ev: StreamEvent) => void) {
await emitTrace({ kind: "thought", label: `Launching campaign for ${_conv!.target}` }, onEvent);
// ---- Determine pipeline tools from the plan ----
const plan = buildPlanProposal();
const pipelineTools = plan.steps.flatMap((s) => s.tools);
try {
// ---- 1. Create real campaign via Phylo backend ----
const campaignParams = {
name: `${_conv!.target}-${_conv!.format}-${Date.now()}`,
target: _conv!.target,
modality: _conv!.format || "VHH",
goal: _conv!.mechanism || "blocking",
constraints: {
epitope: _conv!.epitope,
affinity: _conv!.affinity,
species: _conv!.species,
developability: _conv!.developability,
route: _conv!.route,
scale: _conv!.scale,
seed: _conv!.seed,
},
};
const campaign = await createPhyloCampaign(campaignParams);
const campaignId = campaign.id;
await emitToolCall(
"create_campaign",
campaignParams,
{ campaign_id: campaignId, status: "created" },
onEvent,
);
await emitTrace(
{ kind: "tool_result", label: `campaign_id=${campaignId}`, detail: truncate(JSON.stringify(campaign)) },
onEvent,
);
await streamText(`Campaign **${campaignId}** created on the Innovation Hub.\n\n`, onEvent);
// ---- 2. Estimate costs for each pipeline tool ----
for (const tool of pipelineTools) {
try {
const endpoint = resolveToolEndpoint(tool);
const estimate = await estimateToolCost(endpoint);
await emitToolCall(
"estimate_cost",
{ endpoint_name: endpoint, gpu_type: "A100" },
estimate,
onEvent,
);
const costLine = `**${tool}**: ~$${estimate.estimated_usd.toFixed(2)} (${estimate.minutes_used} min on ${estimate.gpu_type})`;
const alertLine = estimate.cost_alert ? ` -- ${estimate.cost_alert}` : "";
await streamText(costLine + alertLine + "\n", onEvent);
} catch {
// Cost estimation is non-critical — skip silently
await streamText(`**${tool}**: cost estimate unavailable\n`, onEvent);
}
}
await streamText("\nSubmitting jobs to the GPU cluster...\n\n", onEvent);
// ---- 3. Submit jobs sequentially ----
const completedJobs: { tool: string; jobId: string; output: unknown }[] = [];
for (const tool of pipelineTools) {
const jobEndpoint = resolveToolEndpoint(tool);
await emitTrace({ kind: "tool_call", label: tool, detail: `Submitting ${tool} job (${jobEndpoint})` }, onEvent);
// Use scientist-provided file if available, otherwise build placeholder FASTA
let inputBlob: Blob;
let inputFilename: string;
if (_inputFile) {
inputBlob = _inputFile;
inputFilename = _inputFile.name;
} else {
const fastaContent = buildMinimalFasta(_conv!.target, _conv!.seed);
inputBlob = new Blob([fastaContent], { type: "text/plain" });
inputFilename = "input.fasta";
}
const formData = new FormData();
formData.append("endpoint_name", jobEndpoint);
formData.append("provider", "runpod");
formData.append("call_params", JSON.stringify({
target: _conv!.target,
format: _conv!.format,
num_designs: parseInt(_conv!.scale || "50", 10),
}));
formData.append("role", "design");
formData.append("input_file", inputBlob, inputFilename);
let jobResult;
try {
jobResult = await submitPhyloJob(campaignId, formData);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
await emitTrace({ kind: "tool_result", label: `${tool} submission failed`, detail: errMsg }, onEvent);
await streamText(`**${tool}** -- job submission failed: ${errMsg}\n`, onEvent);
continue;
}
const jobId = jobResult.campaign_job_id;
await emitToolCall(
"submit_job",
{ campaign_id: campaignId, endpoint_name: jobEndpoint },
{ job_id: jobId, provider: jobResult.provider },
onEvent,
);
await streamText(`**${tool}** job **${jobId}** submitted. Polling for completion...\n`, onEvent);
// ---- 4. Poll until complete or timeout (10 min) ----
const POLL_INTERVAL_MS = 5_000;
const MAX_POLL_MS = 10 * 60 * 1_000;
const pollStart = Date.now();
let finalStatus: Awaited<ReturnType<typeof getPhyloJobStatus>> | null = null;
while (Date.now() - pollStart < MAX_POLL_MS) {
await sleep(POLL_INTERVAL_MS);
try {
const status = await getPhyloJobStatus(jobId);
const hubStatus = status.hub?.status?.toLowerCase() ?? "";
if (hubStatus === "completed" || hubStatus === "success" || hubStatus === "done") {
finalStatus = status;
break;
} else if (hubStatus === "failed" || hubStatus === "error") {
const detail = typeof status.hub?.output_data === "string"
? status.hub.output_data
: JSON.stringify(status.hub?.output_data ?? "unknown error");
await emitTrace({ kind: "tool_result", label: `${tool} failed`, detail }, onEvent);
await streamText(`**${tool}** failed: ${detail}\n`, onEvent);
break;
}
// Still running — continue polling
} catch {
// Transient poll failure — keep trying
}
}
if (finalStatus) {
await emitTrace(
{ kind: "tool_result", label: `${tool} completed`, detail: truncate(JSON.stringify(finalStatus.hub)) },
onEvent,
);
await streamText(`**${tool}** completed.\n`, onEvent);
completedJobs.push({ tool, jobId, output: finalStatus.hub?.output_data });
// ---- 5. Emit real artifacts ----
if (finalStatus.artifacts && finalStatus.artifacts.length > 0) {
for (const art of finalStatus.artifacts) {
const artifact: Artifact = {
id: `art-${jobId}-${art.name}`,
name: art.name,
bytes: art.bytes ?? 0,
mime: art.mime ?? "application/octet-stream",
url: art.url,
createdAt: Date.now(),
};
onEvent({ type: "artifact", artifact });
}
}
} else if (!finalStatus) {
await streamText(`**${tool}** timed out after 10 minutes.\n`, onEvent);
}
}
// ---- Summary ----
const successCount = completedJobs.length;
const totalTools = pipelineTools.length;
await streamText(
`\n---\n**Campaign ${campaignId}**: ${successCount}/${totalTools} pipeline steps completed.\n` +
`Track full progress in the **Campaigns** tab.`,
onEvent,
);
// Emit design brief artifact (always useful even with real backend)
const designBriefMd = buildDesignBriefMarkdown();
await emitArtifact("design_brief.md", designBriefMd, "text/markdown", onEvent);
} catch (err: unknown) {
// ---- Backend unreachable — fall back to demo mode ----
const errMsg = err instanceof Error ? err.message : String(err);
await streamText(
`> **Backend unreachable** -- showing demo results. (${errMsg})\n\n`,
onEvent,
);
await executePlanDemo(onEvent);
}
}
/** Original demo-only plan execution — used as fallback when Phylo backend is down. */
async function executePlanDemo(onEvent: (ev: StreamEvent) => void) {
const campaignId = "camp-" + Math.random().toString(36).slice(2, 8);
await emitToolCall(
"plan_campaign",
{ target: _conv!.target, modality: _conv!.format, goal: _conv!.mechanism },
{ campaign_id: campaignId, status: "planned" },
onEvent,
);
await streamText(
`Campaign **${campaignId}** created (demo mode).\n\n` +
`Pipeline steps:\n` +
`1. Generate ${_conv!.scale} backbone structures (RFdiffusion)\n` +
`2. Design sequences (ProteinMPNN, 2x per backbone)\n` +
`3. Filter by RF2 iPAE < 10\n` +
`4. Score with Chai-1 AntiConf > 0.50\n` +
`5. Rank by 3-model consensus\n\n` +
`Track progress in the **Campaigns** tab.`,
onEvent,
);
const designBriefMd = buildDesignBriefMarkdown();
const topDesignsCsv = buildTopDesignsCsv();
const executionTraceJson = buildExecutionTraceJson();
await emitArtifact("design_brief.md", designBriefMd, "text/markdown", onEvent);
await emitArtifact("top_designs.csv", topDesignsCsv, "text/csv", onEvent);
await emitArtifact("execution_trace.json", executionTraceJson, "application/json", onEvent);
}
/** Build a minimal FASTA string from interview fields for job submission. */
function buildMinimalFasta(target: string, seed: string): string {
// If the user provided a seed sequence, use it; otherwise use a placeholder
const placeholder = "MEVQLVESGGGLVQPGGSLRLSCAASGFTFSSYAMSWVRQAPGKGLEWVSAISGSGGSTYYADSVKGRFTISRDNSKNTLYLQMNSLRAEDTAVYYCAR";
const header = `>${target}_design seed=${seed || "de_novo"}`;
return `${header}\n${placeholder}\n`;
}
// ============================================================
// ACTION MODE — AI takes the lead
// ============================================================
async function handleActionMode(msg: string, onEvent: (ev: StreamEvent) => void) {
switch (_conv!.actionState) {
case "greeting": {
const target = detectTarget(msg);
if (target) {
_conv!.target = target;
await runActionAutonomous(onEvent);
_conv!.actionState = "free_chat";
} else {
await streamText(
`**Action Mode** — I will handle everything. Just tell me: what is your **target protein**?`,
onEvent
);
_conv!.actionState = "ask_target";
}
break;
}
case "ask_target": {
const target = detectTarget(msg) || msg.replace(/[^a-zA-Z0-9\-\s]/g, "").trim().toUpperCase();
_conv!.target = target;
await runActionAutonomous(onEvent);
_conv!.actionState = "free_chat";
break;
}
case "running":
case "free_chat": {
await handleFreeChat(msg, onEvent);
break;
}
}
onEvent({ type: "done" });
}
async function runActionAutonomous(onEvent: (ev: StreamEvent) => void) {
const target = _conv!.target;
// KB lookups before planning
await emitKBLookup("antibody design best practices", onEvent);
await emitKBLookup("target lookup", onEvent);
// Step 1: Look up target
await emitTrace({ kind: "thought", label: `Assessing target ${target}` }, onEvent);
await emitToolCall("lookup_target", { target_name: target }, getTargetData(target), onEvent);
const known = TARGETS.find((t) => t.name.toLowerCase() === target.toLowerCase());
const tClass = known?.target_class || "unknown";
await streamText(`Analyzing **${target}**...`, onEvent);
// Step 2: Check benchmarks
await emitToolCall("browse_benchmarks", {}, { count: BENCHMARKS.length }, onEvent);
// Step 3: Auto-select format
const format = tClass === "gpcr" ? "VHH" : "VHH";
_conv!.format = format;
// Step 4: Check models
await emitToolCall("browse_models", { capability: "full_design" }, { count: 1 }, onEvent);
// Step 5: Auto-build plan
_conv!.seed = "de novo";
_conv!.scale = "50";
_conv!.mechanism = tClass === "gpcr" ? "blocking" : "blocking / neutralization";
_conv!.indication = "auto-detected";
const benchmarkData = getTargetBindingData(target);
let benchmarkNote = "";
if (benchmarkData.length > 0) {
const best = benchmarkData.sort((a, b) => (b.hit_rate ?? 0) - (a.hit_rate ?? 0))[0];
benchmarkNote = `\n\nBest published result: **${best.benchmark}** achieved ${fmtPct(best.hit_rate)} hit rate on ${target}.`;
}
await streamText(
`Here is my recommended plan for **${target}**:\n\n` +
`| Parameter | Value |\n` +
`|-----------|-------|\n` +
`| Target | **${target}** (${tClass}) |\n` +
`| Format | **${format}** — best hit rates in benchmarks |\n` +
`| Approach | **De novo** — RFdiffusion → ProteinMPNN → RF2 → Chai-1 |\n` +
`| Designs | **50** |\n` +
`| Scoring | 3-model consensus, AntiConf > 0.50 |\n` +
benchmarkNote +
`\n\nI selected VHH because it consistently achieves the highest hit rates across published benchmarks (JAM-2: 39%). ` +
`The de novo pipeline is the most validated approach for new targets.\n\n` +
`**Ready to launch?** Or switch to Plan Mode for a deeper interview.`,
onEvent
);
onEvent({ type: "citation", citation: { label: "JAM-2 benchmark paper", url: "https://doi.org/10.1101/2024.jam2" } });
onEvent({ type: "plan", plan: buildPlanProposal() });
}
// ============================================================
// EXPLORE QUERIES (shared)
// ============================================================
function isExploreQuery(msg: string): boolean {
const kw = ["benchmark", "benchmarks", "compare", "models", "model", "what models", "what benchmarks", "show me", "list", "tell me about", "look up"];
return kw.some((k) => msg.includes(k));
}
async function handleExploreQuery(msg: string, onEvent: (ev: StreamEvent) => void) {
// KB lookups for benchmark/model/target questions
if (msg.includes("benchmark") || msg.includes("model") || msg.includes("target") || msg.includes("compare")) {
await emitKBLookup("benchmark catalog", onEvent);
await emitKBLookup("model registry", onEvent);
}
if (msg.includes("compare")) {
const names = extractBenchmarkNames(msg);
if (names.length >= 2) {
const rows = names.map((n) => {
const bm = BENCHMARKS.find((b) => b.source.toLowerCase() === n.toLowerCase());
return bm ? { source: bm.source, vhh: bm.avg_hit_rate_vhh, mab: bm.avg_hit_rate_mab, designs: bm.total_designs_tested } : { source: n, error: "not found" };
});
await emitToolCall("compare_benchmarks", { benchmark_names: names }, { comparison: rows }, onEvent);
let table = "| Benchmark | VHH Hit Rate | mAb Hit Rate | Designs | Open Source |\n|-----------|:---:|:---:|:---:|:---:|\n";
for (const bm of BENCHMARKS.filter((b) => names.some((n) => n.toLowerCase() === b.source.toLowerCase()))) {
table += `| ${bm.source} | ${fmtPct(bm.avg_hit_rate_vhh)} | ${fmtPct(bm.avg_hit_rate_mab)} | ${bm.total_designs_tested} | ${bm.code_available ? "Yes" : "No"} |\n`;
}
await streamText(table + `\n**JAM-2** leads on hit rates. **Chai-2** has the best developability. **RFantibody** is validated by cryo-EM.`, onEvent);
onEvent({ type: "citation", citation: { label: "JAM-2 paper", url: "https://doi.org/10.1101/2024.jam2" } });
onEvent({ type: "citation", citation: { label: "RFantibody", url: "https://github.com/RosettaCommons/RFantibody" } });
} else {
await emitToolCall("browse_benchmarks", {}, { count: BENCHMARKS.length }, onEvent);
await streamText(`Which benchmarks to compare? Available: ${BENCHMARKS.map((b) => `**${b.source}**`).join(", ")}`, onEvent);
}
} else if (msg.includes("benchmark")) {
await emitToolCall("browse_benchmarks", {}, { count: BENCHMARKS.length }, onEvent);
let table = "| Benchmark | Date | VHH Hit Rate | Best KD | Designs | Open |\n|-----------|------|:---:|:---:|:---:|:---:|\n";
for (const bm of BENCHMARKS) {
table += `| ${bm.source} | ${bm.paper_date} | ${fmtPct(bm.avg_hit_rate_vhh)} | ${bm.best_affinity_pM ? bm.best_affinity_pM + " pM" : "-"} | ${bm.total_designs_tested} | ${bm.code_available ? "Yes" : "No"} |\n`;
}
await streamText(`Published antibody design benchmarks:\n\n${table}\n**JAM-2** is current SOTA at 39% VHH hit rate.`, onEvent);
onEvent({ type: "citation", citation: { label: "JAM-2 paper", url: "https://doi.org/10.1101/2024.jam2" } });
onEvent({ type: "citation", citation: { label: "Chai-2", url: "https://chaidiscovery.com" } });
} else if (msg.includes("model")) {
await emitToolCall("browse_models", {}, { count: MODELS.length }, onEvent);
let table = "| Model | Source | Capabilities | GPU | Formats |\n|-------|--------|------------|:---:|---------|\n";
for (const m of MODELS) {
table += `| ${m.name} | ${m.source} | ${m.capabilities.map((c) => c.replace(/_/g, " ")).join(", ")} | ${m.gpu_required ? m.min_vram_gb + "GB" : "CPU"} | ${m.antibody_formats.join(", ")} |\n`;
}
await streamText(`Model registry:\n\n${table}\nDe novo pipeline: **RFdiffusion** → **ProteinMPNN** → **Chai-1/AF2** (scoring).`, onEvent);
onEvent({ type: "citation", citation: { label: "RFdiffusion", url: "https://github.com/RosettaCommons/RFdiffusion" } });
onEvent({ type: "citation", citation: { label: "ProteinMPNN", url: "https://github.com/dauparas/ProteinMPNN" } });
onEvent({ type: "citation", citation: { label: "Chai-1", url: "https://chaidiscovery.com" } });
} else if (msg.includes("target") || msg.includes("tell me about") || msg.includes("look up")) {
const target = detectTarget(msg);
if (target) {
await emitToolCall("lookup_target", { target_name: target }, getTargetData(target), onEvent);
const t = TARGETS.find((t) => t.name.toLowerCase() === target.toLowerCase());
if (t) {
const bd = getTargetBindingData(target);
let text = `**${t.name}** — ${t.notes}\n\n- Class: ${t.target_class}\n- PDB: ${t.pdb_id}\n`;
if (bd.length > 0) {
text += `\n| Benchmark | Format | Hit Rate | Best KD |\n|-----------|--------|:---:|:---:|\n`;
for (const d of bd) text += `| ${d.benchmark} | ${d.format} | ${fmtPct(d.hit_rate)} | ${d.best_kd ? d.best_kd + " nM" : "-"} |\n`;
}
await streamText(text, onEvent);
}
} else {
await streamText(`Available targets: ${TARGETS.map((t) => `**${t.name}**`).join(", ")}`, onEvent);
}
} else {
await streamText(
`I can help you explore:\n- **Benchmarks** — "What benchmarks exist?"\n- **Models** — "What models can do full design?"\n- **Targets** — "Tell me about HER2"\n- **Compare** — "Compare JAM-2 and Chai-2"`,
onEvent
);
}
onEvent({ type: "done" });
}
// ============================================================
// FREE CHAT (shared)
// ============================================================
async function handleFreeChat(msg: string, onEvent: (ev: StreamEvent) => void) {
if (isExploreQuery(msg)) {
await handleExploreQuery(msg, onEvent);
return;
}
await streamText(
`In the full version, I would use the Innovation Hub and FACADS tools here.\n\n` +
`For now you can:\n- Explore **benchmarks** and **models**\n- **Compare** benchmarks or **look up** targets\n- Start a new design by telling me your target protein`,
onEvent
);
onEvent({ type: "done" });
}
// ============================================================
// UTILITIES
// ============================================================
async function streamText(text: string, onEvent: (ev: StreamEvent) => void): Promise<void> {
const words = text.split(" ");
for (let i = 0; i < words.length; i++) {
onEvent({ type: "text_delta", text: (i === 0 ? "" : " ") + words[i] });
await sleep(18 + Math.random() * 25);
}
}
async function emitToolCall(toolName: string, input: Record<string, unknown>, result: unknown, onEvent: (ev: StreamEvent) => void): Promise<void> {
onEvent({ type: "tool_use", tool: toolName, input });
await sleep(350 + Math.random() * 250);
onEvent({ type: "tool_result", tool: toolName, result });
await sleep(150);
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function detectTarget(msg: string): string | null {
for (const t of TARGETS) {
if (msg.toLowerCase().includes(t.name.toLowerCase())) return t.name;
}
return null;
}
function getTargetNotes(name: string): string {
return TARGETS.find((t) => t.name.toLowerCase() === name.toLowerCase())?.notes || "";
}
function getTargetData(name: string): Record<string, unknown> {
const t = TARGETS.find((t) => t.name.toLowerCase() === name.toLowerCase());
if (!t) return { error: "Target not found" };
return { name: t.name, target_class: t.target_class, pdb_id: t.pdb_id, notes: t.notes, binding_data: getTargetBindingData(name) };
}
function getTargetBindingData(name: string): { benchmark: string; format: string; hit_rate: number | null; best_kd: number | null }[] {
const results: { benchmark: string; format: string; hit_rate: number | null; best_kd: number | null }[] = [];
for (const bm of BENCHMARKS) {
for (const br of bm.binding_results) {
if (br.target.toLowerCase() === name.toLowerCase()) {
results.push({ benchmark: bm.source, format: br.antibody_format, hit_rate: br.hit_rate, best_kd: br.best_affinity_nM });
}
}
}
return results;
}
function extractBenchmarkNames(msg: string): string[] {
return BENCHMARKS.filter((bm) => msg.toLowerCase().includes(bm.source.toLowerCase())).map((bm) => bm.source);
}
function extractChoice(msg: string, options: string[]): string {
for (const opt of options) {
if (msg.includes(opt)) return opt;
}
return "";
}
function fmtPct(v: number | null): string {
return v != null ? `${(v * 100).toFixed(0)}%` : "-";
}
// ============================================================
// v2 EVENT HELPERS
// ============================================================
// ---- Static KB cache ----
interface KBEntry {
id: string;
title: string;
category: string;
content: string;
}
interface KBData {
version: string;
entries: KBEntry[];
}
let _kbCache: KBData | null = null;
let _kbFetchPromise: Promise<KBData | null> | null = null;
/** Keyword map: caller name → terms used for matching KB entries. */
const KB_QUERY_TERMS: Record<string, string[]> = {
"antibody design best practices": ["design", "cdr", "fc", "bispecific", "engineering"],
"benchmark catalog": ["scoring", "developability", "assessment", "metrics"],
"target lookup": ["expression", "immunogenicity", "safety", "production"],
"model registry": ["format", "bispecific", "fc", "knob"],
};
async function fetchKB(): Promise<KBData | null> {
if (_kbCache) return _kbCache;
if (_kbFetchPromise) return _kbFetchPromise;
_kbFetchPromise = fetch("/kb/antibody-design-practices.json")
.then((res) => {
if (!res.ok) return null;
return res.json() as Promise<KBData>;
})
.then((data) => {
_kbCache = data;
return data;
})
.catch(() => null);
return _kbFetchPromise;
}
function matchKBEntry(name: string, entries: KBEntry[]): KBEntry | undefined {
const terms = KB_QUERY_TERMS[name] || name.toLowerCase().split(/\s+/);
let best: KBEntry | undefined;
let bestScore = 0;
for (const entry of entries) {
const hay = `${entry.title} ${entry.category} ${entry.content}`.toLowerCase();
let score = 0;
for (const t of terms) {
if (hay.includes(t)) score++;
}
if (score > bestScore) {
bestScore = score;
best = entry;
}
}
return best;
}
async function emitKBLookup(name: string, onEvent: (ev: StreamEvent) => void): Promise<void> {
const t0 = performance.now();
const kb = await fetchKB();
const ms = Math.round(performance.now() - t0);
if (kb && kb.entries.length > 0) {
const entry = matchKBEntry(name, kb.entries);
const label = entry ? entry.title : name;
onEvent({ type: "kb_lookup", kb: { name: label, ms } });
} else {
// Fallback: emit with original name if fetch failed
onEvent({ type: "kb_lookup", kb: { name, ms: ms || 1 } });
}
}
async function emitTrace(entry: Omit<TraceEntry, "ts">, onEvent: (ev: StreamEvent) => void): Promise<void> {
const t: TraceEntry = { ts: Date.now(), ...entry };
onEvent({ type: "trace", trace: t });
}
function truncate(s: string, n = 400): string {
return s.length > n ? s.slice(0, n) + "…" : s;
}
async function emitArtifact(
name: string,
content: string,
mime: string,
onEvent: (ev: StreamEvent) => void
): Promise<void> {
const id = "art-" + Math.random().toString(36).slice(2, 10);
const bytes = new TextEncoder().encode(content).length;
// Demo-mode artifact — the actual content is embedded as a data: URL so the
// download works fully client-side without requiring the backend to have
// saved the bytes. When the real backend lands and saveArtifact() is wired
// up, switch to /api/artifacts/{id} and serve from disk.
const dataUrl = `data:${mime};base64,${typeof window === "undefined"
? Buffer.from(content, "utf8").toString("base64")
: btoa(unescape(encodeURIComponent(content)))}`;
const artifact: Artifact = {
id,
name,
bytes,
mime,
url: dataUrl,
createdAt: Date.now(),
};
onEvent({ type: "artifact", artifact });
}
function buildPlanProposal(): PlanProposal {
const seed = _conv!.seed || "de novo";
const format = _conv!.format || "VHH";
const target = _conv!.target || "target";
// Plans use short names that resolve to LIVE Hub endpoints via
// resolveToolEndpoint() in tool-mapping.ts. Do not reintroduce Biomni
// research-repro names (RFdiffusion / ProteinMPNN / Protenix) here —
// those endpoints are not live on hub-api and will 404 at run time.
const steps = seed === "de novo"
? [
{ label: `Generate candidates against ${target}`, tools: ["Ankh"] },
{ label: `Rank by Fc effector profile`, tools: ["CRPS-Fc-DMS", "FcgR2B"] },
{ label: `Predict structure (${format})`, tools: ["Chai-1", "AF2"] },
{ label: "Developability triage", tools: ["Agmata", "PTM"] },
{ label: "FcRn half-life screen", tools: ["FcRn-pH6"] },
]
: seed === "humanization"
? [
{ label: "Baseline parent Fc profile", tools: ["FcgR2B", "FcRn-pH6"] },
{ label: "Generate humanised variants", tools: ["Ankh"] },
{ label: "Re-score Fc binding", tools: ["CRPS-Fc-DMS"] },
{ label: "Structural QC", tools: ["Chai-1", "AF2"] },
{ label: "Developability triage", tools: ["Agmata", "PTM"] },
]
: [
{ label: `Variant generation on seed (${format})`, tools: ["D3PM", "Ankh"] },
{ label: "Affinity ranking", tools: ["CRPS-ESM"] },
{ label: "Structure prediction", tools: ["Chai-1"] },
{ label: "Developability triage", tools: ["Agmata", "PTM"] },
];
return {
title: `Design plan — ${target} (${format}, ${seed})`,
steps,
};
}
function buildDesignBriefMarkdown(): string {
const c = _conv!;
return [
`# Design Brief`,
``,
`| Parameter | Value |`,
`|-----------|-------|`,
`| Target | ${c.target} |`,
`| Indication | ${c.indication} |`,
`| Mechanism | ${c.mechanism} |`,
`| Format | ${c.format} |`,
`| Epitope | ${c.epitope} |`,
`| Affinity | ${c.affinity} |`,
`| Species | ${c.species} |`,
`| Developability | ${c.developability} |`,
`| Route | ${c.route} |`,
`| Competition | ${c.competition} |`,
`| Starting point | ${c.seed} |`,
`| Designs | ${c.scale} |`,
``,
`Generated by demo-engine at ${new Date().toISOString()}.`,
``,
// Pad to ~2KB for demo realism
`<!-- ${"notes ".repeat(200)}-->`,
].join("\n");
}
function buildTopDesignsCsv(): string {
const rows: string[] = ["id,sequence,iPAE,pLDDT,score"];
const aa = "ACDEFGHIKLMNPQRSTVWY";
for (let i = 0; i < 20; i++) {
const id = `d${String(i + 1).padStart(3, "0")}`;
let seq = "";
for (let j = 0; j < 120; j++) seq += aa[Math.floor(Math.random() * aa.length)];
const iPAE = (5 + Math.random() * 8).toFixed(2);
const pLDDT = (78 + Math.random() * 15).toFixed(1);
const score = (0.45 + Math.random() * 0.45).toFixed(3);
rows.push(`${id},${seq},${iPAE},${pLDDT},${score}`);
}
return rows.join("\n");
}
function buildExecutionTraceJson(): string {
const now = Date.now();
const events = [
{ ts: now - 5000, kind: "thought", label: "Start campaign" },
{ ts: now - 4500, kind: "tool_call", label: "plan_campaign" },
{ ts: now - 4000, kind: "tool_result", label: "campaign planned" },
{ ts: now - 3500, kind: "tool_call", label: "RFdiffusion" },
{ ts: now - 2500, kind: "tool_result", label: "backbones generated" },
{ ts: now - 2000, kind: "tool_call", label: "ProteinMPNN" },
{ ts: now - 1200, kind: "tool_result", label: "sequences designed" },
{ ts: now - 900, kind: "tool_call", label: "RF2" },
{ ts: now - 500, kind: "tool_result", label: "filtered by iPAE" },
{ ts: now - 200, kind: "tool_call", label: "Chai-1" },
{ ts: now, kind: "tool_result", label: "consensus scored" },
];
// Pad JSON toward ~12KB
const padding = Array.from({ length: 80 }, (_, i) => ({
ts: now - i * 10,
kind: "text",
label: `step-detail-${i}`,
detail: "RFdiffusion/ProteinMPNN/RF2 intermediate log entry with structural metrics",
}));
return JSON.stringify({ events: [...events, ...padding] }, null, 2);
}
// Citation helper kept for parity with other emitters; unused today but exported intent
// stays local for future use.
function _citationEvent(c: Citation): StreamEvent {
return { type: "citation", citation: c };
}
void _citationEvent;