diff --git a/wechat-scheduled-task-manage-llm.mjs b/wechat-scheduled-task-manage-llm.mjs index d6b726d..f58470f 100644 --- a/wechat-scheduled-task-manage-llm.mjs +++ b/wechat-scheduled-task-manage-llm.mjs @@ -10,6 +10,120 @@ function parseJsonReply(reply) { } } +const IMAGE_ONLY_MODEL_HINTS = /(?:image|ocr|vision|vl-ocr|文生图|图生文)/iu; + +export function isLlmProviderUnavailable(result) { + if (result?.ok) return false; + if (Number(result?.status) === 402 || Number(result?.status) === 429) return true; + const message = String(result?.message ?? '').toLowerCase(); + return /insufficient balance|余额不足|quota|rate limit|too many requests/u.test(message); +} + +function isLikelyChatProviderKey(key) { + if (!key || key.status === 'disabled') return false; + const label = `${key.name ?? ''} ${key.defaultModel ?? ''} ${(key.models ?? []).join(' ')}`; + return !IMAGE_ONLY_MODEL_HINTS.test(label); +} + +function buildManageLlmMessages(text, taskList) { + return [ + { + role: 'system', + content: [ + '你是微信服务号定时自动任务管理助手,只做任务选择,不做页面生成。', + '输入包含用户原话和当前 active 定时任务列表(JSON)。', + '请严格输出 JSON,不要输出解释。', + '允许 action:', + '- cancel:用户明确要取消某个任务,且能从列表中唯一确定 taskId', + '- clarify:用户想取消但无法唯一确定是哪一个,message 用中文简短追问', + '- none:不是取消任务,或信息不足且不应猜测', + 'cancel 时必须返回列表里存在的 taskId;禁止编造 taskId。', + '如果只有一条任务且用户明确要取消/停止定时任务,可以返回该 taskId。', + '如果用户在要求生成/修改 HTML 页面,而不是管理已有定时任务,返回 none。', + ].join('\n'), + }, + { + role: 'user', + content: JSON.stringify({ + userMessage: String(text ?? '').trim(), + activeTasks: taskList, + }), + }, + ]; +} + +async function createManageLlmChatCompletion({ + llmProviderService, + modelProviderKeyId = null, + model = null, + messages, + logger = console, +} = {}) { + const candidates = []; + const seen = new Set(); + + const pushCandidate = (providerKeyId, candidateModel) => { + const key = providerKeyId ?? '__default__'; + if (seen.has(key)) return; + seen.add(key); + candidates.push({ + providerKeyId, + model: candidateModel ?? model ?? null, + }); + }; + + pushCandidate(modelProviderKeyId, model); + pushCandidate(null, model); + + if (typeof llmProviderService.listKeys === 'function') { + try { + const keys = await llmProviderService.listKeys(); + for (const key of keys ?? []) { + if (!isLikelyChatProviderKey(key)) continue; + pushCandidate(key.id, model ?? key.defaultModel ?? null); + } + } catch (err) { + logger?.warn?.( + '[wechat-scheduled-task-manage-llm] provider list failed:', + err instanceof Error ? err.message : err, + ); + } + } + + let lastResult = null; + let sawUnavailable = false; + + for (const candidate of candidates) { + const result = await llmProviderService.createChatCompletion({ + ...(candidate.providerKeyId ? { providerKeyId: candidate.providerKeyId } : {}), + ...(candidate.model ? { model: candidate.model } : {}), + temperature: 0, + messages, + }); + lastResult = result; + if (result?.ok) return result; + if (isLlmProviderUnavailable(result)) { + sawUnavailable = true; + logger?.warn?.( + '[wechat-scheduled-task-manage-llm] provider unavailable:', + candidate.providerKeyId ?? 'default', + result?.message ?? 'unknown', + ); + continue; + } + logger?.warn?.( + '[wechat-scheduled-task-manage-llm] provider failed:', + candidate.providerKeyId ?? 'default', + result?.message ?? 'unknown', + ); + } + + return { + ...(lastResult ?? { ok: false, message: '聊天模型不可用' }), + llmUnavailable: sawUnavailable, + }; +} + export function buildScheduledTaskListPayload(tasks = []) { return tasks.map((task) => ({ taskId: task.id, @@ -58,36 +172,15 @@ export async function resolveScheduledTaskCancelWithLlm({ } const taskList = buildScheduledTaskListPayload(tasks); + const messages = buildManageLlmMessages(text, taskList); try { - const result = await llmProviderService.createChatCompletion({ - ...(modelProviderKeyId ? { providerKeyId: modelProviderKeyId } : {}), - ...(model ? { model } : {}), - temperature: 0, - messages: [ - { - role: 'system', - content: [ - '你是微信服务号定时自动任务管理助手,只做任务选择,不做页面生成。', - '输入包含用户原话和当前 active 定时任务列表(JSON)。', - '请严格输出 JSON,不要输出解释。', - '允许 action:', - '- cancel:用户明确要取消某个任务,且能从列表中唯一确定 taskId', - '- clarify:用户想取消但无法唯一确定是哪一个,message 用中文简短追问', - '- none:不是取消任务,或信息不足且不应猜测', - 'cancel 时必须返回列表里存在的 taskId;禁止编造 taskId。', - '如果只有一条任务且用户明确要取消/停止定时任务,可以返回该 taskId。', - '如果用户在要求生成/修改 HTML 页面,而不是管理已有定时任务,返回 none。', - ].join('\n'), - }, - { - role: 'user', - content: JSON.stringify({ - userMessage: String(text ?? '').trim(), - activeTasks: taskList, - }), - }, - ], + const result = await createManageLlmChatCompletion({ + llmProviderService, + modelProviderKeyId, + model, + messages, + logger, }); if (!result?.ok) { @@ -95,7 +188,10 @@ export async function resolveScheduledTaskCancelWithLlm({ '[wechat-scheduled-task-manage-llm] parse skipped:', result?.message ?? 'unknown', ); - return { action: 'none' }; + return { + action: 'none', + ...(result?.llmUnavailable ? { llmUnavailable: true } : {}), + }; } return normalizeScheduledTaskManageLlmDecision(parseJsonReply(result.reply), tasks); diff --git a/wechat-scheduled-task-manage-llm.test.mjs b/wechat-scheduled-task-manage-llm.test.mjs index b071fc9..37f894b 100644 --- a/wechat-scheduled-task-manage-llm.test.mjs +++ b/wechat-scheduled-task-manage-llm.test.mjs @@ -83,3 +83,32 @@ test('resolveScheduledTaskCancelWithLlm returns clarify when ambiguous', async ( assert.equal(reply.action, 'clarify'); assert.match(reply.message, /新闻早报/u); }); + +test('resolveScheduledTaskCancelWithLlm falls back to another provider when primary is unavailable', async () => { + const calls = []; + const reply = await resolveScheduledTaskCancelWithLlm({ + text: '取消定时推送苏州天气任务', + tasks: [tasks[0]], + modelProviderKeyId: 'deepseek-key', + llmProviderService: { + async listKeys() { + return [ + { id: 'deepseek-key', name: 'DeepSeek', status: 'active', defaultModel: 'deepseek-chat', models: ['deepseek-chat'] }, + { id: 'kimi-key', name: 'kimi', status: 'active', defaultModel: 'kimi-k2.6', models: ['kimi-k2.6'] }, + ]; + }, + async createChatCompletion(payload) { + calls.push(payload.providerKeyId ?? 'default'); + if ((payload.providerKeyId ?? 'default') !== 'kimi-key') { + return { ok: false, status: 402, message: '{"error":{"message":"Insufficient Balance"}}' }; + } + return { + ok: true, + reply: JSON.stringify({ action: 'cancel', taskId: 'task-suzhou' }), + }; + }, + }, + }); + assert.deepEqual(reply, { action: 'cancel', taskId: 'task-suzhou' }); + assert.deepEqual(calls, ['deepseek-key', 'default', 'kimi-key']); +}); diff --git a/wechat/handlers/scheduled-task.mjs b/wechat/handlers/scheduled-task.mjs index 6811598..d7a22ce 100644 --- a/wechat/handlers/scheduled-task.mjs +++ b/wechat/handlers/scheduled-task.mjs @@ -91,6 +91,20 @@ export async function handleWechatScheduledTaskIntent({ if (decision.action === 'clarify') { return decision.message; } + if (decision.llmUnavailable) { + if (tasks.length === 1) { + logger.warn?.( + '[wechat-scheduled-task] outage fallback cancel for single active task', + { userId: user.userId, taskId: tasks[0].id }, + ); + const cancelled = await scheduledTaskService.cancelTask({ + userId: user.userId, + taskId: tasks[0].id, + }); + return formatScheduledTaskCancelReply(cancelled); + } + return '暂时无法连接模型来识别要取消的任务,请稍后再试,或明确说出任务名称。'; + } } return null; diff --git a/wechat/handlers/scheduled-task.test.mjs b/wechat/handlers/scheduled-task.test.mjs index e510fad..b65187c 100644 --- a/wechat/handlers/scheduled-task.test.mjs +++ b/wechat/handlers/scheduled-task.test.mjs @@ -131,6 +131,56 @@ test('handleWechatScheduledTaskIntent passes cancel to goose when manage llm is assert.equal(reply, null); }); +test('handleWechatScheduledTaskIntent outage-fallback cancels sole active task when manage llm is unavailable', async () => { + const calls = []; + const reply = await handleWechatScheduledTaskIntent({ + intent: { + msgType: 'text', + agentText: '取消定时推送苏州天气任务', + msgId: 'msg-123-outage', + }, + user: { userId: 'user-123' }, + scheduledTaskService: { + async listTasks() { + return [{ + id: 'task-suzhou', + title: '每天早上6:30推送苏州市天气预报', + recurrence: 'daily', + hour: 6, + minute: 30, + timezone: 'Asia/Shanghai', + }]; + }, + async cancelTask(payload) { + calls.push(payload); + return { + id: 'task-suzhou', + title: '每天早上6:30推送苏州市天气预报', + status: 'cancelled', + }; + }, + }, + wechatScheduledTaskManageLlmConfigService: { + async isManageLlmEnabled() { + return true; + }, + async getConfig() { + return { modelProviderKeyId: 'deepseek-key', model: 'deepseek-chat' }; + }, + }, + llmProviderService: { + async listKeys() { + return [{ id: 'deepseek-key', name: 'DeepSeek', status: 'active', defaultModel: 'deepseek-chat', models: ['deepseek-chat'] }]; + }, + async createChatCompletion() { + return { ok: false, status: 402, message: '{"error":{"message":"Insufficient Balance"}}' }; + }, + }, + }); + assert.match(reply, /已取消定时任务/u); + assert.deepEqual(calls, [{ userId: 'user-123', taskId: 'task-suzhou' }]); +}); + test('handleWechatScheduledTaskIntent returns clarify message when manage llm is ambiguous', async () => { const reply = await handleWechatScheduledTaskIntent({ intent: {