發佈 0.2.0:改名 telegram-bot,新增 Claude Code 引擎(新增 bot 時可選 Codex / Claude),start 自動從 0.1.x 遷移

This commit is contained in:
2026-08-24 11:42:37 +08:00
parent a99152d765
commit f7e94e4bd7
21 changed files with 886 additions and 235 deletions
+90 -20
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>tgcodex 控制台</title>
<title>telegram-bot 控制台</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -99,12 +99,16 @@
.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> tgcodex 控制台</h1>
<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>
@@ -143,7 +147,7 @@
</table>
</div>
<p class="meta" style="margin-top:12px">
Bot 設定存於 <code>~/.tgcodex/bots/&lt;名稱&gt;/</code>;啟動後掛在 pm2 上(同名程序)。
Bot 設定存於 <code>~/.tgbot/bots/&lt;名稱&gt;/</code>;啟動後掛在 pm2 上(同名程序)。每個 bot 可各自選 Codex 或 Claude 引擎。
私訊直接回應;群組中要 @bot 或回覆 bot 的訊息才會回應。
</p>
</div>
@@ -188,36 +192,44 @@
<input id="f-name" placeholder="my-project-bot" autocomplete="off">
<div class="hint">英數字、點、底線、連字號;也是 pm2 程序名稱,建立後不可改</div>
</div>
<div class="field">
<label>AI 引擎</label>
<select id="f-engine" onchange="onEngineChange()">
<option value="codex">CodexOpenAI Codex CLI</option>
<option value="claude">ClaudeClaude Code CLI</option>
</select>
<div class="hint" id="f-engine-hint"></div>
</div>
<div class="field">
<label>Telegram Bot Token <b id="f-token-req">*</b></label>
<input id="f-token" placeholder="123456:ABC-DEF..." autocomplete="off">
<div class="hint" id="f-token-hint">跟 @BotFather 申請;儲存時會自動驗證</div>
</div>
<div class="field">
<label>工作目錄(Codex 可讀寫)<b>*</b></label>
<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">Codex 預設能讀寫這個目錄(workspace-write);選擇視窗會開在這台機器的桌面上</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" selected>workspace-write(可讀寫工作目錄,預設)</option>
<option value="read-only">read-only(唯讀)</option>
<option value="danger-full-access">danger-full-access(不設限,危險)</option>
<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">清單來自本機 codex 的模型快取</div>
<div class="hint" id="f-model-hint">清單來自本機 codex 的模型快取</div>
</div>
<div class="field">
<label>推理強度</label>
<select id="f-effort"></select>
<div class="hint">速度固定為 Fast1.5x</div>
<div class="hint" id="f-effort-hint">速度固定為 Fast1.5x</div>
</div>
<div class="field">
<label>會話模式</label>
@@ -228,7 +240,7 @@
</select>
<div class="hint">記憶會隨對話變慢(尤其含圖);用量達 80% 時所有模式都會自動開新會話</div>
</div>
<div class="field">
<div class="field" id="f-network-wrap">
<label class="check" style="margin-top:2px"><input type="checkbox" id="f-network"> 允許 Codex 連網</label>
<div class="hint">workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開</div>
</div>
@@ -305,9 +317,55 @@ function refreshActive() {
if (activeTab === 'bots') loadBots(); else loadPM2();
}
// ---------- 模型清單(來自 /api/models,讀 codex models cache ----------
let modelsCache = [];
let modelDefaults = { model: '', effort: '', tier: '' }; // codex 全域預設(config.toml
// ---------- 模型清單(來自 /api/modelscodex models cacheclaude 用內建清單 ----------
let enginesCache = {}; // { codex: {available, models, defaults}, claude: {...} }
let defaultEngine = 'codex';
const ENGINE_UI = {
codex: {
hint: '本機 OpenAI Codex CLIcodex exec)。專案規範讀工作目錄的 AGENTS.md',
workdir: '規範檔:AGENTS.md',
modelHint: '清單來自本機 codex 的模型快取',
effortHint: '速度固定為 Fast1.5x',
sandbox: {
'workspace-write': 'workspace-write(可讀寫工作目錄,預設)',
'read-only': 'read-only(唯讀)',
'danger-full-access': 'danger-full-access(不設限,危險)',
},
},
claude: {
hint: '本機 Claude Code CLIclaude -p)。專案規範讀工作目錄的 CLAUDE.md',
workdir: '規範檔:CLAUDE.md',
modelHint: '別名 opus / sonnet / haiku 永遠指向最新版;也可在編輯時手動改成任何模型 id',
effortHint: 'Claude 的 effort 對應 --effort 參數',
sandbox: {
'workspace-write': 'workspace-write(自動接受檔案編輯;不執行指令,預設)',
'read-only': 'read-only(只讀:不改檔、不執行指令)',
'danger-full-access': 'danger-full-access(跳過所有權限確認,危險)',
},
},
};
function currentEngine() { return document.getElementById('f-engine').value || 'codex'; }
function engineData() { return enginesCache[currentEngine()] || { models: [], defaults: {} }; }
function engineTag(engine) {
const e = engine === 'claude' ? 'claude' : 'codex';
return '<span class="tag tag-' + e + '">' + (e === 'claude' ? 'Claude' : 'Codex') + '</span>';
}
// 引擎切換:改文案、顯示/隱藏引擎專屬選項、重載模型清單
function onEngineChange(selModel, selEffort) {
const ui = ENGINE_UI[currentEngine()];
const avail = enginesCache[currentEngine()] && enginesCache[currentEngine()].available;
document.getElementById('f-engine-hint').textContent = ui.hint + (avail === false ? ' ⚠ 這台機器找不到此 CLIbot 會啟動失敗' : '');
document.getElementById('f-workdir-hint').textContent = ui.workdir;
document.getElementById('f-model-hint').textContent = ui.modelHint;
document.getElementById('f-effort-hint').textContent = ui.effortHint;
document.getElementById('f-sb-ww').textContent = ui.sandbox['workspace-write'];
document.getElementById('f-sb-ro').textContent = ui.sandbox['read-only'];
document.getElementById('f-sb-full').textContent = ui.sandbox['danger-full-access'];
document.getElementById('f-network-wrap').style.display = currentEngine() === 'codex' ? '' : 'none';
// 換引擎時強度清單重來,不要把上一個引擎選的值帶過去
if (!selEffort) document.getElementById('f-effort').innerHTML = '';
populateModelSelects(selModel || '', selEffort || '');
}
const EFFORT_LABELS = {
low: 'low(快速、輕度推理)',
medium: 'medium(平衡,一般預設)',
@@ -319,13 +377,20 @@ const EFFORT_LABELS = {
async function loadModels() {
try {
const d = await fetchJson('/api/models');
modelsCache = d.models || [];
modelDefaults = d.defaults || modelDefaults;
} catch { modelsCache = []; }
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)) {
@@ -340,6 +405,8 @@ function populateModelSelects(selModel, 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'];
@@ -382,7 +449,7 @@ function renderBots(bots) {
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>' +
'<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>' +
@@ -456,7 +523,8 @@ function openCreate() {
document.getElementById('f-token-hint').textContent = '跟 @BotFather 申請;儲存時會自動驗證';
document.getElementById('f-workdir').value = '';
document.getElementById('f-sandbox').value = 'workspace-write';
populateModelSelects('', '');
document.getElementById('f-engine').value = defaultEngine;
onEngineChange('', '');
document.getElementById('f-session').value = 'per-chat';
document.getElementById('f-network').checked = false;
document.getElementById('f-timeout').value = '30';
@@ -479,7 +547,8 @@ function openEdit(name) {
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 || '');
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-timeout').value = b.timeoutMinutes || 30;
@@ -499,6 +568,7 @@ async function saveBot() {
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,
+64 -25
View File
@@ -1,10 +1,11 @@
'use strict';
// tgcodex 控制台:管理 TG bot(新增/編輯/啟停,token 等設定都在網頁填)+ PM2 檢視分頁。
// tgbot 控制台:管理 TG bot(新增/編輯/啟停,token 等設定都在網頁填)+ PM2 檢視分頁。
// 零依賴(內建 http)。預設只綁 127.0.0.1 —— 這個介面能控制 pm2 與 bot token,不要裸露到外網。
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { jlist, actionByName, startBotOnPm2 } = require('../pm2util');
const { jlist, actionByName, startBotOnPm2, TGBOT_HOME } = require('../pm2util');
const { listBots, createBot, updateBot, removeBot, getBotConfig } = require('../registry');
const { assertBotConfig, ensureStateDirs } = require('../config');
@@ -94,35 +95,41 @@ async function apiPm2Log(pmId, type, lines) {
}
// ---------- 模型清單 ----------
// 直接讀 codex 自己的 models cache~/.codex/models_cache.json),
// 新模型上線後下拉選單自動跟上;讀不到就退回內建清單。
const FALLBACK_MODELS = [
{ slug: 'gpt-5.6-sol', label: 'GPT-5.6-Sol', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], defaultEffort: 'medium', tiers: [{ id: 'priority', name: 'Fast', description: '1.5x speed' }] },
{ slug: 'gpt-5.6-terra', label: 'GPT-5.6-Terra', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], defaultEffort: 'medium', tiers: [{ id: 'priority', name: 'Fast', description: '1.5x speed' }] },
{ slug: 'gpt-5.6-luna', label: 'GPT-5.6-Luna', efforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultEffort: 'medium', tiers: [{ id: 'priority', name: 'Fast', description: '1.5x speed' }] },
// codex:直接讀它自己的 models cache~/.codex/models_cache.json),新模型上線後下拉選單自動跟上;
// 讀不到就退回內建清單。
// claude:CLI 沒有模型清單可查,用內建清單(別名 opus/sonnet/haiku 永遠指向最新版)。
const CODEX_FALLBACK_MODELS = [
{ slug: 'gpt-5.6-sol', label: 'GPT-5.6-Sol', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], defaultEffort: 'medium' },
{ slug: 'gpt-5.6-terra', label: 'GPT-5.6-Terra', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], defaultEffort: 'medium' },
{ slug: 'gpt-5.6-luna', label: 'GPT-5.6-Luna', efforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultEffort: 'medium' },
];
const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'max'];
const CLAUDE_MODELS = [
{ slug: 'opus', label: 'Opus(最新版,目前 Opus 5', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
{ slug: 'sonnet', label: 'Sonnet(最新版,較快)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
{ slug: 'haiku', label: 'Haiku(最快、最省)', efforts: ['low', 'medium', 'high'], defaultEffort: 'medium' },
{ slug: 'claude-opus-5', label: 'claude-opus-5(鎖定版本)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
{ slug: 'claude-sonnet-5', label: 'claude-sonnet-5(鎖定版本)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
];
// codex 全域預設(~/.codex/config.toml),前端用來當下拉選單的預選值
function codexGlobalDefaults() {
try {
const os = require('os');
const toml = fs.readFileSync(path.join(os.homedir(), '.codex', 'config.toml'), 'utf-8');
return {
model: toml.match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1] || '',
effort: toml.match(/^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m)?.[1] || '',
tier: toml.match(/^\s*service_tier\s*=\s*"([^"]+)"/m)?.[1] || '',
};
} catch {
return { model: '', effort: '', tier: '' };
return { model: '', effort: '' };
}
}
function apiModels() {
function codexModels() {
const defaults = codexGlobalDefaults();
try {
const os = require('os');
const cachePath = path.join(os.homedir(), '.codex', 'models_cache.json');
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
const cache = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.codex', 'models_cache.json'), 'utf-8'));
const models = (cache.models || [])
.filter((m) => m.visibility !== 'hide' && m.slug)
.map((m) => ({
@@ -130,12 +137,41 @@ function apiModels() {
label: m.display_name || m.slug,
efforts: (m.supported_reasoning_levels || []).map((e) => e.effort).filter(Boolean),
defaultEffort: m.default_reasoning_level || '',
tiers: (m.service_tiers || []).map((t) => ({ id: t.id, name: t.name || t.id, description: t.description || '' })),
defaultTier: m.default_service_tier || '',
}));
if (models.length > 0) return { models, defaults, source: 'codex-cache' };
} catch { /* fall through */ }
return { models: FALLBACK_MODELS, defaults, source: 'fallback' };
return { models: CODEX_FALLBACK_MODELS, defaults, source: 'fallback' };
}
// claude 全域預設(~/.claude/settings.json 的 model;沒有就 opus
function claudeModels() {
let model = '';
try {
const s = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'settings.json'), 'utf-8'));
if (typeof s.model === 'string') model = s.model;
} catch { /* ignore */ }
return { models: CLAUDE_MODELS, defaults: { model: model || 'opus', effort: '' }, source: 'builtin' };
}
// 引擎是否裝了(開控制台時查一次就好,之後不變)
let availabilityCache = null;
function engineAvailability() {
if (!availabilityCache) {
const { codexVersion, claudeVersion } = require('../doctor');
availabilityCache = { codex: !!codexVersion(), claude: !!claudeVersion() };
}
return availabilityCache;
}
function apiModels() {
const avail = engineAvailability();
return {
engines: {
codex: { available: avail.codex, ...codexModels() },
claude: { available: avail.claude, ...claudeModels() },
},
defaultEngine: avail.codex || !avail.claude ? 'codex' : 'claude',
};
}
// ---------- Bot 管理分頁 ----------
@@ -259,7 +295,7 @@ function pickFolderNative() {
'$o.Location = [System.Drawing.Point]::new($wa.X + [int]($wa.Width/2), $wa.Y + [int]($wa.Height/2))',
'$o.Show(); $o.Activate()',
'$f = New-Object System.Windows.Forms.FolderBrowserDialog',
"$f.Description = '選擇 Codex 工作目錄'",
"$f.Description = '選擇 bot 的工作目錄'",
'$f.ShowNewFolderButton = $true',
'$r = $f.ShowDialog($o)',
'$o.Close()',
@@ -269,11 +305,11 @@ function pickFolderNative() {
settle(child, err ? new Error('無法開啟資料夾選擇視窗:' + err.message) : null, err ? null : (stdout.trim() || null));
});
} else if (process.platform === 'darwin') {
child = execFile('osascript', ['-e', 'POSIX path of (choose folder with prompt "選擇 Codex 工作目錄")'], opts, (err, stdout) => {
child = execFile('osascript', ['-e', 'POSIX path of (choose folder with prompt "選擇 bot 的工作目錄")'], opts, (err, stdout) => {
settle(child, null, err ? null : (stdout.trim().replace(/\/$/, '') || null)); // 取消會回非零,視同 null
});
} else {
child = execFile('zenity', ['--file-selection', '--directory', '--title=選擇 Codex 工作目錄'], opts, (err, stdout) => {
child = execFile('zenity', ['--file-selection', '--directory', '--title=選擇 bot 的工作目錄'], opts, (err, stdout) => {
settle(child, null, err ? null : (stdout.trim() || null));
});
}
@@ -311,6 +347,9 @@ function createWebServer({ port = 3799, host = '127.0.0.1' } = {}) {
if (url.pathname === '/api/models' && req.method === 'GET') {
return json(200, apiModels());
}
if (url.pathname === '/api/info' && req.method === 'GET') {
return json(200, { home: TGBOT_HOME, version: require('../../package.json').version });
}
if (url.pathname === '/api/pick-folder' && req.method === 'POST') {
const picked = await pickFolderNative();
return json(200, picked ? { path: picked } : { canceled: true });
@@ -360,7 +399,7 @@ function createWebServer({ port = 3799, host = '127.0.0.1' } = {}) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, () => {
console.log(`✅ tgcodex 控制台已啟動:http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`);
console.log(`✅ telegram-bot 控制台已啟動:http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`);
resolve(server);
});
});
@@ -371,12 +410,12 @@ function createWebServer({ port = 3799, host = '127.0.0.1' } = {}) {
module.exports = { createWebServer };
// 直接執行:node src/web/server.jspm2 的 tgcodex-console 跑的就是這個)
// 直接執行:node src/web/server.jspm2 的 tgbot-console 跑的就是這個)
if (require.main === module) {
const { loadConsoleSettings } = require('../pm2util');
const settings = loadConsoleSettings();
const port = Number(process.env.TGCODEX_WEB_PORT) || settings.port;
const host = process.env.TGCODEX_WEB_HOST || settings.host;
const port = Number(process.env.TGBOT_WEB_PORT || process.env.TGCODEX_WEB_PORT) || settings.port;
const host = process.env.TGBOT_WEB_HOST || process.env.TGCODEX_WEB_HOST || settings.host;
createWebServer({ port, host }).listen().catch((err) => {
console.error('啟動失敗:', err.message);
process.exit(1);