DrugOps / ui /modules /m2 /module2_panel.html
chihsing's picture
Upload 180 files
af894e0 verified
Raw History Blame Contribute Delete
43.7 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 2 Ligand Enumeration</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;
--purple:#c084fc;--purple-dim:#2a1a40;
--code-bg:#0a0c10;
--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;
--purple:#7c3aed;--purple-dim:#ede9fe;
--code-bg:#e8ecf4;
}
*{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);display:flex;flex-direction:column;}
.main{padding:1.5rem 2rem;flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:1.25rem;}
/* Session input (no session_id case) */
.session-input-bar{background:var(--surface);border:1px solid var(--amber);border-radius:10px;padding:1rem 1.25rem;display:flex;gap:.75rem;align-items:flex-end;}
.session-input-bar .field{flex:1;margin:0;}
/* Engine card */
.engine{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden;transition:border-color .2s;}
.engine.active{border-color:var(--border2);}
.engine.running{border-color:var(--blue);}
.engine.needs-input{border-color:var(--amber);}
.engine.done{border-color:var(--green);}
.engine.error{border-color:var(--red);}
.engine-header{padding:.875rem 1.25rem;display:flex;align-items:center;gap:.875rem;cursor:pointer;user-select:none;}
.engine-badge{font-family:var(--mono);font-size:13px;font-weight:500;padding:3px 8px;border-radius:4px;background:var(--surface2);color:var(--text3);border:1px solid var(--border);white-space:nowrap;}
.engine.active .engine-badge{background:var(--blue-dim);color:var(--blue);border-color:var(--blue);}
.engine.running .engine-badge{background:var(--blue-dim);color:var(--blue);border-color:var(--blue);animation:pulse 1.2s ease-in-out infinite;}
.engine.needs-input .engine-badge{background:var(--amber-dim);color:var(--amber);border-color:var(--amber);}
.engine.done .engine-badge{background:var(--green-dim);color:var(--green);border-color:var(--green);}
.engine.error .engine-badge{background:var(--red-dim);color:var(--red);border-color:var(--red);}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.engine-title{font-size:18px;font-weight:500;color:var(--text);flex:1;}
.engine-subtitle{font-size:14px;color:var(--text3);}
.engine-chevron{color:var(--text3);font-size:14px;transition:transform .2s;}
.engine.open .engine-chevron{transform:rotate(180deg);}
.engine-body{display:none;border-top:1px solid var(--border);}
.engine.open .engine-body{display:block;}
/* Stage list */
.stage-list{padding:.875rem 1.25rem;display:flex;flex-direction:column;gap:.4rem;}
.stage-row{display:flex;align-items:center;gap:.625rem;padding:.4rem .625rem;border-radius:6px;background:var(--surface2);}
.stage-num{font-family:var(--mono);font-size:13px;color:var(--text3);min-width:24px;}
.stage-name{font-size:15px;color:var(--text2);flex:1;}
.stage-val{font-family:var(--mono);font-size:14px;color:var(--text);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.stage-status{font-size:13px;font-weight:500;padding:2px 6px;border-radius:3px;white-space:nowrap;}
.s-auto{background:var(--green-dim);color:var(--green);}
.s-manual{background:var(--purple-dim);color:var(--purple);border:1px solid var(--purple);}
.s-confirm{background:var(--blue-dim);color:var(--blue);}
.s-fail{background:var(--red-dim);color:var(--red);}
.s-warn{background:var(--amber-dim);color:var(--amber);}
/* SMILES info bar */
.smiles-bar{margin:0 1.25rem .75rem;background:var(--surface2);border-radius:6px;padding:.75rem;display:flex;flex-direction:column;gap:.25rem;}
.smiles-label{font-size:13px;color:var(--text3);font-family:var(--mono);}
.smiles-val{font-family:var(--mono);font-size:14px;color:var(--text);word-break:break-all;line-height:1.6;}
.smiles-meta{font-size:14px;color:var(--text3);}
.smiles-warn{font-size:14px;color:var(--amber);margin-top:.25rem;}
/* Candidate cards */
.cand-section{padding:0 1.25rem .875rem;}
.cand-label{font-family:var(--mono);font-size:13px;color:var(--amber);letter-spacing:.08em;margin-bottom:.75rem;}
.cand-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:8px;}
.cand-card{padding:.875rem;border:1px solid var(--border);border-radius:10px;background:var(--surface2);cursor:pointer;transition:all .15s;position:relative;}
.cand-card:hover{border-color:var(--border2);}
.cand-card.selected{border:2px solid var(--blue);background:var(--blue-dim);}
.cand-card.auto-selected{border:2px solid var(--green);background:var(--green-dim);cursor:default;}
.cand-idx{font-family:var(--mono);font-size:13px;color:var(--text3);margin-bottom:.375rem;}
.cand-state{font-size:17px;font-weight:500;color:var(--text);margin-bottom:.25rem;}
.cand-site{font-family:var(--mono);font-size:14px;color:var(--amber);margin-bottom:.375rem;}
.cand-note{font-size:14px;color:var(--text3);line-height:1.5;}
.cand-check{position:absolute;top:8px;right:8px;width:16px;height:16px;border-radius:50%;background:var(--blue);display:none;align-items:center;justify-content:center;font-size:13px;color:#fff;}
.cand-card.selected .cand-check{display:flex;}
.cand-card.auto-selected .cand-check{display:flex;background:var(--green);}
.cand-confirm-row{margin-top:.875rem;display:flex;gap:.5rem;align-items:center;}
/* Tautomer result */
.taut-result{margin:0 1.25rem .875rem;background:var(--surface2);border-radius:8px;padding:.875rem;border-left:3px solid var(--green);}
.taut-title{font-size:14px;color:var(--text3);margin-bottom:.375rem;}
.taut-smiles{font-family:var(--mono);font-size:14px;color:var(--green);word-break:break-all;line-height:1.6;}
.taut-count{font-size:14px;color:var(--text3);margin-top:.25rem;}
/* Engine C options */
.engine-opts{padding:.875rem 1.25rem;display:flex;gap:.5rem;flex-wrap:wrap;}
.engine-opt{font-family:var(--mono);font-size:14px;padding:6px 14px;border-radius:6px;border:1px solid var(--border2);background:transparent;color:var(--text2);cursor:pointer;transition:all .15s;}
.engine-opt.active{border-color:var(--blue);background:var(--blue-dim);color:var(--blue);}
.engine-opt-label{font-size:13px;color:var(--text3);align-self:center;}
/* PDBQT result */
.pdbqt-result{margin:0 1.25rem .875rem;background:var(--green-dim);border:1px solid var(--green);border-radius:8px;padding:.875rem;}
.pdbqt-title{font-family:var(--mono);font-size:13px;color:var(--green);letter-spacing:.06em;margin-bottom:.5rem;}
.pdbqt-path{font-family:var(--mono);font-size:14px;color:var(--text2);word-break:break-all;}
.pdbqt-meta{font-size:14px;color:var(--text3);margin-top:.375rem;}
/* Log */
.log-box{margin:0 1.25rem .875rem;background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:.625rem .875rem;font-family:var(--mono);font-size:14px;color:var(--text3);max-height:130px;overflow-y:auto;line-height:1.8;}
.log-box .ok{color:var(--green);}
.log-box .warn{color:var(--amber);}
.log-box .err{color:var(--red);}
.log-box .info{color:var(--blue);}
/* Action bar */
.action-bar{padding:.875rem 1.25rem;border-top:1px solid var(--border);display:flex;gap:.625rem;align-items:center;flex-wrap:wrap;}
.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-success{background:var(--green);color:#000;border-color:var(--green);}
.btn-ghost{background:transparent;color:var(--text2);border-color:var(--border2);}
.btn-ghost:hover:not(:disabled){background:var(--surface2);}
.btn-sm{padding:4px 10px;font-size:14px;}
.action-note{font-size:14px;color:var(--text3);margin-left:auto;}
/* Connector */
.connector{display:flex;align-items:center;padding:0 1.5rem;gap:.5rem;}
.connector-line{flex:1;height:1px;background:var(--border);}
.connector-label{font-family:var(--mono);font-size:13px;color:var(--text3);}
/* Summary */
.summary-bar{background:var(--surface);border:1px solid var(--green);border-radius:12px;padding:1.25rem;display:none;}
.summary-bar.visible{display:block;}
.summary-title{font-family:var(--mono);font-size:14px;color:var(--green);letter-spacing:.08em;margin-bottom:.75rem;}
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;}
.summary-item{background:var(--surface2);border-radius:6px;padding:.625rem .875rem;}
.summary-item .lbl{font-size:14px;color:var(--text3);margin-bottom:2px;}
.summary-item .val{font-family:var(--mono);font-size:15px;color:var(--text);}
/* Field */
.field{margin-bottom:0;}
.field-label{font-size:15px;color:var(--text3);margin-bottom:5px;}
.field input{width:100%;background:var(--surface2);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;font-size:17px;color:var(--text);}
.field input:focus{border-color:var(--blue);outline:none;}
</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'">
</head>
<body data-molgate-module="2">
<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-2 · ligand-enumeration</div>
</div>
</div>
<div class="header-meta">
<span class="session-pill" id="session-pill">session: —</span>
<span class="session-pill" id="target-pill">target: —</span>
</div>
</div>
<div class="app-shell">
<!-- ══ LEFT ══ -->
<div class="left">
<div class="main">
<!-- Session input bar (no session_id) -->
<div class="session-input-bar" id="session-input-bar" style="display:none">
<div class="field">
<div class="field-label" data-zh="輸入 Session ID" data-en="Enter Session ID">輸入 Session ID</div>
<input type="text" id="manual-session" placeholder="93e44ebf6158878c" style="font-family:var(--mono)">
</div>
<div class="field" style="flex:0.5;">
<div class="field-label" data-zh="Target" data-en="Target">Target</div>
<input type="text" id="manual-target" placeholder="COX2">
</div>
<button class="btn btn-primary btn-sm" onclick="loadSession()" data-zh="載入" data-en="Load">載入</button>
</div>
<!-- ENGINE B -->
<div class="engine active open" id="engB">
<div class="engine-header">
<div class="engine-badge" id="badgeB">ENGINE B</div>
<div>
<div class="engine-title" data-zh="配體枚舉" data-en="Ligand enumeration">配體枚舉</div>
<div class="engine-subtitle" data-zh="Stage 04 質子化 + Stage 05 互變異構" data-en="Stage 04 protonation + Stage 05 tautomer">Stage 04 質子化 + Stage 05 互變異構</div>
</div>
</div>
<div class="engine-body">
<!-- SMILES 來源顯示 -->
<div class="smiles-bar" id="smiles-bar">
<div class="smiles-label" data-zh="輸入 SMILES(來自 Stage 01)" data-en="Input SMILES (from Stage 01)">輸入 SMILES(來自 Stage 01)</div>
<div class="smiles-val" id="smiles-display">—</div>
<div class="smiles-meta" id="smiles-meta"></div>
<div class="smiles-warn" id="smiles-warn" style="display:none"></div>
</div>
<div class="stage-list">
<div class="stage-row">
<span class="stage-num">04</span>
<span class="stage-name" data-zh="質子化狀態(pH 7.4)" data-en="Protonation state (pH 7.4)">質子化狀態(pH 7.4)</span>
<span class="stage-val" id="v04">—</span>
<span class="stage-status" id="s04" style="display:none"></span>
</div>
<div class="stage-row">
<span class="stage-num">05</span>
<span class="stage-name" data-zh="互變異構(canonical)" data-en="Tautomer (canonical)">互變異構(canonical)</span>
<span class="stage-val" id="v05">—</span>
<span class="stage-status" id="s05" style="display:none"></span>
</div>
</div>
<!-- 候選卡片區(Stage 04 需要人工選擇時顯示)-->
<div class="cand-section" id="cand-section" style="display:none">
<div class="cand-label" id="cand-label" data-zh="選擇質子化形態(pH 7.4)" data-en="Select protonation state (pH 7.4)">選擇質子化形態(pH 7.4)</div>
<div class="cand-grid" id="cand-grid"></div>
<div class="cand-confirm-row">
<button class="btn btn-success btn-sm" id="btn-cand-confirm" onclick="confirmCandidate()" disabled data-zh="✓ 確認選擇" data-en="✓ Confirm">✓ 確認選擇</button>
<span style="font-size:14px;color:var(--text3);" id="cand-hint" data-zh="請選擇一個形態" data-en="Please select a form">請選擇一個形態</span>
</div>
</div>
<!-- Tautomer 結果 -->
<div class="taut-result" id="taut-result" style="display:none">
<div class="taut-title" data-zh="Canonical tautomer(Stage 05)" data-en="Canonical tautomer (Stage 05)">Canonical tautomer(Stage 05)</div>
<div class="taut-smiles" id="taut-smiles">—</div>
<div class="taut-count" id="taut-count"></div>
</div>
<div class="action-bar">
<button class="btn btn-primary" id="btnB" onclick="runEngineB()" disabled data-zh="▶ Run Engine B" data-en="▶ Run Engine B">▶ Run Engine B</button>
<span class="action-note" id="noteB" data-zh="載入 Session 後啟用" data-en="Load session to enable">載入 Session 後啟用</span>
</div>
<div class="log-box" id="logB" style="display:none"></div>
<div class="action-bar" id="nextCHint" style="display:none;background:var(--green-dim);border:1px solid var(--green);border-radius:8px;margin:0 1.25rem .75rem;padding:.75rem 1rem;">
<span style="font-size:15px;color:var(--green);font-family:var(--mono);" data-zh="✓ Engine B 已完成 — 請在下方 Engine C 區塊執行 ▶ Run Engine C" data-en="✓ Engine B done — run ▶ Run Engine C in Engine C below">✓ Engine B 已完成 — 請在下方 Engine C 區塊執行 ▶ Run Engine C</span>
</div>
</div>
</div>
<div class="connector">
<div class="connector-line"></div>
<div class="connector-label">stage_05_tautomer.json → selected_smiles</div>
<div class="connector-line"></div>
</div>
<!-- ENGINE C -->
<div class="engine open" id="engC">
<div class="engine-header">
<div class="engine-badge" id="badgeC">ENGINE C</div>
<div>
<div class="engine-title" data-zh="PDBQT 轉換" data-en="PDBQT conversion">PDBQT 轉換</div>
<div class="engine-subtitle" data-zh="Stage 09 → ligand/ligand.pdbqt" data-en="Stage 09 → ligand/ligand.pdbqt">Stage 09 → ligand/ligand.pdbqt</div>
</div>
</div>
<div class="engine-body">
<div class="stage-list">
<div class="stage-row">
<span class="stage-num">09</span>
<span class="stage-name" data-zh="配體 PDBQT 轉換(Meeko-first)" data-en="Ligand PDBQT (Meeko-first)">配體 PDBQT 轉換(Meeko-first)</span>
<span class="stage-val" id="v09">—</span>
<span class="stage-status" id="s09" style="display:none"></span>
</div>
</div>
<!-- 引擎選擇 -->
<div class="engine-opts">
<span class="engine-opt-label" data-zh="引擎:" data-en="Engine:">引擎:</span>
<button class="engine-opt active" onclick="selectEngine('meeko',this)">meeko</button>
<button class="engine-opt" onclick="selectEngine('auto',this)">auto</button>
<button class="engine-opt" onclick="selectEngine('obabel',this)">obabel</button>
</div>
<!-- PDBQT 結果 -->
<div class="pdbqt-result" id="pdbqt-result" style="display:none">
<div class="pdbqt-title">LIGAND.PDBQT ✓</div>
<div class="pdbqt-path" id="pdbqt-path">—</div>
<div class="pdbqt-meta" id="pdbqt-meta"></div>
</div>
<div class="action-bar">
<button class="btn btn-primary" id="btnC" onclick="runEngineC()" disabled data-zh="▶ Run Engine C" data-en="▶ Run Engine C">▶ Run Engine C</button>
<span class="action-note" id="noteC" data-zh="等待 Engine B 完成" data-en="Waiting for Engine B">等待 Engine B 完成</span>
</div>
<div class="log-box" id="logC" style="display:none"></div>
</div>
</div>
<!-- Summary -->
<div class="summary-bar" id="summary">
<div class="summary-title" data-zh="MODULE 2 完成 ✓" data-en="MODULE 2 COMPLETE ✓">MODULE 2 完成 ✓</div>
<div class="summary-grid">
<div class="summary-item"><div class="lbl" data-zh="最終 SMILES" data-en="Final SMILES">最終 SMILES</div><div class="val" id="sum-smiles" style="font-size:13px;word-break:break-all;">—</div></div>
<div class="summary-item"><div class="lbl" data-zh="Stage 04" data-en="Stage 04">Stage 04</div><div class="val" id="sum-s04">—</div></div>
<div class="summary-item"><div class="lbl" data-zh="Stage 05" data-en="Stage 05">Stage 05</div><div class="val" id="sum-s05">—</div></div>
<div class="summary-item"><div class="lbl">ligand.pdbqt</div><div class="val" style="color:var(--green)" data-zh="已生成" data-en="Generated">已生成</div></div>
</div>
<div style="margin-top:.875rem;">
<button class="btn btn-success" onclick="goModule3()" data-zh="繼續 Module 3 →" data-en="Continue to Module 3 →">繼續 Module 3 →</button>
</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="質子化形態選擇有疑問?在此詢問 AI 關於 pH 7.4 下的主要離子形態。" data-en="Unsure about protonation state? Ask the AI about the dominant form at pH 7.4.">質子化形態選擇有疑問?在此詢問 AI 關於 pH 7.4 下的主要離子形態。</div>
<div class="mg-ai-hint-box" id="llm-welcome" data-zh="執行 Engine B 後,我可以解釋每個質子化候選的差異與選擇建議。" data-en="After running Engine B, I can explain the differences between protonation candidates and help you choose.">執行 Engine B 後,我可以解釋每個質子化候選的差異與選擇建議。</div>
<div class="mg-ai-ctx" id="llm-ctx">context:尚無結果</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 STATE = {
session_id: null,
engineBRunning: false,
engineBDone: false,
target: '',
smiles: '',
drug_label: '',
selectedEngine: 'meeko',
selectedCandIdx: null,
candidates: [],
finalSmiles: '',
};
// ── 初始化:從 URL 讀 session ──────────────────────
window.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
const sid = params.get('session');
const tgt = params.get('target');
if (sid) {
STATE.session_id = sid;
STATE.target = tgt || '';
loadSessionData(sid);
} else {
document.getElementById('session-input-bar').style.display = 'flex';
document.getElementById('noteB').textContent = L('noSession');
}
});
// ── i18n ─────────────────────────────────────────
const T = {
zh: {
noSession:'請先輸入 Session ID',
sessionLoaded:'Session 載入成功',
sessionErr:'Session 載入失敗',
runB:'執行中...',
auto1:'自動通過(唯一候選)',
needSelect:'需要人工選擇',
confirm2:'請確認(預選第一個)',
candConfirmed:'已選定',
tautAuto:'自動(canonical)',
runCNote:'就緒,點擊執行',
waitB:'等待 Engine B 完成',
pdbqtDone:'PDBQT 生成完成',
engineBDone:'Engine B 完成',
engineBAgain:'↻ 重新執行 Engine B',
nextEngineC:'✓ Engine B 已完成 — 請執行下方 Engine C',
engineCDone:'Engine C 完成',
gemmaCtx: (s,m) => `context:${s} | ${m} Da`,
llmB: (n) => `Engine B 完成,找到 ${n} 個質子化候選。${n > 2 ? '需要人工選擇。' : '已自動通過。'}有問題可以問我。`,
llmC: 'Engine C 完成,ligand.pdbqt 已生成。Module 2 全部完成。',
noSession_input:'請輸入 Session ID 和 Target',
stereoWarn: (c,e) => `⚠ 未定義手性中心:${c} 個,未定義 E/Z:${e} 個`,
},
en: {
noSession:'Please enter a Session ID first',
sessionLoaded:'Session loaded',
sessionErr:'Failed to load session',
runB:'Running...',
auto1:'Auto pass (single candidate)',
needSelect:'Manual selection required',
confirm2:'Please confirm (first pre-selected)',
candConfirmed:'Selected',
tautAuto:'Auto (canonical)',
runCNote:'Ready, click to run',
waitB:'Waiting for Engine B',
pdbqtDone:'PDBQT generated',
engineBDone:'Engine B complete',
engineBAgain:'↻ Re-run Engine B',
nextEngineC:'✓ Engine B done — run Engine C below',
engineCDone:'Engine C complete',
gemmaCtx: (s,m) => `context: ${s} | ${m} Da`,
llmB: (n) => `Engine B done. ${n} protonation candidate(s) found. ${n > 2 ? 'Manual selection needed.' : 'Auto-passed.'} Ask me anything.`,
llmC: 'Engine C done. ligand.pdbqt generated. Module 2 complete.',
noSession_input:'Please enter Session ID and Target',
stereoWarn: (c,e) => `⚠ Undefined stereocenters: ${c}, undefined E/Z: ${e}`,
},
};
function L(key, ...args) {
const t = T[LANG.cur][key];
return typeof t === 'function' ? t(...args) : (t || key);
}
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)');
}
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
document.querySelectorAll('.theme-btn').forEach(b =>
b.classList.toggle('active', b.getAttribute('data-theme') === t));
}
// ── Engine toggle ─────────────────────────────────
function toggleEngine(id) {
document.getElementById(id).classList.toggle('open');
}
function setEngineState(id, state) {
const el = document.getElementById(id);
el.classList.remove('active','running','needs-input','done','error');
if (state) el.classList.add(state);
const badge = document.getElementById(id==='engB'?'badgeB':'badgeC');
const prefix = id==='engB' ? 'ENGINE B' : 'ENGINE C';
const labels = {active:'READY',running:'RUNNING...',
'needs-input':'ACTION NEEDED',done:'DONE ✓',error:'ERROR'};
badge.textContent = state ? `${prefix} · ${labels[state]||state}` : prefix;
}
function setStage(vid, sid, val, cls, txt) {
document.getElementById(vid).textContent = val;
const s = document.getElementById(sid);
s.className = 'stage-status ' + cls;
s.textContent = txt;
s.style.display = '';
}
function appendLog(id, msg, cls='') {
const b = document.getElementById(id);
b.style.display = '';
const d = document.createElement('div');
if (cls) d.className = cls;
d.textContent = msg;
b.appendChild(d);
b.scrollTop = b.scrollHeight;
}
function sleep(ms) { return new Promise(r=>setTimeout(r,ms)); }
// ── Session 載入 ──────────────────────────────────
async function loadSession() {
const sid = document.getElementById('manual-session').value.trim();
const tgt = document.getElementById('manual-target').value.trim().toUpperCase();
if (!sid || !tgt) { alert(L('noSession_input')); return; }
STATE.session_id = sid;
STATE.target = tgt;
await loadSessionData(sid);
}
async function loadSessionData(sid) {
try {
const r = await fetch(`/api/session/${sid}/stage01`);
if (!r.ok) {
const err = await r.json();
molgateAiNotify(`❌ ${err.error || L('sessionErr')}`);
return;
}
const data = await r.json();
STATE.smiles = data.canonical_smiles || '';
STATE.drug_label = data.drug_label || '';
if (!STATE.target) STATE.target = data.target || '';
// Update UI
document.getElementById('session-input-bar').style.display = 'none';
document.getElementById('session-pill').textContent = `session: ${sid.slice(0,8)}...`;
document.getElementById('target-pill').textContent = `target: ${STATE.target}`;
// SMILES bar
document.getElementById('smiles-display').textContent = STATE.smiles;
const meta = [];
if (data.mol_weight) meta.push(`MW: ${data.mol_weight} Da`);
if (STATE.drug_label) meta.push(STATE.drug_label);
if (STATE.target) meta.push(STATE.target);
document.getElementById('smiles-meta').textContent = meta.join(' · ');
// Stereo warning
const nc = data.n_undefined_chiral_centers || 0;
const ne = data.n_undefined_ez_bonds || 0;
if (nc > 0 || ne > 0) {
const w = document.getElementById('smiles-warn');
w.textContent = L('stereoWarn', nc, ne);
w.style.display = '';
}
// Enable Engine B (unless already completed — restore handles that)
const restored = await restoreModule2Progress(sid);
if (!restored) {
document.getElementById('btnB').disabled = false;
document.getElementById('noteB').textContent = L('sessionLoaded');
setEngineState('engB', 'active');
}
molgateAiNotify(`Session ${sid.slice(0,8)}... 載入完成。SMILES: ${STATE.smiles.slice(0,40)}...`);
document.getElementById('llm-ctx').textContent = L('gemmaCtx', STATE.target, data.mol_weight||'?');
} catch(e) {
molgateAiNotify(`❌ ${L('sessionErr')}: ${e}`);
}
}
async function restoreModule2Progress(sid) {
try {
const [r4resp, r5resp, r9check] = await Promise.all([
fetch(`/api/session/${sid}/stage04`),
fetch(`/api/session/${sid}/stage05`),
fetch(`/api/session/${sid}/status`),
]);
if (!r4resp.ok || !r5resp.ok) return false;
const r4 = await r4resp.json();
const r5 = await r5resp.json();
handleEngineBResult(r4, r5);
STATE.engineBDone = true;
document.getElementById('btnB').disabled = false;
setBtnBLabel(false);
document.getElementById('noteB').textContent = L('nextEngineC');
document.getElementById('nextCHint').style.display = 'flex';
if (r9check.ok) {
const st = await r9check.json();
const s9 = st.stages?.stage_09;
if (s9 && s9 !== 'unknown') {
document.getElementById('btnC').disabled = false;
setEngineState('engC', 'done');
document.getElementById('noteC').textContent = L('pdbqtDone');
}
}
return true;
} catch (e) {
return false;
}
}
function setBtnBLabel(running) {
const btn = document.getElementById('btnB');
if (running) {
btn.textContent = L('runB');
return;
}
btn.textContent = STATE.engineBDone ? L('engineBAgain') : (LANG.cur === 'zh' ? '▶ Run Engine B' : '▶ Run Engine B');
}
// ── Engine B ──────────────────────────────────────
async function runEngineB() {
if (STATE.engineBRunning) return;
STATE.engineBRunning = true;
document.getElementById('btnB').disabled = true;
setBtnBLabel(true);
setEngineState('engB', 'running');
document.getElementById('logB').style.display = '';
if (!STATE.engineBDone) document.getElementById('logB').innerHTML = '';
document.getElementById('nextCHint').style.display = 'none';
let finalResult = null;
try {
const resp = await fetch('/api/module2/engineB/run', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({session_id: STATE.session_id, target: STATE.target}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
appendLog('logB', `❌ ${err.error || resp.statusText}`, 'err');
setEngineState('engB', 'error');
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream:true});
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' : '';
appendLog('logB', msg.log, cls);
}
if (msg.status) finalResult = msg;
} catch(e) {}
}
}
if (finalResult && finalResult.status === 'complete') {
const r4 = finalResult.result?.stage04 || {};
const r5 = finalResult.result?.stage05 || {};
if (!finalResult.result?.stage04 || !finalResult.result?.stage05) {
await restoreModule2Progress(STATE.session_id);
} else {
handleEngineBResult(r4, r5);
}
} else if (finalResult && finalResult.status === 'error') {
setEngineState('engB', 'error');
appendLog('logB', `❌ Engine B 結束碼 ${finalResult.result?.returncode ?? '?'}`, 'err');
} else {
const restored = await restoreModule2Progress(STATE.session_id);
if (!restored) {
setEngineState('engB', 'error');
appendLog('logB', LANG.cur === 'zh' ? '❌ 未收到完成狀態,請重試' : '❌ No completion status — please retry', 'err');
}
}
} catch (e) {
setEngineState('engB', 'error');
appendLog('logB', `❌ ${e}`, 'err');
} finally {
STATE.engineBRunning = false;
if (!STATE.engineBDone) {
document.getElementById('btnB').disabled = false;
setBtnBLabel(false);
}
}
}
function handleEngineBResult(r4, r5) {
const candidates = r4.candidates || [];
const status4 = r4.status || '';
STATE.candidates = candidates;
STATE.finalSmiles = r5.selected_smiles || r4.selected_smiles || '';
// Stage 04
if (status4 === 'AUTO') {
setStage('v04','s04', candidates[0]?.slice(0,20)+'...', 's-auto', 'AUTO');
STATE.selectedCandIdx = 0;
} else if (status4 === 'CONFIRM' && candidates.length === 2) {
setStage('v04','s04', `${candidates.length} 個候選`, 's-confirm', 'CONFIRM');
renderCandidates(candidates, false);
STATE.selectedCandIdx = 0;
document.querySelectorAll('.cand-card')[0]?.classList.add('selected');
document.getElementById('btn-cand-confirm').disabled = false;
document.getElementById('cand-hint').textContent = candidates.length + ' ' + L('confirm2');
setEngineState('engB', 'needs-input');
return;
} else if (status4 === 'MANUAL') {
setStage('v04','s04', `${candidates.length} 個候選`, 's-manual', 'ACTION NEEDED');
renderCandidates(candidates, true);
setEngineState('engB', 'needs-input');
molgateAiNotify(L('llmB', candidates.length));
return;
}
// Stage 05
handleTautomerResult(r5);
}
function renderCandidates(candidates, needManual) {
const grid = document.getElementById('cand-grid');
const section = document.getElementById('cand-section');
grid.innerHTML = '';
candidates.forEach((smi, i) => {
const info = parseCandidateInfo(smi, i);
const card = document.createElement('div');
card.className = 'cand-card' + (!needManual && i===0 ? ' selected' : '');
card.innerHTML = `
<div class="cand-check">✓</div>
<div class="cand-idx">形態 ${i}</div>
<div class="cand-state">${info.chargeLabel}</div>
<div class="cand-site">${info.site}</div>
<div class="cand-note">${info.note}</div>
`;
card.onclick = () => selectCand(i, card);
grid.appendChild(card);
});
section.style.display = '';
document.getElementById('cand-label').textContent =
needManual ? L('needSelect') : L('confirm2');
}
function parseCandidateInfo(smi, idx) {
// 簡化解析:偵測質子化位置
const hasNH = (smi.match(/\[NH\+\]/g)||[]).length;
const hasNeg = (smi.match(/\[O-\]/g)||[]).length;
const hasPos = (smi.match(/\[N\+\]/g)||[]).length + (smi.match(/\[NH\+\]/g)||[]).length +
(smi.match(/\[NH2\+\]/g)||[]).length;
const totalCharge = hasPos - hasNeg;
let chargeLabel, site, note;
if (totalCharge === 0) {
chargeLabel = idx===0 ? '中性(主要形態)' : '中性';
site = '未帶電';
note = 'pH 7.4 中性形態';
} else if (totalCharge > 0) {
chargeLabel = `+${totalCharge} 質子化`;
site = smi.includes('[NH+]') ? 'N-H⁺ 帶電' :
smi.includes('[N+]') ? 'N⁺ 四級銨' : 'N 帶電';
note = `pKa 接近 7.4,部分質子化`;
} else {
chargeLabel = `${totalCharge} 去質子化`;
site = smi.includes('[O-]') ? '-COO⁻ 羧酸根' : 'O⁻ 帶電';
note = 'pH 7.4 去質子化形態';
}
return {chargeLabel, site, note};
}
function selectCand(idx, el) {
document.querySelectorAll('.cand-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
STATE.selectedCandIdx = idx;
document.getElementById('btn-cand-confirm').disabled = false;
document.getElementById('cand-hint').textContent = `已選:形態 ${idx}`;
}
async function confirmCandidate() {
document.getElementById('cand-section').style.display = 'none';
document.getElementById('btn-cand-confirm').disabled = true;
const selected = STATE.candidates[STATE.selectedCandIdx];
setStage('v04','s04', selected.slice(0,20)+'...', 's-manual', 'MANUAL');
STATE.finalSmiles = selected;
// 讀取 Stage 05 結果(已寫入)
try {
const r = await fetch(`/api/session/${STATE.session_id}/status`);
const data = await r.json();
// stage05 已由 engine B 寫入
const r5resp = await fetch(`/api/session/${STATE.session_id}/stage05`);
if (r5resp.ok) {
const r5 = await r5resp.json();
handleTautomerResult(r5);
} else {
finishEngineB();
}
} catch(e) {
finishEngineB();
}
}
function handleTautomerResult(r5) {
if (!r5 || !r5.selected_smiles) { finishEngineB(); return; }
STATE.finalSmiles = r5.selected_smiles;
setStage('v05','s05', r5.selected_smiles.slice(0,20)+'...', 's-auto', r5.status||'AUTO');
document.getElementById('taut-smiles').textContent = r5.selected_smiles;
document.getElementById('taut-count').textContent =
`${r5.tautomer_count||1} 個互變異構體 · 選定 canonical`;
document.getElementById('taut-result').style.display = '';
finishEngineB();
}
function finishEngineB() {
STATE.engineBDone = true;
setEngineState('engB', 'done');
document.getElementById('btnB').disabled = false;
setBtnBLabel(false);
document.getElementById('noteB').textContent = L('nextEngineC');
document.getElementById('nextCHint').style.display = 'flex';
document.getElementById('btnC').disabled = false;
document.getElementById('noteC').textContent = L('runCNote');
setEngineState('engC', 'active');
document.getElementById('engC').classList.add('open');
molgateAiNotify(L('llmB', STATE.candidates.length));
}
// ── Engine C ──────────────────────────────────────
function selectEngine(name, el) {
STATE.selectedEngine = name;
document.querySelectorAll('.engine-opt').forEach(b => b.classList.remove('active'));
el.classList.add('active');
}
async function runEngineC() {
document.getElementById('btnC').disabled = true;
setEngineState('engC', 'running');
document.getElementById('logC').style.display = '';
document.getElementById('logC').innerHTML = '';
const resp = await fetch('/api/module2/engineC/run', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
session_id: STATE.session_id,
target: STATE.target,
engine: STATE.selectedEngine,
}),
});
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' : '';
appendLog('logC', msg.log, cls);
}
if (msg.status) finalResult = msg;
} catch(e) {}
}
}
if (finalResult && finalResult.status === 'complete') {
const r = finalResult.result || {};
const s9 = r.stage09 || {};
setStage('v09','s09', s9.engine||STATE.selectedEngine, 's-auto', 'AUTO');
if (r.pdbqt_exists) {
document.getElementById('pdbqt-path').textContent = r.pdbqt_path || '—';
document.getElementById('pdbqt-meta').textContent =
`引擎:${s9.engine||STATE.selectedEngine} · ${s9.atom_count||'?'} 原子`;
document.getElementById('pdbqt-result').style.display = '';
}
setEngineState('engC', 'done');
showSummary(s9);
molgateAiNotify(L('llmC'));
} else {
setEngineState('engC', 'error');
document.getElementById('btnC').disabled = false;
}
}
function showSummary(s9) {
document.getElementById('sum-smiles').textContent = STATE.finalSmiles;
document.getElementById('sum-s04').textContent =
STATE.candidates.length > 1 ? 'MANUAL' : 'AUTO';
document.getElementById('sum-s05').textContent = 'AUTO';
document.getElementById('summary').classList.add('visible');
}
function goModule3() {
var qs = 'session=' + encodeURIComponent(STATE.session_id) + '&target=' + encodeURIComponent(STATE.target || '');
window.location.href = (window.molgateModuleUrl ? window.molgateModuleUrl(3, qs) : ('/module/3?' + qs));
}
// ── Stage 05 API ──────────────────────────────────
// 加一個讀取 stage05 的 helper endpoint(server 需支援)
// 若 server 未實作,confirmCandidate 會直接 finishEngineB
</script>
<script src="../../assets/molgate.js" onerror="this.onerror=null;this.src='/molgate_ui_shared.js'"></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: 'm2',
systemPrompt: () => LANG.cur === 'zh'
? '你是 DrugOps Module 2 助手,協助解釋質子化候選、tautomer 與 ligand 準備。請用繁體中文(台灣)回答。'
: 'You are the DrugOps Module 2 assistant for protonation, tautomers, and ligand prep. Reply in Traditional Chinese (Taiwan) unless asked otherwise.',
extraMessages: () => {
const parts = [];
if (STATE.smiles) parts.push({ role: 'system', content: 'SMILES: ' + STATE.smiles });
if (STATE.target) parts.push({ role: 'system', content: 'Target: ' + STATE.target });
if (STATE.session_id) parts.push({ role: 'system', content: 'Session: ' + STATE.session_id });
if (STATE.candidates && STATE.candidates.length) {
parts.push({ role: 'system', content: 'Protonation candidates: ' + JSON.stringify(STATE.candidates) });
}
return parts;
},
});
</script>
</body>
</html>