Files
telegram-bot/src/web/public/index.html
T

753 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>telegram-bot 控制台</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', 'Microsoft JhengHei', system-ui, sans-serif;
background: #0f1117; color: #e2e8f0; min-height: 100vh; padding: 24px;
}
.container { max-width: 1150px; margin: 0 auto; }
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 10px; }
h1 { font-size: 20px; font-weight: 700; }
h1 span { color: #5865f2; }
.meta { font-size: 12px; color: #64748b; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid #262a38; }
.tab { padding: 9px 18px; font-size: 14px; cursor: pointer; color: #94a3b8; border: none; background: none;
border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tab.active { color: #e2e8f0; border-bottom-color: #5865f2; font-weight: 600; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.card { background: #1a1d27; border: 1px solid #262a38; border-radius: 12px; overflow: hidden; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 10px 14px; color: #94a3b8; font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #262a38; white-space: nowrap; }
td { padding: 12px 14px; border-bottom: 1px solid #20242f; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(88,101,242,0.04); }
.badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px;
border-radius: 20px; font-size: 11px; font-weight: 600; white-space: nowrap; }
.badge::before { content: '●'; font-size: 8px; }
.badge-online { background: rgba(59,165,93,0.15); color: #3ba55d; }
.badge-stopped, .badge-not_started { background: rgba(100,116,139,0.15); color: #64748b; }
.badge-error, .badge-errored { background: rgba(237,66,69,0.15); color: #ed4245; }
.badge-launching, .badge-waiting.restart { background: rgba(88,101,242,0.15); color: #5865f2; }
.badge-unknown { background: rgba(148,163,184,0.15); color: #94a3b8; }
.script-name { font-size: 11px; color: #475569; cursor: help; }
.port-link { display: inline-block; background: rgba(88,101,242,0.15); color: #5865f2;
padding: 2px 8px; border-radius: 4px; font-size: 12px; font-family: monospace;
margin: 1px; text-decoration: none; }
.port-link:hover { background: rgba(88,101,242,0.3); }
.mono { font-family: monospace; color: #94a3b8; font-size: 12px; }
.btn { border: none; border-radius: 6px; padding: 5px 10px; font-size: 12px; cursor: pointer;
background: #262a38; color: #cbd5e1; transition: filter 0.15s; white-space: nowrap; }
.btn:hover { filter: brightness(1.25); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: #5865f2; color: #fff; padding: 8px 16px; font-size: 13px; }
.btn-success { background: rgba(59,165,93,0.2); color: #3ba55d; }
.btn-warning { background: rgba(250,166,26,0.15); color: #faa61a; }
.btn-danger { background: rgba(237,66,69,0.15); color: #ed4245; }
.btn-ghost { background: rgba(100,116,139,0.15); color: #94a3b8; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.empty { text-align: center; color: #475569; padding: 34px; }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
label.auto { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 5px; cursor: pointer; }
/* modal 表單 */
.modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 50;
align-items: flex-start; justify-content: center; padding: 40px 16px; overflow-y: auto; }
.modal-backdrop.show { display: flex; }
.modal { background: #1a1d27; border: 1px solid #2e3345; border-radius: 14px; width: 100%; max-width: 520px;
padding: 22px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.modal h2 { font-size: 16px; margin-bottom: 16px; }
.field { margin-bottom: 13px; }
.field label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 5px; }
.field label b { color: #ed4245; }
.field textarea {
width: 100%; background: #12141c; border: 1px solid #2e3345; border-radius: 8px;
color: #e2e8f0; padding: 9px 11px; font-size: 12.5px; font-family: inherit; line-height: 1.5; resize: vertical; min-height: 84px;
}
.field textarea:focus { outline: none; border-color: #5865f2; }
.field input, .field select {
width: 100%; background: #12141c; border: 1px solid #2e3345; border-radius: 8px;
color: #e2e8f0; padding: 9px 11px; font-size: 13px; font-family: inherit;
}
.field input:focus, .field select:focus { outline: none; border-color: #5865f2; }
.field .hint { font-size: 11px; color: #475569; margin-top: 4px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
.check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #cbd5e1; cursor: pointer; }
/* .field 內的 checkbox:不要吃到 .field label(display:block) 與 .field input(width:100%) 的樣式 */
.field label.check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #cbd5e1; margin: 0 0 4px; }
.field label.check input[type=checkbox], .check input[type=checkbox] {
width: 16px; height: 16px; padding: 0; margin: 0; flex: none; accent-color: #5865f2; cursor: pointer;
}
/* log 面板 */
#log-panel { display: none; margin-top: 18px; }
#log-panel .log-header { display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px; border-bottom: 1px solid #262a38; flex-wrap: wrap; gap: 8px; }
#log-title { font-size: 13px; font-weight: 600; }
#log-title small { color: #64748b; font-weight: 400; margin-left: 8px; }
#log-content { padding: 14px; font-family: Consolas, monospace; font-size: 12px; line-height: 1.55;
white-space: pre-wrap; word-break: break-all; max-height: 460px; overflow-y: auto;
color: #a8b3c5; background: #12141c; }
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px);
background: #262a38; color: #e2e8f0; padding: 10px 18px; border-radius: 8px; font-size: 13px;
opacity: 0; transition: all 0.25s; pointer-events: none; box-shadow: 0 8px 24px rgba(0,0,0,0.4);
z-index: 99; max-width: 90vw; }
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
.toast.success { border-left: 3px solid #3ba55d; }
.toast.error { border-left: 3px solid #ed4245; }
.toast.warn { border-left: 3px solid #faa61a; }
.notice { display: flex; gap: 12px; align-items: flex-start; background: rgba(250,166,26,0.08);
border: 1px solid rgba(250,166,26,0.35); border-radius: 10px; padding: 12px 14px;
font-size: 13px; line-height: 1.7; color: #cbd5e1; margin-bottom: 14px; }
.notice-icon { font-size: 18px; line-height: 1.4; }
.notice b { color: #faa61a; }
.notice-steps { color: #e2e8f0; }
.notice-steps b { color: #e2e8f0; background: rgba(88,101,242,0.18); padding: 1px 7px;
border-radius: 5px; font-weight: 600; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 5px; font-size: 10px; font-weight: 700;
margin-left: 6px; vertical-align: middle; letter-spacing: .3px; }
.tag-codex { background: rgba(16,163,127,0.18); color: #34d399; }
.tag-claude { background: rgba(217,119,87,0.20); color: #f8a583; }
</style>
</head>
<body>
<div class="container">
<header>
<h1><span></span> telegram-bot 控制台</h1>
<div class="toolbar">
<label class="auto"><input type="checkbox" id="auto-refresh" checked> 自動更新(5s</label>
<button class="btn" onclick="refreshActive()">🔄 重新整理</button>
</div>
</header>
<div class="tabs">
<button class="tab active" id="tabbtn-bots" onclick="switchTab('bots')">🤖 Bot 管理</button>
<button class="tab" id="tabbtn-pm2" onclick="switchTab('pm2')">📊 PM2</button>
</div>
<!-- ============ Bot 管理 ============ -->
<div class="tab-panel active" id="tab-bots">
<div class="notice">
<div class="notice-icon">📣</div>
<div>
<b>要在群組使用的話,記得關閉 Group Privacy</b>,否則 bot 收不到群組訊息(@ 它也沒用):<br>
<span class="notice-steps">Telegram 開 <b>@BotFather</b> 迷你 APP → 選該機器人 → <b>Bot Settings</b> → 關閉 <b>Group Privacy</b></span><br>
<span style="color:#64748b">改完後把 bot 踢出群組再重新拉回才會生效。只私訊使用的話可以不管這個。</span>
</div>
</div>
<div style="display:flex;justify-content:flex-end;margin-bottom:12px">
<button class="btn btn-primary" onclick="openCreate()"> 新增 Bot</button>
</div>
<div class="card table-wrap">
<table>
<thead>
<tr>
<th>名稱</th><th>狀態</th><th>工作目錄</th><th>沙盒</th><th>Token</th>
<th>記憶體</th><th>重啟</th><th>運行時間</th><th>操作</th>
</tr>
</thead>
<tbody id="bots-tbody">
<tr><td colspan="9" class="empty">載入中...</td></tr>
</tbody>
</table>
</div>
<p class="meta" style="margin-top:12px">
Bot 設定存於 <code>~/.tgbot/bots/&lt;名稱&gt;/</code>;啟動後掛在 pm2 上(同名程序)。每個 bot 可各自選 Codex 或 Claude 引擎。
私訊直接回應;群組中要 @bot 或回覆 bot 的訊息才會回應。
</p>
</div>
<!-- ============ PM2 ============ -->
<div class="tab-panel" id="tab-pm2">
<div class="card table-wrap">
<table>
<thead>
<tr>
<th>名稱</th><th>狀態</th><th>Port</th><th>PID</th><th>CPU</th>
<th>記憶體</th><th>重啟</th><th>運行時間</th><th>Log</th><th>操作</th>
</tr>
</thead>
<tbody id="pm2-tbody">
<tr><td colspan="10" class="empty">載入中...</td></tr>
</tbody>
</table>
</div>
<p class="meta" style="margin-top:12px">資料來源:<code>pm2 jlist</code> · 顯示整台機器所有 pm2 程序</p>
</div>
<!-- log 面板(兩個分頁共用) -->
<div class="card" id="log-panel">
<div class="log-header">
<div id="log-title"></div>
<div class="toolbar">
<label class="auto"><input type="checkbox" id="log-auto-refresh"> 自動更新(5s</label>
<button class="btn btn-ghost" onclick="closeLog()">✖ 關閉</button>
</div>
</div>
<pre id="log-content"></pre>
</div>
</div>
<!-- 新增 / 編輯 Bot -->
<div class="modal-backdrop" id="bot-modal">
<div class="modal">
<h2 id="bot-modal-title">新增 Bot</h2>
<div class="field">
<label>名稱 <b>*</b></label>
<input id="f-name" placeholder="my-project-bot" autocomplete="off">
<div class="hint">英數字、點、底線、連字號;也是 pm2 程序名稱,建立後不可改</div>
</div>
<div class="field">
<label>AI 引擎</label>
<select id="f-engine" onchange="onEngineChange()">
<option value="codex">CodexOpenAI Codex CLI</option>
<option value="claude">ClaudeClaude Code CLI</option>
</select>
<div class="hint" id="f-engine-hint"></div>
</div>
<div class="field">
<label>Telegram Bot Token <b id="f-token-req">*</b></label>
<input id="f-token" placeholder="123456:ABC-DEF..." autocomplete="off">
<div class="hint" id="f-token-hint">跟 @BotFather 申請;儲存時會自動驗證</div>
</div>
<div class="field">
<label>工作目錄(AI 可讀寫)<b>*</b></label>
<div style="display:flex;gap:8px">
<input id="f-workdir" placeholder="按「瀏覽」選擇資料夾" autocomplete="off" style="flex:1">
<button type="button" class="btn" id="f-workdir-btn" onclick="pickFolder()" style="padding:0 14px">📂 瀏覽…</button>
</div>
<div class="hint">bot 預設能讀寫這個目錄(workspace-write);選擇視窗會開在這台機器的桌面上。<span id="f-workdir-hint"></span></div>
</div>
<div class="field">
<label>沙盒模式</label>
<select id="f-sandbox">
<option value="workspace-write" id="f-sb-ww" selected>workspace-write(可讀寫工作目錄,預設)</option>
<option value="read-only" id="f-sb-ro">read-only(唯讀)</option>
<option value="danger-full-access" id="f-sb-full">danger-full-access(不設限,危險)</option>
</select>
</div>
<div class="field">
<label>模型</label>
<select id="f-model" onchange="syncEffortTier()"></select>
<div class="hint" id="f-model-hint">清單來自本機 codex 的模型快取</div>
</div>
<div class="field">
<label>推理強度</label>
<select id="f-effort"></select>
<div class="hint" id="f-effort-hint">速度固定為 Fast1.5x</div>
</div>
<div class="field">
<label>會話模式</label>
<select id="f-session">
<option value="per-chat" selected>各聊天室獨立(私訊、每個群組各自記憶)</option>
<option value="shared">所有聊天室共用(同一條記憶,適合單一專案助理)</option>
<option value="stateless">每則訊息獨立(不保留記憶,最快;適合單張生圖)</option>
</select>
<div class="hint">記憶會隨對話變慢(尤其含圖);用量達 80% 時所有模式都會自動開新會話</div>
</div>
<div class="field" id="f-network-wrap">
<label class="check"><input type="checkbox" id="f-network"> 允許 AI 連網</label>
<div class="hint">workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開。目前只有 Codex 引擎有此選項</div>
</div>
<div class="field">
<label>可讀寫成員(Telegram username</label>
<input id="f-writeusers" placeholder="例:JianMiau, someone(留空 = 所有人都依沙盒設定)" autocomplete="off">
<div class="hint">有填時,清單外的人一律以 read-only 沙盒執行(只能查詢、不能改檔或跑指令),由 CLI 層硬擋</div>
</div>
<div class="field">
<label>回覆規範(每則訊息開頭附加)</label>
<textarea id="f-rules" placeholder="例:只回答與本專案相關的問題;@xxx 是後端工程師,回覆用 API 契約視角…"></textarea>
<div class="hint">選填。適合放「專案守門」「成員身分」這類每輪都要提醒的規則;專案本身的規範請放工作目錄的 AGENTS.md / CLAUDE.md</div>
</div>
<div class="field">
<label>逾時(分鐘)</label>
<input id="f-timeout" type="number" value="30" min="1">
</div>
<label class="check" id="f-autostart-wrap"><input type="checkbox" id="f-autostart" checked> 建立後立即啟動</label>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal()">取消</button>
<button class="btn btn-primary" id="bot-modal-save" onclick="saveBot()">儲存</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
let activeTab = 'bots';
let editingName = null; // null = 新增模式
let currentLog = null;
let logTimer = null;
function esc(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtUptime(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
const h = Math.floor(s/3600);
if (h < 24) return h + 'h ' + Math.floor((s%3600)/60) + 'm';
return Math.floor(h/24) + 'd ' + (h%24) + 'h';
}
function fmtBytes(b) {
if (!b) return '-';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB';
return (b/1073741824).toFixed(1) + ' GB';
}
let toastTimer;
function showToast(msg, type = '') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show ' + type;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 4000);
}
async function fetchJson(url, options) {
const res = await fetch(url, options);
const text = await res.text();
if (!(res.headers.get('content-type') || '').includes('application/json')) {
throw new Error('API 沒有回 JSON' + (text.trim().slice(0,160) || ('HTTP ' + res.status)));
}
const data = JSON.parse(text);
if (!res.ok || data.error) throw new Error(data.error || ('HTTP ' + res.status));
return data;
}
function postJson(url, body) {
return fetchJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
}
// ---------- tabs ----------
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(el => el.classList.remove('active'));
document.getElementById('tabbtn-' + tab).classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
refreshActive();
}
function refreshActive() {
if (activeTab === 'bots') loadBots(); else loadPM2();
}
// ---------- 模型清單(來自 /api/modelscodex 讀 models cacheclaude 用內建清單) ----------
let enginesCache = {}; // { codex: {available, models, defaults}, claude: {...} }
let defaultEngine = 'codex';
const ENGINE_UI = {
codex: {
hint: '本機 OpenAI Codex CLIcodex exec)。專案規範讀工作目錄的 AGENTS.md',
workdir: '規範檔:AGENTS.md',
modelHint: '清單來自本機 codex 的模型快取',
effortHint: '速度固定為 Fast1.5x',
sandbox: {
'workspace-write': 'workspace-write(可讀寫工作目錄,預設)',
'read-only': 'read-only(唯讀)',
'danger-full-access': 'danger-full-access(不設限,危險)',
},
},
claude: {
hint: '本機 Claude Code CLIclaude -p)。專案規範讀工作目錄的 CLAUDE.md',
workdir: '規範檔:CLAUDE.md',
modelHint: 'Claude 5 家族:Fable 5 / Opus 5 / Sonnet 5(設定檔可手填其他模型 id)',
effortHint: 'Claude 的 effort 對應 --effort 參數',
sandbox: {
'workspace-write': 'workspace-write(自動接受檔案編輯;不執行指令,預設)',
'read-only': 'read-only(只讀:不改檔、不執行指令)',
'danger-full-access': 'danger-full-access(跳過所有權限確認,危險)',
},
},
};
function currentEngine() { return document.getElementById('f-engine').value || 'codex'; }
function engineData() { return enginesCache[currentEngine()] || { models: [], defaults: {} }; }
function engineTag(engine) {
const e = engine === 'claude' ? 'claude' : 'codex';
return '<span class="tag tag-' + e + '">' + (e === 'claude' ? 'Claude' : 'Codex') + '</span>';
}
// 引擎切換:改文案、顯示/隱藏引擎專屬選項、重載模型清單
function onEngineChange(selModel, selEffort) {
const ui = ENGINE_UI[currentEngine()];
const avail = enginesCache[currentEngine()] && enginesCache[currentEngine()].available;
document.getElementById('f-engine-hint').textContent = ui.hint + (avail === false ? ' ⚠ 這台機器找不到此 CLIbot 會啟動失敗' : '');
document.getElementById('f-workdir-hint').textContent = ui.workdir;
document.getElementById('f-model-hint').textContent = ui.modelHint;
document.getElementById('f-effort-hint').textContent = ui.effortHint;
document.getElementById('f-sb-ww').textContent = ui.sandbox['workspace-write'];
document.getElementById('f-sb-ro').textContent = ui.sandbox['read-only'];
document.getElementById('f-sb-full').textContent = ui.sandbox['danger-full-access'];
document.getElementById('f-network-wrap').style.display = currentEngine() === 'codex' ? '' : 'none';
// 換引擎時強度清單重來,不要把上一個引擎選的值帶過去
if (!selEffort) document.getElementById('f-effort').innerHTML = '';
populateModelSelects(selModel || '', selEffort || '');
}
const EFFORT_LABELS = {
low: 'low(快速、輕度推理)',
medium: 'medium(平衡,一般預設)',
high: 'high(深入推理)',
xhigh: 'xhigh(更深入)',
max: 'max(最深推理)',
ultra: 'ultra(最深+自動分派子任務)',
};
async function loadModels() {
try {
const d = await fetchJson('/api/models');
enginesCache = d.engines || {};
defaultEngine = d.defaultEngine || 'codex';
// 沒裝的引擎在下拉選單標出來
for (const opt of document.getElementById('f-engine').options) {
const e = enginesCache[opt.value];
if (e && e.available === false) opt.textContent = opt.textContent.replace(/(未安裝)$/, '') + '(未安裝)';
}
} catch { enginesCache = {}; }
}
// 下拉沒有「預設」空選項:直接把真實預設值選起來
function populateModelSelects(selModel, selEffort) {
const mSel = document.getElementById('f-model');
const modelsCache = engineData().models || [];
const modelDefaults = engineData().defaults || {};
let opts = modelsCache.map(m => '<option value="' + esc(m.slug) + '">' + esc(m.label) + '</option>').join('');
// 設定檔裡有但快取沒有的模型(手填過的)也要能顯示
if (selModel && !modelsCache.some(m => m.slug === selModel)) {
opts += '<option value="' + esc(selModel) + '">' + esc(selModel) + '</option>';
}
mSel.innerHTML = opts;
// 優先序:bot 既有設定 > codex 全域預設 > 清單第一個
const wanted = selModel || modelDefaults.model;
mSel.value = wanted;
if (mSel.value !== wanted || !mSel.value) mSel.selectedIndex = 0;
syncEffortTier(selEffort);
}
function syncEffortTier(selEffort) {
const slug = document.getElementById('f-model').value;
const modelsCache = engineData().models || [];
const modelDefaults = engineData().defaults || {};
const m = modelsCache.find(x => x.slug === slug);
let efforts = m ? m.efforts.slice() : ['low', 'medium', 'high', 'xhigh', 'max'];
if (!efforts.length) efforts = ['medium'];
const eSel = document.getElementById('f-effort');
const prevE = (selEffort !== undefined && selEffort !== '') ? selEffort : eSel.value;
eSel.innerHTML = efforts.map(e => '<option value="' + esc(e) + '">' + esc(EFFORT_LABELS[e] || e) + '</option>').join('');
// 優先序:既有值 > codex 全域預設 > 模型自己的預設 > 第一個
eSel.value = efforts.includes(prevE) ? prevE
: efforts.includes(modelDefaults.effort) ? modelDefaults.effort
: (m && efforts.includes(m.defaultEffort)) ? m.defaultEffort
: efforts[0];
}
// ---------- Bot 管理 ----------
let botsCache = [];
async function loadBots() {
try {
const data = await fetchJson('/api/bots');
botsCache = data.bots || [];
renderBots(botsCache);
} catch (e) {
document.getElementById('bots-tbody').innerHTML =
'<tr><td colspan="9" class="empty">❌ ' + esc(e.message) + '</td></tr>';
}
}
function statusBadge(status) {
const cls = ['online','stopped','error','errored','launching','not_started'].includes(status) ? status : 'unknown';
const label = status === 'not_started' ? '未啟動' : status;
return '<span class="badge badge-' + cls + '">' + esc(label) + '</span>';
}
function renderBots(bots) {
const tbody = document.getElementById('bots-tbody');
if (!bots.length) {
tbody.innerHTML = '<tr><td colspan="9" class="empty">還沒有 bot,按右上角「➕ 新增 Bot」建立第一個</td></tr>';
return;
}
tbody.innerHTML = bots.map(b => {
const uptime = (b.status === 'online' && b.uptime) ? fmtUptime(Date.now() - b.uptime) : '-';
const online = b.status === 'online';
return '<tr>' +
'<td><strong>' + esc(b.name) + '</strong>' + engineTag(b.engine) +
(b.error ? '<br><span style="font-size:11px;color:#ed4245">' + esc(b.error) + '</span>' : '') + '</td>' +
'<td>' + statusBadge(b.status) + '</td>' +
'<td><span class="mono" title="' + esc(b.workDir) + '">' + esc(shortPath(b.workDir)) + '</span></td>' +
'<td class="mono">' + esc(b.sandbox || '-') +
((b.model || b.reasoningEffort || b.sessionMode !== 'per-chat' || b.networkAccess)
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.sessionMode === 'shared' ? '共用會話' : b.sessionMode === 'stateless' ? '無記憶' : '', b.networkAccess ? '可連網' : '', (b.writeUsers && b.writeUsers.length) ? '限 @' + b.writeUsers.join(' @') + ' 可寫' : '', b.promptRules ? '有回覆規範' : ''].filter(Boolean).join(' / ')) + '</span>'
: '') + '</td>' +
'<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' +
'<td>' + fmtBytes(b.memory) + '</td>' +
'<td>' + (b.restarts ?? '-') + '</td>' +
'<td style="font-size:12px;color:#64748b">' + uptime + '</td>' +
'<td><div class="actions">' +
(online
? '<button class="btn btn-ghost" onclick="botAction(\'stop\',\'' + esc(b.name) + '\')">⏹ 停止</button>'
: '<button class="btn btn-success" onclick="botAction(\'start\',\'' + esc(b.name) + '\')"' + (b.error ? ' disabled' : '') + '>▶ 啟動</button>') +
'<button class="btn btn-warning" onclick="botAction(\'restart\',\'' + esc(b.name) + '\')"' + (online ? '' : ' disabled') + '>🔄</button>' +
'<button class="btn btn-ghost" onclick="openBotLog(\'' + esc(b.name) + '\')"' + (b.pmId === null ? ' disabled' : '') + '>📄 log</button>' +
'<button class="btn" onclick="openEdit(\'' + esc(b.name) + '\')">✏️ 編輯</button>' +
'<button class="btn btn-danger" onclick="deleteBot(\'' + esc(b.name) + '\')">🗑</button>' +
'</div></td>' +
'</tr>';
}).join('');
}
function shortPath(p) {
if (!p) return '-';
return p.length > 34 ? '…' + p.slice(-32) : p;
}
async function botAction(action, name) {
try {
await postJson('/api/bots/action', { action, name });
showToast('✅ 已' + ({start:'啟動',stop:'停止',restart:'重啟'}[action]) + '' + name, 'success');
loadBots();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
async function deleteBot(name) {
const b = botsCache.find(x => x.name === name);
if (!confirm('確定要刪除 bot「' + name + '」嗎?(會從 pm2 移除)')) return;
let deleteFiles = false;
if (b && b.source === 'ui') {
deleteFiles = confirm('連同設定檔(含 token 與會話記錄)一起刪除嗎?\n「取消」= 只從清單移除,保留檔案');
}
try {
await postJson('/api/bots/action', { action: 'delete', name, deleteFiles });
showToast('✅ 已刪除:' + name, 'success');
loadBots();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
function openBotLog(name) {
const b = botsCache.find(x => x.name === name);
if (!b || b.pmId === null) return;
openLog(b.pmId, 'out', name);
}
// ---------- 新增 / 編輯表單 ----------
function openCreate() {
editingName = null;
document.getElementById('bot-modal-title').textContent = '新增 Bot';
document.getElementById('f-name').value = '';
document.getElementById('f-name').disabled = false;
document.getElementById('f-token').value = '';
document.getElementById('f-token').placeholder = '123456:ABC-DEF...';
document.getElementById('f-token-req').style.display = '';
document.getElementById('f-token-hint').textContent = '跟 @BotFather 申請;儲存時會自動驗證';
document.getElementById('f-workdir').value = '';
document.getElementById('f-sandbox').value = 'workspace-write';
document.getElementById('f-engine').value = defaultEngine;
onEngineChange('', '');
document.getElementById('f-session').value = 'per-chat';
document.getElementById('f-network').checked = false;
document.getElementById('f-writeusers').value = '';
document.getElementById('f-rules').value = '';
document.getElementById('f-timeout').value = '30';
document.getElementById('f-autostart-wrap').style.display = '';
document.getElementById('f-autostart').checked = true;
document.getElementById('bot-modal').classList.add('show');
document.getElementById('f-name').focus();
}
function openEdit(name) {
const b = botsCache.find(x => x.name === name);
if (!b) return;
editingName = name;
document.getElementById('bot-modal-title').textContent = '編輯 Bot' + name;
document.getElementById('f-name').value = name;
document.getElementById('f-name').disabled = true;
document.getElementById('f-token').value = '';
document.getElementById('f-token').placeholder = b.tokenMasked + '(留空 = 不變更)';
document.getElementById('f-token-req').style.display = 'none';
document.getElementById('f-token-hint').textContent = '留空表示沿用現有 token';
document.getElementById('f-workdir').value = b.workDir || '';
document.getElementById('f-sandbox').value = b.sandbox || 'workspace-write';
document.getElementById('f-engine').value = b.engine || 'codex';
onEngineChange(b.model || '', b.reasoningEffort || '');
document.getElementById('f-session').value = b.sessionMode || 'per-chat';
document.getElementById('f-network').checked = !!b.networkAccess;
document.getElementById('f-writeusers').value = (b.writeUsers || []).join(', ');
document.getElementById('f-rules').value = b.promptRules || '';
document.getElementById('f-timeout').value = b.timeoutMinutes || 30;
document.getElementById('f-autostart-wrap').style.display = 'none';
document.getElementById('bot-modal').classList.add('show');
}
function closeModal() {
document.getElementById('bot-modal').classList.remove('show');
}
async function saveBot() {
const btn = document.getElementById('bot-modal-save');
btn.disabled = true;
btn.textContent = '儲存中…';
const payload = {
name: document.getElementById('f-name').value.trim(),
token: document.getElementById('f-token').value.trim(),
workDir: document.getElementById('f-workdir').value.trim(),
engine: document.getElementById('f-engine').value,
sandbox: document.getElementById('f-sandbox').value,
model: document.getElementById('f-model').value,
reasoningEffort: document.getElementById('f-effort').value,
sessionMode: document.getElementById('f-session').value,
networkAccess: document.getElementById('f-network').checked,
writeUsers: document.getElementById('f-writeusers').value,
promptRules: document.getElementById('f-rules').value,
timeoutMinutes: document.getElementById('f-timeout').value,
};
try {
let d;
if (editingName) {
d = await postJson('/api/bots/update', { ...payload, name: editingName });
showToast(d.warning ? '⚠ ' + d.warning : ('✅ 已更新' + (d.restarted ? '並重啟' : '') + (d.botUsername ? '@' + d.botUsername : '')), d.warning ? 'warn' : 'success');
} else {
payload.autoStart = document.getElementById('f-autostart').checked;
d = await postJson('/api/bots', payload);
showToast(d.warning ? '⚠ ' + d.warning : ('✅ 已建立' + (d.started ? '並啟動' : '') + (d.botUsername ? '@' + d.botUsername : '')), d.warning ? 'warn' : 'success');
}
closeModal();
loadBots();
} catch (e) {
showToast('❌ ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '儲存';
}
}
// 開本機的資料夾選擇視窗(由後端叫出原生對話框),選完回填。
// 按鈕不鎖:再點一次會把上一個視窗砍掉重開(後端接手制),不會卡死。
let pickSeq = 0;
async function pickFolder() {
const btn = document.getElementById('f-workdir-btn');
const mySeq = ++pickSeq;
btn.textContent = '選擇中…(再點一次可重開)';
showToast('📂 資料夾選擇視窗已開啟', '');
try {
const d = await postJson('/api/pick-folder', {});
if (mySeq === pickSeq && d.path) document.getElementById('f-workdir').value = d.path;
} catch (e) {
if (mySeq === pickSeq) showToast('❌ ' + e.message, 'error');
} finally {
if (mySeq === pickSeq) btn.textContent = '📂 瀏覽…';
}
}
// ---------- PM2 分頁 ----------
async function loadPM2() {
try {
const data = await fetchJson('/api/pm2');
renderPM2(data.processes || []);
} catch (e) {
document.getElementById('pm2-tbody').innerHTML =
'<tr><td colspan="10" class="empty">❌ ' + esc(e.message) + '</td></tr>';
}
}
function renderPM2(processes) {
const tbody = document.getElementById('pm2-tbody');
if (!processes.length) {
tbody.innerHTML = '<tr><td colspan="10" class="empty">沒有 PM2 程序</td></tr>';
return;
}
tbody.innerHTML = processes.map(p => {
const uptime = (p.status === 'online' && p.uptime) ? fmtUptime(Date.now() - p.uptime) : '-';
const status = p.status || 'unknown';
const ports = (p.ports && p.ports.length)
? p.ports.map(port => '<a class="port-link" target="_blank" href="http://' + location.hostname + ':' + port + '">' + port + '</a>').join(' ')
: '<span style="color:#475569">-</span>';
return '<tr>' +
'<td><strong>' + esc(p.name) + '</strong><br><span class="script-name" title="' + esc(p.script) + '">' + esc((p.script || '').split(/[\\/]/).pop()) + '</span></td>' +
'<td>' + statusBadge(status) + '</td>' +
'<td>' + ports + '</td>' +
'<td class="mono">' + (p.pid || '-') + '</td>' +
'<td>' + p.cpu + '%</td>' +
'<td>' + fmtBytes(p.memory) + '</td>' +
'<td>' + p.restarts + '</td>' +
'<td style="font-size:12px;color:#64748b">' + uptime + '</td>' +
'<td><div class="actions">' +
(p.hasOutLog ? '<button class="btn btn-ghost" onclick="openLog(' + p.id + ',\'out\',\'' + esc(p.name) + '\')">📄 out</button>' : '') +
(p.hasErrLog ? '<button class="btn btn-ghost" onclick="openLog(' + p.id + ',\'err\',\'' + esc(p.name) + '\')">⚠ err</button>' : '') +
'</div></td>' +
'<td><div class="actions">' +
(status === 'online'
? '<button class="btn btn-ghost" onclick="pm2Action(\'stop\',\'' + esc(p.name) + '\')">⏹ 停止</button>'
: '<button class="btn btn-success" onclick="pm2Action(\'start\',\'' + esc(p.name) + '\')">▶ 啟動</button>') +
'<button class="btn btn-warning" onclick="pm2Action(\'restart\',\'' + esc(p.name) + '\')">🔄</button>' +
'<button class="btn btn-danger" onclick="pm2Action(\'delete\',\'' + esc(p.name) + '\')">🗑</button>' +
'</div></td>' +
'</tr>';
}).join('');
}
async function pm2Action(action, name) {
const labels = { stop: '停止', start: '啟動', restart: '重啟', delete: '刪除' };
if (action === 'delete' && !confirm('確定要刪除 PM2 程序「' + name + '」嗎?')) return;
try {
await postJson('/api/pm2/action', { action, name });
showToast('✅ 已' + labels[action] + '' + name, 'success');
loadPM2();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
// ---------- log 面板(共用) ----------
async function openLog(id, type, name) {
currentLog = { id, type, name };
document.getElementById('log-panel').style.display = 'block';
await refreshLog();
document.getElementById('log-panel').scrollIntoView({ behavior: 'smooth' });
}
async function refreshLog() {
if (!currentLog) return;
try {
const d = await fetchJson('/api/pm2/log?id=' + currentLog.id + '&type=' + currentLog.type + '&lines=200');
document.getElementById('log-title').innerHTML =
esc(d.name) + ' · ' + (currentLog.type === 'err' ? '錯誤' : '輸出') + ' log' +
'<small>' + esc(d.path) + '(最後 200 行' + (d.truncated ? ',大檔已截尾' : '') + '</small>';
const pre = document.getElementById('log-content');
pre.textContent = d.content || '(空白)';
pre.scrollTop = pre.scrollHeight;
} catch (e) {
document.getElementById('log-content').textContent = '❌ ' + e.message;
}
}
function closeLog() {
currentLog = null;
document.getElementById('log-panel').style.display = 'none';
document.getElementById('log-auto-refresh').checked = false;
syncLogTimer();
}
function syncLogTimer() {
if (logTimer) { clearInterval(logTimer); logTimer = null; }
if (document.getElementById('log-auto-refresh').checked && currentLog) {
logTimer = setInterval(refreshLog, 5000);
}
}
document.getElementById('log-auto-refresh').addEventListener('change', syncLogTimer);
// modal 以外點擊關閉
document.getElementById('bot-modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
let listTimer = setInterval(refreshActive, 5000);
document.getElementById('auto-refresh').addEventListener('change', (e) => {
clearInterval(listTimer);
if (e.target.checked) listTimer = setInterval(refreshActive, 5000);
});
loadModels();
loadBots();
</script>
</body>
</html>