2026-08-04 16:25:39 +08:00
|
|
|
|
<!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/<名稱>/</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>
|
2026-08-07 12:02:07 +08:00
|
|
|
|
<div class="hint">速度固定為 Fast(1.5x)</div>
|
2026-08-04 16:25:39 +08:00
|
|
|
|
</div>
|
2026-08-10 12:05:30 +08:00
|
|
|
|
<div class="field">
|
|
|
|
|
|
<label>會話模式</label>
|
|
|
|
|
|
<select id="f-session">
|
|
|
|
|
|
<option value="per-chat" selected>各聊天室獨立(私訊、每個群組各自記憶)</option>
|
|
|
|
|
|
<option value="shared">所有聊天室共用(同一條記憶,適合單一專案助理)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<div class="hint">共用模式下 /new 會清掉所有聊天室的記憶</div>
|
|
|
|
|
|
</div>
|
2026-08-04 16:25:39 +08:00
|
|
|
|
<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) ----------
|
|
|
|
|
|
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 = []; }
|
|
|
|
|
|
}
|
|
|
|
|
|
// 下拉沒有「預設」空選項:直接把真實預設值選起來
|
2026-08-07 12:02:07 +08:00
|
|
|
|
function populateModelSelects(selModel, selEffort) {
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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;
|
2026-08-07 12:02:07 +08:00
|
|
|
|
syncEffortTier(selEffort);
|
2026-08-04 16:25:39 +08:00
|
|
|
|
}
|
2026-08-07 12:02:07 +08:00
|
|
|
|
function syncEffortTier(selEffort) {
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 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 || '-') +
|
2026-08-10 12:05:30 +08:00
|
|
|
|
((b.model || b.reasoningEffort || b.sharedSession)
|
|
|
|
|
|
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.sharedSession ? '共用會話' : ''].filter(Boolean).join(' / ')) + '</span>'
|
2026-08-04 16:25:39 +08:00
|
|
|
|
: '') + '</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';
|
2026-08-07 12:02:07 +08:00
|
|
|
|
populateModelSelects('', '');
|
2026-08-10 12:05:30 +08:00
|
|
|
|
document.getElementById('f-session').value = 'per-chat';
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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';
|
2026-08-07 12:02:07 +08:00
|
|
|
|
populateModelSelects(b.model || '', b.reasoningEffort || '');
|
2026-08-10 12:05:30 +08:00
|
|
|
|
document.getElementById('f-session').value = b.sharedSession ? 'shared' : 'per-chat';
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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,
|
2026-08-10 12:05:30 +08:00
|
|
|
|
sharedSession: document.getElementById('f-session').value === 'shared',
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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>
|