9 Commits
Author SHA1 Message Date
JianMiauandClaude Fable 5 34938f0588 0.2.7:回覆別人傳的檔案/圖片再 tag bot,也會讀到那個附件
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 11:50:17 +08:00
JianMiauandClaude Opus 4.6 6faafabc9d 0.2.6:sendFile 暫時性錯誤自動重試、Claude 模型增加 Opus 4.8/4.6/Haiku 4.5、列表沙盒欄精簡
- telegram.js:sendFile 遇到 Bad Gateway/Gateway Timeout/429 等暫時性錯誤時自動重試最多 3 次(間隔遞增 4s/8s/12s)
- server.js:Claude 模型清單增加 Opus 4.8、Opus 4.6、Haiku 4.5
- index.html:沙盒欄第二行只顯示模型/effort,其餘資訊(會話模式、連網、可寫成員、規範)改為滑鼠停留氣泡顯示

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-24 14:57:14 +08:00
JianMiau cfc9671179 更新列表截圖 2026-08-24 12:15:39 +08:00
JianMiau d8f205b349 0.2.1:列表工作目錄只顯示資料夾名稱,完整路徑滑鼠停留才顯示 2026-08-24 12:15:22 +08:00
JianMiau 6120a7bf73 repo 改到 AI/telegram-bot:README 圖片與 package.json repository 連結更新 2026-08-24 12:07:49 +08:00
JianMiau c112c56676 表單:可讀寫成員與回覆規範收進「額外選項」,打勾才顯示並設定 2026-08-24 12:06:45 +08:00
JianMiau ce8ba3cd5a 新增每 bot 回覆規範(promptRules)、可讀寫成員(writeUsers,其他人唯讀)、PDF/文字附件;cluemarket-tg-bot 搬入控制台 2026-08-24 12:04:05 +08:00
JianMiau b0cba06b0c 表單:連網選項改為「允許 AI 連網」,修正 .field 內 checkbox 被撐滿的版面 2026-08-24 11:50:40 +08:00
JianMiau 61ce550871 Claude 模型清單改為 Fable 5 / Opus 5 / Sonnet 5 2026-08-24 11:49:11 +08:00
15 changed files with 201 additions and 57 deletions
+19 -9
View File
@@ -8,7 +8,8 @@
- **零依賴**:不用裝任何 npm 套件,只吃 Node 內建模組 - **零依賴**:不用裝任何 npm 套件,只吃 Node 內建模組
- **pm2 託管**:控制台與每個 bot 都掛在 pm2,開機自啟、當機自動重啟 - **pm2 託管**:控制台與每個 bot 都掛在 pm2,開機自啟、當機自動重啟
- **多 bot**:一頁管理多個 bot,各自不同引擎、token、專案目錄 - **多 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(儲存時自動驗證) 1. 填名稱、**選 AI 引擎(Codex 或 Claude)**、Telegram token(儲存時自動驗證)
2. 工作目錄按「📂 瀏覽」用原生視窗選資料夾(AI 可讀寫的專案路徑;Codex 讀裡面的 `AGENTS.md`、Claude 讀 `CLAUDE.md` 當專案規範) 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` → 勾「建立後立即啟動」→ 儲存,完成 4. 沙盒模式預設 `workspace-write` → 勾「建立後立即啟動」→ 儲存,完成
之後對 bot 私訊即可;群組中要 `@bot` 或回覆 bot 的訊息。 之後對 bot 私訊即可;群組中要 `@bot` 或回覆 bot 的訊息。
@@ -55,7 +56,7 @@ telegram-bot start # 重新 start 一次,bot 程序會自動切到新版
|---|---|---| |---|---|---|
| 底層指令 | `codex exec --json` | `claude -p --output-format stream-json` | | 底層指令 | `codex exec --json` | `claude -p --output-format stream-json` |
| 專案規範檔 | `AGENTS.md` | `CLAUDE.md` | | 專案規範檔 | `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 | | 推理強度 | low ~ ultra(依模型) | low / medium / high / max |
| 速度 | 固定 Fast(priority 1.5x) | — | | 速度 | 固定 Fast(priority 1.5x) | — |
| `read-only` | codex 唯讀沙盒 | 禁用 Write/Edit/Bash 等工具 | | `read-only` | codex 唯讀沙盒 | 禁用 Write/Edit/Bash 等工具 |
@@ -63,6 +64,7 @@ telegram-bot start # 重新 start 一次,bot 程序會自動切到新版
| `danger-full-access` | 不設限 | `--dangerously-skip-permissions`,不設限 | | `danger-full-access` | 不設限 | `--dangerously-skip-permissions`,不設限 |
| 生圖 | 內建 imagegen 技能,產圖自動傳回 | 無(可自行在 CLAUDE.md 接其他工具) | | 生圖 | 內建 imagegen 技能,產圖自動傳回 | 無(可自行在 CLAUDE.md 接其他工具) |
| 傳圖給 bot | `-i` 直接餵給模型 | 存暫存檔後由 Read 工具讀取 | | 傳圖給 bot | `-i` 直接餵給模型 | 存暫存檔後由 Read 工具讀取 |
| 傳檔案給 bot(PDF / 文字 / 程式碼) | 路徑寫進 prompt,由 codex 讀取 | 存暫存檔後由 Read 工具讀取 |
兩種引擎的回覆尾端都會附 `— ✅ 時間 | ctx 用量% +本輪 token | 模型 / 強度` 兩種引擎的回覆尾端都會附 `— ✅ 時間 | ctx 用量% +本輪 token | 模型 / 強度`
AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動傳到 Telegram 並清空。 AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動傳到 Telegram 並清空。
@@ -79,7 +81,7 @@ AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動
**① Bot 管理首頁** — 每個 bot 一列:名稱旁標示引擎、狀態、工作目錄、沙盒與模型設定、遮罩後的 token。 **① Bot 管理首頁** — 每個 bot 一列:名稱旁標示引擎、狀態、工作目錄、沙盒與模型設定、遮罩後的 token。
右側按鈕:▶ 啟動 / ⏹ 停止、🔄 重啟、📄 log、✏️ 編輯、🗑 刪除。上方橫幅提醒群組使用要先關 Group Privacy。 右側按鈕:▶ 啟動 / ⏹ 停止、🔄 重啟、📄 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」: **② 新增 Bot** — 按右上「➕ 新增 Bot」:
@@ -89,21 +91,28 @@ AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動
4. **工作目錄**:按「📂 瀏覽…」會在這台機器桌面跳出原生資料夾視窗,選完自動帶入 4. **工作目錄**:按「📂 瀏覽…」會在這台機器桌面跳出原生資料夾視窗,選完自動帶入
5. **模型 / 推理強度**:下拉選單,預設值自動帶你機器上該引擎的全域設定;換模型時強度選項會連動 5. **模型 / 推理強度**:下拉選單,預設值自動帶你機器上該引擎的全域設定;換模型時強度選項會連動
6. **會話模式**:各聊天室獨立 / 所有聊天室共用 / 每則訊息獨立(不保留記憶,最快) 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 時,模型/強度/沙盒說明會跟著換:
![新增 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 分頁** — 整台機器所有 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 檢視** — 點任一程序的「📄 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 秒自動更新 - 兩個分頁每 5 秒自動更新
- Bot 設定存於 `~/.tgbot/bots/<名稱>/tgbot.config.json`,bot 掛 pm2 的程序名 = bot 名稱 - Bot 設定存於 `~/.tgbot/bots/<名稱>/tgbot.config.json`,bot 掛 pm2 的程序名 = bot 名稱
@@ -134,6 +143,7 @@ telegram-bot web 前景執行控制台(除錯用)
- **私訊**:任何訊息都回應;**群組**:只回應 `@bot` 或回覆 bot 訊息的情況;訊息同時 tag 多個 bot 時只有第一個回應 - **私訊**:任何訊息都回應;**群組**:只回應 `@bot` 或回覆 bot 訊息的情況;訊息同時 tag 多個 bot 時只有第一個回應
- **引用**:回覆/圈選引用某則訊息再 tag bot,引用內容會一起送給 AI;只引用不打字就直接把引用當輸入 - **引用**:回覆/圈選引用某則訊息再 tag bot,引用內容會一起送給 AI;只引用不打字就直接把引用當輸入
- **附件**:圖片、PDF、文字/程式碼檔都會下載給 AI 讀(Telegram Bot 下載上限 20MB;docx/xlsx/zip 等二進位不支援,bot 會回覆說明);「回覆別人傳的檔案/圖片再 tag bot」也會讀到那個附件(回覆另一隻 bot 傳的檔案除外,Telegram 不給)
- **bot 接力**:Telegram 不讓 bot 看到其他 bot 的訊息,所以「回覆 A bot 的回覆 + tag B bot(不打字)」時,B 會改拿同聊天室最近一則我們自家 bot 的發言當輸入(6 小時內) - **bot 接力**:Telegram 不讓 bot 看到其他 bot 的訊息,所以「回覆 A bot 的回覆 + tag B bot(不打字)」時,B 會改拿同聊天室最近一則我們自家 bot 的發言當輸入(6 小時內)
- 對話記憶用到 80% 上下文時自動開新會話(回覆會標示),避免越用越慢 - 對話記憶用到 80% 上下文時自動開新會話(回覆會標示),避免越用越慢
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 65 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", "name": "telegram-bot",
"version": "0.2.0", "version": "0.2.7",
"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", "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", "license": "MIT",
"type": "commonjs", "type": "commonjs",
@@ -9,7 +9,7 @@
}, },
"repository": { "repository": {
"type": "git", "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", "main": "src/index.js",
"bin": { "bin": {
+64 -18
View File
@@ -205,18 +205,28 @@ function startBot(config) {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
// 取出訊息附圖(photo 取最大尺寸;image/* 的 document 也支援) // 取出訊息附件:photo 取最大尺寸;document 依 mime/副檔名分成 image / filePDF、文字、程式碼)/ unsupported
function pickPhoto(message) { 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) { if (message.photo?.length) {
const largest = message.photo[message.photo.length - 1]; 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; const doc = message.document;
if (doc && /^image\//.test(doc.mime_type || '')) { if (!doc) return null;
const ext = path.extname(doc.file_name || '') || '.' + (doc.mime_type.split('/')[1] || 'png'); const mime = doc.mime_type || '';
return { fileId: doc.file_id, uniqueId: doc.file_unique_id, ext }; 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 name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者';
const sender = from.username ? `${name}@${from.username}` : name; const sender = from.username ? `${name}@${from.username}` : name;
const parts = [OUTBOX_RULE]; const parts = [OUTBOX_RULE];
if (config.promptRules) parts.push(config.promptRules);
const replied = message.reply_to_message; const replied = message.reply_to_message;
// 回覆我自己的訊息通常已在會話記憶裡,不重複貼;但使用者沒打字只回覆時例外(那就是輸入本身) // 回覆我自己的訊息通常已在會話記憶裡,不重複貼;但使用者沒打字只回覆時例外(那就是輸入本身)
if (replied && (includeOwnReply || replied.from?.id !== botId) && (replied.text || replied.caption)) { if (replied && (includeOwnReply || replied.from?.id !== botId) && (replied.text || replied.caption)) {
@@ -278,6 +289,8 @@ function startBot(config) {
`模型:${model || `${engine.label} 預設)`}`, `模型:${model || `${engine.label} 預設)`}`,
`推理強度:${effort || '(預設)'}`, `推理強度:${effort || '(預設)'}`,
`會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`, `會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`,
config.writeUsers.length ? `可讀寫成員:${config.writeUsers.map((u) => '@' + u).join('、')}(其他人唯讀)` : '可讀寫成員:所有人(依沙盒設定)',
config.promptRules ? `回覆規範:已設定(${config.promptRules.length} 字)` : '回覆規範:無',
config.sessionMode === 'stateless' config.sessionMode === 'stateless'
? '會話:每則訊息獨立,不保留記憶' ? '會話:每則訊息獨立,不保留記憶'
: s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)', : s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)',
@@ -292,7 +305,7 @@ function startBot(config) {
'/status — 查看會話與設定', '/status — 查看會話與設定',
'/help — 顯示這則說明', '/help — 顯示這則說明',
'', '',
'群組中要 @我 或回覆我的訊息才會觸發。可以直接傳圖片(附文字說明)。', '群組中要 @我 或回覆我的訊息才會觸發。可以直接傳圖片/檔案(附文字說明),或回覆別人傳的檔案再 @我。',
].join('\n'); ].join('\n');
// 會話 key:獨立模式用 chat id,共用模式所有聊天室用同一把 // 會話 key:獨立模式用 chat id,共用模式所有聊天室用同一把
@@ -307,9 +320,20 @@ function startBot(config) {
const chatId = message.chat.id; const chatId = message.chat.id;
const sessionKey = sessionKeyOf(chatId); const sessionKey = sessionKeyOf(chatId);
const text = message.text || message.caption || ''; const text = message.text || message.caption || '';
const photo = pickPhoto(message); // 附件:自己這則沒有的話,退而看被回覆的那則(「別人傳檔案 → 回覆它並 tag 我」)
let attachment = pickAttachment(message);
let attachmentFromReply = false;
if (!attachment && message.reply_to_message) {
attachment = pickAttachment(message.reply_to_message);
attachmentFromReply = !!attachment;
}
const photo = attachment; // 有任何附件都視同有內容
if (!text && !photo) return; if (!text && !photo) return;
if (!shouldRespond(message)) 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); const content = stripMention(text);
@@ -365,14 +389,36 @@ function startBot(config) {
try { try {
const images = []; const images = [];
if (photo) { const files = [];
cleanTmp(); let attachmentNote = '';
const dest = path.join(config.tmpDir, `${Date.now()}-${photo.uniqueId}${photo.ext}`); if (attachment) {
await t.downloadFile(photo.fileId, dest); const meta = [attachment.name && `原檔名 ${attachment.name}`, attachment.mime, attachment.size && `${Math.round(attachment.size / 1024)}KB`]
images.push(dest); .filter(Boolean).join('');
const label = attachmentFromReply ? '被回覆訊息的附件' : '附件';
if (attachment.kind === 'unsupported') {
attachmentNote = `\n\n${label}】(${meta})格式不支援(docx/xlsx/zip 等二進位讀不了),回覆時告知使用者,建議貼成文字或轉 PDF`;
} else if (attachment.size > TELEGRAM_GETFILE_LIMIT) {
attachmentNote = `\n\n${label}】(${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);
if (attachmentFromReply) attachmentNote = '\n\n【附圖是使用者回覆的那則訊息裡的圖片】';
} else {
files.push(dest);
attachmentNote = `\n\n${label}檔案】${meta ? `${meta}` : ''}請先用讀檔工具讀取再處理:\n${dest}`;
}
}
} }
let fallback = '(使用者只傳了圖片,請描述並依上下文處理)'; let fallback = files.length ? '(使用者只傳了檔案,請讀取後依上下文處理)' : '(使用者只傳了圖片,請描述並依上下文處理)';
if (attachmentFromReply) {
fallback = files.length
? '(使用者沒打字,只回覆了一則帶檔案的訊息並 tag 我:請讀取該檔案後依上下文處理)'
: '(使用者沒打字,只回覆了一則帶圖片的訊息並 tag 我:請描述該圖片並依上下文處理)';
}
if (quotedOnly && (quoted || !sibling)) { if (quotedOnly && (quoted || !sibling)) {
fallback = '(使用者沒有輸入文字,只引用了上面的內容並 tag 我:請直接把引用內容當作這次的輸入來處理)'; fallback = '(使用者沒有輸入文字,只引用了上面的內容並 tag 我:請直接把引用內容當作這次的輸入來處理)';
} else if (quotedOnly && sibling) { } else if (quotedOnly && sibling) {
@@ -380,7 +426,7 @@ function startBot(config) {
`【引用的 bot 訊息(@${sibling.botUsername} 在本聊天室的最近發言)】\n${sibling.text}\n\n` + `【引用的 bot 訊息(@${sibling.botUsername} 在本聊天室的最近發言)】\n${sibling.text}\n\n` +
'(使用者沒有輸入文字,只引用了訊息並 tag 我:請直接把上面引用的內容當作這次的輸入來處理)'; '(使用者沒有輸入文字,只引用了訊息並 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 existing = sessionKey ? sessions.get(sessionKey) : null;
const turnStart = Date.now(); const turnStart = Date.now();
@@ -390,7 +436,7 @@ function startBot(config) {
const MAX_ATTEMPTS = 2; const MAX_ATTEMPTS = 2;
for (let attempt = 1; ; attempt++) { for (let attempt = 1; ; attempt++) {
try { try {
result = await engine.run({ config, prompt, threadId: tid, images, onProgress }); result = await engine.run({ config: runConfig, prompt, threadId: tid, images, files, onProgress });
break; break;
} catch (err) { } catch (err) {
// 舊會話 resume 不了:1) 會話已被引擎清掉 2) 換模型後 codex 拒絕跨模型 resume // 舊會話 resume 不了:1) 會話已被引擎清掉 2) 換模型後 codex 拒絕跨模型 resume
@@ -443,7 +489,7 @@ function startBot(config) {
// claude 在非互動模式下被擋掉的操作(例如 workspace-write 不能執行指令),讓使用者知道 // claude 在非互動模式下被擋掉的操作(例如 workspace-write 不能執行指令),讓使用者知道
if (Array.isArray(result.denials) && result.denials.length > 0) { if (Array.isArray(result.denials) && result.denials.length > 0) {
const tools = [...new Set(result.denials.map((d) => d.tool_name).filter(Boolean))].join('、'); 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 }); const footer = buildFooter({ ...display, usage: result.usage });
+14 -1
View File
@@ -35,6 +35,11 @@ const DEFAULTS = {
autoResetCtxPercent: 80, autoResetCtxPercent: 80,
// codex 專用:允許在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開) // codex 專用:允許在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開)
networkAccess: false, networkAccess: false,
// 每則訊息開頭附加的回覆規範(例如:只回答專案相關問題、成員身分說明);空字串 = 不加
promptRules: '',
// 可讀寫的 Telegram username 清單(不含 @);非空時,清單外的人一律以 read-only 沙盒執行。
// 空陣列 = 所有人都依 sandbox 設定
writeUsers: [],
// 單次回應逾時(分鐘) // 單次回應逾時(分鐘)
timeoutMinutes: 30, timeoutMinutes: 30,
// pm2 程序名稱 // pm2 程序名稱
@@ -47,6 +52,12 @@ const DEFAULTS = {
claudePath: '', 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) { function firstExisting(...files) {
return files.find((f) => fs.existsSync(f)) || files[0]; return files.find((f) => fs.existsSync(f)) || files[0];
} }
@@ -131,6 +142,8 @@ function loadConfig(explicitPath) {
cfg.sessionMode = raw.sharedSession ? 'shared' : 'per-chat'; cfg.sessionMode = raw.sharedSession ? 'shared' : 'per-chat';
} }
cfg.autoResetCtxPercent = Number(cfg.autoResetCtxPercent) || 0; 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); const minutes = Number(cfg.timeoutMinutes);
if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`); if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`);
@@ -160,5 +173,5 @@ function ensureStateDirs(cfg) {
module.exports = { module.exports = {
CONFIG_BASENAME, LEGACY_CONFIG_BASENAME, DEFAULTS, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, 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。 * 執行一輪 Claude 對話。介面同 engines/codex.js 的 run。
* @returns {Promise<{text: string, threadId: string|null, usage: object|null, model: string|null, denials: object[]}>} * @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) => { return new Promise((resolve, reject) => {
const { cmd, shell } = resolveClaudeCommand(config); 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.model) args.push('--model', config.model);
if (config.reasoningEffort) args.push('--effort', config.reasoningEffort); if (config.reasoningEffort) args.push('--effort', config.reasoningEffort);
args.push(...permissionArgs(config.sandbox)); args.push(...permissionArgs(config.sandbox));
// 附圖:claude -p 沒有圖片參數,改讓 Read 工具讀(要先把暫存目錄加進允許範圍) // 附圖/附件claude -p 沒有檔案參數,改讓 Read 工具讀(要先把暫存目錄加進允許範圍)
if (images.length > 0) args.push('--add-dir', path.dirname(images[0])); 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); if (threadId) args.push('--resume', threadId);
let fullPrompt = prompt; let fullPrompt = prompt;
+8 -2
View File
@@ -6,7 +6,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { TGBOT_HOME, BOT_SCRIPT, jlist, botConfigPathOf } = require('./pm2util'); 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 REG_FILE = path.join(TGBOT_HOME, 'registry.json');
const BOTS_DIR = path.join(TGBOT_HOME, 'bots'); const BOTS_DIR = path.join(TGBOT_HOME, 'bots');
@@ -80,6 +80,8 @@ async function listBots() {
sessionMode: cfg?.sessionMode || 'per-chat', sessionMode: cfg?.sessionMode || 'per-chat',
networkAccess: !!cfg?.networkAccess, networkAccess: !!cfg?.networkAccess,
serviceTier: cfg?.serviceTier || '', serviceTier: cfg?.serviceTier || '',
promptRules: cfg?.promptRules || '',
writeUsers: cfg?.writeUsers || [],
timeoutMinutes: cfg?.timeoutMinutes || null, timeoutMinutes: cfg?.timeoutMinutes || null,
tokenMasked: maskToken(cfg?.telegramToken), tokenMasked: maskToken(cfg?.telegramToken),
hasToken: !!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 }); validateBotInput({ name, token, workDir, sandbox, engine }, { isCreate: true });
const reg = loadRegistry(); const reg = loadRegistry();
if (reg.bots[name]) throw new Error(`已有同名 bot${name}`); 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 || '', reasoningEffort: reasoningEffort || '',
sessionMode: SESSION_MODES.includes(sessionMode) ? sessionMode : 'per-chat', sessionMode: SESSION_MODES.includes(sessionMode) ? sessionMode : 'per-chat',
networkAccess: !!networkAccess, networkAccess: !!networkAccess,
promptRules: typeof promptRules === 'string' ? promptRules.trim() : '',
writeUsers: normalizeUsers(writeUsers),
serviceTier: 'priority', // codex 速度固定 Fast 1.5x serviceTier: 'priority', // codex 速度固定 Fast 1.5x
timeoutMinutes: Number(timeoutMinutes) || 30, timeoutMinutes: Number(timeoutMinutes) || 30,
pm2Name: name, pm2Name: name,
@@ -172,6 +176,8 @@ async function updateBot(name, patch) {
if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort; if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort;
if (SESSION_MODES.includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; } if (SESSION_MODES.includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; }
if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess; 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) { if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) {
raw.timeoutMinutes = Number(patch.timeoutMinutes); raw.timeoutMinutes = Number(patch.timeoutMinutes);
} }
+22 -2
View File
@@ -86,6 +86,10 @@ function createTelegram(token) {
}).catch(() => {}); }).catch(() => {});
} }
const TRANSIENT_RE = /Bad Gateway|Gateway Timeout|Too Many Requests|Service Unavailable|Internal Server Error|ETIMEDOUT|ECONNRESET|fetch failed/i;
const SEND_FILE_RETRIES = 3;
const SEND_FILE_RETRY_DELAY = 4000;
// 上傳檔案到 Telegram:圖片走 sendPhoto,其餘(或 sendPhoto 失敗時)走 sendDocument // 上傳檔案到 Telegram:圖片走 sendPhoto,其餘(或 sendPhoto 失敗時)走 sendDocument
async function sendFile(chatId, filePath, { caption, replyTo } = {}) { async function sendFile(chatId, filePath, { caption, replyTo } = {}) {
const fs = require('fs'); const fs = require('fs');
@@ -106,13 +110,29 @@ function createTelegram(token) {
return data.result; return data.result;
}; };
// 帶重試的上傳:Telegram 暫時性故障(502/504/429 等)自動等一下重來
const uploadWithRetry = async (method, field) => {
for (let i = 1; ; i++) {
try {
return await upload(method, field);
} catch (err) {
if (i < SEND_FILE_RETRIES && TRANSIENT_RE.test(err.message)) {
console.log(`sendFile 重試 ${i}/${SEND_FILE_RETRIES}${err.message}`);
await new Promise((r) => setTimeout(r, SEND_FILE_RETRY_DELAY * i));
continue;
}
throw err;
}
}
};
// sendPhoto 上限 10MB,且某些格式會被拒,失敗就退回用檔案傳 // sendPhoto 上限 10MB,且某些格式會被拒,失敗就退回用檔案傳
if (isImage && size <= 9.5 * 1024 * 1024) { if (isImage && size <= 9.5 * 1024 * 1024) {
try { try {
return await upload('sendPhoto', 'photo'); return await uploadWithRetry('sendPhoto', 'photo');
} catch { /* fall through */ } } catch { /* fall through */ }
} }
return upload('sendDocument', 'document'); return uploadWithRetry('sendDocument', 'document');
} }
async function downloadFile(fileId, destPath) { async function downloadFile(fileId, destPath) {
+58 -11
View File
@@ -66,6 +66,11 @@
.field { margin-bottom: 13px; } .field { margin-bottom: 13px; }
.field label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 5px; } .field label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 5px; }
.field label b { color: #ed4245; } .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 { .field input, .field select {
width: 100%; background: #12141c; border: 1px solid #2e3345; border-radius: 8px; width: 100%; background: #12141c; border: 1px solid #2e3345; border-radius: 8px;
color: #e2e8f0; padding: 9px 11px; font-size: 13px; font-family: inherit; 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 input:focus, .field select:focus { outline: none; border-color: #5865f2; }
.field .hint { font-size: 11px; color: #475569; margin-top: 4px; } .field .hint { font-size: 11px; color: #475569; margin-top: 4px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; } .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 面板 */
#log-panel { display: none; margin-top: 18px; } #log-panel { display: none; margin-top: 18px; }
#log-panel .log-header { display: flex; align-items: center; justify-content: space-between; #log-panel .log-header { display: flex; align-items: center; justify-content: space-between;
@@ -99,6 +109,8 @@
.notice-steps { color: #e2e8f0; } .notice-steps { color: #e2e8f0; }
.notice-steps b { color: #e2e8f0; background: rgba(88,101,242,0.18); padding: 1px 7px; .notice-steps b { color: #e2e8f0; background: rgba(88,101,242,0.18); padding: 1px 7px;
border-radius: 5px; font-weight: 600; } border-radius: 5px; font-weight: 600; }
.workdir { cursor: help; border-bottom: 1px dotted #475569; }
.sandbox-cell { cursor: help; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 5px; font-size: 10px; font-weight: 700; .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; } margin-left: 6px; vertical-align: middle; letter-spacing: .3px; }
.tag-codex { background: rgba(16,163,127,0.18); color: #34d399; } .tag-codex { background: rgba(16,163,127,0.18); color: #34d399; }
@@ -241,8 +253,24 @@
<div class="hint">記憶會隨對話變慢(尤其含圖);用量達 80% 時所有模式都會自動開新會話</div> <div class="hint">記憶會隨對話變慢(尤其含圖);用量達 80% 時所有模式都會自動開新會話</div>
</div> </div>
<div class="field" id="f-network-wrap"> <div class="field" id="f-network-wrap">
<label class="check" style="margin-top:2px"><input type="checkbox" id="f-network"> 允許 Codex 連網</label> <label class="check"><input type="checkbox" id="f-network"> 允許 AI 連網</label>
<div class="hint">workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開</div> <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>
<div class="field"> <div class="field">
<label>逾時(分鐘)</label> <label>逾時(分鐘)</label>
@@ -335,7 +363,7 @@ const ENGINE_UI = {
claude: { claude: {
hint: '本機 Claude Code CLIclaude -p)。專案規範讀工作目錄的 CLAUDE.md', hint: '本機 Claude Code CLIclaude -p)。專案規範讀工作目錄的 CLAUDE.md',
workdir: '規範檔:CLAUDE.md', workdir: '規範檔:CLAUDE.md',
modelHint: '別名 opus / sonnet / haiku 永遠指向最新版;也可在編輯時手動改成任何模型 id', modelHint: 'Claude 5 家族:Fable 5 / Opus 5 / Sonnet 5(設定檔可手填其他模型 id',
effortHint: 'Claude 的 effort 對應 --effort 參數', effortHint: 'Claude 的 effort 對應 --effort 參數',
sandbox: { sandbox: {
'workspace-write': 'workspace-write(自動接受檔案編輯;不執行指令,預設)', 'workspace-write': 'workspace-write(自動接受檔案編輯;不執行指令,預設)',
@@ -345,6 +373,12 @@ const ENGINE_UI = {
}, },
}; };
function currentEngine() { return document.getElementById('f-engine').value || 'codex'; } 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 engineData() { return enginesCache[currentEngine()] || { models: [], defaults: {} }; }
function engineTag(engine) { function engineTag(engine) {
const e = engine === 'claude' ? 'claude' : 'codex'; const e = engine === 'claude' ? 'claude' : 'codex';
@@ -452,11 +486,14 @@ function renderBots(bots) {
'<td><strong>' + esc(b.name) + '</strong>' + engineTag(b.engine) + '<td><strong>' + esc(b.name) + '</strong>' + engineTag(b.engine) +
(b.error ? '<br><span style="font-size:11px;color:#ed4245">' + esc(b.error) + '</span>' : '') + '</td>' + (b.error ? '<br><span style="font-size:11px;color:#ed4245">' + esc(b.error) + '</span>' : '') + '</td>' +
'<td>' + statusBadge(b.status) + '</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 || '-') + (function() {
((b.model || b.reasoningEffort || b.sessionMode !== 'per-chat' || b.networkAccess) var line2 = [b.model, b.reasoningEffort].filter(Boolean).join(' / ');
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.sessionMode === 'shared' ? '共用會話' : b.sessionMode === 'stateless' ? '無記憶' : '', b.networkAccess ? '可連網' : ''].filter(Boolean).join(' / ')) + '</span>' var extra = [b.sessionMode === 'shared' ? '共用會話' : b.sessionMode === 'stateless' ? '無記憶' : '', b.networkAccess ? '可連網' : '', (b.writeUsers && b.writeUsers.length) ? '限 @' + b.writeUsers.join(' @') + ' 可寫' : '', b.promptRules ? '有回覆規範' : ''].filter(Boolean).join(' / ');
: '') + '</td>' + var tip = [b.sandbox, line2, extra].filter(Boolean).join('\n');
return '<td class="mono sandbox-cell" title="' + esc(tip) + '">' + esc(b.sandbox || '-') +
(line2 ? '<br><span class="script-name">' + esc(line2) + '</span>' : '') + '</td>';
})() +
'<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' + '<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' +
'<td>' + fmtBytes(b.memory) + '</td>' + '<td>' + fmtBytes(b.memory) + '</td>' +
'<td>' + (b.restarts ?? '-') + '</td>' + '<td>' + (b.restarts ?? '-') + '</td>' +
@@ -474,9 +511,11 @@ function renderBots(bots) {
}).join(''); }).join('');
} }
function shortPath(p) { // 列表只顯示資料夾名稱,完整路徑放 title(滑鼠停上去才看)
function folderName(p) {
if (!p) return '-'; 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) { async function botAction(action, name) {
@@ -527,6 +566,9 @@ function openCreate() {
onEngineChange('', ''); onEngineChange('', '');
document.getElementById('f-session').value = 'per-chat'; document.getElementById('f-session').value = 'per-chat';
document.getElementById('f-network').checked = false; 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-timeout').value = '30';
document.getElementById('f-autostart-wrap').style.display = ''; document.getElementById('f-autostart-wrap').style.display = '';
document.getElementById('f-autostart').checked = true; document.getElementById('f-autostart').checked = true;
@@ -551,6 +593,9 @@ function openEdit(name) {
onEngineChange(b.model || '', b.reasoningEffort || ''); onEngineChange(b.model || '', b.reasoningEffort || '');
document.getElementById('f-session').value = b.sessionMode || 'per-chat'; document.getElementById('f-session').value = b.sessionMode || 'per-chat';
document.getElementById('f-network').checked = !!b.networkAccess; 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-timeout').value = b.timeoutMinutes || 30;
document.getElementById('f-autostart-wrap').style.display = 'none'; document.getElementById('f-autostart-wrap').style.display = 'none';
document.getElementById('bot-modal').classList.add('show'); document.getElementById('bot-modal').classList.add('show');
@@ -574,6 +619,8 @@ async function saveBot() {
reasoningEffort: document.getElementById('f-effort').value, reasoningEffort: document.getElementById('f-effort').value,
sessionMode: document.getElementById('f-session').value, sessionMode: document.getElementById('f-session').value,
networkAccess: document.getElementById('f-network').checked, 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, timeoutMinutes: document.getElementById('f-timeout').value,
}; };
try { try {
+10 -9
View File
@@ -97,7 +97,7 @@ async function apiPm2Log(pmId, type, lines) {
// ---------- 模型清單 ---------- // ---------- 模型清單 ----------
// codex:直接讀它自己的 models cache~/.codex/models_cache.json),新模型上線後下拉選單自動跟上; // codex:直接讀它自己的 models cache~/.codex/models_cache.json),新模型上線後下拉選單自動跟上;
// 讀不到就退回內建清單。 // 讀不到就退回內建清單。
// claude:CLI 沒有模型清單可查,用內建清單(別名 opus/sonnet/haiku 永遠指向最新版)。 // claude:CLI 沒有模型清單可查,用內建清單(Claude 5 家族:Fable / Opus / Sonnet;編輯時也可手填任何模型 id)。
const CODEX_FALLBACK_MODELS = [ 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-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-terra', label: 'GPT-5.6-Terra', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], defaultEffort: 'medium' },
@@ -106,11 +106,12 @@ const CODEX_FALLBACK_MODELS = [
const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'max']; const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'max'];
const CLAUDE_MODELS = [ const CLAUDE_MODELS = [
{ slug: 'opus', label: 'Opus(最新版,目前 Opus 5', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' }, { slug: 'claude-fable-5', label: 'Fable 5(最強', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
{ slug: 'sonnet', label: 'Sonnet(最新版,較快)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' }, { slug: 'claude-opus-5', label: 'Opus 5', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
{ slug: 'haiku', label: 'Haiku(最快、最省)', efforts: ['low', 'medium', 'high'], defaultEffort: 'medium' }, { slug: 'claude-opus-4-8', label: 'Opus 4.8', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
{ slug: 'claude-opus-5', label: 'claude-opus-5(鎖定版本)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' }, { slug: 'claude-opus-4-6', label: 'Opus 4.6', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
{ slug: 'claude-sonnet-5', label: 'claude-sonnet-5鎖定版本', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' }, { slug: 'claude-sonnet-5', label: 'Sonnet 5較快', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
{ slug: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5(最快最便宜)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
]; ];
// codex 全域預設(~/.codex/config.toml),前端用來當下拉選單的預選值 // codex 全域預設(~/.codex/config.toml),前端用來當下拉選單的預選值
@@ -143,14 +144,14 @@ function codexModels() {
return { models: CODEX_FALLBACK_MODELS, defaults, source: 'fallback' }; return { models: CODEX_FALLBACK_MODELS, defaults, source: 'fallback' };
} }
// claude 全域預設(~/.claude/settings.json 的 model;沒有就 opus // claude 全域預設(~/.claude/settings.json 的 model 若在清單內就用它;沒有就 Opus 5
function claudeModels() { function claudeModels() {
let model = ''; let model = '';
try { try {
const s = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'settings.json'), 'utf-8')); 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 */ } } 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' };
} }
// 引擎是否裝了(開控制台時查一次就好,之後不變) // 引擎是否裝了(開控制台時查一次就好,之後不變)