From 7c8b45a24ae4e5b29a85d9fe659e8c639bae29ba Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Wed, 2 Sep 2026 15:01:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E6=97=A5=E6=8A=A5=E9=94=99=E8=AF=AF=E9=80=9A=E7=9F=A5=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=9C=BA=E5=99=A8=E4=BA=BA=E5=8A=9F=E8=83=BD=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AE=9A=E6=97=B6=E6=97=A5=E6=8A=A5debug=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=9A=E6=97=B6=E6=97=A5=E6=8A=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=A4=B4=E5=83=8F=E6=98=BE=E7=A4=BA=E5=B9=B6=E5=8C=BA?= =?UTF-8?q?=E5=88=86=E5=AE=9A=E6=97=B6=E6=97=A5=E6=8A=A5=E6=A0=87=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + package.json | 5 +- scripts/prepare-win-runtime.cjs | 37 + src/env.d.ts | 1 + src/main/http-server.ts | 23 +- src/main/index.ts | 19 + src/main/report-history-service.ts | 1 + .../services/agent-group-report-service.ts | 56 +- src/main/services/agent-hub-service.ts | 163 ++- src/main/services/ai-provider-service.ts | 84 +- .../services/scheduled-report-api-service.ts | 37 +- src/main/services/scheduled-report-service.ts | 929 ++++++++++++++++-- src/preload/index.d.ts | 12 + src/preload/index.ts | 16 + src/renderer/src/App.tsx | 7 + .../reports/ReportHistorySidebar.tsx | 30 +- .../reports/ScheduledReportsWorkspace.tsx | 300 +++++- src/shared/debug-flags.ts | 8 + src/shared/report-history.ts | 3 + src/shared/scheduled-report-api.ts | 13 +- src/shared/scheduled-report-error.ts | 312 ++++++ src/shared/scheduled-report.ts | 92 +- tests/component/report-features.test.tsx | 35 + .../scheduled-reports-workspace.test.tsx | 92 +- tests/e2e/scheduled-reports.spec.ts | 64 ++ tests/e2e/support/electron-main.cjs | 74 +- tests/integration/preload-contract.test.ts | 7 + .../integration/scheduled-report-api.test.ts | 19 +- tests/unit/runtime-packaging.test.ts | 27 + .../unit/scheduled-report-api-service.test.ts | 12 +- tests/unit/scheduled-report-error.test.ts | 57 ++ tests/unit/scheduled-report-service.test.ts | 650 +++++++++++- 32 files changed, 2975 insertions(+), 213 deletions(-) create mode 100644 scripts/prepare-win-runtime.cjs create mode 100644 src/shared/debug-flags.ts create mode 100644 src/shared/scheduled-report-error.ts create mode 100644 tests/unit/scheduled-report-error.test.ts diff --git a/.env.example b/.env.example index 1568aab..4fc1a19 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ VITE_DB_KEY= # Set to true/1/yes/on for local development. Default is disabled. VITE_AUTO_LOGIN=false +# Show the scheduled report debug notification test action. Default is disabled. +VITE_SCHEDULED_REPORT_DEBUG=false + # AI API Configuration (Optional, can be entered in UI) # 注意:发布版本不再自动读取以下环境变量。 # 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填, diff --git a/package.json b/package.json index a1accdf..cf8024f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "cp:env": "node scripts/ensure-env.cjs", "prepare:env": "node scripts/ensure-env.cjs", "prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64", + "prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win", "prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs", "start": "electron-vite preview", "predev": "node -e \"require('electron')\"", @@ -58,11 +59,11 @@ "build": "npm run typecheck && npm run build:native-services && electron-vite build", "postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs", "build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir", - "build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64", + "build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64", "build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64", "release": "npm run release:mac && npm run release:win", "release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64 --publish always", - "release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always", + "release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always", "release:beta": "cross-env RELEASE_TYPE=prerelease npm run release", "release:stable": "cross-env RELEASE_TYPE=release npm run release", "build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux" diff --git a/scripts/prepare-win-runtime.cjs b/scripts/prepare-win-runtime.cjs new file mode 100644 index 0000000..ac0e6ed --- /dev/null +++ b/scripts/prepare-win-runtime.cjs @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */ +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') + +const projectRoot = path.resolve(__dirname, '..') +const defaultRuntimeRoot = path.join(projectRoot, 'node_modules', 'sherpa-onnx-win-x64') +const requiredFiles = ['package.json', 'sherpa-onnx.node'] + +function hasWindowsSherpaRuntime(runtimeRoot = defaultRuntimeRoot) { + return requiredFiles.every((fileName) => fs.existsSync(path.join(runtimeRoot, fileName))) +} + +function ensureWindowsSherpaRuntime() { + if (hasWindowsSherpaRuntime()) { + console.log('[prepare-win-runtime] sherpa-onnx-win-x64 is ready') + return + } + + console.log('[prepare-win-runtime] installing cross-platform optional dependencies') + execFileSync( + process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', + ['install', '--force', '--ignore-scripts'], + { + cwd: projectRoot, + stdio: 'inherit' + } + ) + + if (!hasWindowsSherpaRuntime()) { + throw new Error(`Missing Windows sherpa runtime: ${defaultRuntimeRoot}`) + } +} + +if (require.main === module) ensureWindowsSherpaRuntime() + +module.exports = { hasWindowsSherpaRuntime, ensureWindowsSherpaRuntime } diff --git a/src/env.d.ts b/src/env.d.ts index 4785dbb..dc8d53b 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_DEEPSEEK_API_KEY: string + readonly VITE_SCHEDULED_REPORT_DEBUG: string } interface ImportMeta { diff --git a/src/main/http-server.ts b/src/main/http-server.ts index 83a0f77..0546589 100644 --- a/src/main/http-server.ts +++ b/src/main/http-server.ts @@ -460,6 +460,27 @@ function createScheduledReportRoute( } } + const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/` + if (pathname.startsWith(retryPrefix)) { + const segments = pathname.slice(retryPrefix.length).split('/').filter(Boolean) + if (segments.length !== 2 || segments[1] !== 'retry-send') return undefined + let executionId: string + try { + executionId = decodeURIComponent(segments[0]) + } catch { + return undefined + } + return async ({ req, res }) => { + if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求') + try { + const execution = await api.retrySend(executionId) + sendJson(res, 200, { success: execution.status !== 'failed', execution }) + } catch (error) { + sendScheduledError(res, error) + } + } + } + const prefix = `${SCHEDULED_REPORTS_ROUTE}/` if (!pathname.startsWith(prefix)) return undefined const segments = pathname.slice(prefix.length).split('/').filter(Boolean) @@ -500,7 +521,7 @@ function createScheduledReportRoute( } if (action === 'run' && req.method === 'POST') { const execution = await api.run(taskId) - sendJson(res, 200, { success: execution.status === 'success', execution }) + sendJson(res, 200, { success: execution.status !== 'failed', execution }) return } if (action === 'executions' && req.method === 'GET') { diff --git a/src/main/index.ts b/src/main/index.ts index 8360607..0e4733f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -122,6 +122,7 @@ import type { ScheduledReportCreateInput, ScheduledReportUpdateInput } from '../shared/scheduled-report' +import { isTruthyDebugFlag } from '../shared/debug-flags' import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service' import type { ListTextToSpeechVoicesRequest, @@ -1315,6 +1316,12 @@ app.whenReady().then(async () => { ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) => scheduledReportService.listExecutions(taskId) ) + ipcMain.handle('scheduled-report:getNotificationSettings', () => + scheduledReportService.getNotificationSettings() + ) + ipcMain.handle('scheduled-report:setNotificationEnabled', (_, enabled: boolean) => + scheduledReportService.setNotificationEnabled(Boolean(enabled)) + ) ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) => scheduledReportService.createTask(request) ) @@ -1332,6 +1339,18 @@ app.whenReady().then(async () => { ipcMain.handle('scheduled-report:runNow', (_, taskId: string) => scheduledReportService.runScheduledReportNow(taskId) ) + ipcMain.handle('scheduled-report:retrySend', (_, executionId: string) => + scheduledReportService.retryScheduledReportSend(executionId) + ) + ipcMain.handle('scheduled-report:testErrorNotification', (_, taskId: string) => { + if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) { + return Promise.resolve({ + success: false, + error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。' + }) + } + return scheduledReportService.testScheduledReportErrorNotification(taskId) + }) ipcMain.handle('report:reveal', async (_, filePath: string) => { try { diff --git a/src/main/report-history-service.ts b/src/main/report-history-service.ts index 411c9c4..15b0e94 100644 --- a/src/main/report-history-service.ts +++ b/src/main/report-history-service.ts @@ -171,6 +171,7 @@ export async function saveGeneratedReport( contactId: request.contactId, contactName: request.contactName, contactAvatar: request.contactAvatar, + source: request.source, dateRange: request.dateRange, messageCount: request.messageCount, generatedAt: timestamp.toISOString(), diff --git a/src/main/services/agent-group-report-service.ts b/src/main/services/agent-group-report-service.ts index b9142b5..19100da 100644 --- a/src/main/services/agent-group-report-service.ts +++ b/src/main/services/agent-group-report-service.ts @@ -7,6 +7,7 @@ import type { ScheduledReportMemberNameMode, ScheduledReportMessageType } from '../../shared/scheduled-report' +import type { ScheduledReportExecutionStage } from '../../shared/scheduled-report' import type { SelectableReportTemplateId } from '../../shared/report-templates' import { resolveMemberName } from '../../shared/member-names' import { @@ -47,6 +48,10 @@ export interface AgentGroupReportResult { } duration?: number error?: string + errorCode?: string + errorStage?: ScheduledReportExecutionStage + errorStatus?: number + errorType?: string } export async function generateAgentGroupReport( @@ -57,11 +62,11 @@ export async function generateAgentGroupReport( .trim() .replace(/群聊?$/, '') .trim() - if (!query) return { success: false, error: '缺少群聊名称' } + if (!query) return reportFailure('缺少群聊名称', 'data', 'CHAT_NOT_FOUND') const contact = resolveMd5(query) - if (!contact) return { success: false, error: `没有找到群聊“${query}”` } + if (!contact) return reportFailure(`没有找到群聊“${query}”`, 'data', 'CHAT_NOT_FOUND') if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) { - return { success: false, error: `“${query}”不是群聊` } + return reportFailure(`“${query}”不是群聊`, 'data', 'CHAT_NOT_FOUND') } const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today' @@ -73,7 +78,7 @@ export async function generateAgentGroupReport( })() : getSummaryDateRange(range) let messages = listMessages(contact.md5, startTime, endTime) as Message[] - if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' } + if (!messages.length) return reportFailure('所选时间范围没有可总结的消息', 'data', 'NO_MESSAGES') const messageTypeMap: Record = { text: ['普通文本'], @@ -91,7 +96,7 @@ export async function generateAgentGroupReport( ).flatMap((type) => messageTypeMap[type] || []) ) messages = messages.filter((message) => selectedTypes.has(message.type)) - if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' } + if (!messages.length) return reportFailure('所选时间范围没有可总结的消息', 'data', 'NO_MESSAGES') const snapshot = getGroupSnapshot(contact.md5) if (snapshot) { @@ -122,7 +127,13 @@ export async function generateAgentGroupReport( ], { timeoutMs: Math.max(30, Math.min(1800, request.timeoutSeconds || 300)) * 1000 } ) - if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' } + if (!ai.success || !ai.data) { + return { + ...reportFailure(ai.error || 'AI 总结失败', 'ai', ai.errorCode), + ...(ai.errorStatus !== undefined ? { errorStatus: ai.errorStatus } : {}), + ...(ai.errorType ? { errorType: ai.errorType } : {}) + } + } let tokenUsage = ai.usage const parseReport = (raw: string): ReturnType => parseGroupDailyReport( @@ -147,18 +158,24 @@ export async function generateAgentGroupReport( if (!repaired.success || !repaired.data) { const cause = parseError instanceof Error ? parseError.message : String(parseError) return { - success: false, - error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})` + ...reportFailure( + `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})`, + 'ai', + repaired.errorCode + ), + ...(repaired.errorStatus !== undefined ? { errorStatus: repaired.errorStatus } : {}), + ...(repaired.errorType ? { errorType: repaired.errorType } : {}) } } tokenUsage = mergeTokenUsage(tokenUsage, repaired.usage) try { report = parseReport(repaired.data) } catch (repairError) { - return { - success: false, - error: repairError instanceof Error ? repairError.message : String(repairError) - } + return reportFailure( + repairError instanceof Error ? repairError.message : String(repairError), + 'report', + 'REPORT_GENERATION_FAILED' + ) } } const exported = await exportGroupReport({ @@ -167,7 +184,7 @@ export async function generateAgentGroupReport( templateId: request.templateId }) if (!exported.success || !exported.pngPath) { - return { success: false, error: exported.error || '总结图片生成失败' } + return reportFailure(exported.error || '总结图片生成失败', 'report', 'REPORT_GENERATION_FAILED') } return { success: true, @@ -196,3 +213,16 @@ function mergeTokenUsage( estimated: Boolean(first.estimated || second.estimated) } } + +function reportFailure( + error: string, + errorStage: ScheduledReportExecutionStage, + errorCode?: string +): AgentGroupReportResult { + return { + success: false, + error, + errorStage, + ...(errorCode ? { errorCode } : {}) + } +} diff --git a/src/main/services/agent-hub-service.ts b/src/main/services/agent-hub-service.ts index 5c28cdb..19613e0 100644 --- a/src/main/services/agent-hub-service.ts +++ b/src/main/services/agent-hub-service.ts @@ -1,7 +1,7 @@ import { app, BrowserWindow } from 'electron' import { ChildProcess, execFile, spawn } from 'child_process' import { randomBytes, timingSafeEqual } from 'crypto' -import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'fs' +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http' import { dirname, join } from 'path' import { promisify } from 'util' @@ -40,6 +40,19 @@ interface InboundMessage { items?: Array<{ type?: number; text?: string }> } +interface AgentHubNotificationRecipient { + accountId?: string + userId: string + updatedAt: number +} + +export interface AgentHubNotificationResult { + success: boolean + status: 'sent' | 'recipient_unavailable' | 'connector_offline' | 'token_expired' | 'send_failed' + recipient?: string + error?: string +} + interface GroupReportIntent { group: string range: 'today' | 'yesterday' | '7days' @@ -94,7 +107,7 @@ export function resolveWechatConnectorBinaryPath( ) } -class AgentHubService { +export class AgentHubService { private hubServer: Server | null = null private connectorChild: ChildProcess | null = null private loginChild: ChildProcess | null = null @@ -103,6 +116,8 @@ class AgentHubService { private logs: AgentHubLogEntry[] = [] private nextLogId = 1 private readonly processedMessages = new Map() + private notificationRecipient: AgentHubNotificationRecipient | null = null + private notificationRecipientLoaded = false private readonly inboundToken = process.env['AGENT_HUB_INBOUND_TOKEN'] || randomBytes(32).toString('hex') private status: AgentHubStatus = { @@ -115,6 +130,7 @@ class AgentHubService { async start(settings: AppSettings): Promise { void settings this.stopping = false + this.loadNotificationRecipient() const hubStarted = await this.startHub() await this.initializeConnector() return hubStarted @@ -124,6 +140,53 @@ class AgentHubService { return { ...this.status } } + /** The last user who sent an inbound message to this Agent Hub bot. */ + getNotificationRecipient(): string | undefined { + this.loadNotificationRecipient() + return this.notificationRecipient?.userId + } + + async sendNotification(input: { + to?: string + text: string + }): Promise { + const to = String(input.to || this.getNotificationRecipient() || '').trim() + const text = String(input.text || '').trim() + if (!to || !text) { + return { + success: false, + status: 'recipient_unavailable', + error: 'Agent Hub 尚未记录可靠的通知接收者' + } + } + const accountId = this.notificationRecipient?.accountId || this.status.accountId + try { + const response = await this.postConnectorMessage({ + accountId, + to, + text, + timeoutMs: 30_000 + }) + if (response.ok) return { success: true, status: 'sent', recipient: to } + const expired = /token|session|expired|unauthorized/i.test(response.body) + return { + success: false, + status: expired ? 'token_expired' : 'send_failed', + recipient: to, + error: expired + ? 'Agent Hub 微信连接器登录凭证已失效' + : `Agent Hub 通知发送失败:${response.body || response.status}` + } + } catch (error) { + return { + success: false, + status: 'connector_offline', + recipient: to, + error: `Agent Hub 微信连接器不可用:${this.errorMessage(error)}` + } + } + } + getLogs(): AgentHubLogEntry[] { return [...this.logs] } @@ -154,29 +217,24 @@ class AgentHubService { } } try { - const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - account_id: this.status.accountId, - to, - text: text || undefined, - media_url: mediaUrl || undefined - }), - signal: AbortSignal.timeout(30_000) + const response = await this.postConnectorMessage({ + accountId: this.status.accountId, + to, + text: text || undefined, + mediaUrl: mediaUrl || undefined, + timeoutMs: 30_000 }) - const body = await response.text() if (response.ok) { this.addLog('system', 'info', 'API 页面发送测试成功') return { success: true, status: 'sent', message: '发送成功' } } - const expired = /token|session|expired|unauthorized/i.test(body) + const expired = /token|session|expired|unauthorized/i.test(response.body) return { success: false, status: expired ? 'token_expired' : 'send_failed', message: expired ? '微信登录凭证已失效,请重新扫码登录' - : `发送失败:${body || response.status}` + : `发送失败:${response.body || response.status}` } } catch (error) { return { @@ -326,6 +384,7 @@ class AgentHubService { } const from = String(inbound.from_user_id || '').trim() if (!from) return this.sendHubJson(response, 400, { error: 'from_user_id is required' }) + this.rememberNotificationRecipient(inbound.account_id, from) const messageId = String(inbound.message_id || '') this.cleanProcessedMessages() @@ -791,18 +850,35 @@ class AgentHubService { text?: string, mediaUrl?: string ): Promise { + const response = await this.postConnectorMessage({ + accountId: inbound.account_id || this.status.accountId, + to: String(inbound.from_user_id || '').trim(), + text, + mediaUrl, + timeoutMs: mediaUrl ? 60_000 : 30_000 + }) + if (!response.ok) throw new Error(response.body || `HTTP ${response.status}`) + } + + private async postConnectorMessage(input: { + accountId?: string + to: string + text?: string + mediaUrl?: string + timeoutMs: number + }): Promise<{ ok: boolean; status: number; body: string }> { const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - account_id: inbound.account_id, - to: inbound.from_user_id, - text, - media_url: mediaUrl + account_id: input.accountId, + to: input.to, + text: input.text, + media_url: input.mediaUrl }), - signal: AbortSignal.timeout(mediaUrl ? 60_000 : 30_000) + signal: AbortSignal.timeout(input.timeoutMs) }) - if (!response.ok) throw new Error((await response.text()) || `HTTP ${response.status}`) + return { ok: response.ok, status: response.status, body: await response.text() } } private matchRecentChatIntent(text: string): number | null { @@ -965,6 +1041,51 @@ class AgentHubService { } } + private loadNotificationRecipient(): void { + if (this.notificationRecipientLoaded) return + this.notificationRecipientLoaded = true + try { + const stored = JSON.parse(readFileSync(this.notificationRecipientPath(), 'utf8')) as { + accountId?: unknown + userId?: unknown + updatedAt?: unknown + } + const userId = String(stored.userId || '').trim() + if (userId) { + this.notificationRecipient = { + userId, + accountId: String(stored.accountId || '').trim() || undefined, + updatedAt: Number(stored.updatedAt) || Date.now() + } + } + } catch { + this.notificationRecipient = null + } + } + + private rememberNotificationRecipient(accountId: string | undefined, userId: string): void { + const normalizedUserId = String(userId || '').trim() + if (!normalizedUserId) return + const recipient: AgentHubNotificationRecipient = { + userId: normalizedUserId, + accountId: String(accountId || this.status.accountId || '').trim() || undefined, + updatedAt: Date.now() + } + this.notificationRecipient = recipient + this.notificationRecipientLoaded = true + try { + const filePath = this.notificationRecipientPath() + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(recipient, null, 2), 'utf8') + } catch (error) { + this.addLog('agent-hub', 'warn', `通知接收者保存失败:${this.errorMessage(error)}`) + } + } + + private notificationRecipientPath(): string { + return join(app.getPath('userData'), 'agent-hub', 'notification-recipient.json') + } + private async loadAccounts(): Promise<{ accountId: string; wechatUserId: string }[]> { const executable = resolveWechatConnectorBinaryPath() if (!existsSync(executable)) throw new Error(`微信连接器不存在:${executable}`) diff --git a/src/main/services/ai-provider-service.ts b/src/main/services/ai-provider-service.ts index 853dcb6..48bcf8b 100644 --- a/src/main/services/ai-provider-service.ts +++ b/src/main/services/ai-provider-service.ts @@ -31,7 +31,7 @@ type AIRequestResult = { usage?: { input?: number; output?: number; total?: number; estimated?: boolean } } interface OpenAIResponsePayload { - error?: { message?: string } + error?: { message?: string; code?: string | number; type?: string } choices?: Array<{ message?: { content?: string | Array<{ type?: string; text?: string }> | null @@ -42,12 +42,12 @@ interface OpenAIResponsePayload { usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } } interface OpenAIStreamPayload { - error?: { message?: string } + error?: { message?: string; code?: string | number; type?: string } choices?: Array<{ delta?: { content?: string }; finish_reason?: string }> usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } } interface OpenAIResponsesPayload { - error?: { message?: string } + error?: { message?: string; code?: string | number; type?: string } incomplete_details?: { reason?: string } status?: string output_text?: string @@ -61,16 +61,35 @@ interface OpenAIResponsesStreamPayload { type?: string delta?: string message?: string - error?: { message?: string } + error?: { message?: string; code?: string | number; type?: string } response?: OpenAIResponsesPayload } interface AnthropicResponsePayload { - error?: { message?: string } + error?: { message?: string; type?: string } content?: Array<{ type?: string; text?: string }> stop_reason?: string usage?: { input_tokens?: number; output_tokens?: number } } +export class AIProviderRequestError extends Error { + readonly status?: number + readonly code?: string + readonly type?: string + readonly responseBody?: unknown + + constructor( + message: string, + details: { status?: number; code?: unknown; type?: unknown; responseBody?: unknown } = {} + ) { + super(message) + this.name = 'AIProviderRequestError' + this.status = details.status + this.code = typeof details.code === 'string' ? details.code : undefined + this.type = typeof details.type === 'string' ? details.type : undefined + this.responseBody = details.responseBody + } +} + export class AIProviderService { constructor(private readonly keyStore = new AIProviderKeyStore()) {} @@ -272,12 +291,15 @@ export class AIProviderService { finishReason?: string usage?: { input?: number; output?: number; total?: number; estimated?: boolean } error?: string + errorCode?: string + errorStatus?: number + errorType?: string }> { try { return { success: true, ...(await this.request(messages, options, false, signal, onDelta)) } } catch (error) { if (signal?.aborted) throw error - return { success: false, error: safeAIError(error) } + return { success: false, error: safeAIError(error), ...aiProviderErrorDetails(error) } } } @@ -752,7 +774,15 @@ async function requestOpenAICompatible( async (response) => { if (!response.ok) { const payload = await parseJsonResponse(response) - throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`) + throw new AIProviderRequestError( + payload.error?.message || `AI 请求失败 (${response.status})`, + { + status: response.status, + code: payload.error?.code, + type: payload.error?.type, + responseBody: payload.error + } + ) } if ( provider.advanced.stream && @@ -810,7 +840,15 @@ async function requestOpenAIResponses( async (response) => { if (!response.ok) { const payload = await parseJsonResponse(response) - throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`) + throw new AIProviderRequestError( + payload.error?.message || `AI 请求失败 (${response.status})`, + { + status: response.status, + code: payload.error?.code, + type: payload.error?.type, + responseBody: payload.error + } + ) } if (provider.advanced.stream) return parseOpenAIResponsesStream(response, onDelta) const payload = await parseJsonResponse(response) @@ -867,7 +905,14 @@ async function requestAnthropic( async (response) => { const payload = await parseJsonResponse(response) if (!response.ok) - throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`) + throw new AIProviderRequestError( + payload.error?.message || `Anthropic 请求失败 (${response.status})`, + { + status: response.status, + type: payload.error?.type, + responseBody: payload.error + } + ) return { data: Array.isArray(payload.content) ? payload.content @@ -1156,6 +1201,27 @@ function safeAIError(error: unknown): string { return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300) } +function aiProviderErrorDetails(error: unknown): { + errorCode?: string + errorStatus?: number + errorType?: string +} { + if (error instanceof AIProviderRequestError) { + return { + ...(error.code ? { errorCode: error.code } : {}), + ...(error.status !== undefined ? { errorStatus: error.status } : {}), + ...(error.type ? { errorType: error.type } : {}) + } + } + const record = error && typeof error === 'object' ? (error as Record) : undefined + const status = Number(record?.status) + return { + ...(typeof record?.code === 'string' ? { errorCode: record.code } : {}), + ...(Number.isFinite(status) && status > 0 ? { errorStatus: status } : {}), + ...(typeof record?.type === 'string' ? { errorType: record.type } : {}) + } +} + function parseVisionImage(dataUrl: string): { mimeType: string; base64: string; bytes: number } { const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/=]+)$/i.exec(dataUrl) if (!match) throw new Error('图片格式不受支持,请选择 PNG、JPG、JPEG 或 WebP') diff --git a/src/main/services/scheduled-report-api-service.ts b/src/main/services/scheduled-report-api-service.ts index 35da1c1..d0dddb6 100644 --- a/src/main/services/scheduled-report-api-service.ts +++ b/src/main/services/scheduled-report-api-service.ts @@ -27,7 +27,8 @@ export interface ScheduledReportApiDependencies { | 'deleteTask' | 'setTaskEnabled' | 'runScheduledReportNow' - > + > & + Partial> getCapability: () => Promise listContacts: () => FormattedContact[] isDatabaseReady: () => boolean @@ -102,7 +103,6 @@ export class ScheduledReportApiService { request: ScheduledReportApiCreateRequest ): Promise { this.assertDatabaseReady() - await this.assertCapabilityReady() if (!isRecord(request)) throw new ScheduledReportApiError(400, 'invalid_request', '请求体格式无效') @@ -250,6 +250,18 @@ export class ScheduledReportApiService { return result.data } + async retrySend(executionId: string): Promise { + const retry = this.deps.service.retryScheduledReportSend + if (!retry) { + throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报') + } + const result = await retry.call(this.deps.service, executionId) + if (!result.data) { + throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录') + } + return result.data + } + async executions(taskId: string): Promise { this.assertDatabaseReady() const task = await this.findTask(taskId) @@ -277,27 +289,6 @@ export class ScheduledReportApiService { } } - private async assertCapabilityReady(): Promise { - let capability: PersonalWechatSendCapability - try { - capability = this.applyPlatformCapability(await this.deps.getCapability()) - } catch (error) { - throw new ScheduledReportApiError( - 503, - 'wechat_capability_unavailable', - '微信发送能力检测失败,请在 TraceMemo 设置中检查个人微信。', - error instanceof Error ? error.message : String(error) - ) - } - if (capability.ready && capability.capabilities.image) return - throw new ScheduledReportApiError( - 409, - 'wechat_not_ready', - capability.message || 'TraceMemo 当前还没有完成微信消息发送能力配置', - { capability: publicCapability(capability) } - ) - } - private applyPlatformCapability( capability: PersonalWechatSendCapability ): PersonalWechatSendCapability { diff --git a/src/main/services/scheduled-report-service.ts b/src/main/services/scheduled-report-service.ts index 7c61e17..045d9f4 100644 --- a/src/main/services/scheduled-report-service.ts +++ b/src/main/services/scheduled-report-service.ts @@ -6,27 +6,48 @@ import type { PersonalWechatSendCapability, PersonalWechatSendRequest } from '../../shared/personal-wechat' +import type { AgentHubStatus } from '../../shared/agent-hub' import type { ScheduledReportCreateInput, ScheduledReportExecution, + ScheduledReportExecutionStage, ScheduledReportMessageType, ScheduledReportMemberNameMode, + ScheduledReportNotification, + ScheduledReportNotificationCapabilityReason, + ScheduledReportNotificationCapability, + ScheduledReportNotificationSettings, + ScheduledReportNotificationSettingsResult, + ScheduledReportNotificationSeverity, + ScheduledReportNotificationType, ScheduledReportRange, ScheduledReportResult, + ScheduledReportSendStatus, ScheduledReportTask, ScheduledReportUpdateInput } from '../../shared/scheduled-report' +import { + legacyScheduledReportError, + normalizeScheduledReportError +} from '../../shared/scheduled-report-error' import type { SaveGeneratedReportRequest } from '../../shared/report-history' import { saveGeneratedReport } from '../report-history-service' import { generateAgentGroupReport } from './agent-group-report-service' import { personalWechatSendService } from './personal-wechat-send-service' import { personalWechatCapabilityService } from './personal-wechat-capability-service' -import { isReady as isChatReady, resolveMd5 } from './chat-service' +import { getContactAvatars, isReady as isChatReady, resolveMd5 } from './chat-service' +import { agentHubService, type AgentHubNotificationResult } from './agent-hub-service' const STORAGE_DIR = 'scheduled-reports' const TASKS_FILE = 'tasks.json' const EXECUTIONS_FILE = 'executions.json' +const NOTIFICATIONS_FILE = 'notifications.json' +const SETTINGS_FILE = 'settings.json' const TICK_MS = 15_000 +const NOTIFICATION_TEST_MESSAGE = `✅ TraceMemo 定时日报通知已开启 + +以后定时日报生成或发送出现异常时, +我会通过这里通知你。` export interface ScheduledReportDependencies { getCapability: () => Promise @@ -35,6 +56,10 @@ export interface ScheduledReportDependencies { request: SaveGeneratedReportRequest ) => ReturnType send: (request: PersonalWechatSendRequest) => ReturnType + sendNotification: (input: { to?: string; text: string }) => Promise + getNotificationRecipient: () => string | undefined + getAgentHubStatus: () => AgentHubStatus + getContactAvatars?: (usernames: string[]) => Promise> storageDir: string isDatabaseReady: () => boolean now?: () => Date @@ -45,6 +70,10 @@ const defaultDependencies = (): ScheduledReportDependencies => ({ generateReport: generateAgentGroupReport, saveGeneratedReport, send: (request) => personalWechatSendService.send(request), + sendNotification: (input) => agentHubService.sendNotification(input), + getNotificationRecipient: () => agentHubService.getNotificationRecipient(), + getAgentHubStatus: () => agentHubService.getStatus(), + getContactAvatars: (usernames) => getContactAvatars(usernames), storageDir: path.join(app.getPath('userData'), STORAGE_DIR), isDatabaseReady: () => isChatReady() }) @@ -60,6 +89,14 @@ const reportRangeLabel = (range: ScheduledReportRange): string => ? '今天' : '昨日' +interface ScheduledReportNotificationPayload { + type: ScheduledReportNotificationType + severity: ScheduledReportNotificationSeverity + title: string + message: string + suggestedAction?: string +} + export function validateScheduleTime(value: string): boolean { return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim()) } @@ -75,12 +112,40 @@ export function calculateNextRunAt(scheduleTime: string, from = new Date()): str const asArray = (value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []) +const executionStatuses = new Set([ + 'running', + 'success', + 'waiting_to_send', + 'partial_success', + 'failed', + 'waiting_for_recovery', + 'skipped' +]) + +const normalizeExecution = (value: ScheduledReportExecution): ScheduledReportExecution => { + const status = executionStatuses.has(value.status) + ? value.status + : value.error + ? 'failed' + : 'success' + const retryCount = Number(value.retryCount) + return { + ...value, + status, + triggerType: value.triggerType || 'scheduled', + retryCount: Number.isFinite(retryCount) && retryCount >= 0 ? retryCount : 0 + } +} + export class ScheduledReportService { private readonly deps: ScheduledReportDependencies private tasks: ScheduledReportTask[] | null = null private executions: ScheduledReportExecution[] | null = null + private notifications: ScheduledReportNotification[] | null = null + private notificationSettings: ScheduledReportNotificationSettings | null = null private timer: NodeJS.Timeout | null = null private readonly running = new Map>() + private readonly retrying = new Map>() constructor(deps?: Partial) { this.deps = { ...defaultDependencies(), ...deps } @@ -89,6 +154,7 @@ export class ScheduledReportService { async start(): Promise { await this.load() if (this.timer) return + await this.flushNotifications() this.timer = setInterval(() => { void this.tick().catch((error) => console.warn('[ScheduledReport] tick failed:', error)) }, TICK_MS) @@ -113,11 +179,148 @@ export class ScheduledReportService { return items.map((item) => ({ ...item })) } + async listNotifications(): Promise { + await this.load() + return this.notifications!.map((notification) => ({ ...notification })) + } + + async getNotificationSettings(): Promise { + await this.load() + return { ...this.notificationSettings! } + } + + async checkNotificationCapability(): Promise { + let status: AgentHubStatus + try { + status = this.deps.getAgentHubStatus() + } catch (error) { + return { + ready: false, + reason: 'agent_hub_offline', + error: error instanceof Error ? error.message : String(error) + } + } + if (status.hub !== 'online') { + return { + ready: false, + reason: 'agent_hub_offline', + error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。' + } + } + if (status.connector !== 'online') { + return { + ready: false, + reason: 'connector_offline', + error: 'Agent Hub 微信连接器当前未在线。' + } + } + let recipient: string | undefined + try { + recipient = String(this.deps.getNotificationRecipient() || '').trim() || undefined + } catch (error) { + return { + ready: false, + reason: 'recipient_not_bound', + error: error instanceof Error ? error.message : String(error) + } + } + if (!recipient) { + return { + ready: false, + reason: 'recipient_not_bound', + error: + 'Agent Hub 已连接,但还不知道异常通知应该发送给谁。请先在微信中给 TraceMemo 机器人发送一条消息,完成通知接收者绑定。' + } + } + return { ready: true, recipient } + } + + async setNotificationEnabled( + enabled: boolean + ): Promise { + await this.load() + const currentEnabled = this.notificationSettings!.enabled + if (!enabled && !currentEnabled) { + await this.suppressPendingNotifications() + return { success: true, data: { enabled: false } } + } + if (enabled && currentEnabled) { + return { success: true, data: { enabled: true } } + } + + if (!enabled) { + this.notificationSettings = { enabled: false } + try { + await this.saveNotificationSettings() + } catch (error) { + this.notificationSettings = { enabled: currentEnabled } + return { + success: false, + data: { enabled: currentEnabled }, + reason: 'settings_persist_failed', + error: error instanceof Error ? error.message : String(error) + } + } + try { + await this.suppressPendingNotifications() + } catch (error) { + console.warn('[ScheduledReport] failed to suppress pending notifications:', error) + } + return { success: true, data: { enabled: false } } + } + + await this.suppressPendingNotifications() + const capability = await this.checkNotificationCapability() + if (!capability.ready || !capability.recipient) { + return { + success: false, + data: { enabled: false }, + reason: capability.reason || 'send_failed', + error: capability.error || '定时日报异常通知能力尚未就绪。' + } + } + + let testResult: AgentHubNotificationResult + try { + testResult = await this.deps.sendNotification({ + to: capability.recipient, + text: NOTIFICATION_TEST_MESSAGE + }) + } catch (error) { + return { + success: false, + data: { enabled: false }, + reason: 'send_failed', + error: error instanceof Error ? error.message : String(error) + } + } + if (!testResult.success) { + return { + success: false, + data: { enabled: false }, + reason: this.notificationCapabilityReasonForSend(testResult), + error: testResult.error || '异常通知测试发送失败。' + } + } + + this.notificationSettings = { enabled: true } + try { + await this.saveNotificationSettings() + } catch (error) { + this.notificationSettings = { enabled: false } + return { + success: false, + data: { enabled: false }, + reason: 'settings_persist_failed', + error: error instanceof Error ? error.message : String(error) + } + } + return { success: true, data: { enabled: true } } + } + async createTask( input: ScheduledReportCreateInput ): Promise> { - const capability = await this.deps.getCapability() - if (!capability.ready) return { success: false, error: this.creationError(capability) } const normalized = this.normalizeInput(input) if (!normalized.success) return { success: false, error: normalized.error } const values = normalized.data! @@ -205,14 +408,88 @@ export class ScheduledReportService { if (!task) return { success: false, error: '未找到定时日报任务' } const execution = await this.runTask(task) return { - success: execution.status === 'success', + success: execution.status !== 'failed', data: execution, ...(execution.error ? { error: execution.error } : {}) } } + async retryScheduledReportSend( + executionId: string + ): Promise> { + await this.load() + const execution = this.executions!.find((item) => item.id === executionId) + if (!execution) return { success: false, error: '未找到定时日报执行记录' } + const existing = this.retrying.get(executionId) + if (existing) return { success: true, data: await existing } + const promise = this.retrySend(execution).finally(() => this.retrying.delete(executionId)) + this.retrying.set(executionId, promise) + const result = await promise + return { + success: result.status !== 'failed', + data: result, + ...(result.error ? { error: result.error } : {}) + } + } + + async testScheduledReportErrorNotification( + taskId: string + ): Promise> { + await this.load() + const task = this.tasks!.find((item) => item.id === taskId) + if (!task) return { success: false, error: '未找到定时日报任务' } + if (!this.notificationSettings!.enabled) { + return { + success: false, + error: '请先开启微信异常通知,再发送测试错误信息。' + } + } + + const now = (this.deps.now?.() || new Date()).toISOString() + const execution: ScheduledReportExecution = { + id: `scheduled_report_execution_${randomUUID()}`, + taskId: task.id, + triggerType: 'scheduled', + startedAt: now, + finishedAt: now, + status: 'failed', + currentStage: 'report', + failedStage: 'report', + error: 'debug_test_notification:模拟定时日报生成错误', + errorCode: 'DEBUG_TEST_NOTIFICATION', + technicalMessage: '这是调试用的模拟错误信息;本次没有调用 AI,也没有生成或发送日报图片。', + userTitle: '定时日报错误通知测试', + userMessage: '这是一条调试用的模拟错误通知,用于验证 Agent Hub 推送链路。', + suggestedAction: '确认微信中是否收到这条测试通知。', + retryable: false, + retryCount: 0, + sendStatus: 'unavailable', + notificationStatus: 'pending' + } + this.executions!.unshift(execution) + this.executions = this.executions!.slice(0, 500) + await this.saveExecutions() + + const notified = await this.notifyExecution(task, execution, { + type: 'failure', + severity: 'error', + title: execution.userTitle || '定时日报错误通知测试', + message: + execution.userMessage || '这是一条调试用的模拟错误通知,用于验证 Agent Hub 推送链路。', + suggestedAction: execution.suggestedAction || '确认微信中是否收到这条测试通知。' + }) + return { + success: notified.notificationStatus === 'sent', + data: notified, + ...(notified.notificationStatus === 'sent' + ? {} + : { error: '测试错误已创建,但通知尚未送达。' }) + } + } + async tick(at = this.deps.now?.() || new Date()): Promise { await this.load() + await this.flushNotifications() if (!this.deps.isDatabaseReady()) return const nowMs = at.getTime() for (const task of [...this.tasks!]) { @@ -239,7 +516,7 @@ export class ScheduledReportService { this.tasks![index] = claimed await this.saveTasks() if (executionOverlapsSlot) continue - void this.runTask(claimed, slot).catch((error) => + void this.runTask(claimed, slot, 'scheduled').catch((error) => console.warn('[ScheduledReport] execution failed:', error) ) } @@ -247,11 +524,12 @@ export class ScheduledReportService { private async runTask( task: ScheduledReportTask, - scheduledSlot?: string + scheduledSlot?: string, + triggerType: 'scheduled' | 'manual' = 'manual' ): Promise { const existing = this.running.get(task.id) if (existing) return existing - const promise = this.executeTask(task, scheduledSlot).finally(() => + const promise = this.executeTask(task, scheduledSlot, triggerType).finally(() => this.running.delete(task.id) ) this.running.set(task.id, promise) @@ -260,51 +538,99 @@ export class ScheduledReportService { private async executeTask( task: ScheduledReportTask, - scheduledSlot?: string + scheduledSlot?: string, + triggerType: 'scheduled' | 'manual' = 'manual' ): Promise { await this.load() const startedAt = (this.deps.now?.() || new Date()).toISOString() const execution: ScheduledReportExecution = { id: `scheduled_report_execution_${randomUUID()}`, taskId: task.id, + triggerType, startedAt, status: 'running', + currentStage: 'precheck', + retryCount: 0, + sendStatus: 'pending', + notificationStatus: 'not_needed', ...(scheduledSlot ? { scheduledSlot } : {}) } this.executions!.push(execution) await this.saveExecutions() - const finish = async ( - status: ScheduledReportExecution['status'], - error?: string, - message?: string + const update = async (patch: Partial): Promise => { + Object.assign(execution, patch) + await this.persistExecution(execution) + } + const finishError = async ( + rawError: unknown, + fallbackStage: ScheduledReportExecutionStage, + status: 'failed' | 'waiting_to_send' | 'partial_success' = 'failed', + patch: Partial = {}, + notificationType: ScheduledReportNotificationType = 'failure', + code?: string, + errorStatus?: number, + errorType?: string ): Promise => { - const completed: ScheduledReportExecution = { - ...execution, - status, - finishedAt: (this.deps.now?.() || new Date()).toISOString(), - ...(error ? { error } : {}), - ...(message ? { message } : {}) + const normalized = normalizeScheduledReportError( + { + error: rawError, + code, + status: errorStatus, + type: errorType, + stage: fallbackStage + }, + fallbackStage + ) + if (normalized.code === 'NO_MESSAGES') { + return this.finalizeExecution(task, execution, { + ...patch, + status: 'skipped', + currentStage: 'data', + errorCode: 'NO_MESSAGES', + userTitle: '暂无可生成的日报', + userMessage: normalized.userMessage, + message: normalized.userMessage, + suggestedAction: normalized.suggestedAction, + retryable: false, + sendStatus: 'unavailable', + notificationStatus: 'not_needed' + }) } - const index = this.executions!.findIndex((item) => item.id === execution.id) - if (index >= 0) this.executions![index] = completed - await this.saveExecutions() - const taskIndex = this.tasks!.findIndex((item) => item.id === task.id) - if (taskIndex >= 0) { - this.tasks![taskIndex] = { - ...this.tasks![taskIndex], - ...(status !== 'running' ? { lastRunAt: completed.finishedAt } : {}), - updatedAt: completed.finishedAt! + const completed = await this.finalizeExecution( + task, + execution, + { + ...patch, + status, + currentStage: normalized.stage, + failedStage: normalized.stage, + error: legacyScheduledReportError(normalized.code, normalized.technicalMessage), + message: normalized.userMessage, + errorCode: normalized.code, + technicalMessage: normalized.technicalMessage, + userTitle: normalized.userTitle, + userMessage: normalized.userMessage, + suggestedAction: normalized.suggestedAction, + retryable: normalized.retryable + }, + { + type: notificationType, + severity: normalized.severity, + title: normalized.userTitle, + message: normalized.userMessage, + suggestedAction: normalized.suggestedAction } - await this.saveTasks() - } + ) return completed } try { - const capability = await this.deps.getCapability() - if (!capability.ready) { - return finish('failed', `wechat_not_ready:${capability.status}`, capability.message) - } + await update({ currentStage: 'precheck' }) + let capability: PersonalWechatSendCapability | null = null + let capabilityCheckError = '' + + await update({ currentStage: 'data' }) + await update({ currentStage: 'ai' }) let generated: Awaited> try { generated = await this.deps.generateReport({ @@ -316,25 +642,43 @@ export class ScheduledReportService { timeoutSeconds: task.timeoutSeconds }) } catch (error) { - return finish( - 'failed', - `report_generation_failed:${error instanceof Error ? error.message : String(error)}` - ) + return finishError(error, 'report') } if (!generated.success || !generated.pngPath) { - return finish('failed', `report_generation_failed:${generated.error || '日报生成失败'}`) + return finishError( + generated.error || '日报生成失败', + generated.errorStage || 'report', + 'failed', + {}, + 'failure', + generated.errorCode, + generated.errorStatus, + generated.errorType + ) } + + await update({ currentStage: 'persist' }) const reportContact = resolveMd5(task.group) - let savedHistory: Awaited> + let contactAvatar = reportContact?.avatar + if (!contactAvatar && reportContact?.m_nsUsrName && this.deps.getContactAvatars) { + try { + const avatars = await this.deps.getContactAvatars([reportContact.m_nsUsrName]) + contactAvatar = avatars[reportContact.m_nsUsrName] + } catch (error) { + console.warn('[ScheduledReport] failed to hydrate group avatar:', error) + } + } + let savedHistory: Awaited> try { savedHistory = await this.deps.saveGeneratedReport({ contactId: reportContact?.md5 || task.group, contactName: generated.groupName || reportContact?.m_nsNickName || task.group, - contactAvatar: reportContact?.avatar, + contactAvatar, + source: 'scheduled', dateRange: generated.reportMetadata?.dateRange || reportRangeLabel(task.reportRange), reportDate: generated.reportMetadata?.reportDate, - messageCount: generated.messageCount || generated.reportMetadata?.messageCount || 0, - generatedAt: new Date().toISOString(), + messageCount: generated.messageCount ?? generated.reportMetadata?.messageCount ?? 0, + generatedAt: (this.deps.now?.() || new Date()).toISOString(), htmlPath: generated.htmlPath, pngPath: generated.pngPath, duration: generated.duration, @@ -345,42 +689,473 @@ export class ScheduledReportService { templateId: task.templateId }) } catch (error) { - return finish( - 'failed', - `report_history_save_failed:${error instanceof Error ? error.message : String(error)}` - ) + return finishError(error, 'persist') } if (!savedHistory.success) { - return finish( + return finishError( + savedHistory.error || '日报历史保存失败', + 'persist', 'failed', - `report_history_save_failed:${savedHistory.error || '日报历史保存失败'}` + {}, + 'failure', + 'REPORT_HISTORY_SAVE_FAILED' ) } + + const reportId = savedHistory.record?.id + const pngPath = savedHistory.record?.pngPath || generated.pngPath + const htmlPath = savedHistory.record?.htmlPath || generated.htmlPath + if (!pngPath) { + return finishError( + '日报历史未返回可发送的 PNG 文件', + 'persist', + 'failed', + {}, + 'failure', + 'REPORT_HISTORY_SAVE_FAILED' + ) + } + await update({ reportId, htmlPath, pngPath, currentStage: 'send' }) + + try { + capability = await this.deps.getCapability() + } catch (error) { + capabilityCheckError = error instanceof Error ? error.message : String(error) + } + if (!capability?.ready || !capability.capabilities.image) { + const technicalMessage = + capabilityCheckError || + capability?.error || + capability?.message || + '个人微信发送能力不可用' + return finishError( + technicalMessage, + 'send', + 'waiting_to_send', + { sendStatus: 'unavailable', sendError: technicalMessage }, + 'partial_success', + 'WECHAT_SEND_UNAVAILABLE' + ) + } + const target = this.resolveTarget(task) - if (!target) return finish('failed', 'wechat_send_failed:未找到指定微信群') - let sent: Awaited> + if (!target) { + return finishError( + '未找到指定微信群', + 'send', + 'partial_success', + { sendStatus: 'failed', sendError: '未找到指定微信群' }, + 'partial_success', + 'WECHAT_SEND_FAILED' + ) + } + await update({ sendTarget: target, currentStage: 'send' }) + let sent: Awaited> try { sent = await this.deps.send({ type: 'image', to: target, isGroup: true, - filePath: generated.pngPath + filePath: pngPath }) } catch (error) { - return finish( - 'failed', - `wechat_send_failed:${error instanceof Error ? error.message : String(error)}` + return finishError( + error, + 'send', + 'partial_success', + { + sendStatus: 'failed', + sendError: error instanceof Error ? error.message : String(error) + }, + 'partial_success', + 'WECHAT_SEND_FAILED' ) } - if (!sent.success) - return finish('failed', `wechat_send_failed:${sent.error || '微信发送失败'}`) - return finish('success', undefined, '日报生成成功,微信发送成功') + if (!sent.success) { + return finishError( + sent.error || '微信发送失败', + 'send', + 'partial_success', + { sendStatus: 'failed', sendError: sent.error || '微信发送失败' }, + 'partial_success', + 'WECHAT_SEND_FAILED' + ) + } + return this.finalizeExecution(task, execution, { + status: 'success', + currentStage: 'send', + sendTarget: target, + sendStatus: 'success', + notificationStatus: 'not_needed', + message: '日报生成成功,微信发送成功' + }) } catch (error) { - return finish( + return finishError(error, execution.currentStage || 'report') + } + } + + private async retrySend(original: ScheduledReportExecution): Promise { + const task = this.tasks!.find((item) => item.id === original.taskId) + if (!task) return original + const retryCount = (original.retryCount || 0) + 1 + const working: ScheduledReportExecution = { + ...original, + status: 'running', + currentStage: 'send', + retryCount, + sendStatus: 'pending' + } + await this.persistExecution(working) + + const finishRetryError = async ( + rawError: unknown, + code: string, + sendStatus: ScheduledReportSendStatus, + preferredStatus: 'waiting_to_send' | 'partial_success' + ): Promise => { + const normalized = normalizeScheduledReportError( + { error: rawError, code, stage: 'send' }, + 'send' + ) + return this.finalizeExecution(task, working, { + status: preferredStatus, + currentStage: normalized.stage, + failedStage: normalized.stage, + error: legacyScheduledReportError(normalized.code, normalized.technicalMessage), + message: normalized.userMessage, + errorCode: normalized.code, + technicalMessage: normalized.technicalMessage, + userTitle: normalized.userTitle, + userMessage: normalized.userMessage, + suggestedAction: normalized.suggestedAction, + retryable: normalized.retryable, + retryCount, + sendStatus, + sendError: normalized.technicalMessage + }) + } + + if (!working.pngPath) { + return finishRetryError( + '执行记录缺少已保存的 PNG 文件', + 'REPORT_HISTORY_SAVE_FAILED', 'failed', - `wechat_not_ready:${error instanceof Error ? error.message : String(error)}` + 'partial_success' ) } + + let capability: PersonalWechatSendCapability | null = null + try { + capability = await this.deps.getCapability() + } catch (error) { + return finishRetryError(error, 'WECHAT_SEND_UNAVAILABLE', 'unavailable', 'waiting_to_send') + } + if (!capability.ready || !capability.capabilities.image) { + return finishRetryError( + capability.error || capability.message || '个人微信发送能力不可用', + 'WECHAT_SEND_UNAVAILABLE', + 'unavailable', + 'waiting_to_send' + ) + } + + const target = working.sendTarget || this.resolveTarget(task) + if (!target) { + return finishRetryError('未找到指定微信群', 'WECHAT_SEND_FAILED', 'failed', 'partial_success') + } + try { + const sent = await this.deps.send({ + type: 'image', + to: target, + isGroup: true, + filePath: working.pngPath + }) + if (!sent.success) { + return finishRetryError( + sent.error || '微信发送失败', + 'WECHAT_SEND_FAILED', + 'failed', + 'partial_success' + ) + } + } catch (error) { + return finishRetryError(error, 'WECHAT_SEND_FAILED', 'failed', 'partial_success') + } + + const previousStatus = original.status + const cleared = this.clearFailureFields(working, { + status: 'success', + currentStage: 'send', + sendTarget: target, + sendStatus: 'success', + retryCount, + message: '日报生成成功,微信发送成功', + notificationStatus: original.notificationStatus || 'not_needed' + }) + const completed = await this.finalizeExecution(task, cleared, {}) + if (previousStatus === 'waiting_to_send' || previousStatus === 'partial_success') { + return this.notifyExecution(task, completed, { + type: 'recovery', + severity: 'info', + title: `${task.name} 已恢复`, + message: '刚才未发送的日报已经成功发送。' + }) + } + return completed + } + + private clearFailureFields( + execution: ScheduledReportExecution, + patch: Partial + ): ScheduledReportExecution { + const next = { ...execution, ...patch } + delete next.error + delete next.failedStage + delete next.errorCode + delete next.technicalMessage + delete next.userTitle + delete next.userMessage + delete next.suggestedAction + delete next.retryable + delete next.sendError + return next + } + + private async finalizeExecution( + task: ScheduledReportTask, + execution: ScheduledReportExecution, + patch: Partial, + notification?: ScheduledReportNotificationPayload + ): Promise { + const completed: ScheduledReportExecution = { + ...execution, + ...patch, + finishedAt: (this.deps.now?.() || new Date()).toISOString() + } + await this.persistExecution(completed) + const taskIndex = this.tasks!.findIndex((item) => item.id === task.id) + if (taskIndex >= 0) { + this.tasks![taskIndex] = { + ...this.tasks![taskIndex], + lastRunAt: completed.finishedAt, + updatedAt: completed.finishedAt! + } + await this.saveTasks() + } + if (notification) return this.notifyExecution(task, completed, notification) + return { ...completed } + } + + private async persistExecution(execution: ScheduledReportExecution): Promise { + const index = this.executions!.findIndex((item) => item.id === execution.id) + if (index >= 0) this.executions![index] = { ...execution } + else this.executions!.push({ ...execution }) + await this.saveExecutions() + } + + private async notifyExecution( + task: ScheduledReportTask, + execution: ScheduledReportExecution, + payload: ScheduledReportNotificationPayload + ): Promise { + await this.load() + if ( + execution.triggerType !== 'scheduled' || + execution.status === 'skipped' || + !this.notificationSettings!.enabled + ) { + if (execution.notificationStatus !== 'not_needed') { + const updated = { ...execution, notificationStatus: 'not_needed' as const } + await this.persistExecution(updated) + return updated + } + return { ...execution } + } + let notification: ScheduledReportNotification + try { + notification = await this.enqueueNotification(task, execution, payload) + } catch (error) { + notification = { + id: `scheduled_report_notification_${randomUUID()}`, + executionId: execution.id, + taskId: task.id, + type: payload.type, + severity: payload.severity, + title: payload.title, + message: payload.message, + dedupeKey: `${execution.id}:${payload.type}`, + channel: 'agent_hub', + status: 'failed', + createdAt: (this.deps.now?.() || new Date()).toISOString(), + attempts: 0, + lastError: error instanceof Error ? error.message : String(error) + } + } + const updated: ScheduledReportExecution = { + ...execution, + currentStage: 'notify', + notificationStatus: notification.status + } + await this.persistExecution(updated) + return { ...updated } + } + + private async enqueueNotification( + task: ScheduledReportTask, + execution: ScheduledReportExecution, + payload: ScheduledReportNotificationPayload + ): Promise { + await this.load() + const dedupeKey = `${execution.id}:${payload.type}` + const existing = this.notifications!.find((item) => item.dedupeKey === dedupeKey) + if (existing) return { ...existing } + let recipient: string | undefined + try { + recipient = this.deps.getNotificationRecipient() + } catch { + recipient = undefined + } + const notification: ScheduledReportNotification = { + id: `scheduled_report_notification_${randomUUID()}`, + executionId: execution.id, + taskId: task.id, + type: payload.type, + severity: payload.severity, + title: payload.title, + message: payload.message, + dedupeKey, + channel: 'agent_hub', + ...(recipient ? { recipient } : {}), + status: 'pending', + createdAt: (this.deps.now?.() || new Date()).toISOString(), + attempts: 0 + } + this.notifications!.unshift(notification) + this.notifications = this.notifications!.slice(0, 500) + await this.saveNotifications() + if (recipient) await this.tryDeliverNotification(notification, payload, task) + return { ...notification } + } + + private async tryDeliverNotification( + notification: ScheduledReportNotification, + payload?: ScheduledReportNotificationPayload, + task?: ScheduledReportTask + ): Promise { + if (notification.status === 'sent') return + await this.load() + const execution = this.executions!.find((item) => item.id === notification.executionId) + if ( + !this.notificationSettings!.enabled || + execution?.triggerType === 'manual' || + execution?.status === 'skipped' + ) { + notification.status = 'suppressed' + notification.suppressedAt = (this.deps.now?.() || new Date()).toISOString() + notification.lastError = '定时日报微信异常通知已关闭或不适用于本次执行。' + await this.saveNotifications() + return + } + let recipient = notification.recipient + if (!recipient) { + try { + recipient = this.deps.getNotificationRecipient() + } catch { + recipient = undefined + } + } + if (!recipient) return + notification.recipient = recipient + notification.attempts += 1 + try { + const result = await this.deps.sendNotification({ + to: recipient, + text: this.notificationText( + task?.name || '定时日报', + payload?.severity || notification.severity, + payload?.title || notification.title, + payload?.message || notification.message, + payload?.suggestedAction + ) + }) + if (result.success) { + notification.status = 'sent' + notification.sentAt = (this.deps.now?.() || new Date()).toISOString() + delete notification.lastError + } else { + notification.status = 'pending' + notification.lastError = result.error || result.status + } + } catch (error) { + notification.status = 'pending' + notification.lastError = error instanceof Error ? error.message : String(error) + } + await this.saveNotifications() + } + + private async flushNotifications(): Promise { + await this.load() + if (!this.notificationSettings!.enabled) { + await this.suppressPendingNotifications() + return + } + const pending = this.notifications!.filter((item) => item.status === 'pending') + if (!pending.length) return + for (const notification of pending) { + const task = this.tasks!.find((item) => item.id === notification.taskId) + await this.tryDeliverNotification(notification, undefined, task) + const execution = this.executions!.find((item) => item.id === notification.executionId) + if (execution) { + execution.notificationStatus = notification.status + await this.persistExecution(execution) + } + } + } + + private async suppressPendingNotifications(): Promise { + await this.load() + const pending = this.notifications!.filter((item) => item.status === 'pending') + if (!pending.length) return + const suppressedAt = (this.deps.now?.() || new Date()).toISOString() + const executionIds = new Set() + for (const notification of pending) { + notification.status = 'suppressed' + notification.suppressedAt = suppressedAt + notification.lastError = '定时日报微信异常通知已关闭。' + executionIds.add(notification.executionId) + } + for (const execution of this.executions!) { + if (executionIds.has(execution.id) && execution.notificationStatus === 'pending') { + execution.notificationStatus = 'suppressed' + } + } + await Promise.all([this.saveNotifications(), this.saveExecutions()]) + } + + private notificationCapabilityReasonForSend( + result: AgentHubNotificationResult + ): ScheduledReportNotificationCapabilityReason { + if (result.status === 'recipient_unavailable') return 'recipient_not_bound' + if (result.status === 'connector_offline') return 'connector_offline' + return 'send_failed' + } + + private notificationText( + taskName: string, + severity: ScheduledReportNotificationSeverity, + title: string, + message: string, + suggestedAction?: string + ): string { + const icon = severity === 'error' ? '❌' : severity === 'warning' ? '⚠️' : '✅' + return [ + `${icon} ${taskName}`, + title, + message, + suggestedAction ? `建议:${suggestedAction}` : '' + ] + .filter(Boolean) + .join('\n') } private resolveTarget(task: ScheduledReportTask): string | undefined { @@ -390,14 +1165,6 @@ export class ScheduledReportService { return contact?.m_nsUsrName?.endsWith('@chatroom') ? contact.m_nsUsrName : undefined } - private creationError(capability: PersonalWechatSendCapability): string { - if (capability.status === 'unsupported') return '微信消息发送目前仅支持 macOS 和 Windows' - if (capability.status === 'needs_binding' || capability.status === 'unconfigured') - return '请先绑定个人微信' - if (capability.status === 'needs_verification') return '请先完成微信消息能力检测' - return capability.error || capability.message || '个人微信发送能力异常' - } - private normalizeInput(input: ScheduledReportCreateInput): ScheduledReportResult<{ name: string group: string @@ -447,14 +1214,18 @@ export class ScheduledReportService { } private async load(): Promise { - if (this.tasks && this.executions) return + if (this.tasks && this.executions && this.notifications && this.notificationSettings) return await fs.mkdir(this.deps.storageDir, { recursive: true }) - const [tasks, executions] = await Promise.all([ + const [tasks, executions, notifications, settings] = await Promise.all([ this.readJson(TASKS_FILE), - this.readJson(EXECUTIONS_FILE) + this.readJson(EXECUTIONS_FILE), + this.readJson(NOTIFICATIONS_FILE), + this.readJson>(SETTINGS_FILE) ]) this.tasks = asArray(tasks) - this.executions = asArray(executions) + this.executions = asArray(executions).map(normalizeExecution) + this.notifications = asArray(notifications) + this.notificationSettings = { enabled: settings?.enabled === true } } private async readJson(file: string): Promise { @@ -483,6 +1254,22 @@ export class ScheduledReportService { 'utf8' ) } + + private async saveNotifications(): Promise { + await fs.writeFile( + path.join(this.deps.storageDir, NOTIFICATIONS_FILE), + JSON.stringify(this.notifications, null, 2), + 'utf8' + ) + } + + private async saveNotificationSettings(): Promise { + await fs.writeFile( + path.join(this.deps.storageDir, SETTINGS_FILE), + JSON.stringify(this.notificationSettings, null, 2), + 'utf8' + ) + } } export const scheduledReportService = new ScheduledReportService() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 1cd380b..70857ee 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -65,6 +65,8 @@ import type { PersonalWechatSendCapability } from '../shared/personal-wechat' import type { ScheduledReportCreateInput, ScheduledReportExecution, + ScheduledReportNotificationSettings, + ScheduledReportNotificationSettingsResult, ScheduledReportResult, ScheduledReportTask, ScheduledReportUpdateInput @@ -636,6 +638,10 @@ declare global { ) => Promise listScheduledReports: () => Promise listScheduledReportExecutions: (taskId?: string) => Promise + getScheduledReportNotificationSettings: () => Promise + setScheduledReportNotificationEnabled: ( + enabled: boolean + ) => Promise createScheduledReport: ( request: ScheduledReportCreateInput ) => Promise> @@ -653,6 +659,12 @@ declare global { runScheduledReportNow: ( taskId: string ) => Promise> + retryScheduledReportSend: ( + executionId: string + ) => Promise> + testScheduledReportErrorNotification: ( + taskId: string + ) => Promise> getPersonalWechatVoiceDiagnostic: () => Promise getAgentHubStatus: () => Promise getAgentHubLogs: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 45ac003..8eabe81 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -37,6 +37,8 @@ import type { PersonalWechatSendCapability } from '../shared/personal-wechat' import type { ScheduledReportCreateInput, ScheduledReportExecution, + ScheduledReportNotificationSettings, + ScheduledReportNotificationSettingsResult, ScheduledReportResult, ScheduledReportTask, ScheduledReportUpdateInput @@ -441,6 +443,12 @@ const api = { ipcRenderer.invoke('scheduled-report:list'), listScheduledReportExecutions: (taskId?: string): Promise => ipcRenderer.invoke('scheduled-report:listExecutions', taskId), + getScheduledReportNotificationSettings: (): Promise => + ipcRenderer.invoke('scheduled-report:getNotificationSettings'), + setScheduledReportNotificationEnabled: ( + enabled: boolean + ): Promise => + ipcRenderer.invoke('scheduled-report:setNotificationEnabled', enabled), createScheduledReport: ( request: ScheduledReportCreateInput ): Promise> => @@ -461,6 +469,14 @@ const api = { taskId: string ): Promise> => ipcRenderer.invoke('scheduled-report:runNow', taskId), + retryScheduledReportSend: ( + executionId: string + ): Promise> => + ipcRenderer.invoke('scheduled-report:retrySend', executionId), + testScheduledReportErrorNotification: ( + taskId: string + ): Promise> => + ipcRenderer.invoke('scheduled-report:testErrorNotification', taskId), getPersonalWechatVoiceDiagnostic: (): Promise => ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'), getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5f7844b..cad707e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1512,6 +1512,10 @@ function App(): React.ReactElement { setActivePage('settings') } + const openAgentHub = (): void => { + setActivePage('agent-hub') + } + const dismissFirstUseWelcome = (): void => { try { localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1') @@ -1604,6 +1608,7 @@ function App(): React.ReactElement { contactId: reportSourceContact.md5, contactName: contactDisplayName(reportSourceContact), contactAvatar: reportSourceContact.avatar || undefined, + source: 'manual', dateRange: summaryDateRange === 'yesterday' ? '昨日' @@ -1816,6 +1821,7 @@ function App(): React.ReactElement { contacts={contacts} platformSupported={supportsPersonalWechatSend} onOpenWechatSettings={openWechatSendSettings} + onOpenAgentHub={openAgentHub} onOpenModelSettings={openModelSettings} onNotice={(message, variant) => toast({ description: message, variant, duration: 3200 }) @@ -1825,6 +1831,7 @@ function App(): React.ReactElement {
{ return groups } +const reportTitle = (report: GeneratedReportRecord): string => + report.source === 'scheduled' ? `${report.contactName}的定时日报` : report.contactName + export function ReportHistorySidebar({ reports, + contacts = [], selectedReportId, selfInfo, dbReady, @@ -105,6 +111,15 @@ export function ReportHistorySidebar({ const [deletePending, setDeletePending] = useState(false) const deleteTriggerRef = useRef(null) const restoreDeleteFocusRef = useRef(false) + const contactAvatars = useMemo(() => { + const result = new Map() + contacts.forEach((contact) => { + if (!contact.avatar) return + result.set(contact.md5, contact.avatar) + result.set(contact.m_nsUsrName, contact.avatar) + }) + return result + }, [contacts]) useEffect(() => { if (pendingDelete || !restoreDeleteFocusRef.current) return @@ -116,9 +131,10 @@ export function ReportHistorySidebar({ const lower = keyword.trim().toLowerCase() const filteredReports = reports .filter((report) => { - const haystack = `${report.contactName} ${report.dateRange} ${formatGeneratedAt( - report.generatedAt - )}`.toLowerCase() + const haystack = + `${reportTitle(report)} ${report.contactName} ${report.dateRange} ${formatGeneratedAt( + report.generatedAt + )}`.toLowerCase() return lower ? haystack.includes(lower) : true }) .sort((left, right) => dateKey(right.generatedAt) - dateKey(left.generatedAt)) @@ -187,10 +203,10 @@ export function ReportHistorySidebar({ }} > - {report.contactAvatar ? ( + {report.contactAvatar || contactAvatars.get(report.contactId) ? ( {report.contactName} ) : ( @@ -198,7 +214,7 @@ export function ReportHistorySidebar({ )} - {report.contactName} + {reportTitle(report)} {formatGeneratedAt(report.generatedAt)} {report.messageCount} 条消息 PNG{report.pngStatus === 'ready' ? '已保存' : '缺失'} diff --git a/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx b/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx index 23f6f5a..e52df21 100644 --- a/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx +++ b/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx @@ -6,6 +6,7 @@ import type { ScheduledReportExecution, ScheduledReportMessageType, ScheduledReportMemberNameMode, + ScheduledReportNotificationCapabilityReason, ScheduledReportRange, ScheduledReportTask } from '../../../../shared/scheduled-report' @@ -46,6 +47,7 @@ import { type SummaryDateRange } from '../../utils/group-report' import { REPORT_TEMPLATES, DEFAULT_REPORT_TEMPLATE } from '../../../../shared/report-templates' +import { isTruthyDebugFlag } from '../../../../shared/debug-flags' type ReportDialogMode = 'create' | 'edit' @@ -53,6 +55,7 @@ interface ScheduledReportsWorkspaceProps { contacts: Contact[] platformSupported?: boolean onOpenWechatSettings: () => void + onOpenAgentHub: () => void onOpenModelSettings?: () => void onNotice: (message: string, variant?: 'default' | 'success' | 'warning' | 'destructive') => void } @@ -125,24 +128,37 @@ const capabilityTone = (capability: PersonalWechatSendCapability | null): string const readableError = (error?: string): string => { const value = String(error || '') - if (value.includes('wechat_not_ready') || value.includes('仅支持 macOS')) { - if (value.includes('unsupported') || value.includes('仅支持')) - return '微信消息发送目前仅支持 macOS 和 Windows' - if (value.includes('needs_binding') || value.includes('unconfigured')) return '请先绑定个人微信' - if (value.includes('needs_verification')) return '请先完成微信消息能力检测' - return '微信发送能力不可用,请检查微信连接。' - } - if (value.includes('wechat_send_failed')) return '微信发送失败,请检查微信连接。' - if (value.includes('report_generation_failed')) return '日报生成失败,请稍后重试。' return value || '操作失败,请稍后重试。' } +const notificationFailureCopy = ( + reason?: ScheduledReportNotificationCapabilityReason, + error?: string +): string => { + if (reason === 'agent_hub_offline') return '需要先连接 Agent Hub 微信机器人,才能接收异常通知。' + if (reason === 'connector_offline') return 'Agent Hub 微信连接器当前未在线,请先恢复连接。' + if (reason === 'recipient_not_bound') { + return 'Agent Hub 已连接,但还不知道异常通知应该发送给谁。请先在微信中给 TraceMemo 机器人发送一条消息,完成通知接收者绑定。' + } + if (reason === 'settings_persist_failed') return '异常通知开关保存失败,请稍后重试。' + return error ? `异常通知测试发送失败:${error}` : '异常通知测试发送失败,请检查 Agent Hub 连接。' +} + const executionLabel = (execution: ScheduledReportExecution): string => { if (execution.status === 'running') return '生成中 · 待发送' if (execution.status === 'success') return '已生成并发送' - if (execution.error?.includes('report_generation_failed')) return '日报生成失败' - if (execution.error?.includes('wechat_not_ready')) return '微信发送能力不可用' - return '微信发送失败' + if (execution.status === 'waiting_to_send') return '日报已生成,但未发送' + if (execution.status === 'partial_success') return '日报已生成,微信发送失败' + if (execution.status === 'waiting_for_recovery') return '等待恢复' + if (execution.status === 'skipped') return execution.userTitle || '暂无可生成的日报' + return execution.userTitle || '日报生成失败' +} + +const executionDescription = (execution: ScheduledReportExecution): string => { + if (execution.status === 'success') { + return execution.sendTarget ? `已发送到:${execution.sendTarget}` : '日报已生成并发送' + } + return execution.userMessage || execution.message || '本次定时日报执行失败。' } function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement { @@ -550,11 +566,19 @@ export function ScheduledReportsWorkspace({ contacts, platformSupported = true, onOpenWechatSettings, + onOpenAgentHub, onOpenModelSettings, onNotice }: ScheduledReportsWorkspaceProps): React.ReactElement { const [capability, setCapability] = React.useState(null) const [capabilityError, setCapabilityError] = React.useState(false) + const [notificationEnabled, setNotificationEnabled] = React.useState(false) + const [notificationBusy, setNotificationBusy] = React.useState(false) + const [notificationSettingsError, setNotificationSettingsError] = React.useState(false) + const [notificationFailure, setNotificationFailure] = React.useState<{ + reason?: ScheduledReportNotificationCapabilityReason + error?: string + } | null>(null) const [tasks, setTasks] = React.useState([]) const [executions, setExecutions] = React.useState([]) const [loading, setLoading] = React.useState(true) @@ -580,14 +604,23 @@ export function ScheduledReportsWorkspace({ }) setTasks([]) setExecutions([]) + try { + const settings = await window.api.getScheduledReportNotificationSettings() + setNotificationEnabled(settings.enabled) + setNotificationSettingsError(false) + } catch { + setNotificationSettingsError(true) + } setLoading(false) return } - const [taskResult, executionResult, capabilityResult] = await Promise.allSettled([ - window.api.listScheduledReports(), - window.api.listScheduledReportExecutions(), - window.api.getPersonalWechatSendCapability() - ]) + const [taskResult, executionResult, capabilityResult, notificationSettingsResult] = + await Promise.allSettled([ + window.api.listScheduledReports(), + window.api.listScheduledReportExecutions(), + window.api.getPersonalWechatSendCapability(), + window.api.getScheduledReportNotificationSettings() + ]) if (taskResult.status === 'fulfilled') setTasks(taskResult.value) else setListError(true) if (executionResult.status === 'fulfilled') setExecutions(executionResult.value) @@ -597,6 +630,12 @@ export function ScheduledReportsWorkspace({ } else { setCapabilityError(true) } + if (notificationSettingsResult.status === 'fulfilled') { + setNotificationEnabled(notificationSettingsResult.value.enabled) + setNotificationSettingsError(false) + } else { + setNotificationSettingsError(true) + } setLoading(false) }, [platformSupported]) @@ -624,10 +663,39 @@ export function ScheduledReportsWorkspace({ setCapabilityError(false) }) .catch(() => setCapabilityError(true)) + void window.api + .getScheduledReportNotificationSettings() + .then((settings) => { + setNotificationEnabled(settings.enabled) + setNotificationSettingsError(false) + }) + .catch(() => setNotificationSettingsError(true)) }, 15_000) return () => window.clearInterval(timer) }, [dialogOpen, refreshTasks]) + const setNotification = async (enabled: boolean): Promise => { + setNotificationBusy(true) + try { + const result = await window.api.setScheduledReportNotificationEnabled(enabled) + setNotificationEnabled(result.data.enabled) + if (result.success) { + setNotificationFailure(null) + onNotice(enabled ? '微信异常通知已开启' : '微信异常通知已关闭', 'success') + } else { + setNotificationFailure({ reason: result.reason, error: result.error }) + onNotice(notificationFailureCopy(result.reason, result.error), 'warning') + } + } catch (error) { + setNotificationEnabled(false) + const message = readableError(error instanceof Error ? error.message : String(error)) + setNotificationFailure({ reason: 'send_failed', error: message }) + onNotice(message, 'warning') + } finally { + setNotificationBusy(false) + } + } + const submit = async (input: ScheduledReportCreateInput, taskId?: string): Promise => { setSaving(true) try { @@ -666,9 +734,21 @@ export function ScheduledReportsWorkspace({ try { const result = await window.api.runScheduledReportNow(task.id) await refreshTasks() - if (!result.success) - onNotice(readableError(result.error || result.data?.error), 'destructive') - else onNotice('日报已生成并发送', 'success') + const execution = result.data + if (execution?.status === 'waiting_to_send') { + onNotice('日报已生成,但未发送', 'warning') + } else if (execution?.status === 'partial_success') { + onNotice('日报已生成,微信发送失败', 'warning') + } else if (execution?.status === 'success') { + onNotice('日报已生成并发送', 'success') + } else if (execution?.status === 'skipped') { + onNotice('本次没有可生成的日报', 'default') + } else if (!result.success) { + onNotice( + execution?.userMessage || readableError(result.error || execution?.error), + 'destructive' + ) + } } catch (error) { onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive') } finally { @@ -676,6 +756,50 @@ export function ScheduledReportsWorkspace({ } } + const retrySend = async (execution: ScheduledReportExecution): Promise => { + const task = tasks.find((item) => item.id === execution.taskId) + const busyId = task?.id || execution.id + setBusyTaskId(busyId) + try { + const result = await window.api.retryScheduledReportSend(execution.id) + await refreshTasks() + if (result.data?.status === 'success') onNotice('日报已重新发送', 'success') + else if (result.data?.status === 'waiting_to_send') + onNotice('微信发送能力仍未恢复', 'warning') + else onNotice(result.data?.userMessage || readableError(result.error), 'warning') + } catch (error) { + onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive') + } finally { + setBusyTaskId(null) + } + } + + const testErrorNotification = async (task: ScheduledReportTask): Promise => { + setBusyTaskId(task.id) + try { + const result = await window.api.testScheduledReportErrorNotification(task.id) + await refreshTasks() + if (result.data?.notificationStatus === 'sent') { + onNotice('测试错误信息已发送到 Agent Hub 微信通知接收者', 'success') + } else { + onNotice(readableError(result.error), 'warning') + } + } catch (error) { + onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive') + } finally { + setBusyTaskId(null) + } + } + + const openReport = async (execution: ScheduledReportExecution): Promise => { + if (!execution.pngPath) { + onNotice('该执行记录没有可查看的日报文件', 'destructive') + return + } + const result = await window.api.revealGroupReport(execution.pngPath) + if (!result.success) onNotice(result.error || '日报文件打开失败', 'destructive') + } + const confirmDelete = async (): Promise => { if (!deletingTask) return setBusyTaskId(deletingTask.id) @@ -711,8 +835,7 @@ export function ScheduledReportsWorkspace({ .slice(0, 8), [executions] ) - const canCreate = capability?.status === 'ready' - + const showDebugNotificationButton = isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG) return (
@@ -720,10 +843,50 @@ export function ScheduledReportsWorkspace({

定时日报

每天自动生成群聊日报,并发送到指定微信群。

- +
+
+ 微信异常通知 + void setNotification(value)} + /> + {notificationEnabled && ✓} +
+ +
+ {notificationEnabled && ( +

+ 定时日报出现异常时,通过 Agent Hub 微信机器人通知你。 +

+ )} + {notificationFailure && ( +
+ + {notificationFailureCopy(notificationFailure.reason, notificationFailure.error)} + +
+ + +
+
+ )}
微信发送能力
-
+
任务名称 执行时间 日报范围 @@ -776,7 +939,7 @@ export function ScheduledReportsWorkspace({ return (
{group && } @@ -813,6 +976,16 @@ export function ScheduledReportsWorkspace({ > {running ? '生成中…' : '立即执行'} + {showDebugNotificationButton && ( + + )}
@@ -867,20 +1040,85 @@ export function ScheduledReportsWorkspace({ {sortedExecutions.map((execution) => { const task = tasks.find((item) => item.id === execution.taskId) const success = execution.status === 'success' + const skipped = execution.status === 'skipped' + const sendPending = + execution.status === 'waiting_to_send' || execution.status === 'partial_success' + const busy = busyTaskId === (task?.id || execution.id) return (
- {success ? '✓' : execution.status === 'running' ? '…' : '×'} + {success + ? '✓' + : skipped + ? '–' + : execution.status === 'running' + ? '…' + : sendPending + ? '!' + : '×'}

{task?.name || '定时日报'}

{executionLabel(execution)}

+

+ {executionDescription(execution)} +

+ {execution.suggestedAction && execution.status !== 'success' && ( +

+ 建议:{execution.suggestedAction} +

+ )} +
+ {sendPending && ( + + )} + {execution.status === 'failed' && ( + + )} + {execution.pngPath && ( + + )} + {execution.status !== 'success' && + execution.errorCode?.startsWith('WECHAT_') && ( + + )} + {execution.technicalMessage && ( +
+ 查看详情 +

+ {execution.technicalMessage} +

+
+ )} +