feat: 通知摘要 - AI 摘要、情况分类、推送时机开关

- 通知标题按情况区分:任务完成/回复就绪/任务异常/上下文压缩
- AI 摘要:可选 Claude 活跃模板 / Codex 活跃 Profile / 独立 API 配置
- 推送时机:仅后台任务 / 所有任务
- 摘要 API 故障时自动降级为原始信息
- 按渠道截断内容(Telegram/Qmsg 3800 字符,飞书/PushPlus 18000 字符等)
This commit is contained in:
cc-dan
2026-03-14 04:35:24 +00:00
parent a0e3998da6
commit 47f087815f
2 changed files with 329 additions and 16 deletions

255
server.js
View File

@@ -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 });