From 24c66130d0435b9ec475d4f4d11bc68d0de6c4c7 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Mon, 17 Aug 2026 16:18:33 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BC=E4=BD=88=200.1.12=EF=BC=9A=E6=9C=83?= =?UTF-8?q?=E8=A9=B1=E6=A8=A1=E5=BC=8F=E6=96=B0=E5=A2=9E=E3=80=8C=E6=AF=8F?= =?UTF-8?q?=E5=89=87=E7=8D=A8=E7=AB=8B=E3=80=8D=EF=BC=8Cctx=20=E9=81=94=20?= =?UTF-8?q?80%=20=E8=87=AA=E5=8B=95=E9=96=8B=E6=96=B0=E6=9C=83=E8=A9=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根本原因: 會話越用越慢——每張生成圖都會塞回上下文,實測同一會話從 36 秒 一路漲到 135 秒,也更容易在串流時被切斷。 修法: 1. 會話模式 sharedSession 布林改為 sessionMode(per-chat / shared / stateless), stateless = 每則訊息獨立、不保留記憶,適合單張生圖這類不需上下文的 bot; 舊設定檔的 sharedSession 自動相容轉換。 2. 新增 autoResetCtxPercent(預設 80):任一會話上下文用量達門檻即自動 開新會話並在回覆末尾提示,避免無限累積。 Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/bot.js | 46 ++++++++++++++++++++++++++++----------- src/config.js | 12 ++++++++-- src/registry.js | 8 +++---- src/web/public/index.html | 11 +++++----- 5 files changed, 54 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index ed410bf..0d56cce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telegram-codex-bot", - "version": "0.1.11", + "version": "0.1.12", "description": "Telegram bot powered by the local Codex CLI — zero-dependency, pm2-managed, with a built-in PM2 web viewer", "license": "MIT", "type": "commonjs", diff --git a/src/bot.js b/src/bot.js index 6bb8b38..64653a8 100644 --- a/src/bot.js +++ b/src/bot.js @@ -94,6 +94,8 @@ const OUTBOX_RULE = 'bot 會在回覆後自動傳送到 Telegram 並清空該資料夾。'; const MAX_OUTBOX_FILES = 10; +const SESSION_MODE_LABELS = { 'per-chat': '各聊天室獨立', shared: '所有聊天室共用', stateless: '每則訊息獨立(不保留記憶)' }; + const NEW_COMMANDS = ['/new', '!clear', '!reset', '!new', '!清除', '!重置', '!新會話']; const STATUS_COMMANDS = ['/status', '!status', '!狀態']; const HELP_COMMANDS = ['/start', '/help', '!help', '!幫助']; @@ -225,15 +227,17 @@ function startBot(config) { } function statusText(chatId) { - const s = sessions.get(sessionKeyOf(chatId)); + const s = sessionKeyOf(chatId) ? sessions.get(sessionKeyOf(chatId)) : null; return [ '📋 目前狀態', `工作目錄:${config.workDir}`, `沙盒模式:${config.sandbox}`, `模型:${config.model || '(codex 預設)'}`, `推理強度:${config.reasoningEffort || '(預設)'}`, - `會話模式:${config.sharedSession ? '所有聊天室共用' : '各聊天室獨立'}`, - s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)', + `會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`, + config.sessionMode === 'stateless' + ? '會話:每則訊息獨立,不保留記憶' + : s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)', ].join('\n'); } @@ -249,8 +253,10 @@ function startBot(config) { ].join('\n'); // 會話 key:獨立模式用 chat id,共用模式所有聊天室用同一把 + // stateless 模式回傳 null:永遠開新會話、不存 function sessionKeyOf(chatId) { - return config.sharedSession ? 'shared' : chatId; + if (config.sessionMode === 'stateless') return null; + return config.sessionMode === 'shared' ? 'shared' : chatId; } async function handleMessage(message) { @@ -269,12 +275,14 @@ function startBot(config) { return; } if (matchCommand(content, NEW_COMMANDS)) { - sessions.clear(sessionKey); + if (sessionKey) sessions.clear(sessionKey); await t.sendMessage( chatId, - config.sharedSession - ? '🆕 已開新會話(此 bot 為共用會話模式,所有聊天室的記憶一併清除)。' - : '🆕 已開新會話,之前的對話記憶已清除。', + config.sessionMode === 'stateless' + ? 'ℹ️ 此 bot 每則訊息都是獨立會話,本來就不保留記憶。' + : config.sessionMode === 'shared' + ? '🆕 已開新會話(此 bot 為共用會話模式,所有聊天室的記憶一併清除)。' + : '🆕 已開新會話,之前的對話記憶已清除。', { replyTo: message.message_id } ); return; @@ -317,7 +325,7 @@ function startBot(config) { } const prompt = buildPrompt(message, content || '(使用者只傳了圖片,請描述並依上下文處理)'); - const existing = sessions.get(sessionKey); + const existing = sessionKey ? sessions.get(sessionKey) : null; const turnStart = Date.now(); let result; @@ -334,7 +342,7 @@ function startBot(config) { 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 (tid && (sessionGone || modelMismatch)) { - sessions.clear(sessionKey); + if (sessionKey) sessions.clear(sessionKey); tid = null; sessionResetNote = modelMismatch ? '🆕 模型設定已變更,舊會話無法沿用,已自動開新會話(先前的對話記憶未帶入)。\n\n' @@ -344,7 +352,7 @@ function startBot(config) { // 暫時性連線中斷(OpenAI 端切斷串流等) if (err.transient) { if (err.threadId) tid = err.threadId; - if (tid) sessions.set(sessionKey, tid); // 會話已建立,先記下來以便 resume + if (tid && sessionKey) sessions.set(sessionKey, tid); // 會話已建立,先記下來以便 resume // 中斷前若圖已生成,直接交付,不重跑(避免重複生圖、多花時間) if (collectGeneratedImages(tid, turnStart).length > 0) { result = { text: '(回覆文字因連線中斷遺失,圖片如下)', threadId: tid, usage: null }; @@ -364,10 +372,22 @@ function startBot(config) { } } - if (result.threadId) sessions.set(sessionKey, result.threadId); + if (result.threadId && sessionKey) sessions.set(sessionKey, result.threadId); + + // 上下文用量過高就自動開新會話:越積越慢(尤其含圖),也容易在串流時被切斷 + let ctxNote = ''; + const cwModel = config.model || codexDefaults.model; + const cw = modelMeta[cwModel]?.context_window; + if (sessionKey && config.autoResetCtxPercent > 0 && result.usage && cw) { + const pct = Math.round(((result.usage.input_tokens || 0) + (result.usage.output_tokens || 0)) / cw * 100); + if (pct >= config.autoResetCtxPercent) { + sessions.clear(sessionKey); + ctxNote = `\n\n🧹 對話記憶已用到 ${pct}%,下一則訊息會自動開新會話(避免越來越慢)。`; + } + } const footer = buildFooter(config, result.usage, modelMeta, codexDefaults); - const html = t.mdToTgHtml(sessionResetNote + (result.text || '(Codex 沒有回覆文字)') + footer); + const html = t.mdToTgHtml(sessionResetNote + (result.text || '(Codex 沒有回覆文字)') + ctxNote + 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, '👍'); diff --git a/src/config.js b/src/config.js index 1b88e4d..d172d3e 100644 --- a/src/config.js +++ b/src/config.js @@ -20,8 +20,11 @@ const DEFAULTS = { reasoningEffort: '', // 速度固定 Fast(priority = 1.5x);要改只能手動編輯設定檔 serviceTier: 'priority', - // 會話模式:false = 每個聊天室獨立會話(預設);true = 所有聊天室共用一個會話 - sharedSession: false, + // 會話模式:per-chat = 每個聊天室獨立(預設);shared = 所有聊天室共用; + // stateless = 每則訊息獨立、不保留記憶(最快,適合單張生圖這類不需上下文的 bot) + sessionMode: 'per-chat', + // 會話上下文用量達此百分比時自動開新會話(避免越積越慢、串流被切);0 = 不自動 + autoResetCtxPercent: 80, // 允許 Codex 在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開) networkAccess: false, // 單次回應逾時(分鐘) @@ -86,6 +89,11 @@ function loadConfig(explicitPath) { } // 速度政策固定 Fast:舊設定檔存了空值也矯正回 priority if (!cfg.serviceTier) cfg.serviceTier = 'priority'; + // 舊欄位 sharedSession 相容:轉成 sessionMode + if (!['per-chat', 'shared', 'stateless'].includes(cfg.sessionMode)) { + cfg.sessionMode = raw.sharedSession ? 'shared' : 'per-chat'; + } + cfg.autoResetCtxPercent = Number(cfg.autoResetCtxPercent) || 0; const minutes = Number(cfg.timeoutMinutes); if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`); diff --git a/src/registry.js b/src/registry.js index f76af53..062fd56 100644 --- a/src/registry.js +++ b/src/registry.js @@ -78,7 +78,7 @@ async function listBots() { sandbox: cfg?.sandbox || null, model: cfg?.model || '', reasoningEffort: cfg?.reasoningEffort || '', - sharedSession: !!cfg?.sharedSession, + sessionMode: cfg?.sessionMode || 'per-chat', networkAccess: !!cfg?.networkAccess, serviceTier: cfg?.serviceTier || '', timeoutMinutes: cfg?.timeoutMinutes || null, @@ -114,7 +114,7 @@ function validateBotInput({ name, token, workDir, sandbox }, { isCreate }) { } } -async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, sharedSession, networkAccess, timeoutMinutes }) { +async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, sessionMode, networkAccess, timeoutMinutes }) { validateBotInput({ name, token, workDir, sandbox }, { isCreate: true }); const reg = loadRegistry(); if (reg.bots[name]) throw new Error(`已有同名 bot:${name}`); @@ -130,7 +130,7 @@ async function createBot({ name, token, workDir, sandbox, model, reasoningEffort sandbox: sandbox || 'workspace-write', model: model || '', reasoningEffort: reasoningEffort || '', - sharedSession: !!sharedSession, + sessionMode: ['per-chat','shared','stateless'].includes(sessionMode) ? sessionMode : 'per-chat', networkAccess: !!networkAccess, serviceTier: 'priority', // 速度固定 Fast 1.5x timeoutMinutes: Number(timeoutMinutes) || 30, @@ -162,7 +162,7 @@ async function updateBot(name, patch) { } if (patch.model !== undefined) raw.model = patch.model; if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort; - if (patch.sharedSession !== undefined) raw.sharedSession = !!patch.sharedSession; + if (['per-chat','shared','stateless'].includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; } if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess; if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) { raw.timeoutMinutes = Number(patch.timeoutMinutes); diff --git a/src/web/public/index.html b/src/web/public/index.html index d385091..69a8f26 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -224,8 +224,9 @@ -
共用模式下 /new 會清掉所有聊天室的記憶
+
記憶會隨對話變慢(尤其含圖);用量達 80% 時所有模式都會自動開新會話
@@ -386,8 +387,8 @@ function renderBots(bots) { '' + statusBadge(b.status) + '' + '' + esc(shortPath(b.workDir)) + '' + '' + esc(b.sandbox || '-') + - ((b.model || b.reasoningEffort || b.sharedSession || b.networkAccess) - ? '
' + esc([b.model, b.reasoningEffort, b.sharedSession ? '共用會話' : '', b.networkAccess ? '可連網' : ''].filter(Boolean).join(' / ')) + '' + ((b.model || b.reasoningEffort || b.sessionMode !== 'per-chat' || b.networkAccess) + ? '
' + esc([b.model, b.reasoningEffort, b.sessionMode === 'shared' ? '共用會話' : b.sessionMode === 'stateless' ? '無記憶' : '', b.networkAccess ? '可連網' : ''].filter(Boolean).join(' / ')) + '' : '') + '' + '' + esc(b.tokenMasked || '-') + '' + '' + fmtBytes(b.memory) + '' + @@ -479,7 +480,7 @@ function openEdit(name) { document.getElementById('f-workdir').value = b.workDir || ''; document.getElementById('f-sandbox').value = b.sandbox || 'workspace-write'; populateModelSelects(b.model || '', b.reasoningEffort || ''); - document.getElementById('f-session').value = b.sharedSession ? 'shared' : 'per-chat'; + document.getElementById('f-session').value = b.sessionMode || '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'; @@ -501,7 +502,7 @@ async function saveBot() { sandbox: document.getElementById('f-sandbox').value, model: document.getElementById('f-model').value, reasoningEffort: document.getElementById('f-effort').value, - sharedSession: document.getElementById('f-session').value === 'shared', + sessionMode: document.getElementById('f-session').value, networkAccess: document.getElementById('f-network').checked, timeoutMinutes: document.getElementById('f-timeout').value, };