feat: 通知摘要 - AI 摘要、情况分类、推送时机开关
- 通知标题按情况区分:任务完成/回复就绪/任务异常/上下文压缩 - AI 摘要:可选 Claude 活跃模板 / Codex 活跃 Profile / 独立 API 配置 - 推送时机:仅后台任务 / 所有任务 - 摘要 API 故障时自动降级为原始信息 - 按渠道截断内容(Telegram/Qmsg 3800 字符,飞书/PushPlus 18000 字符等)
This commit is contained in:
@@ -2835,6 +2835,14 @@
|
|||||||
const sc = panel.querySelector('#notify-sc-sendkey');
|
const sc = panel.querySelector('#notify-sc-sendkey');
|
||||||
const feishuWh = panel.querySelector('#notify-feishu-webhook');
|
const feishuWh = panel.querySelector('#notify-feishu-webhook');
|
||||||
const qmsgKey = panel.querySelector('#notify-qmsg-key');
|
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 {
|
return {
|
||||||
provider,
|
provider,
|
||||||
pushplus: { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') },
|
pushplus: { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') },
|
||||||
@@ -2845,9 +2853,78 @@
|
|||||||
serverchan: { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') },
|
serverchan: { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') },
|
||||||
feishu: { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') },
|
feishu: { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') },
|
||||||
qqbot: { qmsgKey: qmsgKey ? qmsgKey.value.trim() : (currentConfig?.qqbot?.qmsgKey || '') },
|
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 `
|
||||||
|
<div class="settings-divider"></div>
|
||||||
|
<div class="settings-section-title">通知摘要</div>
|
||||||
|
<div class="settings-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||||
|
<label style="margin:0;flex:1">启用 AI 摘要</label>
|
||||||
|
<input type="checkbox" id="notify-summary-enabled" ${enabled ? 'checked' : ''} style="width:auto;margin:0">
|
||||||
|
</div>
|
||||||
|
<div id="notify-summary-options" style="${enabled ? '' : 'display:none'}">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>推送时机</label>
|
||||||
|
<select class="settings-select" id="notify-summary-trigger">
|
||||||
|
<option value="background" ${trigger === 'background' ? 'selected' : ''}>仅后台任务</option>
|
||||||
|
<option value="always" ${trigger === 'always' ? 'selected' : ''}>所有任务</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>摘要 API 来源</label>
|
||||||
|
<select class="settings-select" id="notify-summary-source">
|
||||||
|
<option value="claude" ${src === 'claude' ? 'selected' : ''}>Claude 活跃模板</option>
|
||||||
|
<option value="codex" ${src === 'codex' ? 'selected' : ''}>Codex 活跃 Profile</option>
|
||||||
|
<option value="custom" ${src === 'custom' ? 'selected' : ''}>独立配置</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="notify-summary-custom" style="${customVisible}">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>API Base URL</label>
|
||||||
|
<input type="text" id="notify-summary-apibase" placeholder="https://api.example.com" value="${escapeHtml(s.apiBase || '')}">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>API Key</label>
|
||||||
|
<input type="text" id="notify-summary-apikey" placeholder="sk-..." value="${escapeHtml(s.apiKey || '')}">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>模型</label>
|
||||||
|
<input type="text" id="notify-summary-model" placeholder="claude-opus-4-6" value="${escapeHtml(s.model || '')}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function openPasswordModal() {
|
||||||
const pwOverlay = document.createElement('div');
|
const pwOverlay = document.createElement('div');
|
||||||
pwOverlay.className = 'settings-overlay';
|
pwOverlay.className = 'settings-overlay';
|
||||||
@@ -2991,6 +3068,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="notify-fields"></div>
|
<div id="notify-fields"></div>
|
||||||
|
<div id="notify-summary-area"></div>
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
<button class="btn-test" id="notify-test-btn">测试</button>
|
<button class="btn-test" id="notify-test-btn">测试</button>
|
||||||
<button class="btn-save" id="notify-save-btn">保存</button>
|
<button class="btn-save" id="notify-save-btn">保存</button>
|
||||||
@@ -3020,6 +3098,7 @@
|
|||||||
|
|
||||||
const providerSelect = panel.querySelector('#notify-provider');
|
const providerSelect = panel.querySelector('#notify-provider');
|
||||||
const fieldsDiv = panel.querySelector('#notify-fields');
|
const fieldsDiv = panel.querySelector('#notify-fields');
|
||||||
|
const summaryArea = panel.querySelector('#notify-summary-area');
|
||||||
const statusDiv = panel.querySelector('#notify-status');
|
const statusDiv = panel.querySelector('#notify-status');
|
||||||
const testBtn = panel.querySelector('#notify-test-btn');
|
const testBtn = panel.querySelector('#notify-test-btn');
|
||||||
const saveBtn = panel.querySelector('#notify-save-btn');
|
const saveBtn = panel.querySelector('#notify-save-btn');
|
||||||
@@ -3040,6 +3119,10 @@
|
|||||||
|
|
||||||
function renderFields(provider) {
|
function renderFields(provider) {
|
||||||
renderNotifyFields(fieldsDiv, currentNotifyConfig, provider);
|
renderNotifyFields(fieldsDiv, currentNotifyConfig, provider);
|
||||||
|
if (summaryArea) {
|
||||||
|
summaryArea.innerHTML = buildSummarySettingsHtml(currentNotifyConfig);
|
||||||
|
bindSummarySettingsEvents(panel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectNotifyConfig() {
|
function collectNotifyConfig() {
|
||||||
@@ -3331,6 +3414,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="notify-fields"></div>
|
<div id="notify-fields"></div>
|
||||||
|
<div id="notify-summary-area"></div>
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
<button class="btn-test" id="notify-test-btn">测试</button>
|
<button class="btn-test" id="notify-test-btn">测试</button>
|
||||||
<button class="btn-save" id="notify-save-btn">保存</button>
|
<button class="btn-save" id="notify-save-btn">保存</button>
|
||||||
@@ -3340,7 +3424,6 @@
|
|||||||
<div class="settings-divider"></div>
|
<div class="settings-divider"></div>
|
||||||
|
|
||||||
<div class="settings-section-title">系统</div>
|
<div class="settings-section-title">系统</div>
|
||||||
|
|
||||||
<div class="settings-actions" style="margin-top:0;flex-wrap:wrap;gap:10px">
|
<div class="settings-actions" style="margin-top:0;flex-wrap:wrap;gap:10px">
|
||||||
<button class="btn-test" id="pw-open-modal-btn" style="padding:6px 16px">修改密码</button>
|
<button class="btn-test" id="pw-open-modal-btn" style="padding:6px 16px">修改密码</button>
|
||||||
<button class="btn-test" id="check-update-btn" style="padding:6px 16px">检查更新</button>
|
<button class="btn-test" id="check-update-btn" style="padding:6px 16px">检查更新</button>
|
||||||
@@ -3616,6 +3699,7 @@
|
|||||||
// === Notify Config UI ===
|
// === Notify Config UI ===
|
||||||
const providerSelect = panel.querySelector('#notify-provider');
|
const providerSelect = panel.querySelector('#notify-provider');
|
||||||
const fieldsDiv = panel.querySelector('#notify-fields');
|
const fieldsDiv = panel.querySelector('#notify-fields');
|
||||||
|
const summaryArea = panel.querySelector('#notify-summary-area');
|
||||||
const statusDiv = panel.querySelector('#notify-status');
|
const statusDiv = panel.querySelector('#notify-status');
|
||||||
const closeBtn = panel.querySelector('.settings-close');
|
const closeBtn = panel.querySelector('.settings-close');
|
||||||
const testBtn = panel.querySelector('#notify-test-btn');
|
const testBtn = panel.querySelector('#notify-test-btn');
|
||||||
@@ -3625,6 +3709,10 @@
|
|||||||
|
|
||||||
function renderFields(provider) {
|
function renderFields(provider) {
|
||||||
renderNotifyFields(fieldsDiv, currentConfig, provider);
|
renderNotifyFields(fieldsDiv, currentConfig, provider);
|
||||||
|
if (summaryArea) {
|
||||||
|
summaryArea.innerHTML = buildSummarySettingsHtml(currentConfig);
|
||||||
|
bindSummarySettingsEvents(panel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
providerSelect.addEventListener('change', () => renderFields(providerSelect.value));
|
providerSelect.addEventListener('change', () => renderFields(providerSelect.value));
|
||||||
|
|||||||
255
server.js
255
server.js
@@ -65,10 +65,22 @@ function plog(level, event, data = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Notification System ===
|
// === Notification System ===
|
||||||
|
const DEFAULT_SUMMARY_CONFIG = {
|
||||||
|
enabled: false,
|
||||||
|
trigger: 'background', // 'background' | 'always'
|
||||||
|
apiSource: 'claude', // 'claude' | 'codex' | 'custom'
|
||||||
|
apiBase: '',
|
||||||
|
apiKey: '',
|
||||||
|
model: '',
|
||||||
|
};
|
||||||
|
|
||||||
function loadNotifyConfig() {
|
function loadNotifyConfig() {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(NOTIFY_CONFIG_PATH)) {
|
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 {}
|
} catch {}
|
||||||
// First run: migrate from .env PUSHPLUS_TOKEN
|
// First run: migrate from .env PUSHPLUS_TOKEN
|
||||||
@@ -80,6 +92,7 @@ function loadNotifyConfig() {
|
|||||||
serverchan: { sendKey: '' },
|
serverchan: { sendKey: '' },
|
||||||
feishu: { webhook: '' },
|
feishu: { webhook: '' },
|
||||||
qqbot: { qmsgKey: '' },
|
qqbot: { qmsgKey: '' },
|
||||||
|
summary: { ...DEFAULT_SUMMARY_CONFIG },
|
||||||
};
|
};
|
||||||
saveNotifyConfig(config);
|
saveNotifyConfig(config);
|
||||||
return config;
|
return config;
|
||||||
@@ -96,6 +109,7 @@ function maskToken(str) {
|
|||||||
|
|
||||||
function getNotifyConfigMasked() {
|
function getNotifyConfigMasked() {
|
||||||
const config = loadNotifyConfig();
|
const config = loadNotifyConfig();
|
||||||
|
const s = config.summary || {};
|
||||||
return {
|
return {
|
||||||
provider: config.provider,
|
provider: config.provider,
|
||||||
pushplus: { token: maskToken(config.pushplus?.token) },
|
pushplus: { token: maskToken(config.pushplus?.token) },
|
||||||
@@ -103,13 +117,206 @@ function getNotifyConfigMasked() {
|
|||||||
serverchan: { sendKey: maskToken(config.serverchan?.sendKey) },
|
serverchan: { sendKey: maskToken(config.serverchan?.sendKey) },
|
||||||
feishu: { webhook: maskToken(config.feishu?.webhook) },
|
feishu: { webhook: maskToken(config.feishu?.webhook) },
|
||||||
qqbot: { qmsgKey: maskToken(config.qqbot?.qmsgKey) },
|
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) {
|
function sendNotification(title, content) {
|
||||||
const config = loadNotifyConfig();
|
const config = loadNotifyConfig();
|
||||||
if (!config.provider || config.provider === 'off') return Promise.resolve({ ok: true, skipped: true });
|
if (!config.provider || config.provider === 'off') return Promise.resolve({ ok: true, skipped: true });
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
const truncated = truncateForChannel(content, config.provider);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let url, data;
|
let url, data;
|
||||||
@@ -118,31 +325,31 @@ function sendNotification(title, content) {
|
|||||||
case 'pushplus': {
|
case 'pushplus': {
|
||||||
if (!config.pushplus?.token) return resolve({ ok: false, error: 'PushPlus token 未配置' });
|
if (!config.pushplus?.token) return resolve({ ok: false, error: 'PushPlus token 未配置' });
|
||||||
url = 'https://www.pushplus.plus/send';
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'telegram': {
|
case 'telegram': {
|
||||||
if (!config.telegram?.botToken || !config.telegram?.chatId) return resolve({ ok: false, error: 'Telegram botToken 或 chatId 未配置' });
|
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`;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'serverchan': {
|
case 'serverchan': {
|
||||||
if (!config.serverchan?.sendKey) return resolve({ ok: false, error: 'Server酱 sendKey 未配置' });
|
if (!config.serverchan?.sendKey) return resolve({ ok: false, error: 'Server酱 sendKey 未配置' });
|
||||||
url = `https://sctapi.ftqq.com/${config.serverchan.sendKey}.send`;
|
url = `https://sctapi.ftqq.com/${config.serverchan.sendKey}.send`;
|
||||||
data = JSON.stringify({ title, desp: content });
|
data = JSON.stringify({ title, desp: truncated });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'feishu': {
|
case 'feishu': {
|
||||||
if (!config.feishu?.webhook) return resolve({ ok: false, error: '飞书 Webhook 未配置' });
|
if (!config.feishu?.webhook) return resolve({ ok: false, error: '飞书 Webhook 未配置' });
|
||||||
url = config.feishu.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;
|
break;
|
||||||
}
|
}
|
||||||
case 'qqbot': {
|
case 'qqbot': {
|
||||||
if (!config.qqbot?.qmsgKey) return resolve({ ok: false, error: 'Qmsg Key 未配置' });
|
if (!config.qqbot?.qmsgKey) return resolve({ ok: false, error: 'Qmsg Key 未配置' });
|
||||||
url = `https://qmsg.zendee.cn/send/${config.qqbot.qmsgKey}`;
|
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;
|
isFormData = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1048,10 +1255,20 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
|||||||
|
|
||||||
wsSend(entry.ws, { type: 'done', sessionId, costUsd: entry.lastCost || null });
|
wsSend(entry.ws, { type: 'done', sessionId, costUsd: entry.lastCost || null });
|
||||||
sendSessionList(entry.ws);
|
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 {
|
} else {
|
||||||
// Process completed while browser was disconnected — notify all connected clients
|
// Process completed while browser was disconnected — notify all connected clients
|
||||||
const session = loadSession(sessionId);
|
const sess = loadSession(sessionId);
|
||||||
const title = session?.title || 'Untitled';
|
const title = sess?.title || 'Untitled';
|
||||||
for (const client of wss.clients) {
|
for (const client of wss.clients) {
|
||||||
if (client.readyState === 1) {
|
if (client.readyState === 1) {
|
||||||
wsSend(client, {
|
wsSend(client, {
|
||||||
@@ -1063,13 +1280,10 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Push notification
|
// Push notification (background task)
|
||||||
const cost = entry.lastCost ? `$${entry.lastCost.toFixed(4)}` : '';
|
buildNotifyContent(entry, sess, completionError, contextLimitExceeded).then(({ title: ntitle, content }) => {
|
||||||
const respLen = (entry.fullText || '').length;
|
sendNotification(ntitle, content);
|
||||||
sendNotification(
|
});
|
||||||
`CC-Web 任务完成`,
|
|
||||||
`会话: ${title}\n字数: ${respLen}\n费用: ${cost}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldReturnForFollowup && !shouldAutoCompact && !contextLimitExceeded && pendingRetry && pendingRetry.text === (entry.fullText || '').trim()) {
|
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 || '' };
|
merged.feishu = { webhook: (newConfig.feishu?.webhook && !newConfig.feishu.webhook.includes('****')) ? newConfig.feishu.webhook : current.feishu?.webhook || '' };
|
||||||
// qqbot
|
// qqbot
|
||||||
merged.qqbot = { qmsgKey: (newConfig.qqbot?.qmsgKey && !newConfig.qqbot.qmsgKey.includes('****')) ? newConfig.qqbot.qmsgKey : current.qqbot?.qmsgKey || '' };
|
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);
|
saveNotifyConfig(merged);
|
||||||
plog('INFO', 'notify_config_saved', { provider: merged.provider });
|
plog('INFO', 'notify_config_saved', { provider: merged.provider });
|
||||||
|
|||||||
Reference in New Issue
Block a user