From 47f087815f22d195978b4a71f3fc1c08a7b42f3c Mon Sep 17 00:00:00 2001 From: cc-dan Date: Sat, 14 Mar 2026 04:35:24 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=9A=E7=9F=A5=E6=91=98=E8=A6=81=20?= =?UTF-8?q?-=20AI=20=E6=91=98=E8=A6=81=E3=80=81=E6=83=85=E5=86=B5=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E3=80=81=E6=8E=A8=E9=80=81=E6=97=B6=E6=9C=BA=E5=BC=80?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通知标题按情况区分:任务完成/回复就绪/任务异常/上下文压缩 - AI 摘要:可选 Claude 活跃模板 / Codex 活跃 Profile / 独立 API 配置 - 推送时机:仅后台任务 / 所有任务 - 摘要 API 故障时自动降级为原始信息 - 按渠道截断内容(Telegram/Qmsg 3800 字符,飞书/PushPlus 18000 字符等) --- public/app.js | 90 +++++++++++++++++- server.js | 255 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 329 insertions(+), 16 deletions(-) diff --git a/public/app.js b/public/app.js index d0465cb..7a56d3b 100644 --- a/public/app.js +++ b/public/app.js @@ -2835,6 +2835,14 @@ const sc = panel.querySelector('#notify-sc-sendkey'); const feishuWh = panel.querySelector('#notify-feishu-webhook'); const qmsgKey = panel.querySelector('#notify-qmsg-key'); + // Summary config + const summaryEnabled = panel.querySelector('#notify-summary-enabled'); + const summaryTrigger = panel.querySelector('#notify-summary-trigger'); + const summarySource = panel.querySelector('#notify-summary-source'); + const summaryApiBase = panel.querySelector('#notify-summary-apibase'); + const summaryApiKey = panel.querySelector('#notify-summary-apikey'); + const summaryModel = panel.querySelector('#notify-summary-model'); + const cs = currentConfig?.summary || {}; return { provider, pushplus: { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') }, @@ -2845,9 +2853,78 @@ serverchan: { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') }, feishu: { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') }, qqbot: { qmsgKey: qmsgKey ? qmsgKey.value.trim() : (currentConfig?.qqbot?.qmsgKey || '') }, + summary: { + enabled: summaryEnabled ? summaryEnabled.checked : !!cs.enabled, + trigger: summaryTrigger ? summaryTrigger.value : (cs.trigger || 'background'), + apiSource: summarySource ? summarySource.value : (cs.apiSource || 'claude'), + apiBase: summaryApiBase ? summaryApiBase.value.trim() : (cs.apiBase || ''), + apiKey: summaryApiKey ? summaryApiKey.value.trim() : (cs.apiKey || ''), + model: summaryModel ? summaryModel.value.trim() : (cs.model || ''), + }, }; } + function buildSummarySettingsHtml(config) { + const s = config?.summary || {}; + const enabled = !!s.enabled; + const trigger = s.trigger || 'background'; + const src = s.apiSource || 'claude'; + const customVisible = src === 'custom' ? '' : 'display:none'; + return ` +
+
通知摘要
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ `; + } + + function bindSummarySettingsEvents(panel) { + const enabledCb = panel.querySelector('#notify-summary-enabled'); + const optionsDiv = panel.querySelector('#notify-summary-options'); + const sourceSelect = panel.querySelector('#notify-summary-source'); + const customDiv = panel.querySelector('#notify-summary-custom'); + if (!enabledCb || !optionsDiv || !sourceSelect || !customDiv) return; + enabledCb.addEventListener('change', () => { + optionsDiv.style.display = enabledCb.checked ? '' : 'none'; + }); + sourceSelect.addEventListener('change', () => { + customDiv.style.display = sourceSelect.value === 'custom' ? '' : 'none'; + }); + } + function openPasswordModal() { const pwOverlay = document.createElement('div'); pwOverlay.className = 'settings-overlay'; @@ -2991,6 +3068,7 @@
+
@@ -3020,6 +3098,7 @@ const providerSelect = panel.querySelector('#notify-provider'); const fieldsDiv = panel.querySelector('#notify-fields'); + const summaryArea = panel.querySelector('#notify-summary-area'); const statusDiv = panel.querySelector('#notify-status'); const testBtn = panel.querySelector('#notify-test-btn'); const saveBtn = panel.querySelector('#notify-save-btn'); @@ -3040,6 +3119,10 @@ function renderFields(provider) { renderNotifyFields(fieldsDiv, currentNotifyConfig, provider); + if (summaryArea) { + summaryArea.innerHTML = buildSummarySettingsHtml(currentNotifyConfig); + bindSummarySettingsEvents(panel); + } } function collectNotifyConfig() { @@ -3331,6 +3414,7 @@
+
@@ -3340,7 +3424,6 @@
系统
-
@@ -3616,6 +3699,7 @@ // === Notify Config UI === const providerSelect = panel.querySelector('#notify-provider'); const fieldsDiv = panel.querySelector('#notify-fields'); + const summaryArea = panel.querySelector('#notify-summary-area'); const statusDiv = panel.querySelector('#notify-status'); const closeBtn = panel.querySelector('.settings-close'); const testBtn = panel.querySelector('#notify-test-btn'); @@ -3625,6 +3709,10 @@ function renderFields(provider) { renderNotifyFields(fieldsDiv, currentConfig, provider); + if (summaryArea) { + summaryArea.innerHTML = buildSummarySettingsHtml(currentConfig); + bindSummarySettingsEvents(panel); + } } providerSelect.addEventListener('change', () => renderFields(providerSelect.value)); diff --git a/server.js b/server.js index cda1fd9..ca707c8 100644 --- a/server.js +++ b/server.js @@ -65,10 +65,22 @@ function plog(level, event, data = {}) { } // === Notification System === +const DEFAULT_SUMMARY_CONFIG = { + enabled: false, + trigger: 'background', // 'background' | 'always' + apiSource: 'claude', // 'claude' | 'codex' | 'custom' + apiBase: '', + apiKey: '', + model: '', +}; + function loadNotifyConfig() { try { if (fs.existsSync(NOTIFY_CONFIG_PATH)) { - return JSON.parse(fs.readFileSync(NOTIFY_CONFIG_PATH, 'utf8')); + const raw = JSON.parse(fs.readFileSync(NOTIFY_CONFIG_PATH, 'utf8')); + // Ensure summary field exists for older configs + if (!raw.summary) raw.summary = { ...DEFAULT_SUMMARY_CONFIG }; + return raw; } } catch {} // First run: migrate from .env PUSHPLUS_TOKEN @@ -80,6 +92,7 @@ function loadNotifyConfig() { serverchan: { sendKey: '' }, feishu: { webhook: '' }, qqbot: { qmsgKey: '' }, + summary: { ...DEFAULT_SUMMARY_CONFIG }, }; saveNotifyConfig(config); return config; @@ -96,6 +109,7 @@ function maskToken(str) { function getNotifyConfigMasked() { const config = loadNotifyConfig(); + const s = config.summary || {}; return { provider: config.provider, pushplus: { token: maskToken(config.pushplus?.token) }, @@ -103,13 +117,206 @@ function getNotifyConfigMasked() { serverchan: { sendKey: maskToken(config.serverchan?.sendKey) }, feishu: { webhook: maskToken(config.feishu?.webhook) }, qqbot: { qmsgKey: maskToken(config.qqbot?.qmsgKey) }, + summary: { + enabled: !!s.enabled, + trigger: s.trigger || 'background', + apiSource: s.apiSource || 'claude', + apiBase: s.apiBase || '', + apiKey: maskToken(s.apiKey), + model: s.model || '', + }, }; } +// === Notification Summary === + +// Per-channel content length limits (chars) +const NOTIFY_CONTENT_LIMITS = { + telegram: 3800, + qqbot: 3800, + serverchan: 30000, + pushplus: 18000, + feishu: 18000, +}; + +function truncateForChannel(text, provider) { + const limit = NOTIFY_CONTENT_LIMITS[provider] || 18000; + if (text.length <= limit) return text; + return text.slice(0, limit - 20) + '\n\n[内容已截断]'; +} + +function getSummaryApiCredentials(summaryConfig) { + // Returns { apiBase, apiKey, model } or null + const src = summaryConfig.apiSource || 'claude'; + if (src === 'claude') { + const modelCfg = loadModelConfig(); + if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) { + const tpl = (modelCfg.templates || []).find(t => t.name === modelCfg.activeTemplate); + if (tpl && tpl.apiKey && tpl.apiBase) { + return { apiBase: tpl.apiBase, apiKey: tpl.apiKey, model: tpl.defaultModel || tpl.opusModel || '' }; + } + } + return null; // local mode — no API credentials available + } + if (src === 'codex') { + const codexCfg = loadCodexConfig(); + if (codexCfg.mode === 'custom' && codexCfg.activeProfile) { + const profile = (codexCfg.profiles || []).find(p => p.name === codexCfg.activeProfile); + if (profile && profile.apiKey && profile.apiBase) { + return { apiBase: profile.apiBase, apiKey: profile.apiKey, model: summaryConfig.model || '' }; + } + } + return null; + } + if (src === 'custom') { + if (summaryConfig.apiBase && summaryConfig.apiKey) { + return { apiBase: summaryConfig.apiBase, apiKey: summaryConfig.apiKey, model: summaryConfig.model || '' }; + } + return null; + } + return null; +} + +function callSummaryApi(creds, prompt) { + return new Promise((resolve) => { + try { + const base = creds.apiBase.replace(/\/+$/, ''); + const url = new URL(base + '/v1/chat/completions'); + const mod = url.protocol === 'https:' ? require('https') : require('http'); + const model = creds.model || 'claude-opus-4-6'; + const body = JSON.stringify({ + model, + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }], + }); + const req = mod.request(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${creds.apiKey}`, + 'Content-Length': Buffer.byteLength(body), + }, + timeout: 20000, + }, (res) => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => { + try { + const json = JSON.parse(data); + const text = json.choices?.[0]?.message?.content || json.content?.[0]?.text || ''; + resolve({ ok: !!text, text: text.trim() }); + } catch { + resolve({ ok: false, text: '' }); + } + }); + }); + req.on('error', () => resolve({ ok: false, text: '' })); + req.on('timeout', () => { req.destroy(); resolve({ ok: false, text: '' }); }); + req.write(body); + req.end(); + } catch { + resolve({ ok: false, text: '' }); + } + }); +} + +function buildSummaryPrompt(sessionTitle, lastUserMsg, fullText, isError, errorDesc) { + const userSnip = (lastUserMsg || '').slice(0, 500); + const outputSnip = (fullText || '').slice(0, 20000); + if (isError) { + return `以下是一个 AI 编程助手的任务记录,该任务异常退出。\n` + + `会话名称:${sessionTitle}\n` + + `用户提问:${userSnip}\n` + + `助手输出:${outputSnip}\n` + + `错误信息:${(errorDesc || '').slice(0, 500)}\n\n` + + `请用纯文本说明异常现象和可能原因,不超过 400 字,不使用任何 markdown 格式,不使用星号、井号、横线等符号。`; + } + return `以下是一个 AI 编程助手的任务记录。\n` + + `会话名称:${sessionTitle}\n` + + `用户提问:${userSnip}\n` + + `助手输出:${outputSnip}\n\n` + + `请用纯文本提炼关键信息,不超过 600 字,不使用任何 markdown 格式,不使用星号、井号、横线等符号。说明完成了什么、主要步骤、结果是否成功。`; +} + +async function buildNotifyContent(entry, session, completionError, contextLimitExceeded) { + const title = session?.title || 'Untitled'; + const agent = entry.agent || 'claude'; + const agentLabel = agent === 'codex' ? 'Codex' : 'Claude'; + const hasTools = (entry.toolCalls || []).length > 0; + const cost = entry.lastCost ? `$${entry.lastCost.toFixed(4)}` : ''; + const costLine = cost ? `费用: ${cost}` : ''; + + // Determine notify title + let notifyTitle; + if (contextLimitExceeded) { + notifyTitle = `⚠ ${title} 上下文已压缩`; + } else if (completionError) { + notifyTitle = `✗ ${title} 任务异常`; + } else if (hasTools) { + notifyTitle = `✓ ${title} 任务完成`; + } else { + notifyTitle = `✓ ${title} 回复就绪`; + } + + // Context limit: fixed message, no AI + if (contextLimitExceeded) { + return { title: notifyTitle, content: `${agentLabel} 会话上下文已达上限,已自动触发压缩。\n会话: ${title}${costLine ? '\n' + costLine : ''}` }; + } + + // Check if summary is enabled and applicable + const notifyCfg = loadNotifyConfig(); + const summaryCfg = notifyCfg.summary || {}; + const summaryEnabled = !!summaryCfg.enabled; + + if (!summaryEnabled) { + // Fallback: simple content + const respLen = (entry.fullText || '').length; + const lines = [`会话: ${title}`, `字数: ${respLen}`]; + if (costLine) lines.push(costLine); + if (completionError) lines.push(`错误: ${completionError.slice(0, 200)}`); + return { title: notifyTitle, content: lines.join('\n') }; + } + + const creds = getSummaryApiCredentials(summaryCfg); + if (!creds) { + // No credentials — fallback + const respLen = (entry.fullText || '').length; + const lines = [`会话: ${title}`, `字数: ${respLen}`]; + if (costLine) lines.push(costLine); + if (completionError) lines.push(`错误: ${completionError.slice(0, 200)}`); + return { title: notifyTitle, content: lines.join('\n') }; + } + + // Get last user message from session + const messages = session?.messages || []; + const lastUser = [...messages].reverse().find(m => m.role === 'user'); + const lastUserMsg = typeof lastUser?.content === 'string' ? lastUser.content : ''; + + const prompt = buildSummaryPrompt(title, lastUserMsg, entry.fullText || '', !!completionError, completionError || ''); + const result = await callSummaryApi(creds, prompt); + + let bodyText; + if (result.ok && result.text) { + bodyText = result.text; + if (costLine) bodyText += '\n\n' + costLine; + } else { + // Fallback on API failure + const respLen = (entry.fullText || '').length; + const lines = [`会话: ${title}`, `字数: ${respLen}`]; + if (costLine) lines.push(costLine); + if (completionError) lines.push(`错误: ${completionError.slice(0, 200)}`); + if (!result.ok) lines.push('(摘要生成失败,以上为原始信息)'); + bodyText = lines.join('\n'); + } + + return { title: notifyTitle, content: bodyText }; +} + function sendNotification(title, content) { const config = loadNotifyConfig(); if (!config.provider || config.provider === 'off') return Promise.resolve({ ok: true, skipped: true }); const https = require('https'); + const truncated = truncateForChannel(content, config.provider); return new Promise((resolve) => { let url, data; @@ -118,31 +325,31 @@ function sendNotification(title, content) { case 'pushplus': { if (!config.pushplus?.token) return resolve({ ok: false, error: 'PushPlus token 未配置' }); url = 'https://www.pushplus.plus/send'; - data = JSON.stringify({ token: config.pushplus.token, title, content, template: 'txt' }); + data = JSON.stringify({ token: config.pushplus.token, title, content: truncated, template: 'txt' }); break; } case 'telegram': { if (!config.telegram?.botToken || !config.telegram?.chatId) return resolve({ ok: false, error: 'Telegram botToken 或 chatId 未配置' }); url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`; - data = JSON.stringify({ chat_id: config.telegram.chatId, text: `${title}\n\n${content}` }); + data = JSON.stringify({ chat_id: config.telegram.chatId, text: `${title}\n\n${truncated}` }); break; } case 'serverchan': { if (!config.serverchan?.sendKey) return resolve({ ok: false, error: 'Server酱 sendKey 未配置' }); url = `https://sctapi.ftqq.com/${config.serverchan.sendKey}.send`; - data = JSON.stringify({ title, desp: content }); + data = JSON.stringify({ title, desp: truncated }); break; } case 'feishu': { if (!config.feishu?.webhook) return resolve({ ok: false, error: '飞书 Webhook 未配置' }); url = config.feishu.webhook; - data = JSON.stringify({ msg_type: 'text', content: { text: `${title}\n\n${content}` } }); + data = JSON.stringify({ msg_type: 'text', content: { text: `${title}\n\n${truncated}` } }); break; } case 'qqbot': { if (!config.qqbot?.qmsgKey) return resolve({ ok: false, error: 'Qmsg Key 未配置' }); url = `https://qmsg.zendee.cn/send/${config.qqbot.qmsgKey}`; - data = `msg=${encodeURIComponent(`${title}\n\n${content}`)}`; + data = `msg=${encodeURIComponent(`${title}\n\n${truncated}`)}`; isFormData = true; break; } @@ -1048,10 +1255,20 @@ function handleProcessComplete(sessionId, exitCode, signal) { wsSend(entry.ws, { type: 'done', sessionId, costUsd: entry.lastCost || null }); sendSessionList(entry.ws); + // Push notification when trigger='always' (user online but still wants notification) + (() => { + const notifyCfg = loadNotifyConfig(); + if (!notifyCfg.provider || notifyCfg.provider === 'off') return; + if ((notifyCfg.summary?.trigger || 'background') !== 'always') return; + const sess = loadSession(sessionId); + buildNotifyContent(entry, sess, completionError, contextLimitExceeded).then(({ title: ntitle, content }) => { + sendNotification(ntitle, content); + }); + })(); } else { // Process completed while browser was disconnected — notify all connected clients - const session = loadSession(sessionId); - const title = session?.title || 'Untitled'; + const sess = loadSession(sessionId); + const title = sess?.title || 'Untitled'; for (const client of wss.clients) { if (client.readyState === 1) { wsSend(client, { @@ -1063,13 +1280,10 @@ function handleProcessComplete(sessionId, exitCode, signal) { }); } } - // Push notification - const cost = entry.lastCost ? `$${entry.lastCost.toFixed(4)}` : ''; - const respLen = (entry.fullText || '').length; - sendNotification( - `CC-Web 任务完成`, - `会话: ${title}\n字数: ${respLen}\n费用: ${cost}` - ); + // Push notification (background task) + buildNotifyContent(entry, sess, completionError, contextLimitExceeded).then(({ title: ntitle, content }) => { + sendNotification(ntitle, content); + }); } if (!shouldReturnForFollowup && !shouldAutoCompact && !contextLimitExceeded && pendingRetry && pendingRetry.text === (entry.fullText || '').trim()) { @@ -1438,6 +1652,17 @@ function handleSaveNotifyConfig(ws, newConfig) { merged.feishu = { webhook: (newConfig.feishu?.webhook && !newConfig.feishu.webhook.includes('****')) ? newConfig.feishu.webhook : current.feishu?.webhook || '' }; // qqbot merged.qqbot = { qmsgKey: (newConfig.qqbot?.qmsgKey && !newConfig.qqbot.qmsgKey.includes('****')) ? newConfig.qqbot.qmsgKey : current.qqbot?.qmsgKey || '' }; + // summary + const ns = newConfig.summary || {}; + const cs = current.summary || {}; + merged.summary = { + enabled: !!ns.enabled, + trigger: ['background', 'always'].includes(ns.trigger) ? ns.trigger : (cs.trigger || 'background'), + apiSource: ['claude', 'codex', 'custom'].includes(ns.apiSource) ? ns.apiSource : (cs.apiSource || 'claude'), + apiBase: ns.apiBase !== undefined ? ns.apiBase : (cs.apiBase || ''), + apiKey: (ns.apiKey && !ns.apiKey.includes('****')) ? ns.apiKey : (cs.apiKey || ''), + model: ns.model !== undefined ? ns.model : (cs.model || ''), + }; saveNotifyConfig(merged); plog('INFO', 'notify_config_saved', { provider: merged.provider });