Download ui/modules/m1/module1_panel.html from chihsing/DrugOps: direct link, hf CLI and curl.
- Browser
- Download file 54.6 kB
-
https://huggingface.co/spaces/chihsing/DrugOps/resolve/main/ui/modules/m1/module1_panel.html
- Command line
-
hf download hf://spaces/chihsing/DrugOps/ui/modules/m1/module1_panel.html
-
curl -L -o module1_panel.html https://huggingface.co/spaces/chihsing/DrugOps/resolve/main/ui/modules/m1/module1_panel.html
54.6 kB
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DrugOps — Module 1 Input Validation</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700;800&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
:root{
--bg:#0d0f12;--surface:#141720;--surface2:#1c2030;--border:#2a2f42;--border2:#363d55;
--text:#e8eaf2;--text2:#8890aa;--text3:#545d78;
--blue:#4a9eff;--blue-dim:#1a3a6e;
--green:#3ecf8e;--green-dim:#0d3d27;
--amber:#f5a623;--amber-dim:#3d2a08;
--red:#f56565;--red-dim:#3d1515;
--code-bg:#0a0c10;--code-fg:#8890aa;
--mono:'IBM Plex Mono',monospace;--sans:'IBM Plex Sans',sans-serif;
}
/* 淺色(白底)版型:僅覆寫變數,版面結構不變 */
html[data-theme="light"]{
color-scheme:light;
--bg:#f4f5f8;--surface:#ffffff;--surface2:#eef1f6;--border:#e2e6ef;--border2:#cdd5e4;
--text:#141823;--text2:#4b5568;--text3:#6b7280;
--blue:#2563eb;--blue-dim:#dbeafe;
--green:#059669;--green-dim:#d1fae5;
--amber:#d97706;--amber-dim:#fef3c7;
--red:#dc2626;--red-dim:#fee2e2;
--code-bg:#e8ecf4;--code-fg:#374151;
}
html[data-theme="dark"]{ color-scheme:dark; }
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:var(--sans);font-size:18px;min-height:100vh;}
.left{border-right:1px solid var(--border);}
.main{padding:1.5rem 2rem;flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:1.25rem;}
/* Section cards */
.section{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden;}
.section.active{border-color:var(--border2);}
.section.done{border-color:var(--green);}
.section.error{border-color:var(--red);}
.section-header{padding:.875rem 1.25rem;display:flex;align-items:center;gap:.75rem;}
.section-badge{font-family:var(--mono);font-size:13px;font-weight:500;padding:2px 8px;border-radius:4px;background:var(--surface2);color:var(--text3);border:1px solid var(--border);}
.section.active .section-badge{background:var(--blue-dim);color:var(--blue);border-color:var(--blue);}
.section.done .section-badge{background:var(--green-dim);color:var(--green);border-color:var(--green);}
.section.error .section-badge{background:var(--red-dim);color:var(--red);border-color:var(--red);}
.section-title{font-size:18px;font-weight:500;color:var(--text);}
.section-sub{font-size:14px;color:var(--text3);}
.section-body{border-top:1px solid var(--border);padding:1.25rem;}
/* Form */
.field{margin-bottom:1rem;}
.field:last-child{margin-bottom:0;}
.field-label{font-size:15px;color:var(--text3);margin-bottom:5px;display:flex;align-items:center;gap:6px;}
.required{color:var(--red);font-size:13px;}
.field input,.field select,.field textarea{width:100%;background:var(--surface2);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;font-size:17px;color:var(--text);font-family:var(--sans);}
.field textarea{font-family:var(--mono);font-size:14px;resize:vertical;}
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--blue);outline:none;}
.field input.err,.field select.err,.field textarea.err{border-color:var(--red);}
.field input.ok,.field select.ok,.field textarea.ok{border-color:var(--green);}
.field-row{display:flex;gap:.5rem;align-items:flex-end;}
.field-row .field{flex:1;margin-bottom:0;}
/* Tabs */
.tabs{display:flex;gap:1px;border-bottom:1px solid var(--border);margin-bottom:1rem;}
.tab{font-family:var(--mono);font-size:14px;padding:7px 14px;cursor:pointer;color:var(--text3);border-bottom:2px solid transparent;margin-bottom:-1px;}
.tab.active{color:var(--blue);border-bottom-color:var(--blue);}
/* Buttons */
.btn{font-family:var(--mono);font-size:15px;font-weight:500;padding:7px 16px;border-radius:6px;border:1px solid;cursor:pointer;transition:all .15s;}
.btn:disabled{opacity:.35;cursor:not-allowed;}
.btn-primary{background:var(--blue);color:#fff;border-color:var(--blue);}
.btn-primary:hover:not(:disabled){background:#6ab3ff;}
.btn-ghost{background:transparent;color:var(--text2);border-color:var(--border2);}
.btn-ghost:hover:not(:disabled){background:var(--surface2);}
.btn-green{background:var(--green);color:#fff;border-color:var(--green);}
.btn-sm{padding:4px 10px;font-size:14px;}
/* Validation messages */
.val-msgs{margin-top:.5rem;}
.val-msg{font-size:14px;padding:3px 0;display:flex;align-items:center;gap:5px;}
.val-msg.err{color:var(--red);}
.val-msg.warn{color:var(--amber);}
.val-msg.ok{color:var(--green);}
/* JSON preview */
.json-preview{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:.75rem;font-family:var(--mono);font-size:14px;color:var(--text2);max-height:200px;overflow-y:auto;white-space:pre;}
/* Log */
.log-box{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:.75rem;font-family:var(--mono);font-size:14px;color:var(--code-fg);max-height:160px;overflow-y:auto;line-height:1.8;margin-top:.75rem;}
.log-box .ok{color:var(--green);}
.log-box .warn{color:var(--amber);}
.log-box .err{color:var(--red);}
.log-box .info{color:var(--blue);}
/* PDB status */
.pdb-status{display:flex;align-items:center;gap:.5rem;margin-top:.5rem;font-size:15px;}
.pdb-dot{width:8px;height:8px;border-radius:50%;}
.pdb-dot.found{background:var(--green);}
.pdb-dot.missing{background:var(--red);}
/* Result bar */
.result-bar{background:var(--green-dim);border:1px solid var(--green);border-radius:8px;padding:1rem;margin-top:.75rem;display:none;}
.result-bar.visible{display:block;}
.result-title{font-family:var(--mono);font-size:14px;color:var(--green);margin-bottom:.5rem;}
.result-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem;}
.result-item .lbl{font-size:13px;color:var(--text3);}
.result-item .val{font-family:var(--mono);font-size:15px;color:var(--text);}
/* Action bar */
.action-bar{padding:1rem 1.25rem;border-top:1px solid var(--border);display:flex;gap:.5rem;align-items:center;}
.prompt-box{background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:.75rem;font-family:var(--mono);font-size:13px;color:var(--text3);line-height:1.8;margin-top:.5rem;}
.copy-btn{font-family:var(--mono);font-size:13px;padding:3px 8px;border-radius:4px;border:1px solid var(--border2);background:transparent;color:var(--text3);cursor:pointer;margin-top:.5rem;}
.copy-btn:hover{background:var(--surface2);color:var(--text);}
/* Pipeline progress */
.pipeline-progress{display:flex;flex-direction:column;gap:4px;}
.pp-step{font-family:var(--mono);font-size:13px;padding:4px 8px;border-radius:4px;border:1px solid var(--border);color:var(--text3);}
.pp-step.active{background:var(--blue-dim);color:var(--blue);border-color:var(--blue);}
.pp-step.done{background:var(--green-dim);color:var(--green);border-color:var(--green);}
.pp-step.fail{background:var(--red-dim);color:var(--red);border-color:var(--red);}
</style>
<link rel="stylesheet" href="../../assets/molgate.css" onerror="this.onerror=null;this.href='/assets/molgate.css'">
<link rel="stylesheet" href="../../assets/molgate_panel_scale.css" onerror="this.onerror=null;this.href='/assets/molgate_panel_scale.css'">
<link rel="stylesheet" href="../../assets/molgate_ai_sidebar.css" onerror="this.onerror=null;this.href='/assets/molgate_ai_sidebar.css'">
<script src="../../assets/molgate.js" onerror="this.onerror=null;this.src='/molgate_ui_shared.js'"></script>
</head>
<body data-molgate-module="1">
<div class="mg-top-bar">
<div class="mg-toolbar">
<button type="button" class="nav-btn" id="mgNavPrev" onclick="molgateNavPrev()" data-zh="← 上一頁" data-en="← Previous">← 上一頁</button>
<button type="button" class="nav-btn" id="mgNavNext" onclick="molgateNavNext()" data-zh="下一頁 →" data-en="Next →">下一頁 →</button>
<div class="theme-btns" aria-label="Theme">
<button type="button" class="theme-btn active" data-theme="dark" onclick="setTheme('dark')" data-zh="深色" data-en="Dark">深色</button>
<button type="button" class="theme-btn" data-theme="light" onclick="setTheme('light')" data-zh="淺色" data-en="Light">淺色</button>
</div>
<div class="lang-btns">
<button type="button" class="lang-btn active" data-lang="zh" onclick="molgateSetLang('zh')">中文</button>
<button type="button" class="lang-btn" data-lang="en" onclick="molgateSetLang('en')">EN</button>
</div>
</div>
</div>
<div class="mg-chrome">
<div class="header">
<div class="molgate-logo">
<div class="molgate-logo-main"><span class="logo-mol">Drug</span><span class="logo-gate">Ops</span></div>
<div class="logo-sub">CreaDrug · module-1 · input-validation</div>
</div>
</div>
</div>
<div class="app-shell">
<!-- ══ LEFT ══ -->
<div class="left">
<div class="main">
<!-- Section 1: SMILES + PDB -->
<div class="section active" id="sec1">
<div class="section-header">
<div class="section-badge">STEP 1</div>
<div>
<div class="section-title" data-zh="基本輸入" data-en="Basic input">基本輸入</div>
<div class="section-sub" data-zh="SMILES + PDB ID + Target" data-en="SMILES + PDB ID + Target">SMILES + PDB ID + Target</div>
</div>
</div>
<div class="section-body">
<div class="field">
<div class="field-label"><span data-zh="藥物 SMILES" data-en="Drug SMILES">藥物 SMILES</span><span class="required">*</span></div>
<input type="text" id="smiles" placeholder="CC(C)Cc1ccc(cc1)C(C)C(=O)O" oninput="onSmilesInput()">
<div class="val-msgs" id="smiles-msgs"></div>
</div>
<div class="field-row">
<div class="field">
<div class="field-label"><span data-zh="Target 名稱" data-en="Target name">Target 名稱</span><span class="required">*</span></div>
<input type="text" id="target" placeholder="COX2" oninput="onTargetInput()">
</div>
<div class="field">
<div class="field-label"><span data-zh="PDB ID" data-en="PDB ID">PDB ID</span><span class="required">*</span></div>
<div class="field-row">
<input type="text" id="pdb_id" placeholder="5IKT" maxlength="4" oninput="onPdbInput()" style="text-transform:uppercase;">
<button class="btn btn-ghost btn-sm" onclick="openRCSB()" data-zh="查 RCSB" data-en="RCSB">查 RCSB</button>
</div>
<div class="pdb-status" id="pdb-status" style="display:none">
<div class="pdb-dot" id="pdb-dot"></div>
<span id="pdb-status-text"></span>
</div>
</div>
</div>
<div class="field">
<div class="field-label"><span data-zh="藥物顯示名稱(選填)" data-en="Drug label (optional)">藥物顯示名稱(選填)</span></div>
<input type="text" id="drug_label" placeholder="Ibuprofen" oninput="onDrugLabelInput()">
</div>
</div>
</div>
<!-- Section 2: Index -->
<div class="section" id="sec2">
<div class="section-header">
<div class="section-badge">STEP 2</div>
<div>
<div class="section-title" data-zh="Index 建立" data-en="Index setup">Index 建立</div>
<div class="section-sub" data-zh="表單填寫 或 貼上 JSON" data-en="Form or paste JSON">表單填寫 或 貼上 JSON</div>
</div>
</div>
<div class="section-body">
<div class="tabs">
<div class="tab active" onclick="switchTab('form')" data-zh="表單填寫" data-en="Form">表單填寫</div>
<div class="tab" onclick="switchTab('json')" data-zh="貼上 JSON" data-en="Paste JSON">貼上 JSON</div>
</div>
<!-- Tab: Form -->
<div id="tab-form">
<div class="field-row">
<div class="field">
<div class="field-label"><span data-zh="HET ID(共結晶配體,選填)" data-en="HET ID (optional)">HET ID(選填)</span></div>
<input type="text" id="f_het_id" placeholder="TLF" maxlength="3" oninput="onFormChange()" style="text-transform:uppercase;">
</div>
<div class="field">
<div class="field-label"><span data-zh="蛋白家族" data-en="Protein family">蛋白家族</span><span class="required">*</span></div>
<select id="f_family" onchange="onFormChange()">
<option value="">— 選擇 —</option>
</select>
</div>
<div class="field">
<div class="field-label"><span data-zh="結構態" data-en="State">結構態</span></div>
<select id="f_state" onchange="onFormChange()">
<option value="bound">bound</option>
<option value="active">active</option>
<option value="inactive">inactive</option>
<option value="unknown">unknown</option>
</select>
</div>
</div>
<div class="field-row">
<div class="field">
<div class="field-label"><span data-zh="共晶配體俗名(選填)" data-en="Common name (optional)">共晶配體俗名(選填)</span></div>
<input type="text" id="f_common_name" placeholder="Flurbiprofen" oninput="onFormChange()">
</div>
<div class="field">
<div class="field-label"><span data-zh="Docking 目標藥物(可留空,預設用 Step1 藥物名)" data-en="Target drug (optional)">Docking 目標藥物(可留空)</span></div>
<input type="text" id="f_target_drug" placeholder="Ibuprofen(留空→用上方藥物顯示名稱)" oninput="onFormChange()">
</div>
</div>
<div class="field">
<div class="field-label"><span data-zh="Pocket rule(自動生成)" data-en="Pocket rule (auto)">Pocket rule</span></div>
<input type="text" id="f_pocket_rule" placeholder="cocrystal_ligand:TLF" oninput="onFormChange()">
</div>
<div class="field-row">
<div class="field">
<div class="field-label"><span data-zh="污染 chain(選填,逗號分隔)" data-en="Contaminant chains">污染 chain</span></div>
<input type="text" id="f_contaminant" placeholder="B,C" oninput="onFormChange()">
</div>
<div class="field">
<div class="field-label"><span data-zh="Box padding(預設 10)" data-en="Box padding">Box padding</span></div>
<input type="number" id="f_padding" value="10" min="8" max="20" step="0.5" oninput="onFormChange()">
</div>
</div>
<div class="field">
<div class="field-label"><span data-zh="備註(選填)" data-en="Notes">備註</span></div>
<input type="text" id="f_notes" placeholder="" oninput="onFormChange()">
</div>
</div>
<!-- Tab: JSON -->
<div id="tab-json" style="display:none">
<div class="field">
<div class="field-label" data-zh="貼上 AI 生成的 Index JSON" data-en="Paste AI-generated Index JSON">貼上 AI 生成的 Index JSON</div>
<textarea id="json_input" rows="10" placeholder='{"het_id":"TLF","family":"cyclooxygenase",...}' oninput="onJsonInput()"></textarea>
<div class="val-msgs" id="json-msgs"></div>
</div>
</div>
<!-- JSON Preview -->
<div style="margin-top:.75rem;display:flex;justify-content:flex-end;">
<button class="btn btn-ghost btn-sm" id="btn-generate-index" onclick="generateIndexFromForm()" data-zh="生成 Index JSON" data-en="Generate Index JSON">生成 Index JSON</button>
</div>
<div style="margin-top:.75rem;">
<div style="font-size:14px;color:var(--text3);margin-bottom:.5rem;" data-zh="預覽 JSON" data-en="JSON preview">預覽 JSON</div>
<div class="json-preview" id="json-preview">{}</div>
</div>
<div class="val-msgs" id="index-msgs" style="margin-top:.5rem;"></div>
</div>
</div>
<!-- Section 3: Run -->
<div class="section" id="sec3">
<div class="section-header">
<div class="section-badge">STEP 3</div>
<div>
<div class="section-title" data-zh="執行 Module 1" data-en="Run Module 1">執行 Module 1</div>
<div class="section-sub" data-zh="Stage 01 / 02 / 03" data-en="Stage 01 / 02 / 03">Stage 01 / 02 / 03</div>
</div>
</div>
<div class="section-body">
<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;">
<button class="btn btn-primary" id="btn-run" onclick="runModule1()" disabled data-zh="▶ Run Module 1" data-en="▶ Run Module 1">▶ Run Module 1</button>
<button class="btn btn-green" id="btn-run-pipeline" onclick="runFullPipeline()" disabled data-zh="▶ 一鍵 Pipeline (M1→M5)" data-en="▶ Full Pipeline (M1→M5)">▶ 一鍵 Pipeline (M1→M5)</button>
<div id="run-ready-hint" style="flex:1 1 100%;display:none;"></div>
<label style="display:flex;align-items:center;gap:.4rem;font-size:15px;color:var(--text3);">
<input type="checkbox" id="allow_network" checked> <span data-zh="允許網路下載 PDB" data-en="Allow network PDB download">允許網路下載 PDB</span>
</label>
<label style="display:flex;align-items:center;gap:.4rem;font-size:15px;color:var(--text3);">
<input type="checkbox" id="non_interactive" checked> <span data-zh="全自動(跳過互動確認)" data-en="Non-interactive (auto-accept)">全自動(跳過互動確認)</span>
</label>
</div>
<div class="pipeline-progress" id="pipeline-progress" style="display:none;margin-top:.75rem;"></div>
<div class="log-box" id="log" style="display:none"></div>
<div class="result-bar" id="result-bar">
<div class="result-title" data-zh="MODULE 1 完成 ✓" data-en="MODULE 1 COMPLETE ✓">MODULE 1 完成 ✓</div>
<div class="result-grid">
<div class="result-item"><div class="lbl">Session ID</div><div class="val" id="r-sid">—</div></div>
<div class="result-item"><div class="lbl">MW</div><div class="val" id="r-mw">—</div></div>
<div class="result-item"><div class="lbl">Stage 01</div><div class="val" id="r-s1">—</div></div>
<div class="result-item"><div class="lbl">Stage 02</div><div class="val" id="r-s2">—</div></div>
<div class="result-item"><div class="lbl">Stage 03</div><div class="val" id="r-s3">—</div></div>
<div class="result-item"><div class="lbl">Index source</div><div class="val" id="r-src">—</div></div>
</div>
<div style="margin-top:.75rem;display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn btn-green" onclick="goModule2()" data-zh="繼續 Module 2 →" data-en="Continue to Module 2 →">繼續 Module 2 →</button>
<button class="btn btn-ghost" onclick="goIndexStatus()" data-zh="查看 Pipeline 狀態" data-en="View pipeline status">查看 Pipeline 狀態</button>
</div>
</div>
</div>
</div>
</div><!-- main -->
</div><!-- left -->
<!-- ══ RIGHT: AI 助手(統一格式) ══ -->
<aside class="mg-ai-sidebar" id="mgAiSidebar">
<div class="mg-ai-head">
<h2 data-zh="170B · AI 助手" data-en="170B · AI Assistant">170B · AI 助手</h2>
<div class="mg-ai-opts">
<label><input type="checkbox" id="mgAiShowRaw"> <span data-zh="顯示原始串流(含思考/CoT 類文字)" data-en="Show raw stream (CoT)">顯示原始串流(含思考/CoT 類文字)</span></label>
<label><input type="checkbox" id="mgAiStream" checked> <span data-zh="串流輸出" data-en="Stream output">串流輸出</span></label>
</div>
</div>
<div class="mg-ai-scroll">
<div class="mg-ai-static" id="mgAiStatic">
<div class="mg-ai-hint-box" data-zh="需要 Index JSON?複製下方 prompt 去問 AI,把回傳結果貼到「貼上 JSON」tab。" data-en="Need Index JSON? Copy the prompt below, ask your AI, then paste the result in the JSON tab.">需要 Index JSON?複製下方 prompt 去問 AI,把回傳結果貼到「貼上 JSON」tab。</div>
<div>
<div style="font-size:14px;color:var(--text3);margin-bottom:.25rem;" data-zh="AI Prompt 模板" data-en="AI Prompt template">AI Prompt 模板</div>
<div class="prompt-box" id="prompt-box">請幫我生成 DrugOps Index JSON。
藥物:[drug_name]
Target:[target_name]
PDB ID:[pdb_id]
需要的欄位:
- het_id(去 RCSB 查 CCD 代碼)
- family(蛋白家族)
- state(bound/active/inactive)
- common_name(配體俗名)
- target_drug(docking 藥物名)
- pocket_rule(格式:cocrystal_ligand:HET)
- contaminant_chains(污染鏈,無則 [])
- box_padding(預設 10)
- override(無則 {})
- notes
請回傳純 JSON,不要其他說明。</div>
<button class="copy-btn" onclick="copyPrompt()" data-zh="複製 Prompt" data-en="Copy prompt">複製 Prompt</button>
</div>
<div class="mg-ai-hint-box" id="llm-welcome" data-zh="填入 SMILES 和 PDB ID 後,Prompt 會自動更新藥物資訊。" data-en="Enter SMILES and PDB ID to auto-update the prompt.">填入 SMILES 和 PDB ID 後,Prompt 會自動更新藥物資訊。</div>
</div>
<div class="mg-ai-messages" id="mgAiMessages" aria-live="polite"></div>
</div>
<div class="mg-ai-foot">
<div class="mg-ai-input-row">
<textarea id="mgAiInput" rows="2" placeholder="輸入訊息…(Enter 送出,Shift+Enter 換行)"></textarea>
<button type="button" class="btn-mg-ai-send" id="mgAiSend" data-zh="送出" data-en="Send">送出</button>
</div>
<div class="mg-ai-actions">
<button type="button" class="btn btn-ghost" id="mgAiStop" disabled data-zh="停止生成" data-en="Stop">停止生成</button>
<button type="button" class="btn btn-ghost" id="mgAiClear" data-zh="清除對話" data-en="Clear chat">清除對話</button>
</div>
<p class="mg-ai-foot-hint">經同源後端轉發至 <strong>170B AI</strong> 推理服務。</p>
</div>
</aside>
</div><!-- app-shell -->
<script>
const LANG = {cur:'zh'};
const THEME = {cur:'dark'};
const THEME_STORAGE_KEY = 'molgate_module1_theme';
const T = {
masterPrefilled: {zh: '✓ 已從 Master Index 預填 Step 2 欄位', en: '✓ Step 2 prefilled from Master Index'},
catalogMatched: {zh: '✓ Catalog 比對:', en: '✓ Catalog match: '},
catalogAmbiguous: {zh: '⚠ 此 SMILES 對應多個 Target,請手選:', en: '⚠ Multiple targets for this SMILES — pick one: '},
catalogNotFound: {zh: 'Catalog 中無此 SMILES,請手填 Target / PDB', en: 'SMILES not in catalog — enter Target / PDB manually'},
runHintStep1: {zh: '尚缺 Step 1:', en: 'Step 1 missing: '},
runHintIndex: {zh: 'Index 問題:', en: 'Index issues: '},
jsonOk: {zh: '✓ JSON 格式正確,驗證通過', en: '✓ JSON syntax is valid and passed checks.'},
jsonSyntaxErr: {zh: '✗ JSON 語法錯誤:', en: '✗ JSON syntax error: '},
requiredMissing: {zh: '缺少必填:', en: 'Missing required field: '},
pdbRule: {zh: 'pdb_id 必須是 4 碼英數字', en: 'pdb_id must be 4 alphanumeric characters'},
hetRule: {zh: 'het_id 必須是 3 碼英數字', en: 'het_id must be 3 alphanumeric characters'},
pocketMismatch: {zh: 'pocket_rule 與 het_id 不一致', en: 'pocket_rule does not match het_id'},
padRule: {zh: 'box_padding 建議 8~20', en: 'box_padding is recommended between 8 and 20'},
validatePass: {zh: '✓ 驗證通過', en: '✓ Validation passed'},
createSession: {zh: '[建立 Session...]', en: '[Creating session...]'},
backendConnErr: {zh: '❌ 無法連線後端:', en: '❌ Cannot connect to backend: '},
runModule1: {zh: '[執行 Module 1...]', en: '[Running Module 1...]'},
runPipeline: {zh: '[執行完整 Pipeline M1→M5...]', en: '[Running full pipeline M1→M5...]'},
pipelineDone: {zh: 'Pipeline 完成!', en: 'Pipeline complete!'},
pipelineFail: {zh: '❌ Pipeline 中斷', en: '❌ Pipeline stopped'},
runFail: {zh: '❌ Module 1 執行失敗', en: '❌ Module 1 execution failed'},
moduleDone: {zh: 'Module 1 完成!Session:', en: 'Module 1 done! Session:'},
singlePathNote: {
zh: 'ℹ 單次流程僅跑 1 條 SMILES;若要比較另一立體,請從 Module 1 另開新 session。',
en: 'ℹ Single-path mode: each run uses one SMILES only. To compare another stereoisomer, start a new session from Module 1.'
},
copied: {zh: '✓ 已複製', en: '✓ Copied'},
gemmaHint: {
zh: '正在連線 170B AI…',
en: 'Connecting to 170B AI…'
},
llmSystem: {
zh: '你是 DrugOps Module 1 助手,協助使用者理解 Index JSON、SMILES、PDB ID 與藥物標的設定。請用繁體中文(台灣)回答。',
en: 'You are the DrugOps Module 1 assistant for Index JSON, SMILES, PDB ID, and target setup. Reply in Traditional Chinese (Taiwan) unless asked otherwise.'
},
};
const tr = (k) => (LANG.cur === 'zh' ? T[k].zh : T[k].en);
function updateThemeButtons() {
document.querySelectorAll('.theme-btn').forEach(b =>
b.classList.toggle('active', b.getAttribute('data-theme') === THEME.cur));
}
function setTheme(mode) {
const m = mode === 'light' ? 'light' : 'dark';
THEME.cur = m;
document.documentElement.setAttribute('data-theme', m);
try { localStorage.setItem(THEME_STORAGE_KEY, m); } catch (e) {}
updateThemeButtons();
}
function initTheme() {
let m = 'dark';
try {
const s = localStorage.getItem(THEME_STORAGE_KEY);
if (s === 'light' || s === 'dark') m = s;
} catch (e) {}
setTheme(m);
}
const STATE = {
smiles:'', target:'', pdb_id:'',
pdb_found: false,
index: {},
index_valid: false,
form_generated: false,
session_id: null,
tab: 'form',
};
// ── Lang ──────────────────────────────────────────────
function setLang(l) {
LANG.cur = l;
document.querySelectorAll('.lang-btn').forEach(b =>
b.classList.toggle('active', (l==='zh'&&b.textContent==='中文')||(l==='en'&&b.textContent==='EN')));
document.querySelectorAll('[data-zh]').forEach(el => {
const t = el.getAttribute('data-'+l);
if (!t) return;
if (el.tagName==='INPUT') el.placeholder = t;
else el.textContent = t;
});
molgateAiSetPlaceholder('輸入訊息…(Enter 送出,Shift+Enter 換行)', 'Message… (Enter send, Shift+Enter newline)');
updatePrompt();
checkRunReady();
renderFamilySelect();
}
let PROTEIN_FAMILIES_GROUPED = null;
async function loadProteinFamilies() {
try {
const lang = LANG.cur || 'zh';
const r = await fetch(`/api/protein-families?lang=${encodeURIComponent(lang)}`);
if (!r.ok) return;
const j = await r.json();
PROTEIN_FAMILIES_GROUPED = j.groups || [];
renderFamilySelect();
} catch (e) {
console.warn('loadProteinFamilies failed', e);
}
}
function renderFamilySelect(selected) {
const sel = document.getElementById('f_family');
if (!sel || !PROTEIN_FAMILIES_GROUPED) return;
const cur = selected !== undefined ? selected : sel.value;
sel.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = LANG.cur === 'zh' ? '— 選擇 —' : '— select —';
sel.appendChild(ph);
for (const g of PROTEIN_FAMILIES_GROUPED) {
const og = document.createElement('optgroup');
og.label = g.label || g.group || '';
for (const f of (g.families || [])) {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.label || f.id;
if (f.id === cur) opt.selected = true;
og.appendChild(opt);
}
sel.appendChild(og);
}
}
// ── Tab ───────────────────────────────────────────────
function switchTab(t) {
STATE.tab = t;
document.querySelectorAll('.tab').forEach((el,i) =>
el.classList.toggle('active', (i===0&&t==='form')||(i===1&&t==='json')));
document.getElementById('tab-form').style.display = t==='form' ? '' : 'none';
document.getElementById('tab-json').style.display = t==='json' ? '' : 'none';
checkRunReady();
}
// ── SMILES input ──────────────────────────────────────
function onDrugLabelInput() {
const label = document.getElementById('drug_label').value.trim();
const td = document.getElementById('f_target_drug');
if (td && label && !td.value.trim() && !td.dataset.manual) {
td.value = label;
}
onFormChange();
}
function onSmilesInput() {
STATE.smiles = document.getElementById('smiles').value.trim();
updatePrompt();
clearTimeout(smilesTimer);
if (STATE.smiles.length >= 6) {
smilesTimer = setTimeout(() => loadCatalogForSmiles(STATE.smiles), 450);
} else {
document.getElementById('smiles-msgs').innerHTML = '';
}
checkRunReady();
}
let smilesTimer = null;
async function loadCatalogForSmiles(smiles) {
if (!smiles || smiles !== document.getElementById('smiles').value.trim()) return;
const msgEl = document.getElementById('smiles-msgs');
try {
const r = await molgateFetch('/api/catalog/lookup/smiles', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({smiles}),
});
if (!r.ok) return;
const data = await r.json();
if (!data.found) {
msgEl.innerHTML = `<div class="val-msg warn">${tr('catalogNotFound')}</div>`;
return;
}
if (data.ambiguous) {
const opts = data.matches.map(m =>
`${m.index_key} · ${m.canonical_name} · ${m.target_label} (${m.family})`
).join('<br>');
msgEl.innerHTML = `<div class="val-msg warn">${tr('catalogAmbiguous')}<br>${opts}</div>`;
return;
}
const m = data.matches[0];
const targetEl = document.getElementById('target');
if (m.index_key && !targetEl.dataset.manual) {
targetEl.value = m.index_key;
STATE.target = m.index_key;
}
setFieldIfEmpty('drug_label', m.canonical_name || '');
const pdbEl = document.getElementById('pdb_id');
if (m.pdb_id && pdbEl && !String(pdbEl.value || '').trim()) {
pdbEl.value = m.pdb_id;
STATE.pdb_id = m.pdb_id;
checkPdb(m.pdb_id);
}
const protein = `${m.target_label || '—'} · ${m.family || '—'}`;
msgEl.innerHTML =
`<div class="val-msg ok">${tr('catalogMatched')}${m.canonical_name} → ${m.index_key} · ${protein}</div>`;
if (m.master) {
applyMasterIndexEntry(m.master);
} else if (m.index_key) {
await loadMasterIndexForTarget(m.index_key);
}
checkRunReady();
} catch (e) {}
}
let targetTimer = null;
function onTargetInput() {
STATE.target = document.getElementById('target').value.trim().toUpperCase();
document.getElementById('target').value = STATE.target;
updatePrompt();
clearTimeout(targetTimer);
if (STATE.target.length >= 2) {
targetTimer = setTimeout(() => loadMasterIndexForTarget(STATE.target), 350);
}
checkRunReady();
}
function setFieldIfEmpty(id, val) {
const el = document.getElementById(id);
if (!el || val === undefined || val === null || val === '') return;
if (el.dataset.manual) return;
if (String(el.value || '').trim()) return;
el.value = val;
}
function applyMasterIndexEntry(entry) {
if (!entry || typeof entry !== 'object') return;
const pdbEl = document.getElementById('pdb_id');
if (entry.pdb_id && pdbEl && !String(pdbEl.value || '').trim()) {
pdbEl.value = String(entry.pdb_id).trim().toUpperCase();
STATE.pdb_id = pdbEl.value;
checkPdb(STATE.pdb_id);
}
setFieldIfEmpty('f_het_id', entry.het_id || entry.cocrystal_het || '');
setFieldIfEmpty('f_family', entry.family || '');
setFieldIfEmpty('f_state', entry.state || 'bound');
setFieldIfEmpty('f_common_name', entry.common_name || '');
const drugLabel = document.getElementById('drug_label').value.trim();
if (!drugLabel) setFieldIfEmpty('f_target_drug', entry.target_drug || '');
setFieldIfEmpty('f_pocket_rule', entry.pocket_rule || '');
setFieldIfEmpty('f_notes', entry.notes || '');
if (entry.box_padding !== undefined && entry.box_padding !== null) {
setFieldIfEmpty('f_padding', entry.box_padding);
}
if (Array.isArray(entry.contaminant_chains) && entry.contaminant_chains.length) {
setFieldIfEmpty('f_contaminant', entry.contaminant_chains.join(','));
}
STATE.form_generated = true;
}
async function loadMasterIndexForTarget(key) {
if (!key) return;
try {
const r = await molgateFetch(`/api/index/entry/${encodeURIComponent(key)}`);
if (!r.ok) return;
const data = await r.json();
if (!data.found || !data.entry) return;
applyMasterIndexEntry(data.entry);
document.getElementById('index-msgs').innerHTML =
`<div class="val-msg ok">${tr('masterPrefilled')} (${data.key})</div>`;
checkRunReady();
} catch (e) {}
}
// ── PDB input ─────────────────────────────────────────
let pdbTimer = null;
function onPdbInput() {
const val = document.getElementById('pdb_id').value.trim().toUpperCase();
document.getElementById('pdb_id').value = val;
STATE.pdb_id = val;
updatePrompt();
clearTimeout(pdbTimer);
if (val.length === 4) {
pdbTimer = setTimeout(() => checkPdb(val), 400);
} else {
document.getElementById('pdb-status').style.display = 'none';
STATE.pdb_found = false;
checkRunReady();
}
}
async function checkPdb(pdb_id) {
try {
const r = await molgateFetch(`/api/pdb/check/${pdb_id}`);
const data = await r.json();
const el = document.getElementById('pdb-status');
const dot = document.getElementById('pdb-dot');
const txt = document.getElementById('pdb-status-text');
el.style.display = 'flex';
STATE.pdb_found = data.found;
if (data.found) {
dot.className = 'pdb-dot found';
txt.textContent = LANG.cur==='zh' ? `本地找到:${data.path}` : `Found: ${data.path}`;
txt.style.color = 'var(--green)';
} else {
dot.className = 'pdb-dot missing';
txt.innerHTML = LANG.cur==='zh'
? `本地未找到 → <a href="${data.download_url}" target="_blank" style="color:var(--blue)">下載 PDB</a>`
: `Not found locally → <a href="${data.download_url}" target="_blank" style="color:var(--blue)">Download PDB</a>`;
txt.style.color = 'var(--amber)';
}
} catch(e) {
STATE.pdb_found = false;
}
checkRunReady();
}
function openRCSB() {
const pdb = STATE.pdb_id || '5IKT';
window.open(`https://www.rcsb.org/structure/${pdb}`, '_blank');
}
// ── Form change ───────────────────────────────────────
function onFormChange() {
const het = document.getElementById('f_het_id').value.trim().toUpperCase();
document.getElementById('f_het_id').value = het;
// Auto-generate pocket_rule
const pr = document.getElementById('f_pocket_rule');
if (!pr.dataset.manual && het) {
pr.value = `cocrystal_ligand:${het}`;
}
STATE.form_generated = true;
checkRunReady();
}
document.addEventListener('DOMContentLoaded', () => {
initTheme();
loadProteinFamilies();
document.getElementById('f_pocket_rule').addEventListener('input', function() {
this.dataset.manual = '1';
onFormChange();
});
document.getElementById('f_target_drug').addEventListener('input', function() {
this.dataset.manual = '1';
onFormChange();
});
checkRunReady();
});
function buildIndexFromForm() {
const get = id => document.getElementById(id).value.trim();
const cont = get('f_contaminant');
const drugLabel = get('drug_label');
let targetDrug = get('f_target_drug') || drugLabel || STATE.target || 'ligand';
STATE.index = {
pdb_id: STATE.pdb_id || get('pdb_id'),
het_id: get('f_het_id').toUpperCase(),
family: get('f_family'),
state: get('f_state'),
common_name: get('f_common_name'),
target_drug: targetDrug,
pocket_rule: get('f_pocket_rule'),
contaminant_chains: cont ? cont.split(',').map(s=>s.trim()).filter(Boolean) : [],
box_padding: parseFloat(get('f_padding')) || 10,
override: {},
notes: get('f_notes'),
};
}
function generateIndexFromForm() {
if (STATE.tab !== 'form') return;
buildIndexFromForm();
STATE.form_generated = true;
validateIndex();
updateJsonPreview();
checkRunReady();
}
// ── JSON paste ────────────────────────────────────────
function onJsonInput() {
const raw = document.getElementById('json_input').value.trim();
const msgs = document.getElementById('json-msgs');
msgs.innerHTML = '';
if (!raw) {
STATE.index = {};
STATE.index_valid = false;
updateJsonPreview();
checkRunReady();
return;
}
try {
const parsed = JSON.parse(raw);
const { errors, normalized } = clientValidate(parsed);
STATE.index = normalized;
STATE.form_generated = true;
syncFormFromJson(normalized);
if (errors.length === 0) {
STATE.index_valid = true;
msgs.innerHTML = `<div class="val-msg ok">${tr('jsonOk')}</div>`;
} else {
STATE.index_valid = false;
msgs.innerHTML = errors.map(e => `<div class="val-msg err">✗ ${e}</div>`).join('');
}
} catch(e) {
STATE.index = {};
STATE.index_valid = false;
msgs.innerHTML = `<div class="val-msg err">${tr('jsonSyntaxErr')}${e.message}</div>`;
}
updateJsonPreview();
checkRunReady();
}
function syncFormFromJson(data) {
const set = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; };
set('f_het_id', data.het_id || '');
set('f_family', data.family || '');
set('f_state', data.state || 'bound');
set('f_common_name', data.common_name || '');
set('f_target_drug', data.target_drug || '');
set('f_pocket_rule', data.pocket_rule || '');
set('f_padding', data.box_padding || 10);
set('f_notes', data.notes || '');
if (Array.isArray(data.contaminant_chains)) {
set('f_contaminant', data.contaminant_chains.join(','));
}
}
// ── Index defaults (mirror molgate_ui/index_normalize.py) ──
function normalizeIndexDefaults(data) {
const out = Object.assign({}, data || {});
const state = String(out.state || 'bound').trim().toLowerCase() || 'bound';
out.state = state;
let targetDrug = String(out.target_drug || out.drug_label || out._target || '').trim();
if (!targetDrug) targetDrug = 'ligand';
out.target_drug = targetDrug;
delete out.drug_label;
delete out._target;
const het = String(out.het_id || out.cocrystal_het || '').trim().toUpperCase();
if (het) out.het_id = het;
else {
delete out.het_id;
delete out.cocrystal_het;
}
if (!String(out.common_name || '').trim() && targetDrug) out.common_name = targetDrug;
if (!String(out.pocket_rule || '').trim()) {
if (het) out.pocket_rule = 'cocrystal_ligand:' + het;
else if (state === 'apo' || state === 'unbound' || state === 'open') out.pocket_rule = 'manual_coords';
else out.pocket_rule = 'auto_detect';
}
if (out.box_padding === undefined || out.box_padding === null || out.box_padding === '') out.box_padding = 10;
if (!Array.isArray(out.contaminant_chains)) out.contaminant_chains = [];
if (!out.override || typeof out.override !== 'object') out.override = {};
if (out.notes === undefined) out.notes = String(out.notes || '');
return out;
}
// ── Client-side validation ────────────────────────────
function clientValidate(data) {
const d = normalizeIndexDefaults(data);
const errors = [];
const required = ['pdb_id', 'family', 'state', 'target_drug'];
required.forEach(f => { if (!d[f] || !String(d[f]).trim()) errors.push(`${tr('requiredMissing')}${f}`); });
const pdb = String(d.pdb_id || '');
if (pdb && (pdb.length !== 4 || !/^[A-Za-z0-9]+$/.test(pdb))) errors.push(tr('pdbRule'));
const het = String(d.het_id || '');
if (het && (het.length !== 3 || !/^[A-Za-z0-9]+$/.test(het))) errors.push(tr('hetRule'));
const pr = String(d.pocket_rule || '');
if (pr && het && !pr.toUpperCase().includes(het.toUpperCase()) && !/^(auto_detect|manual_)/.test(pr)) {
errors.push(tr('pocketMismatch'));
}
const pad = d.box_padding;
if (pad !== undefined && (isNaN(pad) || pad < 8 || pad > 20)) errors.push(tr('padRule'));
return { errors, normalized: d };
}
function validateIndex() {
const { errors, normalized } = clientValidate({
...STATE.index,
pdb_id: STATE.pdb_id,
drug_label: document.getElementById('drug_label').value.trim(),
_target: STATE.target,
});
STATE.index = normalized;
const msgs = document.getElementById('index-msgs');
if (errors.length === 0) {
STATE.index_valid = true;
msgs.innerHTML = `<div class="val-msg ok">${tr('validatePass')}</div>`;
} else {
STATE.index_valid = false;
msgs.innerHTML = errors.map(e => `<div class="val-msg err">✗ ${e}</div>`).join('');
}
updateJsonPreview();
}
function updateJsonPreview() {
const el = document.getElementById('json-preview');
try {
el.textContent = JSON.stringify(STATE.index, null, 2);
} catch(e) {
el.textContent = '{}';
}
}
// ── Run ready check ───────────────────────────────────
function updateRunHint(ready, errors) {
const el = document.getElementById('run-ready-hint');
if (!el) return;
if (ready) {
el.style.display = 'none';
el.innerHTML = '';
return;
}
const missing = [];
if (!STATE.smiles) missing.push('SMILES');
if (!STATE.target) missing.push('Target');
if (STATE.pdb_id.length !== 4) missing.push('PDB ID');
const parts = [];
if (missing.length) parts.push(tr('runHintStep1') + missing.join(', '));
if (errors.length) parts.push(tr('runHintIndex') + errors.slice(0, 3).join('; '));
el.style.display = '';
el.innerHTML = parts.length
? `<div class="val-msg warn">${parts.join(' · ')}</div>`
: '';
}
function checkRunReady() {
if (STATE.tab === 'form') {
buildIndexFromForm();
}
const { errors, normalized } = clientValidate({
...STATE.index,
pdb_id: STATE.pdb_id,
drug_label: document.getElementById('drug_label').value.trim(),
_target: STATE.target,
});
STATE.index = normalized;
STATE.index_valid = errors.length === 0;
if (STATE.tab === 'form') validateIndex();
updateJsonPreview();
const ready = STATE.smiles && STATE.target && STATE.pdb_id.length === 4 && STATE.index_valid;
document.getElementById('btn-run').disabled = !ready;
document.getElementById('btn-run-pipeline').disabled = !ready;
updateRunHint(ready, errors);
}
// ── Run Module 1 ──────────────────────────────────────
async function runModule1() {
const btn = document.getElementById('btn-run');
btn.disabled = true;
const log = document.getElementById('log');
log.style.display = '';
log.innerHTML = '';
const addLog = (msg, cls='') => {
const d = document.createElement('div');
if (cls) d.className = cls;
d.textContent = msg;
log.appendChild(d);
log.scrollTop = log.scrollHeight;
};
// Step 1: Create session
addLog(tr('createSession'), 'info');
const { normalized: indexNorm } = clientValidate({
...STATE.index,
pdb_id: STATE.pdb_id,
drug_label: document.getElementById('drug_label').value.trim(),
_target: STATE.target,
});
STATE.index = indexNorm;
updateJsonPreview();
try {
const r = await molgateFetch('/api/session/create', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
smiles: STATE.smiles,
pdb_id: STATE.pdb_id,
target: STATE.target,
index: indexNorm,
}),
});
const data = await r.json();
if (!r.ok) {
addLog(`❌ ${data.error}`, 'err');
(data.errors||[]).forEach(e => addLog(` ${e}`, 'err'));
btn.disabled = false;
return;
}
STATE.session_id = data.session_id;
addLog(` ✓ Session: ${data.session_id}`, 'ok');
} catch(e) {
addLog(`${tr('backendConnErr')}${e}`, 'err');
btn.disabled = false;
return;
}
// Step 2: Run Module 1 via SSE
addLog(tr('runModule1'), 'info');
const allow = document.getElementById('allow_network').checked;
const es = new EventSource(
`/api/module1/run?` +
new URLSearchParams({
smiles: STATE.smiles,
pdb_id: STATE.pdb_id,
target: STATE.target,
session_id: STATE.session_id,
drug_label: document.getElementById('drug_label').value.trim(),
allow_network: allow ? '1' : '0',
})
);
// POST via fetch with SSE-like streaming
const resp = await molgateFetch('/api/module1/run', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
smiles: STATE.smiles,
pdb_id: STATE.pdb_id,
target: STATE.target,
session_id: STATE.session_id,
drug_label: document.getElementById('drug_label').value.trim(),
allow_network: allow,
}),
});
es.close();
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finalResult = null;
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream:true});
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
try {
const msg = JSON.parse(line.slice(5).trim());
if (msg.log !== undefined) {
const cls = msg.log.includes('✓') ? 'ok' :
msg.log.includes('⚠') ? 'warn' :
msg.log.includes('❌') ? 'err' : '';
addLog(msg.log, cls);
}
if (msg.status) finalResult = msg;
} catch(e) {}
}
}
if (finalResult && finalResult.status === 'complete') {
const res = finalResult.result || {};
const s1 = res.stage01 || {};
const s2 = res.stage02 || {};
const s3 = res.stage03 || {};
const s31 = res.stage31 || {};
document.getElementById('r-sid').textContent = STATE.session_id.slice(0,8)+'...';
document.getElementById('r-mw').textContent = s1.mol_weight ? `${s1.mol_weight} Da` : '—';
document.getElementById('r-s1').textContent = s1.status || '—';
document.getElementById('r-s2').textContent = s2.status || '—';
document.getElementById('r-s3').textContent = s3.status || '—';
document.getElementById('r-src').textContent = s3.status === 'PASS' ? 'session_index' : '—';
document.getElementById('result-bar').classList.add('visible');
document.getElementById('sec3').classList.add('done');
molgateAiNotify(`${tr('moduleDone')} ${STATE.session_id.slice(0,8)}... | MW: ${s1.mol_weight} Da | Stage 03: ${s3.status}`);
addLog(tr('singlePathNote'), 'info');
if (s31.ccd_lookup_status === 'ok' && s31.recommended_smiles_stereo) {
addLog(` ✓ Stage 03b stereo 建議: ${s31.recommended_smiles_stereo}`, 'info');
}
} else {
addLog(tr('runFail'), 'err');
}
btn.disabled = false;
}
function goIndexStatus() {
if (!STATE.session_id) return;
window.location.href = '/?session=' + encodeURIComponent(STATE.session_id) + '#pipeline';
}
async function consumeSseResponse(resp, addLog, onEvent) {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finalResult = null;
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream:true});
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
try {
const msg = JSON.parse(line.slice(5).trim());
if (msg.log !== undefined) {
const cls = msg.log.includes('✓') ? 'ok' :
msg.log.includes('⚠') ? 'warn' :
msg.log.includes('❌') ? 'err' : '';
addLog(msg.log, cls);
}
if (onEvent) onEvent(msg);
if (msg.status) finalResult = msg;
} catch(e) {}
}
}
return finalResult;
}
async function runFullPipeline() {
const btnRun = document.getElementById('btn-run');
const btnPipe = document.getElementById('btn-run-pipeline');
btnRun.disabled = true;
btnPipe.disabled = true;
const log = document.getElementById('log');
log.style.display = '';
log.innerHTML = '';
const pp = document.getElementById('pipeline-progress');
pp.style.display = '';
pp.innerHTML = '';
const addLog = (msg, cls='') => {
const d = document.createElement('div');
if (cls) d.className = cls;
d.textContent = msg;
log.appendChild(d);
log.scrollTop = log.scrollHeight;
};
const stepEls = {};
const ensureStep = (engine, label) => {
if (stepEls[engine]) return stepEls[engine];
const el = document.createElement('div');
el.className = 'pp-step';
el.textContent = label || engine;
el.dataset.engine = engine;
pp.appendChild(el);
stepEls[engine] = el;
return el;
};
const { normalized: indexNorm } = clientValidate({
...STATE.index,
pdb_id: STATE.pdb_id,
drug_label: document.getElementById('drug_label').value.trim(),
_target: STATE.target,
});
STATE.index = indexNorm;
updateJsonPreview();
addLog(tr('runPipeline'), 'info');
try {
const resp = await molgateFetch('/api/pipeline/run', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
smiles: STATE.smiles,
pdb_id: STATE.pdb_id,
target: STATE.target,
drug_label: document.getElementById('drug_label').value.trim(),
index: indexNorm,
allow_network: document.getElementById('allow_network').checked,
non_interactive: document.getElementById('non_interactive').checked,
create_session: true,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
addLog(`❌ ${err.error || resp.statusText}`, 'err');
checkRunReady();
return;
}
const finalResult = await consumeSseResponse(resp, addLog, (msg) => {
if (msg.phase === 'start') {
const label = LANG.cur === 'zh' ? msg.label_zh : msg.label_en;
ensureStep(msg.engine, label).classList.add('active');
addLog(`\n── ${label} (${msg.step_index}/${msg.step_total}) ──`, 'info');
}
if (msg.phase === 'end') {
const el = stepEls[msg.engine];
if (el) {
el.classList.remove('active');
el.classList.add(msg.ok ? 'done' : 'fail');
}
}
if (msg.phase === 'pipeline_start') STATE.session_id = msg.session_id;
});
if (finalResult && finalResult.status === 'complete') {
STATE.session_id = finalResult.session_id || STATE.session_id;
addLog(`✓ ${tr('pipelineDone')} Session: ${STATE.session_id}`, 'ok');
document.getElementById('result-bar').classList.add('visible');
document.getElementById('sec3').classList.add('done');
document.getElementById('r-sid').textContent = STATE.session_id.slice(0,8)+'...';
molgateAiNotify(`${tr('pipelineDone')} ${STATE.session_id.slice(0,8)}...`);
} else {
addLog(tr('pipelineFail'), 'err');
document.getElementById('sec3').classList.add('error');
}
} catch(e) {
addLog(`${tr('backendConnErr')}${e}`, 'err');
}
checkRunReady();
}
function goModule2() {
var qs = 'session=' + encodeURIComponent(STATE.session_id) + '&target=' + encodeURIComponent(STATE.target || '');
window.location.href = (window.molgateModuleUrl ? window.molgateModuleUrl(2, qs) : '/module/2?' + qs);
}
// ── Prompt ────────────────────────────────────────────
function updatePrompt() {
const drug = document.getElementById('drug_label').value.trim() || '[drug_name]';
const target = STATE.target || '[target_name]';
const pdb = STATE.pdb_id || '[pdb_id]';
document.getElementById('prompt-box').textContent = LANG.cur === 'zh'
? `請幫我生成 DrugOps Index JSON。
藥物:${drug}
Target:${target}
PDB ID:${pdb}
需要的欄位:
- het_id(去 RCSB 查 CCD 代碼)
- family(蛋白家族)
- state(bound/active/inactive)
- common_name(配體俗名)
- target_drug(docking 藥物名)
- pocket_rule(格式:cocrystal_ligand:HET)
- contaminant_chains(污染鏈,無則 [])
- box_padding(預設 10)
- override(無則 {})
- notes
請回傳純 JSON,不要其他說明。`
: `Please generate a DrugOps Index JSON.
Drug: ${drug}
Target: ${target}
PDB ID: ${pdb}
Required fields:
- het_id (look up CCD code from RCSB)
- family (protein family)
- state (bound/active/inactive)
- common_name (co-crystal ligand common name)
- target_drug (docking drug name)
- pocket_rule (format: cocrystal_ligand:HET)
- contaminant_chains (use [] if none)
- box_padding (default 10)
- override (use {} if none)
- notes
Return pure JSON only, with no extra explanation.`;
}
function copyPrompt() {
const t = document.getElementById('prompt-box').textContent;
navigator.clipboard.writeText(t).then(() => {
const btn = event.target;
btn.textContent = tr('copied');
setTimeout(() => btn.textContent = LANG.cur==='zh' ? '複製 Prompt' : 'Copy prompt', 1500);
});
}
</script>
<script src="../../assets/molgate_ai_sidebar.js" onerror="this.onerror=null;this.src='/assets/molgate_ai_sidebar.js'"></script>
<script>
window.LANG = LANG;
molgateAiBind({
panelKey: 'm1',
systemPrompt: () => tr('llmSystem'),
extraMessages: () => {
const parts = [];
if (STATE.smiles) parts.push({ role: 'system', content: 'SMILES: ' + STATE.smiles });
if (STATE.pdb_id) parts.push({ role: 'system', content: 'PDB ID: ' + STATE.pdb_id });
if (STATE.target) parts.push({ role: 'system', content: 'Target: ' + STATE.target });
if (STATE.session_id) parts.push({ role: 'system', content: 'Session: ' + STATE.session_id });
return parts;
},
});
</script>
</body>
</html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DrugOps — Module 1 Input Validation</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700;800&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
:root{
--bg:#0d0f12;--surface:#141720;--surface2:#1c2030;--border:#2a2f42;--border2:#363d55;
--text:#e8eaf2;--text2:#8890aa;--text3:#545d78;
--blue:#4a9eff;--blue-dim:#1a3a6e;
--green:#3ecf8e;--green-dim:#0d3d27;
--amber:#f5a623;--amber-dim:#3d2a08;
--red:#f56565;--red-dim:#3d1515;
--code-bg:#0a0c10;--code-fg:#8890aa;
--mono:'IBM Plex Mono',monospace;--sans:'IBM Plex Sans',sans-serif;
}
/* 淺色(白底)版型:僅覆寫變數,版面結構不變 */
html[data-theme="light"]{
color-scheme:light;
--bg:#f4f5f8;--surface:#ffffff;--surface2:#eef1f6;--border:#e2e6ef;--border2:#cdd5e4;
--text:#141823;--text2:#4b5568;--text3:#6b7280;
--blue:#2563eb;--blue-dim:#dbeafe;
--green:#059669;--green-dim:#d1fae5;
--amber:#d97706;--amber-dim:#fef3c7;
--red:#dc2626;--red-dim:#fee2e2;
--code-bg:#e8ecf4;--code-fg:#374151;
}
html[data-theme="dark"]{ color-scheme:dark; }
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:var(--sans);font-size:18px;min-height:100vh;}
.left{border-right:1px solid var(--border);}
.main{padding:1.5rem 2rem;flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:1.25rem;}
/* Section cards */
.section{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden;}
.section.active{border-color:var(--border2);}
.section.done{border-color:var(--green);}
.section.error{border-color:var(--red);}
.section-header{padding:.875rem 1.25rem;display:flex;align-items:center;gap:.75rem;}
.section-badge{font-family:var(--mono);font-size:13px;font-weight:500;padding:2px 8px;border-radius:4px;background:var(--surface2);color:var(--text3);border:1px solid var(--border);}
.section.active .section-badge{background:var(--blue-dim);color:var(--blue);border-color:var(--blue);}
.section.done .section-badge{background:var(--green-dim);color:var(--green);border-color:var(--green);}
.section.error .section-badge{background:var(--red-dim);color:var(--red);border-color:var(--red);}
.section-title{font-size:18px;font-weight:500;color:var(--text);}
.section-sub{font-size:14px;color:var(--text3);}
.section-body{border-top:1px solid var(--border);padding:1.25rem;}
/* Form */
.field{margin-bottom:1rem;}
.field:last-child{margin-bottom:0;}
.field-label{font-size:15px;color:var(--text3);margin-bottom:5px;display:flex;align-items:center;gap:6px;}
.required{color:var(--red);font-size:13px;}
.field input,.field select,.field textarea{width:100%;background:var(--surface2);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;font-size:17px;color:var(--text);font-family:var(--sans);}
.field textarea{font-family:var(--mono);font-size:14px;resize:vertical;}
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--blue);outline:none;}
.field input.err,.field select.err,.field textarea.err{border-color:var(--red);}
.field input.ok,.field select.ok,.field textarea.ok{border-color:var(--green);}
.field-row{display:flex;gap:.5rem;align-items:flex-end;}
.field-row .field{flex:1;margin-bottom:0;}
/* Tabs */
.tabs{display:flex;gap:1px;border-bottom:1px solid var(--border);margin-bottom:1rem;}
.tab{font-family:var(--mono);font-size:14px;padding:7px 14px;cursor:pointer;color:var(--text3);border-bottom:2px solid transparent;margin-bottom:-1px;}
.tab.active{color:var(--blue);border-bottom-color:var(--blue);}
/* Buttons */
.btn{font-family:var(--mono);font-size:15px;font-weight:500;padding:7px 16px;border-radius:6px;border:1px solid;cursor:pointer;transition:all .15s;}
.btn:disabled{opacity:.35;cursor:not-allowed;}
.btn-primary{background:var(--blue);color:#fff;border-color:var(--blue);}
.btn-primary:hover:not(:disabled){background:#6ab3ff;}
.btn-ghost{background:transparent;color:var(--text2);border-color:var(--border2);}
.btn-ghost:hover:not(:disabled){background:var(--surface2);}
.btn-green{background:var(--green);color:#fff;border-color:var(--green);}
.btn-sm{padding:4px 10px;font-size:14px;}
/* Validation messages */
.val-msgs{margin-top:.5rem;}
.val-msg{font-size:14px;padding:3px 0;display:flex;align-items:center;gap:5px;}
.val-msg.err{color:var(--red);}
.val-msg.warn{color:var(--amber);}
.val-msg.ok{color:var(--green);}
/* JSON preview */
.json-preview{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:.75rem;font-family:var(--mono);font-size:14px;color:var(--text2);max-height:200px;overflow-y:auto;white-space:pre;}
/* Log */
.log-box{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:.75rem;font-family:var(--mono);font-size:14px;color:var(--code-fg);max-height:160px;overflow-y:auto;line-height:1.8;margin-top:.75rem;}
.log-box .ok{color:var(--green);}
.log-box .warn{color:var(--amber);}
.log-box .err{color:var(--red);}
.log-box .info{color:var(--blue);}
/* PDB status */
.pdb-status{display:flex;align-items:center;gap:.5rem;margin-top:.5rem;font-size:15px;}
.pdb-dot{width:8px;height:8px;border-radius:50%;}
.pdb-dot.found{background:var(--green);}
.pdb-dot.missing{background:var(--red);}