Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34938f0588 | ||
|
|
6faafabc9d | ||
|
|
cfc9671179 | ||
|
|
d8f205b349 | ||
|
|
6120a7bf73 | ||
|
|
c112c56676 | ||
|
|
ce8ba3cd5a | ||
|
|
b0cba06b0c | ||
|
|
61ce550871 |
@@ -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** — 按右上「➕ 新增 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 就上線了
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
引擎切到 Claude 時,模型/強度/沙盒說明會跟著換:
|
引擎切到 Claude 時,模型/強度/沙盒說明會跟著換:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
**編輯既有 bot** — 引擎、模型、可讀寫成員、回覆規範都可事後修改;執行中的 bot 儲存後會自動重啟套用。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
**③ PM2 分頁** — 整台機器所有 pm2 程序。Port 可直接點開對應網頁,右側可啟停/重啟/刪除任何程序。
|
**③ PM2 分頁** — 整台機器所有 pm2 程序。Port 可直接點開對應網頁,右側可啟停/重啟/刪除任何程序。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**④ Log 檢視** — 點任一程序的「📄 out」/「⚠ err」,下方展開尾端 200 行,可勾自動更新(5s)。
|
**④ Log 檢視** — 點任一程序的「📄 out」/「⚠ err」,下方展開尾端 200 行,可勾自動更新(5s)。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- 兩個分頁每 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% 上下文時自動開新會話(回覆會標示),避免越用越慢
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 130 KiB |
@@ -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": {
|
||||||
|
|||||||
@@ -205,18 +205,28 @@ function startBot(config) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取出訊息附圖(photo 取最大尺寸;image/* 的 document 也支援)
|
// 取出訊息附件:photo 取最大尺寸;document 依 mime/副檔名分成 image / file(PDF、文字、程式碼)/ 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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 CLI(claude -p)。專案規範讀工作目錄的 CLAUDE.md',
|
hint: '本機 Claude Code CLI(claude -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 {
|
||||||
|
|||||||
@@ -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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 引擎是否裝了(開控制台時查一次就好,之後不變)
|
// 引擎是否裝了(開控制台時查一次就好,之後不變)
|
||||||
|
|||||||