Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec06c77bde | ||
|
|
2324347868 | ||
|
|
b80d6f0a1b | ||
|
|
d5c4464fe1 | ||
|
|
6e39a8084f | ||
|
|
491ab5bc8f | ||
|
|
8d4cec9bb3 | ||
|
|
c8b3ff68a7 | ||
|
|
ef9fcb9d4a | ||
|
|
7b9bc0ea5b | ||
|
|
6d8d964e5c |
@@ -20,11 +20,20 @@
|
||||
|
||||
## 快速開始
|
||||
|
||||
套件不在官方 npmjs 上,安裝要指定 `--registry`(不用登入):
|
||||
|
||||
```bash
|
||||
npm install -g telegram-codex-bot
|
||||
npm install -g telegram-codex-bot --registry=https://jianmiau.ddns.net/api/packages/AI/npm/
|
||||
|
||||
telegram-codex-bot start # 先做環境檢查(codex/登入/pm2/git),再把控制台掛上 pm2
|
||||
```
|
||||
|
||||
之後升級同樣帶 `--registry`:
|
||||
|
||||
```bash
|
||||
npm update -g telegram-codex-bot --registry=https://jianmiau.ddns.net/api/packages/AI/npm/
|
||||
```
|
||||
|
||||
打開 **http://localhost:3799** →「🤖 Bot 管理」→「➕ 新增 Bot」:
|
||||
|
||||
1. 填名稱、Telegram token(儲存時自動驗證)
|
||||
@@ -50,7 +59,7 @@ telegram-codex-bot start # 先做環境檢查(codex/登入/pm2/git),再
|
||||
**① Bot 管理首頁** — 每個 bot 一列:狀態、工作目錄、沙盒與模型設定、遮罩後的 token。
|
||||
右側按鈕:▶ 啟動 / ⏹ 停止、🔄 重啟、📄 log、✏️ 編輯、🗑 刪除。上方橫幅提醒群組使用要先關 Group Privacy。
|
||||
|
||||

|
||||

|
||||
|
||||
**② 新增 Bot** — 按右上「➕ 新增 Bot」:
|
||||
|
||||
@@ -60,15 +69,15 @@ telegram-codex-bot start # 先做環境檢查(codex/登入/pm2/git),再
|
||||
4. **模型 / 推理強度 / 速度**:下拉選單,預設值自動帶你機器上 codex 的全域設定;清單讀自本機 codex 模型快取,換模型時強度選項會連動(如 ultra 只有支援的模型才出現)
|
||||
5. 勾「建立後立即啟動」→ 儲存,bot 就上線了
|
||||
|
||||

|
||||

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

|
||||

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

|
||||

