From dac06753ce8db242b4c2b0a87a10e950df096447 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 1 Aug 2026 21:44:08 +0800 Subject: [PATCH] fix(wechat): stop relay bootstrap from hijacking LLM provider selection. Schedule LLM now binds an explicit provider key and fails closed on network errors so dead relay endpoints cannot block ordinary chat; relay bootstrap is opt-in only. Co-authored-by: Cursor --- .env.example | 9 +- llm-providers.mjs | 3 - server/portal-agent-services-bootstrap.mjs | 3 +- .../portal-agent-services-bootstrap.test.mjs | 4 + wechat-schedule-llm-config.mjs | 12 +++ wechat/handlers/schedule.mjs | 85 +++++++++++++------ wechat/handlers/schedule.test.mjs | 44 ++++++++-- 7 files changed, 121 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index 2bfac21..a5f0b19 100644 --- a/.env.example +++ b/.env.example @@ -178,7 +178,12 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # MEMIND_WECHAT_INTENT_TIMEOUT_MS=4000 # MEMIND_WECHAT_INTENT_CANARY_OPENIDS= -# H5 Session stream replay(docs/h5-session-architecture-20260706.md Patch 4c) +# 微信待办/提醒 LLM 意图(独立于 Intent Router;须绑定 DeepSeek 等直连 key,勿依赖全局 selected) +# H5_WECHAT_SCHEDULE_LLM_ENABLED=0 +# MEMIND_WECHAT_SCHEDULE_LLM_MODEL_PROVIDER_KEY_ID= +# MEMIND_WECHAT_SCHEDULE_LLM_MODEL=deepseek-v4-pro + +# H5 Session stream replay # 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。 # MEMIND_SESSION_STREAM_REPLAY=0 @@ -232,7 +237,7 @@ H5_ADMIN_PASSWORD=change-me-admin # H5_ADMIN_WORKSPACE_ROOT=/Users/john/PycharmProjects/tkmind # LLM Key 数据库加密(可选;默认派生自 TKMIND_SERVER__SECRET_KEY) # H5_SETTINGS_ENCRYPTION_KEY=change-me-encryption-key -# 启动时自动导入 Relay(若不存在同名配置);改用手动 Provider 时请设为 1 +# Relay bootstrap 默认关闭;仅本地调试 relay 时设 H5_RELAY_BOOTSTRAP_DISABLED=0 # H5_RELAY_BOOTSTRAP_DISABLED=1 # H5_RELAY_BOOTSTRAP_NAME=Local Goose Relay DeepSeek # H5_RELAY_BOOTSTRAP_URL=http://127.0.0.1:18300/relay/buyer/v1/chat/completions diff --git a/llm-providers.mjs b/llm-providers.mjs index fbf603f..f86b1b0 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -2304,9 +2304,6 @@ export function createLlmProviderService( relayProvider: RELAY_BOOTSTRAP.relayProvider, }); if (!result.ok) return result; - if (result.key && !result.key.isSelected) { - await this.selectKey(result.key.id); - } return { ok: true, created: true, key: result.key }; }, }; diff --git a/server/portal-agent-services-bootstrap.mjs b/server/portal-agent-services-bootstrap.mjs index 5f0bf99..6071046 100644 --- a/server/portal-agent-services-bootstrap.mjs +++ b/server/portal-agent-services-bootstrap.mjs @@ -190,7 +190,8 @@ export async function bootstrapPortalAgentServices({ const wordFilterService = createWordFilterServiceFn(pool); - const relayBootstrapTask = env.H5_RELAY_BOOTSTRAP_DISABLED === '1' + // Relay bootstrap is opt-in only; production uses direct provider keys (e.g. DeepSeek). + const relayBootstrapTask = env.H5_RELAY_BOOTSTRAP_DISABLED !== '0' ? Promise.resolve({ ok: true, created: false, skipped: true }) : llmProviderService .ensureBootstrapRelay() diff --git a/server/portal-agent-services-bootstrap.test.mjs b/server/portal-agent-services-bootstrap.test.mjs index 4e4cd8f..0dd6a4e 100644 --- a/server/portal-agent-services-bootstrap.test.mjs +++ b/server/portal-agent-services-bootstrap.test.mjs @@ -367,6 +367,10 @@ test('falls back from PG experience and degrades invalid image config', async () test('keeps disabled experience null and reports background LLM failures', async () => { const setup = createSetup({ + env: { + EXPERIENCE_PG_URL: 'postgres://experience', + H5_RELAY_BOOTSTRAP_DISABLED: '0', + }, runtime: { experienceEnabled: false, agentWorker: { enabled: false }, diff --git a/wechat-schedule-llm-config.mjs b/wechat-schedule-llm-config.mjs index 2dac3c6..413582f 100644 --- a/wechat-schedule-llm-config.mjs +++ b/wechat-schedule-llm-config.mjs @@ -34,6 +34,8 @@ async function ensureConfigTable(pool) { function defaultsFromEnv(env = process.env) { return { scheduleLlmEnabled: normalizeBoolean(env.H5_WECHAT_SCHEDULE_LLM_ENABLED, false), + modelProviderKeyId: String(env.MEMIND_WECHAT_SCHEDULE_LLM_MODEL_PROVIDER_KEY_ID ?? '').trim() || null, + model: String(env.MEMIND_WECHAT_SCHEDULE_LLM_MODEL ?? '').trim() || null, }; } @@ -65,6 +67,8 @@ export function createWechatScheduleLlmConfigService(pool, { env = process.env } stored.scheduleLlmEnabled, defaults.scheduleLlmEnabled, ), + modelProviderKeyId: String(stored.modelProviderKeyId ?? defaults.modelProviderKeyId ?? '').trim() || null, + model: String(stored.model ?? defaults.model ?? '').trim() || null, }; } @@ -90,6 +94,14 @@ export function createWechatScheduleLlmConfigService(pool, { env = process.env } payload.scheduleLlmEnabled === undefined ? current.scheduleLlmEnabled : normalizeBoolean(payload.scheduleLlmEnabled, current.scheduleLlmEnabled), + modelProviderKeyId: + payload.modelProviderKeyId === undefined + ? (current.modelProviderKeyId ?? '') + : String(payload.modelProviderKeyId ?? '').trim(), + model: + payload.model === undefined + ? (current.model ?? '') + : String(payload.model ?? '').trim(), }; const now = Date.now(); await pool.query( diff --git a/wechat/handlers/schedule.mjs b/wechat/handlers/schedule.mjs index 74a629c..74c8d6c 100644 --- a/wechat/handlers/schedule.mjs +++ b/wechat/handlers/schedule.mjs @@ -60,40 +60,50 @@ function normalizeLlmScheduleIntent(payload) { return { action: 'none' }; } -async function parseScheduleIntentWithLlm({ text, llmProviderService, logger = console }) { +async function parseScheduleIntentWithLlm({ text, llmProviderService, modelProviderKeyId = null, model = null, logger = console }) { if (!llmProviderService || typeof llmProviderService.createChatCompletion !== 'function') { return { action: 'none' }; } - const result = await llmProviderService.createChatCompletion({ - temperature: 0, - messages: [ - { - role: 'system', - content: [ - '你是公众号提醒助手,只做意图识别。', - '请严格输出 JSON,不要输出解释。', - '允许 action: create_todo, create_daily_todo_digest, create_balance_alert, query_schedule, schedule_agent, none。', - 'create_todo 需要 title。', - 'create_daily_todo_digest 需要 hour(0-23) 和 minute(0-59)。', - 'create_balance_alert 需要 thresholdYuan 数字。', - '如果用户在说具体时间提醒、一次性闹钟、某天某时提醒,返回 schedule_agent。', - '不确定时返回 none。', - ].join('\n'), - }, - { - role: 'user', - content: text, - }, - ], - }); + try { + const result = await llmProviderService.createChatCompletion({ + ...(modelProviderKeyId ? { providerKeyId: modelProviderKeyId } : {}), + ...(model ? { model } : {}), + temperature: 0, + messages: [ + { + role: 'system', + content: [ + '你是公众号提醒助手,只做意图识别。', + '请严格输出 JSON,不要输出解释。', + '允许 action: create_todo, create_daily_todo_digest, create_balance_alert, query_schedule, schedule_agent, none。', + 'create_todo 需要 title。', + 'create_daily_todo_digest 需要 hour(0-23) 和 minute(0-59)。', + 'create_balance_alert 需要 thresholdYuan 数字。', + '如果用户在说具体时间提醒、一次性闹钟、某天某时提醒,返回 schedule_agent。', + '不确定时返回 none。', + ].join('\n'), + }, + { + role: 'user', + content: text, + }, + ], + }); - if (!result?.ok) { - logger?.warn?.('[wechat-schedule-llm] parse skipped:', result?.message ?? 'unknown'); + if (!result?.ok) { + logger?.warn?.('[wechat-schedule-llm] parse skipped:', result?.message ?? 'unknown'); + return { action: 'none' }; + } + + return normalizeLlmScheduleIntent(parseJsonReply(result.reply)); + } catch (err) { + logger?.warn?.( + '[wechat-schedule-llm] parse skipped:', + err instanceof Error ? err.message : err, + ); return { action: 'none' }; } - - return normalizeLlmScheduleIntent(parseJsonReply(result.reply)); } async function resolveScheduleIntent({ @@ -118,7 +128,26 @@ async function resolveScheduleIntent({ } if (!enabled) return ruleIntent; - return parseScheduleIntentWithLlm({ text, llmProviderService, logger }); + let modelProviderKeyId = null; + let model = null; + try { + const config = await wechatScheduleLlmConfigService.getConfig(); + modelProviderKeyId = config.modelProviderKeyId ?? null; + model = config.model ?? null; + } catch (err) { + logger?.warn?.( + '[wechat-schedule-llm] provider config load failed:', + err instanceof Error ? err.message : err, + ); + } + + return parseScheduleIntentWithLlm({ + text, + llmProviderService, + modelProviderKeyId, + model, + logger, + }); } export async function handleWechatScheduleIntent({ diff --git a/wechat/handlers/schedule.test.mjs b/wechat/handlers/schedule.test.mjs index 285cd4f..cd5b627 100644 --- a/wechat/handlers/schedule.test.mjs +++ b/wechat/handlers/schedule.test.mjs @@ -19,13 +19,28 @@ function createScheduleService() { }; } +function createScheduleLlmConfigService(enabled = true) { + return { + async isScheduleLlmEnabled() { + return enabled; + }, + async getConfig() { + return { + scheduleLlmEnabled: enabled, + modelProviderKeyId: 'deepseek-key', + model: 'deepseek-v4-pro', + }; + }, + }; +} + test('schedule handler keeps rule-based parsing as first priority', async () => { let llmCalled = false; const reply = await handleWechatScheduleIntent({ intent: { agentText: '帮我记一下 跟进合同', msgId: 'msg-1' }, user: { userId: 'user-1' }, scheduleService: createScheduleService(), - wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } }, + wechatScheduleLlmConfigService: createScheduleLlmConfigService(true), llmProviderService: { async createChatCompletion() { llmCalled = true; @@ -42,7 +57,7 @@ test('schedule handler can use llm fallback for ambiguous todo text', async () = intent: { agentText: '下周把合同发给客户,别忘了', msgId: 'msg-2' }, user: { userId: 'user-1' }, scheduleService: createScheduleService(), - wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } }, + wechatScheduleLlmConfigService: createScheduleLlmConfigService(true), llmProviderService: { async createChatCompletion() { return { @@ -71,7 +86,7 @@ test('schedule handler does not call llm when switch is off', async () => { intent: { agentText: '别忘了报销', msgId: 'msg-4' }, user: { userId: 'user-1' }, scheduleService: createScheduleService(), - wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return false; } }, + wechatScheduleLlmConfigService: createScheduleLlmConfigService(false), llmProviderService: { async createChatCompletion() { llmCalled = true; @@ -89,7 +104,7 @@ test('schedule handler falls back quietly when llm request fails', async () => { intent: { agentText: '别忘了报销', msgId: 'msg-5' }, user: { userId: 'user-1' }, scheduleService: createScheduleService(), - wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } }, + wechatScheduleLlmConfigService: createScheduleLlmConfigService(true), llmProviderService: { async createChatCompletion() { return { ok: false, message: 'provider unavailable' }; @@ -102,12 +117,31 @@ test('schedule handler falls back quietly when llm request fails', async () => { assert.match(warnings[0], /provider unavailable/); }); +test('schedule handler falls back quietly when llm request throws', async () => { + const warnings = []; + const reply = await handleWechatScheduleIntent({ + intent: { agentText: '什么是机器学习', msgId: 'msg-6' }, + user: { userId: 'user-1' }, + scheduleService: createScheduleService(), + wechatScheduleLlmConfigService: createScheduleLlmConfigService(true), + llmProviderService: { + async createChatCompletion() { + throw new TypeError('fetch failed'); + }, + }, + logger: { warn: (...args) => warnings.push(args.join(' ')) }, + }); + assert.equal(reply, null); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /fetch failed/); +}); + test('schedule handler falls through for llm schedule_agent results', async () => { const reply = await handleWechatScheduleIntent({ intent: { agentText: '明天下午三点提醒我开会', msgId: 'msg-3' }, user: { userId: 'user-1' }, scheduleService: createScheduleService(), - wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } }, + wechatScheduleLlmConfigService: createScheduleLlmConfigService(true), llmProviderService: { async createChatCompletion() { return {