建立 telegram-codex-bot:串接 Codex CLI 的 Telegram bot(npm 包)

摘要:
用 Telegram 操控本機 Codex CLI 的 bot,附 web 控制台,封裝為 npm 包。

內容:
- 零依賴(僅 Node 內建模組);bot 以 codex exec --json 驅動,支援會話延續
  (exec resume)、workspace-write 沙盒、圖片輸入、進度回報與 ctx%/tokens footer
- web 控制台(pm2 託管,預設 127.0.0.1:3799):Bot 管理分頁(新增/編輯/啟停,
  token 自動驗證、工作目錄用原生視窗選、模型/推理強度/速度下拉,清單來自
  ~/.codex/models_cache.json)+ PM2 檢視分頁(狀態/port/log/啟停)
- CLI:start(環境檢查後把控制台掛上 pm2)/ stop / restart / delete / status /
  logs / web / doctor
- Windows 相容:解析 codex.cmd shim 直接以 node 執行、taskkill 整樹砍程序、
  資料夾選擇視窗以 TopMost 透明 owner 置中

影響:
新專案初始版本;bot 設定存於 ~/.tgcodex/bots/<name>/,含明文 token 的實例
設定不進版控(.gitignore 已涵蓋 tgcodex.config.json 與 .tgcodex/)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 16:25:39 +08:00
co-authored by Claude Fable 5
commit 5b32816ad4
16 changed files with 2540 additions and 0 deletions
+657
View File
@@ -0,0 +1,657 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>tgcodex 控制台</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 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: 7px; font-size: 13px; color: #cbd5e1; 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; }
</style>
</head>
<body>
<div class="container">
<header>
<h1><span></span> tgcodex 控制台</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>~/.tgcodex/bots/&lt;名稱&gt;/</code>;啟動後掛在 pm2 上(同名程序)。
私訊直接回應;群組中要 @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>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>工作目錄(Codex 可讀寫)<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">Codex 預設能讀寫這個目錄(workspace-write);選擇視窗會開在這台機器的桌面上</div>
</div>
<div class="field">
<label>沙盒模式</label>
<select id="f-sandbox">
<option value="workspace-write" selected>workspace-write(可讀寫工作目錄,預設)</option>
<option value="read-only">read-only(唯讀)</option>
<option value="danger-full-access">danger-full-access(不設限,危險)</option>
</select>
</div>
<div class="field">
<label>模型</label>
<select id="f-model" onchange="syncEffortTier()"></select>
<div class="hint">清單來自本機 codex 的模型快取</div>
</div>
<div class="field">
<label>推理強度</label>
<select id="f-effort"></select>
</div>
<div class="field">
<label>速度</label>
<select id="f-tier"></select>
</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/models,讀 codex 的 models cache ----------
let modelsCache = [];
let modelDefaults = { model: '', effort: '', tier: '' }; // codex 全域預設(config.toml
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');
modelsCache = d.models || [];
modelDefaults = d.defaults || modelDefaults;
} catch { modelsCache = []; }
}
// 下拉沒有「預設」空選項:直接把真實預設值選起來
function populateModelSelects(selModel, selEffort, selTier) {
const mSel = document.getElementById('f-model');
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, selTier);
}
function syncEffortTier(selEffort, selTier) {
const slug = document.getElementById('f-model').value;
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];
const tiers = m ? m.tiers.slice() : [];
const tSel = document.getElementById('f-tier');
const prevT = (selTier !== undefined && selTier !== '') ? selTier : tSel.value;
if (!tiers.length) {
tSel.innerHTML = '<option value="">標準</option>';
tSel.value = '';
} else {
// 模型快取只列「額外」的速度層級,補一個標準選項在前面
tSel.innerHTML = '<option value="">標準</option>' +
tiers.map(t => '<option value="' + esc(t.id) + '">' + esc(t.name) + (t.description ? '' + esc(t.description) + '' : '') + '</option>').join('');
tSel.value = tiers.some(t => t.id === prevT) ? prevT
: tiers.some(t => t.id === modelDefaults.tier) ? modelDefaults.tier
: tiers.some(t => t.id === (m && m.defaultTier)) ? m.defaultTier
: '';
}
}
// ---------- 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>' +
(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.serviceTier)
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.serviceTier].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';
populateModelSelects('', '', '');
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';
populateModelSelects(b.model || '', b.reasoningEffort || '', b.serviceTier || '');
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(),
sandbox: document.getElementById('f-sandbox').value,
model: document.getElementById('f-model').value,
reasoningEffort: document.getElementById('f-effort').value,
serviceTier: document.getElementById('f-tier').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>