6 Commits
14 changed files with 155 additions and 51 deletions
+19 -9
View File
@@ -8,7 +8,8 @@
- **零依賴**:不用裝任何 npm 套件,只吃 Node 內建模組
- **pm2 託管**:控制台與每個 bot 都掛在 pm2,開機自啟、當機自動重啟
- **多 bot**:一頁管理多個 bot,各自不同引擎、token、專案目錄
- **會話延續**:每個聊天室一個會話,`/new` 開新會話;支援傳圖、引用回覆;bot 之間可互相接力(引用另一隻 bot 的回覆再 tag)
- **會話延續**:每個聊天室一個會話,`/new` 開新會話;支援傳圖與附件(PDF/文字/程式碼檔)、引用回覆;bot 之間可互相接力(引用另一隻 bot 的回覆再 tag)
- **權限與規範**:可限定只有哪些 Telegram 帳號能讓 AI 改檔/跑指令(其他人自動唯讀);可設每則訊息前綴的回覆規範(專案守門、成員身分)
## 前置需求
@@ -40,7 +41,7 @@ telegram-bot start # 重新 start 一次,bot 程序會自動切到新版
1. 填名稱、**選 AI 引擎(Codex 或 Claude)**、Telegram token(儲存時自動驗證)
2. 工作目錄按「📂 瀏覽」用原生視窗選資料夾(AI 可讀寫的專案路徑;Codex 讀裡面的 `AGENTS.md`、Claude 讀 `CLAUDE.md` 當專案規範)
3. 模型/推理強度用下拉選(Codex 清單來自本機 codex 的模型快取;Claude 用 opus/sonnet/haiku 別名永遠指向最新版)
3. 模型/推理強度用下拉選,清單依引擎不同(Codex 來自本機 codex 的模型快取,如 GPT-5.6 Sol/Terra/Luna;Claude 為 Fable 5 / Opus 5 / Sonnet 5)
4. 沙盒模式預設 `workspace-write` → 勾「建立後立即啟動」→ 儲存,完成
之後對 bot 私訊即可;群組中要 `@bot` 或回覆 bot 的訊息。
@@ -55,7 +56,7 @@ telegram-bot start # 重新 start 一次,bot 程序會自動切到新版
|---|---|---|
| 底層指令 | `codex exec --json` | `claude -p --output-format stream-json` |
| 專案規範檔 | `AGENTS.md` | `CLAUDE.md` |
| 模型 | 本機 codex 模型快取(GPT-5.6 Sol/Terra/Luna…) | `opus` / `sonnet` / `haiku` 別名或任何模型 id |
| 模型 | 本機 codex 模型快取(GPT-5.6 Sol/Terra/Luna…) | Fable 5 / Opus 5 / Sonnet 5(設定檔可手填其他模型 id) |
| 推理強度 | low ~ ultra(依模型) | low / medium / high / max |
| 速度 | 固定 Fast(priority 1.5x) | — |
| `read-only` | codex 唯讀沙盒 | 禁用 Write/Edit/Bash 等工具 |
@@ -63,6 +64,7 @@ telegram-bot start # 重新 start 一次,bot 程序會自動切到新版
| `danger-full-access` | 不設限 | `--dangerously-skip-permissions`,不設限 |
| 生圖 | 內建 imagegen 技能,產圖自動傳回 | 無(可自行在 CLAUDE.md 接其他工具) |
| 傳圖給 bot | `-i` 直接餵給模型 | 存暫存檔後由 Read 工具讀取 |
| 傳檔案給 bot(PDF / 文字 / 程式碼) | 路徑寫進 prompt,由 codex 讀取 | 存暫存檔後由 Read 工具讀取 |
兩種引擎的回覆尾端都會附 `— ✅ 時間 | ctx 用量% +本輪 token | 模型 / 強度`
AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動傳到 Telegram 並清空。
@@ -79,7 +81,7 @@ AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動
**① Bot 管理首頁** — 每個 bot 一列:名稱旁標示引擎、狀態、工作目錄、沙盒與模型設定、遮罩後的 token。
右側按鈕:▶ 啟動 / ⏹ 停止、🔄 重啟、📄 log、✏️ 編輯、🗑 刪除。上方橫幅提醒群組使用要先關 Group Privacy。
![Bot 管理首頁](https://jianmiau.ddns.net/AI/telegram-codex-bot/media/branch/master/docs/01-bots.png)
![Bot 管理首頁](https://jianmiau.ddns.net/AI/telegram-bot/media/branch/master/docs/01-bots.png)
**② 新增 Bot** — 按右上「➕ 新增 Bot」:
@@ -89,21 +91,28 @@ AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動
4. **工作目錄**:按「📂 瀏覽…」會在這台機器桌面跳出原生資料夾視窗,選完自動帶入
5. **模型 / 推理強度**:下拉選單,預設值自動帶你機器上該引擎的全域設定;換模型時強度選項會連動
6. **會話模式**:各聊天室獨立 / 所有聊天室共用 / 每則訊息獨立(不保留記憶,最快)
7. 勾「建立後立即啟動」→ 儲存,bot 就上線了
7. **額外選項**(打勾才展開;取消打勾儲存會清空):
- **可讀寫成員**:填 Telegram username(逗號分隔),清單外的人一律以 read-only 沙盒執行,由 CLI 層硬擋;留空 = 所有人都依沙盒設定
- **回覆規範**:每則訊息開頭附加的規則,例如「只回答本專案相關問題」「@xxx 是後端工程師,回覆用 API 契約視角」;專案本身的規範仍放工作目錄的 `AGENTS.md` / `CLAUDE.md`
8. 勾「建立後立即啟動」→ 儲存,bot 就上線了
![新增 Bot 表單](https://jianmiau.ddns.net/AI/telegram-codex-bot/media/branch/master/docs/02-create.png)
![新增 Bot 表單](https://jianmiau.ddns.net/AI/telegram-bot/media/branch/master/docs/02-create.png)
引擎切到 Claude 時,模型/強度/沙盒說明會跟著換:
![新增 Claude Bot](https://jianmiau.ddns.net/AI/telegram-codex-bot/media/branch/master/docs/02b-create-claude.png)
![新增 Claude Bot](https://jianmiau.ddns.net/AI/telegram-bot/media/branch/master/docs/02b-create-claude.png)
**編輯既有 bot** — 引擎、模型、可讀寫成員、回覆規範都可事後修改;執行中的 bot 儲存後會自動重啟套用。
![編輯 Claude Bot](https://jianmiau.ddns.net/AI/telegram-bot/media/branch/master/docs/05-edit-claude.png)
**③ PM2 分頁** — 整台機器所有 pm2 程序。Port 可直接點開對應網頁,右側可啟停/重啟/刪除任何程序。
![PM2 分頁](https://jianmiau.ddns.net/AI/telegram-codex-bot/media/branch/master/docs/03-pm2.png)
![PM2 分頁](https://jianmiau.ddns.net/AI/telegram-bot/media/branch/master/docs/03-pm2.png)
**④ Log 檢視** — 點任一程序的「📄 out」/「⚠ err」,下方展開尾端 200 行,可勾自動更新(5s)。
![Log 檢視](https://jianmiau.ddns.net/AI/telegram-codex-bot/media/branch/master/docs/04-log.png)
![Log 檢視](https://jianmiau.ddns.net/AI/telegram-bot/media/branch/master/docs/04-log.png)
- 兩個分頁每 5 秒自動更新
- Bot 設定存於 `~/.tgbot/bots/<名稱>/tgbot.config.json`,bot 掛 pm2 的程序名 = bot 名稱
@@ -134,6 +143,7 @@ telegram-bot web 前景執行控制台(除錯用)
- **私訊**:任何訊息都回應;**群組**:只回應 `@bot` 或回覆 bot 訊息的情況;訊息同時 tag 多個 bot 時只有第一個回應
- **引用**:回覆/圈選引用某則訊息再 tag bot,引用內容會一起送給 AI;只引用不打字就直接把引用當輸入
- **附件**:圖片、PDF、文字/程式碼檔都會下載給 AI 讀(Telegram Bot 下載上限 20MB;docx/xlsx/zip 等二進位不支援,bot 會回覆說明)
- **bot 接力**:Telegram 不讓 bot 看到其他 bot 的訊息,所以「回覆 A bot 的回覆 + tag B bot(不打字)」時,B 會改拿同聊天室最近一則我們自家 bot 的發言當輸入(6 小時內)
- 對話記憶用到 80% 上下文時自動開新會話(回覆會標示),避免越用越慢
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 99 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "telegram-bot",
"version": "0.2.0",
"version": "0.2.1",
"description": "Telegram bot powered by your local AI CLI (OpenAI Codex or Claude Code) — zero-dependency, pm2-managed, with a web console to manage multiple bots",
"license": "MIT",
"type": "commonjs",
@@ -9,7 +9,7 @@
},
"repository": {
"type": "git",
"url": "ssh://git@jianmiau.ddns.net:8022/AI/telegram-codex-bot.git"
"url": "ssh://git@jianmiau.ddns.net:8022/AI/telegram-bot.git"
},
"main": "src/index.js",
"bin": {
+50 -17
View File
@@ -205,18 +205,28 @@ function startBot(config) {
} catch { /* ignore */ }
}
// 取出訊息附圖(photo 取最大尺寸;image/* 的 document 也支援)
function pickPhoto(message) {
// 取出訊息附件:photo 取最大尺寸;document 依 mime/副檔名分成 image / filePDF、文字、程式碼)/ unsupported
const TELEGRAM_GETFILE_LIMIT = 20 * 1024 * 1024; // Bot API getFile 上限
const TEXT_EXTS = new Set(['.txt', '.md', '.log', '.json', '.yaml', '.yml', '.csv', '.tsv', '.xml', '.html', '.htm', '.css',
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rs', '.c', '.cc', '.cpp', '.h', '.hpp', '.cs',
'.php', '.rb', '.sh', '.bat', '.ps1', '.sql', '.toml', '.ini', '.cfg', '.env', '.conf', '.vue', '.svelte', '.kt', '.swift']);
function pickAttachment(message) {
if (message.photo?.length) {
const largest = message.photo[message.photo.length - 1];
return { fileId: largest.file_id, uniqueId: largest.file_unique_id, ext: '.jpg' };
return { kind: 'image', fileId: largest.file_id, uniqueId: largest.file_unique_id, ext: '.jpg', name: '', size: largest.file_size || 0 };
}
const doc = message.document;
if (doc && /^image\//.test(doc.mime_type || '')) {
const ext = path.extname(doc.file_name || '') || '.' + (doc.mime_type.split('/')[1] || 'png');
return { fileId: doc.file_id, uniqueId: doc.file_unique_id, ext };
if (!doc) return null;
const mime = doc.mime_type || '';
const name = doc.file_name || '';
const extFromName = path.extname(name).toLowerCase();
const base = { fileId: doc.file_id, uniqueId: doc.file_unique_id, name, mime, size: doc.file_size || 0 };
if (/^image\//.test(mime)) return { ...base, kind: 'image', ext: extFromName || '.' + (mime.split('/')[1] || 'png') };
if (mime === 'application/pdf' || extFromName === '.pdf') return { ...base, kind: 'file', ext: '.pdf' };
if (/^text\//.test(mime) || /^application\/(json|xml|x-yaml|yaml|javascript|typescript|x-sh|toml)$/.test(mime) || TEXT_EXTS.has(extFromName)) {
return { ...base, kind: 'file', ext: extFromName || '.txt' };
}
return null;
return { ...base, kind: 'unsupported', ext: extFromName };
}
// 使用者引用/回覆的文字(圈選引用優先,其次是被回覆的整則訊息)
@@ -255,6 +265,7 @@ function startBot(config) {
const name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者';
const sender = from.username ? `${name}@${from.username}` : name;
const parts = [OUTBOX_RULE];
if (config.promptRules) parts.push(config.promptRules);
const replied = message.reply_to_message;
// 回覆我自己的訊息通常已在會話記憶裡,不重複貼;但使用者沒打字只回覆時例外(那就是輸入本身)
if (replied && (includeOwnReply || replied.from?.id !== botId) && (replied.text || replied.caption)) {
@@ -278,6 +289,8 @@ function startBot(config) {
`模型:${model || `${engine.label} 預設)`}`,
`推理強度:${effort || '(預設)'}`,
`會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`,
config.writeUsers.length ? `可讀寫成員:${config.writeUsers.map((u) => '@' + u).join('、')}(其他人唯讀)` : '可讀寫成員:所有人(依沙盒設定)',
config.promptRules ? `回覆規範:已設定(${config.promptRules.length} 字)` : '回覆規範:無',
config.sessionMode === 'stateless'
? '會話:每則訊息獨立,不保留記憶'
: s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)',
@@ -307,9 +320,14 @@ function startBot(config) {
const chatId = message.chat.id;
const sessionKey = sessionKeyOf(chatId);
const text = message.text || message.caption || '';
const photo = pickPhoto(message);
const attachment = pickAttachment(message);
const photo = attachment; // 有任何附件都視同有內容
if (!text && !photo) return;
if (!shouldRespond(message)) return;
// 讀寫權限:有設 writeUsers 時,清單外的人一律 read-only(沙盒層硬擋,不靠模型自律)
const senderName = (message.from?.username || '').toLowerCase();
const canWrite = config.writeUsers.length === 0 || config.writeUsers.some((u) => u.toLowerCase() === senderName);
const runConfig = canWrite ? config : { ...config, sandbox: 'read-only', networkAccess: false };
const content = stripMention(text);
@@ -365,14 +383,29 @@ function startBot(config) {
try {
const images = [];
if (photo) {
cleanTmp();
const dest = path.join(config.tmpDir, `${Date.now()}-${photo.uniqueId}${photo.ext}`);
await t.downloadFile(photo.fileId, dest);
images.push(dest);
const files = [];
let attachmentNote = '';
if (attachment) {
const meta = [attachment.name && `原檔名 ${attachment.name}`, attachment.mime, attachment.size && `${Math.round(attachment.size / 1024)}KB`]
.filter(Boolean).join('');
if (attachment.kind === 'unsupported') {
attachmentNote = `\n\n【附件】(${meta})格式不支援(docx/xlsx/zip 等二進位讀不了),回覆時告知使用者,建議貼成文字或轉 PDF`;
} else if (attachment.size > TELEGRAM_GETFILE_LIMIT) {
attachmentNote = `\n\n【附件】(${meta})超過 Telegram Bot 下載上限 20MB 無法讀取,回覆時告知使用者`;
} else {
cleanTmp();
const dest = path.join(config.tmpDir, `${Date.now()}-${attachment.uniqueId}${attachment.ext}`);
await t.downloadFile(attachment.fileId, dest);
if (attachment.kind === 'image') {
images.push(dest);
} else {
files.push(dest);
attachmentNote = `\n\n【附件檔案】${meta ? `${meta}` : ''}請先用讀檔工具讀取再處理:\n${dest}`;
}
}
}
let fallback = '(使用者只傳了圖片,請描述並依上下文處理)';
let fallback = files.length ? '(使用者只傳了檔案,請讀取後依上下文處理)' : '(使用者只傳了圖片,請描述並依上下文處理)';
if (quotedOnly && (quoted || !sibling)) {
fallback = '(使用者沒有輸入文字,只引用了上面的內容並 tag 我:請直接把引用內容當作這次的輸入來處理)';
} else if (quotedOnly && sibling) {
@@ -380,7 +413,7 @@ function startBot(config) {
`【引用的 bot 訊息(@${sibling.botUsername} 在本聊天室的最近發言)】\n${sibling.text}\n\n` +
'(使用者沒有輸入文字,只引用了訊息並 tag 我:請直接把上面引用的內容當作這次的輸入來處理)';
}
const prompt = buildPrompt(message, content || fallback, { includeOwnReply: quotedOnly });
const prompt = buildPrompt(message, (content || fallback) + attachmentNote, { includeOwnReply: quotedOnly });
const existing = sessionKey ? sessions.get(sessionKey) : null;
const turnStart = Date.now();
@@ -390,7 +423,7 @@ function startBot(config) {
const MAX_ATTEMPTS = 2;
for (let attempt = 1; ; attempt++) {
try {
result = await engine.run({ config, prompt, threadId: tid, images, onProgress });
result = await engine.run({ config: runConfig, prompt, threadId: tid, images, files, onProgress });
break;
} catch (err) {
// 舊會話 resume 不了:1) 會話已被引擎清掉 2) 換模型後 codex 拒絕跨模型 resume
@@ -443,7 +476,7 @@ function startBot(config) {
// claude 在非互動模式下被擋掉的操作(例如 workspace-write 不能執行指令),讓使用者知道
if (Array.isArray(result.denials) && result.denials.length > 0) {
const tools = [...new Set(result.denials.map((d) => d.tool_name).filter(Boolean))].join('、');
ctxNote += `\n\n⚠️ 有 ${result.denials.length} 個操作因沙盒權限被拒${tools ? `${tools}` : ''};目前沙盒為 ${config.sandbox}`;
ctxNote += `\n\n⚠️ 有 ${result.denials.length} 個操作因沙盒權限被拒${tools ? `${tools}` : ''};目前沙盒為 ${runConfig.sandbox}${canWrite ? '' : '(你不在可讀寫成員清單內)'}`;
}
const footer = buildFooter({ ...display, usage: result.usage });
+14 -1
View File
@@ -35,6 +35,11 @@ const DEFAULTS = {
autoResetCtxPercent: 80,
// codex 專用:允許在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開)
networkAccess: false,
// 每則訊息開頭附加的回覆規範(例如:只回答專案相關問題、成員身分說明);空字串 = 不加
promptRules: '',
// 可讀寫的 Telegram username 清單(不含 @);非空時,清單外的人一律以 read-only 沙盒執行。
// 空陣列 = 所有人都依 sandbox 設定
writeUsers: [],
// 單次回應逾時(分鐘)
timeoutMinutes: 30,
// pm2 程序名稱
@@ -47,6 +52,12 @@ const DEFAULTS = {
claudePath: '',
};
// 'a, @b' / ['a','@b'] → ['a','b'](去 @、去空白、去重)
function normalizeUsers(v) {
const arr = Array.isArray(v) ? v : String(v || '').split(/[\s,;]+/);
return [...new Set(arr.map((u) => String(u || '').trim().replace(/^@/, '')).filter(Boolean))];
}
function firstExisting(...files) {
return files.find((f) => fs.existsSync(f)) || files[0];
}
@@ -131,6 +142,8 @@ function loadConfig(explicitPath) {
cfg.sessionMode = raw.sharedSession ? 'shared' : 'per-chat';
}
cfg.autoResetCtxPercent = Number(cfg.autoResetCtxPercent) || 0;
cfg.promptRules = typeof cfg.promptRules === 'string' ? cfg.promptRules.trim() : '';
cfg.writeUsers = normalizeUsers(cfg.writeUsers);
const minutes = Number(cfg.timeoutMinutes);
if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`);
@@ -160,5 +173,5 @@ function ensureStateDirs(cfg) {
module.exports = {
CONFIG_BASENAME, LEGACY_CONFIG_BASENAME, DEFAULTS, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES,
loadConfig, assertBotConfig, ensureStateDirs, findConfigPath,
loadConfig, assertBotConfig, ensureStateDirs, findConfigPath, normalizeUsers,
};
+4 -3
View File
@@ -75,7 +75,7 @@ function permissionArgs(sandbox) {
* 執行一輪 Claude 對話。介面同 engines/codex.js 的 run。
* @returns {Promise<{text: string, threadId: string|null, usage: object|null, model: string|null, denials: object[]}>}
*/
function run({ config, prompt, threadId, images = [], onProgress }) {
function run({ config, prompt, threadId, images = [], files = [], onProgress }) {
return new Promise((resolve, reject) => {
const { cmd, shell } = resolveClaudeCommand(config);
@@ -83,8 +83,9 @@ function run({ config, prompt, threadId, images = [], onProgress }) {
if (config.model) args.push('--model', config.model);
if (config.reasoningEffort) args.push('--effort', config.reasoningEffort);
args.push(...permissionArgs(config.sandbox));
// 附圖:claude -p 沒有圖片參數,改讓 Read 工具讀(要先把暫存目錄加進允許範圍)
if (images.length > 0) args.push('--add-dir', path.dirname(images[0]));
// 附圖/附件claude -p 沒有檔案參數,改讓 Read 工具讀(要先把暫存目錄加進允許範圍)
const attachDirs = [...new Set([...images, ...files].map((f) => path.dirname(f)))];
for (const d of attachDirs) args.push('--add-dir', d);
if (threadId) args.push('--resume', threadId);
let fullPrompt = prompt;
+8 -2
View File
@@ -6,7 +6,7 @@
const fs = require('fs');
const path = require('path');
const { TGBOT_HOME, BOT_SCRIPT, jlist, botConfigPathOf } = require('./pm2util');
const { loadConfig, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, CONFIG_BASENAME } = require('./config');
const { loadConfig, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, CONFIG_BASENAME, normalizeUsers } = require('./config');
const REG_FILE = path.join(TGBOT_HOME, 'registry.json');
const BOTS_DIR = path.join(TGBOT_HOME, 'bots');
@@ -80,6 +80,8 @@ async function listBots() {
sessionMode: cfg?.sessionMode || 'per-chat',
networkAccess: !!cfg?.networkAccess,
serviceTier: cfg?.serviceTier || '',
promptRules: cfg?.promptRules || '',
writeUsers: cfg?.writeUsers || [],
timeoutMinutes: cfg?.timeoutMinutes || null,
tokenMasked: maskToken(cfg?.telegramToken),
hasToken: !!cfg?.telegramToken,
@@ -116,7 +118,7 @@ function validateBotInput({ name, token, workDir, sandbox, engine }, { isCreate
}
}
async function createBot({ name, token, workDir, engine, sandbox, model, reasoningEffort, sessionMode, networkAccess, timeoutMinutes }) {
async function createBot({ name, token, workDir, engine, sandbox, model, reasoningEffort, sessionMode, networkAccess, promptRules, writeUsers, timeoutMinutes }) {
validateBotInput({ name, token, workDir, sandbox, engine }, { isCreate: true });
const reg = loadRegistry();
if (reg.bots[name]) throw new Error(`已有同名 bot${name}`);
@@ -135,6 +137,8 @@ async function createBot({ name, token, workDir, engine, sandbox, model, reasoni
reasoningEffort: reasoningEffort || '',
sessionMode: SESSION_MODES.includes(sessionMode) ? sessionMode : 'per-chat',
networkAccess: !!networkAccess,
promptRules: typeof promptRules === 'string' ? promptRules.trim() : '',
writeUsers: normalizeUsers(writeUsers),
serviceTier: 'priority', // codex 速度固定 Fast 1.5x
timeoutMinutes: Number(timeoutMinutes) || 30,
pm2Name: name,
@@ -172,6 +176,8 @@ async function updateBot(name, patch) {
if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort;
if (SESSION_MODES.includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; }
if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess;
if (patch.promptRules !== undefined) raw.promptRules = String(patch.promptRules || '').trim();
if (patch.writeUsers !== undefined) raw.writeUsers = normalizeUsers(patch.writeUsers);
if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) {
raw.timeoutMinutes = Number(patch.timeoutMinutes);
}
+51 -8
View File
@@ -66,6 +66,11 @@
.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;
@@ -73,7 +78,12 @@
.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; }
.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;
@@ -99,6 +109,7 @@
.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; }
.workdir { cursor: help; border-bottom: 1px dotted #475569; }
.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; }
@@ -241,8 +252,24 @@
<div class="hint">記憶會隨對話變慢(尤其含圖);用量達 80% 時所有模式都會自動開新會話</div>
</div>
<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>
<label class="check"><input type="checkbox" id="f-network"> 允許 AI 連網</label>
<div class="hint">workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開。目前只有 Codex 引擎有此選項</div>
</div>
<div class="field">
<label class="check"><input type="checkbox" id="f-extra" onchange="toggleExtra()"> 額外選項(可讀寫成員、回覆規範)</label>
<div class="hint">打勾才會設定;取消打勾儲存時會清空這兩項</div>
</div>
<div id="f-extra-fields" style="display:none;border-left:2px solid #2e3345;padding-left:12px;margin-bottom:13px">
<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" style="margin-bottom:0">
<label>回覆規範(每則訊息開頭附加)</label>
<textarea id="f-rules" placeholder="例:只回答與本專案相關的問題;@xxx 是後端工程師,回覆用 API 契約視角…"></textarea>
<div class="hint">適合放「專案守門」「成員身分」這類每輪都要提醒的規則;專案本身的規範請放工作目錄的 AGENTS.md / CLAUDE.md</div>
</div>
</div>
<div class="field">
<label>逾時(分鐘)</label>
@@ -335,7 +362,7 @@ const ENGINE_UI = {
claude: {
hint: '本機 Claude Code CLIclaude -p)。專案規範讀工作目錄的 CLAUDE.md',
workdir: '規範檔:CLAUDE.md',
modelHint: '別名 opus / sonnet / haiku 永遠指向最新版;也可在編輯時手動改成任何模型 id',
modelHint: 'Claude 5 家族:Fable 5 / Opus 5 / Sonnet 5(設定檔可手填其他模型 id',
effortHint: 'Claude 的 effort 對應 --effort 參數',
sandbox: {
'workspace-write': 'workspace-write(自動接受檔案編輯;不執行指令,預設)',
@@ -345,6 +372,12 @@ const ENGINE_UI = {
},
};
function currentEngine() { return document.getElementById('f-engine').value || 'codex'; }
// 額外選項(可讀寫成員、回覆規範):打勾才顯示;沒打勾儲存時兩項清空
function toggleExtra(on) {
const cb = document.getElementById('f-extra');
if (on !== undefined) cb.checked = !!on;
document.getElementById('f-extra-fields').style.display = cb.checked ? '' : 'none';
}
function engineData() { return enginesCache[currentEngine()] || { models: [], defaults: {} }; }
function engineTag(engine) {
const e = engine === 'claude' ? 'claude' : 'codex';
@@ -452,10 +485,10 @@ function renderBots(bots) {
'<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><span class="mono workdir" title="' + esc(b.workDir) + '">' + esc(folderName(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 ? '可連網' : ''].filter(Boolean).join(' / ')) + '</span>'
? '<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>' +
@@ -474,9 +507,11 @@ function renderBots(bots) {
}).join('');
}
function shortPath(p) {
// 列表只顯示資料夾名稱,完整路徑放 title(滑鼠停上去才看)
function folderName(p) {
if (!p) return '-';
return p.length > 34 ? '' + p.slice(-32) : p;
const parts = String(p).replace(/[\\/]+$/, '').split(/[\\/]/);
return parts[parts.length - 1] || p;
}
async function botAction(action, name) {
@@ -527,6 +562,9 @@ function openCreate() {
onEngineChange('', '');
document.getElementById('f-session').value = 'per-chat';
document.getElementById('f-network').checked = false;
document.getElementById('f-writeusers').value = '';
document.getElementById('f-rules').value = '';
toggleExtra(false);
document.getElementById('f-timeout').value = '30';
document.getElementById('f-autostart-wrap').style.display = '';
document.getElementById('f-autostart').checked = true;
@@ -551,6 +589,9 @@ function openEdit(name) {
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 || '';
toggleExtra(!!((b.writeUsers && b.writeUsers.length) || 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');
@@ -574,6 +615,8 @@ async function saveBot() {
reasoningEffort: document.getElementById('f-effort').value,
sessionMode: document.getElementById('f-session').value,
networkAccess: document.getElementById('f-network').checked,
writeUsers: document.getElementById('f-extra').checked ? document.getElementById('f-writeusers').value : '',
promptRules: document.getElementById('f-extra').checked ? document.getElementById('f-rules').value : '',
timeoutMinutes: document.getElementById('f-timeout').value,
};
try {
+7 -9
View File
@@ -97,7 +97,7 @@ async function apiPm2Log(pmId, type, lines) {
// ---------- 模型清單 ----------
// codex:直接讀它自己的 models cache~/.codex/models_cache.json),新模型上線後下拉選單自動跟上;
// 讀不到就退回內建清單。
// claude:CLI 沒有模型清單可查,用內建清單(別名 opus/sonnet/haiku 永遠指向最新版)。
// claude:CLI 沒有模型清單可查,用內建清單(Claude 5 家族:Fable / Opus / Sonnet;編輯時也可手填任何模型 id)。
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' },
@@ -106,11 +106,9 @@ const CODEX_FALLBACK_MODELS = [
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' },
{ slug: 'claude-fable-5', label: 'Fable 5(最強,Mythos ', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
{ slug: 'claude-opus-5', label: 'Opus 5', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
{ slug: 'claude-sonnet-5', label: 'Sonnet 5(較快', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
];
// codex 全域預設(~/.codex/config.toml),前端用來當下拉選單的預選值
@@ -143,14 +141,14 @@ function codexModels() {
return { models: CODEX_FALLBACK_MODELS, defaults, source: 'fallback' };
}
// claude 全域預設(~/.claude/settings.json 的 model;沒有就 opus
// claude 全域預設(~/.claude/settings.json 的 model 若在清單內就用它;沒有就 Opus 5
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;
if (typeof s.model === 'string' && CLAUDE_MODELS.some((m) => m.slug === s.model)) model = s.model;
} catch { /* ignore */ }
return { models: CLAUDE_MODELS, defaults: { model: model || 'opus', effort: '' }, source: 'builtin' };
return { models: CLAUDE_MODELS, defaults: { model: model || 'claude-opus-5', effort: '' }, source: 'builtin' };
}
// 引擎是否裝了(開控制台時查一次就好,之後不變)