DrugOps / ui /modules /m1 /module1_panel.html
chihsing's picture
Upload 180 files
af894e0 verified
Raw History Blame Contribute Delete
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>
<!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);}