753 lines
37 KiB
HTML
753 lines
37 KiB
HTML
<!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/<名稱>/</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">Codex(OpenAI Codex CLI)</option>
|
||
<option value="claude">Claude(Claude 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">速度固定為 Fast(1.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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
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,claude 用內建清單) ----------
|
||
let enginesCache = {}; // { codex: {available, models, defaults}, claude: {...} }
|
||
let defaultEngine = 'codex';
|
||
const ENGINE_UI = {
|
||
codex: {
|
||
hint: '本機 OpenAI Codex CLI(codex exec)。專案規範讀工作目錄的 AGENTS.md',
|
||
workdir: '規範檔:AGENTS.md',
|
||
modelHint: '清單來自本機 codex 的模型快取',
|
||
effortHint: '速度固定為 Fast(1.5x)',
|
||
sandbox: {
|
||
'workspace-write': 'workspace-write(可讀寫工作目錄,預設)',
|
||
'read-only': 'read-only(唯讀)',
|
||
'danger-full-access': 'danger-full-access(不設限,危險)',
|
||
},
|
||
},
|
||
claude: {
|
||
hint: '本機 Claude Code CLI(claude -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 ? ' ⚠ 這台機器找不到此 CLI,bot 會啟動失敗' : '');
|
||
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>
|