|
||||
|
||||
- 兩個分頁每 5 秒自動更新
|
||||
- Bot 設定存於 `~/.tgcodex/bots/<名稱>/tgcodex.config.json`,bot 掛 pm2 的程序名 = bot 名稱
|
||||
|
||||
+3
-3
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "telegram-codex-bot",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.10",
|
||||
"description": "Telegram bot powered by the local Codex CLI — zero-dependency, pm2-managed, with a built-in PM2 web viewer",
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
"publishConfig": {
|
||||
"registry": "https://git.catan.com.tw/api/packages/Frontend/npm/"
|
||||
"registry": "https://jianmiau.ddns.net/api/packages/AI/npm/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@git.catan.com.tw:Frontend/telegram-codex-bot.git"
|
||||
"url": "ssh://git@jianmiau.ddns.net:8022/AI/telegram-codex-bot.git"
|
||||
},
|
||||
"main": "src/index.js",
|
||||
"bin": {
|
||||
|
||||
+130
-24
@@ -32,7 +32,7 @@ function loadCodexDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
// — ✅ 16:06:32 | ctx 24% +1.2k | gpt-5.6-sol / high / Fast
|
||||
// — ✅ 16:06:32 | ctx 24% +1.2k | gpt-5.6-sol / high
|
||||
function buildFooter(config, usage, modelMeta, codexDefaults) {
|
||||
const time = new Date().toLocaleTimeString('zh-TW', { hour12: false });
|
||||
const parts = [`— ✅ ${time}`];
|
||||
@@ -50,16 +50,50 @@ function buildFooter(config, usage, modelMeta, codexDefaults) {
|
||||
|
||||
if (model) {
|
||||
const effort = config.reasoningEffort || codexDefaults.effort || meta?.default_reasoning_level || '';
|
||||
const tier = config.serviceTier || codexDefaults.tier || '';
|
||||
// 顯示速度層級的名稱(priority → Fast),對照模型快取
|
||||
const tierName = tier
|
||||
? (meta?.service_tiers?.find((t) => t.id === tier)?.name || tier)
|
||||
: '';
|
||||
parts.push([model, effort, tierName].filter(Boolean).join(' / '));
|
||||
parts.push(effort ? `${model} / ${effort}` : model);
|
||||
}
|
||||
return `\n\n${parts.join(' | ')}`;
|
||||
}
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Codex 要傳檔案給使用者的交件匣(在工作目錄底下,沙盒內可寫)
|
||||
const OUTBOX_DIRNAME = '.tgcodex-outbox';
|
||||
// codex 內建 imagegen 技能的預設輸出位置(依會話 id 分資料夾)
|
||||
const GENERATED_IMAGES_DIR = path.join(require('os').homedir(), '.codex', 'generated_images');
|
||||
|
||||
// 這一輪 codex 用 imagegen 生成、但沒複製進交件匣的圖(模型偶爾會忘)
|
||||
function collectGeneratedImages(threadId, sinceMs) {
|
||||
if (!threadId) return [];
|
||||
const dir = path.join(GENERATED_IMAGES_DIR, String(threadId));
|
||||
try {
|
||||
return fs.readdirSync(dir)
|
||||
.filter((f) => /\.(png|jpe?g|gif|webp)$/i.test(f))
|
||||
.map((f) => path.join(dir, f))
|
||||
.filter((p) => {
|
||||
try {
|
||||
const st = fs.statSync(p);
|
||||
return st.isFile() && st.mtimeMs >= sinceMs;
|
||||
} catch { return false; }
|
||||
})
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function md5File(p) {
|
||||
try {
|
||||
return crypto.createHash('md5').update(fs.readFileSync(p)).digest('hex');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const OUTBOX_RULE =
|
||||
`【系統規則】若要把圖片或檔案傳給使用者,請將檔案寫入工作目錄下的 ${OUTBOX_DIRNAME}/ 資料夾,` +
|
||||
'bot 會在回覆後自動傳送到 Telegram 並清空該資料夾。';
|
||||
const MAX_OUTBOX_FILES = 10;
|
||||
|
||||
const NEW_COMMANDS = ['/new', '!clear', '!reset', '!new', '!清除', '!重置', '!新會話'];
|
||||
const STATUS_COMMANDS = ['/status', '!status', '!狀態'];
|
||||
const HELP_COMMANDS = ['/start', '/help', '!help', '!幫助'];
|
||||
@@ -77,14 +111,21 @@ function startBot(config) {
|
||||
|
||||
function shouldRespond(message) {
|
||||
if (message.chat.type === 'private') return true;
|
||||
if (message.reply_to_message?.from?.id === botId) return true;
|
||||
const text = message.text || message.caption || '';
|
||||
const entities = message.entities || message.caption_entities || [];
|
||||
return entities.some((e) =>
|
||||
(e.type === 'mention' &&
|
||||
text.slice(e.offset, e.offset + e.length).toLowerCase() === `@${botUsername.toLowerCase()}`) ||
|
||||
(e.type === 'text_mention' && e.user?.id === botId)
|
||||
);
|
||||
// 訊息中所有被 tag 的 bot(Telegram bot username 一定以 bot 結尾;
|
||||
// tag 到一般使用者不算,不影響回覆觸發)
|
||||
const botMentions = entities
|
||||
.filter((e) => e.type === 'mention')
|
||||
.map((e) => text.slice(e.offset + 1, e.offset + e.length))
|
||||
.filter((u) => /bot$/i.test(u));
|
||||
if (botMentions.length > 0) {
|
||||
// 有明確 tag 時以 tag 為準:只有第一個被 tag 的 bot 回應。
|
||||
// 就算這則訊息是回覆我的(例如回覆我的訊息但 tag 別的 bot),我也讓位。
|
||||
return botMentions[0].toLowerCase() === botUsername.toLowerCase();
|
||||
}
|
||||
if (message.reply_to_message?.from?.id === botId) return true;
|
||||
return entities.some((e) => e.type === 'text_mention' && e.user?.id === botId);
|
||||
}
|
||||
|
||||
function stripMention(text) {
|
||||
@@ -98,6 +139,49 @@ function startBot(config) {
|
||||
return commands.includes(head.toLowerCase());
|
||||
}
|
||||
|
||||
// 把 codex 放進交件匣的檔案傳到 Telegram(傳完清掉),
|
||||
// 再補傳這一輪 imagegen 生成但沒進交件匣的圖(用內容 hash 去重,避免重複傳)。
|
||||
async function flushOutputs(chatId, replyTo, threadId, sinceMs) {
|
||||
const sentHashes = new Set();
|
||||
let sentCount = 0;
|
||||
|
||||
const send = async (file, { deleteAfter }) => {
|
||||
const hash = md5File(file);
|
||||
if (!hash || sentHashes.has(hash)) return;
|
||||
try {
|
||||
await t.sendFile(chatId, file, { replyTo });
|
||||
sentHashes.add(hash);
|
||||
sentCount++;
|
||||
if (deleteAfter) fs.unlinkSync(file);
|
||||
} catch (err) {
|
||||
console.error('傳送檔案失敗:', file, err.message);
|
||||
await t.sendMessage(chatId, `⚠️ 檔案傳送失敗:${path.basename(file)}(${err.message})`).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// 1) 交件匣(明確交付:任何檔案類型)
|
||||
const dir = path.join(config.workDir, OUTBOX_DIRNAME);
|
||||
let outbox = [];
|
||||
try {
|
||||
outbox = fs.readdirSync(dir)
|
||||
.map((f) => path.join(dir, f))
|
||||
.filter((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } })
|
||||
.sort();
|
||||
} catch { /* 沒有交件匣 */ }
|
||||
for (const file of outbox.slice(0, MAX_OUTBOX_FILES)) {
|
||||
await send(file, { deleteAfter: true });
|
||||
}
|
||||
if (outbox.length > MAX_OUTBOX_FILES) {
|
||||
await t.sendMessage(chatId, `⚠️ 交件匣一次最多傳 ${MAX_OUTBOX_FILES} 個檔案,還有 ${outbox.length - MAX_OUTBOX_FILES} 個留在 ${OUTBOX_DIRNAME}/`).catch(() => {});
|
||||
}
|
||||
|
||||
// 2) 保險網:imagegen 這一輪的產圖(不刪原檔,codex 之後編輯圖片可能還要用)
|
||||
for (const file of collectGeneratedImages(threadId, sinceMs).slice(0, MAX_OUTBOX_FILES)) {
|
||||
if (sentCount >= MAX_OUTBOX_FILES) break;
|
||||
await send(file, { deleteAfter: false });
|
||||
}
|
||||
}
|
||||
|
||||
function cleanTmp() {
|
||||
const ttl = 24 * 60 * 60 * 1000;
|
||||
try {
|
||||
@@ -128,7 +212,7 @@ function startBot(config) {
|
||||
const from = message.from || {};
|
||||
const name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者';
|
||||
const sender = from.username ? `${name}(@${from.username})` : name;
|
||||
const parts = [];
|
||||
const parts = [OUTBOX_RULE];
|
||||
const replied = message.reply_to_message;
|
||||
if (replied && replied.from?.id !== botId && (replied.text || replied.caption)) {
|
||||
parts.push(`【被回覆的訊息】\n${replied.text || replied.caption}`);
|
||||
@@ -141,14 +225,14 @@ function startBot(config) {
|
||||
}
|
||||
|
||||
function statusText(chatId) {
|
||||
const s = sessions.get(chatId);
|
||||
const s = sessions.get(sessionKeyOf(chatId));
|
||||
return [
|
||||
'📋 目前狀態',
|
||||
`工作目錄:${config.workDir}`,
|
||||
`沙盒模式:${config.sandbox}`,
|
||||
`模型:${config.model || '(codex 預設)'}`,
|
||||
`推理強度:${config.reasoningEffort || '(預設)'}`,
|
||||
`速度:${config.serviceTier || '(預設)'}`,
|
||||
`會話模式:${config.sharedSession ? '所有聊天室共用' : '各聊天室獨立'}`,
|
||||
s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -164,9 +248,15 @@ function startBot(config) {
|
||||
'群組中要 @我 或回覆我的訊息才會觸發。可以直接傳圖片(附文字說明)。',
|
||||
].join('\n');
|
||||
|
||||
// 會話 key:獨立模式用 chat id,共用模式所有聊天室用同一把
|
||||
function sessionKeyOf(chatId) {
|
||||
return config.sharedSession ? 'shared' : chatId;
|
||||
}
|
||||
|
||||
async function handleMessage(message) {
|
||||
if (!message || message.from?.id === botId) return;
|
||||
const chatId = message.chat.id;
|
||||
const sessionKey = sessionKeyOf(chatId);
|
||||
const text = message.text || message.caption || '';
|
||||
const photo = pickPhoto(message);
|
||||
if (!text && !photo) return;
|
||||
@@ -179,8 +269,14 @@ function startBot(config) {
|
||||
return;
|
||||
}
|
||||
if (matchCommand(content, NEW_COMMANDS)) {
|
||||
sessions.clear(chatId);
|
||||
await t.sendMessage(chatId, '🆕 已開新會話,之前的對話記憶已清除。', { replyTo: message.message_id });
|
||||
sessions.clear(sessionKey);
|
||||
await t.sendMessage(
|
||||
chatId,
|
||||
config.sharedSession
|
||||
? '🆕 已開新會話(此 bot 為共用會話模式,所有聊天室的記憶一併清除)。'
|
||||
: '🆕 已開新會話,之前的對話記憶已清除。',
|
||||
{ replyTo: message.message_id }
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (matchCommand(content, STATUS_COMMANDS)) {
|
||||
@@ -221,26 +317,36 @@ function startBot(config) {
|
||||
}
|
||||
|
||||
const prompt = buildPrompt(message, content || '(使用者只傳了圖片,請描述並依上下文處理)');
|
||||
const existing = sessions.get(chatId);
|
||||
const existing = sessions.get(sessionKey);
|
||||
const turnStart = Date.now();
|
||||
|
||||
let result;
|
||||
let sessionResetNote = '';
|
||||
try {
|
||||
result = await runCodex({ config, prompt, threadId: existing?.threadId || null, images, onProgress });
|
||||
} catch (err) {
|
||||
// 舊會話可能已被 codex 清掉,找不到就自動開新會話重試一次
|
||||
if (existing && /session|thread|conversation/i.test(err.message) && /not.*found|找不到|no .*(session|thread)/i.test(err.message)) {
|
||||
sessions.clear(chatId);
|
||||
// 舊會話 resume 不了的情況,自動開新會話重試一次:
|
||||
// 1) 會話已被 codex 清掉(not found)
|
||||
// 2) bot 換了模型,codex 拒絕跨模型 resume(recorded with model X but resuming with Y)
|
||||
const sessionGone = /session|thread|conversation/i.test(err.message) && /not.*found|找不到|no .*(session|thread)/i.test(err.message);
|
||||
const modelMismatch = /recorded with model/i.test(err.message);
|
||||
if (existing && (sessionGone || modelMismatch)) {
|
||||
sessions.clear(sessionKey);
|
||||
result = await runCodex({ config, prompt, threadId: null, images, onProgress });
|
||||
sessionResetNote = modelMismatch
|
||||
? '🆕 模型設定已變更,舊會話無法沿用,已自動開新會話(先前的對話記憶未帶入)。\n\n'
|
||||
: '🆕 舊會話已失效,已自動開新會話。\n\n';
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.threadId) sessions.set(chatId, result.threadId);
|
||||
if (result.threadId) sessions.set(sessionKey, result.threadId);
|
||||
|
||||
const footer = buildFooter(config, result.usage, modelMeta, codexDefaults);
|
||||
const html = t.mdToTgHtml((result.text || '(Codex 沒有回覆文字)') + footer);
|
||||
const html = t.mdToTgHtml(sessionResetNote + (result.text || '(Codex 沒有回覆文字)') + footer);
|
||||
await t.editOrSplit(chatId, statusMsg.message_id, html, { html: true });
|
||||
await flushOutputs(chatId, message.message_id, result.threadId, turnStart);
|
||||
await t.react(chatId, message.message_id, '👍');
|
||||
} catch (error) {
|
||||
console.error('處理訊息失敗:', error);
|
||||
|
||||
@@ -101,6 +101,7 @@ function runCodex({ config, prompt, threadId, images = [], onProgress }) {
|
||||
}
|
||||
if (config.reasoningEffort) args.push('-c', `model_reasoning_effort="${config.reasoningEffort}"`);
|
||||
if (config.serviceTier) args.push('-c', `service_tier="${config.serviceTier}"`);
|
||||
if (config.networkAccess) args.push('-c', 'sandbox_workspace_write.network_access=true');
|
||||
for (const img of images) args.push('-i', img);
|
||||
args.push('-'); // prompt 從 stdin 讀
|
||||
|
||||
|
||||
+9
-2
@@ -18,8 +18,12 @@ const DEFAULTS = {
|
||||
model: '',
|
||||
// 推理強度(low/medium/high/xhigh/max/ultra…依模型而定);空字串 = codex 預設
|
||||
reasoningEffort: '',
|
||||
// 速度(service tier,如 priority = Fast 1.5x);空字串 = codex 預設
|
||||
serviceTier: '',
|
||||
// 速度固定 Fast(priority = 1.5x);要改只能手動編輯設定檔
|
||||
serviceTier: 'priority',
|
||||
// 會話模式:false = 每個聊天室獨立會話(預設);true = 所有聊天室共用一個會話
|
||||
sharedSession: false,
|
||||
// 允許 Codex 在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開)
|
||||
networkAccess: false,
|
||||
// 單次回應逾時(分鐘)
|
||||
timeoutMinutes: 30,
|
||||
// pm2 程序名稱(web 檢視工具會叫 <pm2Name>-web)
|
||||
@@ -80,6 +84,9 @@ function loadConfig(explicitPath) {
|
||||
}
|
||||
cfg.web.port = port;
|
||||
}
|
||||
// 速度政策固定 Fast:舊設定檔存了空值也矯正回 priority
|
||||
if (!cfg.serviceTier) cfg.serviceTier = 'priority';
|
||||
|
||||
const minutes = Number(cfg.timeoutMinutes);
|
||||
if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`);
|
||||
cfg.timeoutMinutes = minutes;
|
||||
|
||||
+8
-3
@@ -78,6 +78,8 @@ async function listBots() {
|
||||
sandbox: cfg?.sandbox || null,
|
||||
model: cfg?.model || '',
|
||||
reasoningEffort: cfg?.reasoningEffort || '',
|
||||
sharedSession: !!cfg?.sharedSession,
|
||||
networkAccess: !!cfg?.networkAccess,
|
||||
serviceTier: cfg?.serviceTier || '',
|
||||
timeoutMinutes: cfg?.timeoutMinutes || null,
|
||||
tokenMasked: maskToken(cfg?.telegramToken),
|
||||
@@ -112,7 +114,7 @@ function validateBotInput({ name, token, workDir, sandbox }, { isCreate }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, serviceTier, timeoutMinutes }) {
|
||||
async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, sharedSession, networkAccess, timeoutMinutes }) {
|
||||
validateBotInput({ name, token, workDir, sandbox }, { isCreate: true });
|
||||
const reg = loadRegistry();
|
||||
if (reg.bots[name]) throw new Error(`已有同名 bot:${name}`);
|
||||
@@ -128,7 +130,9 @@ async function createBot({ name, token, workDir, sandbox, model, reasoningEffort
|
||||
sandbox: sandbox || 'workspace-write',
|
||||
model: model || '',
|
||||
reasoningEffort: reasoningEffort || '',
|
||||
serviceTier: serviceTier || '',
|
||||
sharedSession: !!sharedSession,
|
||||
networkAccess: !!networkAccess,
|
||||
serviceTier: 'priority', // 速度固定 Fast 1.5x
|
||||
timeoutMinutes: Number(timeoutMinutes) || 30,
|
||||
pm2Name: name,
|
||||
// UI 建的 bot 不各自開 web,統一由控制台管理
|
||||
@@ -158,7 +162,8 @@ async function updateBot(name, patch) {
|
||||
}
|
||||
if (patch.model !== undefined) raw.model = patch.model;
|
||||
if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort;
|
||||
if (patch.serviceTier !== undefined) raw.serviceTier = patch.serviceTier;
|
||||
if (patch.sharedSession !== undefined) raw.sharedSession = !!patch.sharedSession;
|
||||
if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess;
|
||||
if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) {
|
||||
raw.timeoutMinutes = Number(patch.timeoutMinutes);
|
||||
}
|
||||
|
||||
+30
-1
@@ -86,6 +86,35 @@ function createTelegram(token) {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// 上傳檔案到 Telegram:圖片走 sendPhoto,其餘(或 sendPhoto 失敗時)走 sendDocument
|
||||
async function sendFile(chatId, filePath, { caption, replyTo } = {}) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const name = path.basename(filePath);
|
||||
const isImage = /\.(png|jpe?g|gif|webp)$/i.test(name);
|
||||
const size = fs.statSync(filePath).size;
|
||||
|
||||
const upload = async (method, field) => {
|
||||
const form = new FormData();
|
||||
form.append('chat_id', String(chatId));
|
||||
if (caption) form.append('caption', caption.slice(0, 1000));
|
||||
if (replyTo) form.append('reply_parameters', JSON.stringify({ message_id: replyTo, allow_sending_without_reply: true }));
|
||||
form.append(field, new Blob([fs.readFileSync(filePath)]), name);
|
||||
const res = await fetch(`${API}/${method}`, { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(`${method} 失敗:${data.description}`);
|
||||
return data.result;
|
||||
};
|
||||
|
||||
// sendPhoto 上限 10MB,且某些格式會被拒,失敗就退回用檔案傳
|
||||
if (isImage && size <= 9.5 * 1024 * 1024) {
|
||||
try {
|
||||
return await upload('sendPhoto', 'photo');
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return upload('sendDocument', 'document');
|
||||
}
|
||||
|
||||
async function downloadFile(fileId, destPath) {
|
||||
const fs = require('fs');
|
||||
const info = await tg('getFile', { file_id: fileId });
|
||||
@@ -96,7 +125,7 @@ function createTelegram(token) {
|
||||
return destPath;
|
||||
}
|
||||
|
||||
return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile };
|
||||
return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile, sendFile };
|
||||
}
|
||||
|
||||
module.exports = { createTelegram };
|
||||
|
||||
+24
-26
@@ -217,10 +217,19 @@
|
||||
<div class="field">
|
||||
<label>推理強度</label>
|
||||
<select id="f-effort"></select>
|
||||
<div class="hint">速度固定為 Fast(1.5x)</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>速度</label>
|
||||
<select id="f-tier"></select>
|
||||
<label>會話模式</label>
|
||||
<select id="f-session">
|
||||
<option value="per-chat" selected>各聊天室獨立(私訊、每個群組各自記憶)</option>
|
||||
<option value="shared">所有聊天室共用(同一條記憶,適合單一專案助理)</option>
|
||||
</select>
|
||||
<div class="hint">共用模式下 /new 會清掉所有聊天室的記憶</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="check" style="margin-top:2px"><input type="checkbox" id="f-network"> 允許 Codex 連網</label>
|
||||
<div class="hint">workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>逾時(分鐘)</label>
|
||||
@@ -314,7 +323,7 @@ async function loadModels() {
|
||||
} catch { modelsCache = []; }
|
||||
}
|
||||
// 下拉沒有「預設」空選項:直接把真實預設值選起來
|
||||
function populateModelSelects(selModel, selEffort, selTier) {
|
||||
function populateModelSelects(selModel, selEffort) {
|
||||
const mSel = document.getElementById('f-model');
|
||||
let opts = modelsCache.map(m => '<option value="' + esc(m.slug) + '">' + esc(m.label) + '</option>').join('');
|
||||
// 設定檔裡有但快取沒有的模型(手填過的)也要能顯示
|
||||
@@ -326,9 +335,9 @@ function populateModelSelects(selModel, selEffort, selTier) {
|
||||
const wanted = selModel || modelDefaults.model;
|
||||
mSel.value = wanted;
|
||||
if (mSel.value !== wanted || !mSel.value) mSel.selectedIndex = 0;
|
||||
syncEffortTier(selEffort, selTier);
|
||||
syncEffortTier(selEffort);
|
||||
}
|
||||
function syncEffortTier(selEffort, selTier) {
|
||||
function syncEffortTier(selEffort) {
|
||||
const slug = document.getElementById('f-model').value;
|
||||
const m = modelsCache.find(x => x.slug === slug);
|
||||
let efforts = m ? m.efforts.slice() : ['low', 'medium', 'high', 'xhigh', 'max'];
|
||||
@@ -341,22 +350,6 @@ function syncEffortTier(selEffort, selTier) {
|
||||
: efforts.includes(modelDefaults.effort) ? modelDefaults.effort
|
||||
: (m && efforts.includes(m.defaultEffort)) ? m.defaultEffort
|
||||
: efforts[0];
|
||||
|
||||
const tiers = m ? m.tiers.slice() : [];
|
||||
const tSel = document.getElementById('f-tier');
|
||||
const prevT = (selTier !== undefined && selTier !== '') ? selTier : tSel.value;
|
||||
if (!tiers.length) {
|
||||
tSel.innerHTML = '<option value="">標準</option>';
|
||||
tSel.value = '';
|
||||
} else {
|
||||
// 模型快取只列「額外」的速度層級,補一個標準選項在前面
|
||||
tSel.innerHTML = '<option value="">標準</option>' +
|
||||
tiers.map(t => '<option value="' + esc(t.id) + '">' + esc(t.name) + (t.description ? '(' + esc(t.description) + ')' : '') + '</option>').join('');
|
||||
tSel.value = tiers.some(t => t.id === prevT) ? prevT
|
||||
: tiers.some(t => t.id === modelDefaults.tier) ? modelDefaults.tier
|
||||
: tiers.some(t => t.id === (m && m.defaultTier)) ? m.defaultTier
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Bot 管理 ----------
|
||||
@@ -393,8 +386,8 @@ function renderBots(bots) {
|
||||
'<td>' + statusBadge(b.status) + '</td>' +
|
||||
'<td><span class="mono" title="' + esc(b.workDir) + '">' + esc(shortPath(b.workDir)) + '</span></td>' +
|
||||
'<td class="mono">' + esc(b.sandbox || '-') +
|
||||
((b.model || b.reasoningEffort || b.serviceTier)
|
||||
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.serviceTier].filter(Boolean).join(' / ')) + '</span>'
|
||||
((b.model || b.reasoningEffort || b.sharedSession || b.networkAccess)
|
||||
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.sharedSession ? '共用會話' : '', b.networkAccess ? '可連網' : ''].filter(Boolean).join(' / ')) + '</span>'
|
||||
: '') + '</td>' +
|
||||
'<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' +
|
||||
'<td>' + fmtBytes(b.memory) + '</td>' +
|
||||
@@ -462,7 +455,9 @@ function openCreate() {
|
||||
document.getElementById('f-token-hint').textContent = '跟 @BotFather 申請;儲存時會自動驗證';
|
||||
document.getElementById('f-workdir').value = '';
|
||||
document.getElementById('f-sandbox').value = 'workspace-write';
|
||||
populateModelSelects('', '', '');
|
||||
populateModelSelects('', '');
|
||||
document.getElementById('f-session').value = 'per-chat';
|
||||
document.getElementById('f-network').checked = false;
|
||||
document.getElementById('f-timeout').value = '30';
|
||||
document.getElementById('f-autostart-wrap').style.display = '';
|
||||
document.getElementById('f-autostart').checked = true;
|
||||
@@ -483,7 +478,9 @@ function openEdit(name) {
|
||||
document.getElementById('f-token-hint').textContent = '留空表示沿用現有 token';
|
||||
document.getElementById('f-workdir').value = b.workDir || '';
|
||||
document.getElementById('f-sandbox').value = b.sandbox || 'workspace-write';
|
||||
populateModelSelects(b.model || '', b.reasoningEffort || '', b.serviceTier || '');
|
||||
populateModelSelects(b.model || '', b.reasoningEffort || '');
|
||||
document.getElementById('f-session').value = b.sharedSession ? 'shared' : 'per-chat';
|
||||
document.getElementById('f-network').checked = !!b.networkAccess;
|
||||
document.getElementById('f-timeout').value = b.timeoutMinutes || 30;
|
||||
document.getElementById('f-autostart-wrap').style.display = 'none';
|
||||
document.getElementById('bot-modal').classList.add('show');
|
||||
@@ -504,7 +501,8 @@ async function saveBot() {
|
||||
sandbox: document.getElementById('f-sandbox').value,
|
||||
model: document.getElementById('f-model').value,
|
||||
reasoningEffort: document.getElementById('f-effort').value,
|
||||
serviceTier: document.getElementById('f-tier').value,
|
||||
sharedSession: document.getElementById('f-session').value === 'shared',
|
||||
networkAccess: document.getElementById('f-network').checked,
|
||||
timeoutMinutes: document.getElementById('f-timeout').value,
|
||||
};
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user