feat: 集成 Agent Hub 微信机器人能力

This commit is contained in:
电摇小子
2026-07-15 17:26:15 +08:00
committed by Wxw-Gu
parent 597667e005
commit 1e97953d67
25 changed files with 4249 additions and 187 deletions
+64 -4
View File
@@ -9,6 +9,8 @@ import {
} from './services/chat-service'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { generateAgentGroupReport } from './services/agent-group-report-service'
import { agentHubService } from './services/agent-hub-service'
import { safeError, safeLog, safeWarn } from './safe-log'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
@@ -157,7 +159,9 @@ const routes: Record<string, RouteHandler> = {
if (keyword) {
const lower = keyword.toLowerCase()
groups = groups.filter(
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
(c) =>
c.m_nsNickName.toLowerCase().includes(lower) ||
c.m_nsUsrName.toLowerCase().includes(lower)
)
}
sendJson(res, 200, { count: groups.length, chatrooms: groups })
@@ -236,13 +240,62 @@ const routes: Record<string, RouteHandler> = {
try {
request = JSON.parse(body) as GroupReportExportRequest
} catch (error) {
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
return sendError(
res,
400,
'请求体 JSON 解析失败',
error instanceof Error ? error.message : String(error)
)
}
if (!request?.report || !request?.metadata) {
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
}
const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result)
},
'/api/v1/agent/group-report': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
try {
request = JSON.parse(typeof body === 'string' ? body : '{}')
} catch {
return sendError(res, 400, '请求体 JSON 解析失败')
}
const result = await generateAgentGroupReport({
group: request.group || '',
range: request.range
})
sendJson(res, result.success ? 200 : 400, result)
},
'/api/v1/agent/status': ({ res }) => {
const status = agentHubService.getStatus()
sendJson(res, 200, {
ok: status.hub === 'online' && status.connector === 'online',
hub: status.hub,
connector: status.connector,
dataApi: status.dataApi,
databaseReady: status.databaseReady,
accountId: status.accountId
})
},
'/api/v1/agent/send': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let request: { to?: string; text?: string; media_url?: string }
try {
request = JSON.parse(typeof body === 'string' ? body : '{}')
} catch {
return sendError(res, 400, '请求体 JSON 解析失败')
}
const result = await agentHubService.testSend({
to: request.to,
text: request.text,
mediaUrl: request.media_url
})
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
}
}
@@ -311,7 +364,11 @@ export interface ApiServerState {
}
let singleton: HttpServerHandle | null = null
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
let singletonState: ApiServerState = {
running: false,
host: DEFAULT_HTTP_HOST,
port: DEFAULT_HTTP_PORT
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
@@ -326,7 +383,10 @@ export const apiServer = {
return { ...singletonState }
},
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
async start(
host: string = DEFAULT_HTTP_HOST,
port: number = DEFAULT_HTTP_PORT
): Promise<ApiServerState> {
if (singleton) {
return this.getState()
}
+23
View File
@@ -68,6 +68,7 @@ import {
saveCachedMessages
} from './services/bootstrap-cache'
import { installSafeConsole } from './safe-log'
import { agentHubService } from './services/agent-hub-service'
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
@@ -698,6 +699,25 @@ app.whenReady().then(async () => {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle('agent-hub:getStatus', () => agentHubService.getStatus())
ipcMain.handle('agent-hub:getLogs', () => agentHubService.getLogs())
ipcMain.handle('agent-hub:clearLogs', () => agentHubService.clearLogs())
ipcMain.handle('agent-hub:startLogin', () => agentHubService.startLogin())
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, {
title: '选择要测试发送的图片',
properties: ['openFile'],
filters: [
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
{ name: '所有文件', extensions: ['*'] }
]
})
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
})
createWindow()
@@ -707,6 +727,8 @@ app.whenReady().then(async () => {
await apiServer.start(settings.apiHost, settings.apiPort)
}
await agentHubService.start(settings)
if (TRAY_MODE) {
app.dock?.hide()
setupTray()
@@ -730,6 +752,7 @@ app.on('window-all-closed', () => {
})
app.on('before-quit', async () => {
agentHubService.stop()
chat.setChatDb(null)
await apiServer.stop().catch(() => undefined)
if (tray) {
@@ -0,0 +1,89 @@
import type { Contact, Message } from '../../shared/types'
import { exportGroupReport } from '../group-report-service'
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
import { AIProviderService } from './ai-provider-service'
import {
buildGroupReportInput,
getSummaryDateRange,
GROUP_REPORT_SYSTEM_PROMPT,
isInternalName,
parseGroupDailyReport,
type SummaryDateRange
} from '../../renderer/src/utils/group-report'
const aiProvider = new AIProviderService()
export interface AgentGroupReportRequest {
group: string
range?: SummaryDateRange
}
export interface AgentGroupReportResult {
success: boolean
groupName?: string
pngPath?: string
messageCount?: number
error?: string
}
export async function generateAgentGroupReport(
request: AgentGroupReportRequest
): Promise<AgentGroupReportResult> {
const query = String(request.group || '')
.trim()
.replace(/群聊?$/, '')
.trim()
if (!query) return { success: false, error: '缺少群聊名称' }
const contact = resolveMd5(query)
if (!contact) return { success: false, error: `没有找到群聊“${query}` }
if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) {
return { success: false, error: `${query}”不是群聊` }
}
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
const { startTime, endTime } = getSummaryDateRange(range)
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
const snapshot = getGroupSnapshot(contact.md5)
if (snapshot) {
const members = new Map(
snapshot.members.map((member) => [
member.wxid,
{ name: member.nickname, avatar: member.avatar }
])
)
messages = messages.map((message) => {
if (!isInternalName(message.name)) return message
const member = members.get(String(message.senderId || message.name || ''))
return member?.name
? { ...message, name: member.name, img: message.img || member.avatar }
: message
})
}
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
const ai = await aiProvider.chat([
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
])
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
const report = parseGroupDailyReport(
ai.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard,
input.metadata,
input.media
)
const exported = await exportGroupReport({ report, metadata: input.metadata })
if (!exported.success || !exported.pngPath) {
return { success: false, error: exported.error || '总结图片生成失败' }
}
return {
success: true,
groupName: input.metadata.groupName,
pngPath: exported.pngPath,
messageCount: messages.length
}
}
+672
View File
@@ -0,0 +1,672 @@
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 { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'
import { dirname, join } from 'path'
import { promisify } from 'util'
import type {
AgentHubActionResult,
AgentHubLogEntry,
AgentHubLogLevel,
AgentHubLogSource,
AgentHubStatus
} from '../../shared/agent-hub'
import type { AppSettings } from './settings-store'
import { generateAgentGroupReport } from './agent-group-report-service'
import { isReady, listRecentChat } from './chat-service'
const execFileAsync = promisify(execFile)
const HEALTH_INTERVAL_MS = 5_000
const HUB_ADDR = '127.0.0.1:5300'
const HUB_HOST = '127.0.0.1'
const HUB_PORT = 5300
const CONNECTOR_ADDR = '127.0.0.1:18011'
const MAX_LOG_ENTRIES = 800
interface InboundMessage {
account_id?: string
from_user_id?: string
message_id?: string | number
items?: Array<{ type?: number; text?: string }>
}
interface GroupReportIntent {
group: string
range: 'today' | 'yesterday' | '7days'
}
function resolveBundledBinary(
resourceSegments: string[],
executable: string,
packaged = app.isPackaged,
platform = process.platform,
arch = process.arch
): string {
const relativeSegments = [...resourceSegments, `${platform}-${arch}`, executable]
const packagedPath = join(process.resourcesPath, 'resources', ...relativeSegments)
const developmentPath = join(app.getAppPath(), 'resources', ...relativeSegments)
const candidates = packaged ? [packagedPath, developmentPath] : [developmentPath, packagedPath]
return candidates.find((candidate) => existsSync(candidate)) || candidates[0]
}
export function resolveWechatConnectorBinaryPath(
packaged = app.isPackaged,
platform = process.platform,
arch = process.arch
): string {
return resolveBundledBinary(
['connectors', 'wechat'],
platform === 'win32' ? 'wechat-connector.exe' : 'wechat-connector',
packaged,
platform,
arch
)
}
class AgentHubService {
private hubServer: Server | null = null
private connectorChild: ChildProcess | null = null
private loginChild: ChildProcess | null = null
private stopping = false
private healthTimer: NodeJS.Timeout | null = null
private logs: AgentHubLogEntry[] = []
private nextLogId = 1
private readonly processedMessages = new Map<string, number>()
private readonly inboundToken =
process.env['AGENT_HUB_INBOUND_TOKEN'] || randomBytes(32).toString('hex')
private status: AgentHubStatus = {
hub: 'offline',
connector: 'checking',
dataApi: 'checking',
updatedAt: Date.now()
}
async start(settings: AppSettings): Promise<boolean> {
void settings
this.stopping = false
const hubStarted = await this.startHub()
await this.initializeConnector()
return hubStarted
}
getStatus(): AgentHubStatus {
return { ...this.status }
}
getLogs(): AgentHubLogEntry[] {
return [...this.logs]
}
clearLogs(): void {
this.logs = []
try {
writeFileSync(this.logFilePath(), '', 'utf8')
} catch {
// The live log remains usable when the persistent file cannot be cleared.
}
this.addLog('system', 'info', '运行日志已清空')
}
async testSend(input: { to?: string; text?: string; mediaUrl?: string }): Promise<{
success: boolean
status: 'sent' | 'token_expired' | 'connector_offline' | 'invalid_request' | 'send_failed'
message: string
}> {
const to = String(input.to || this.status.wechatUserId || '').trim()
const text = String(input.text || '').trim()
const mediaUrl = String(input.mediaUrl || '').trim()
if (!to || (!text && !mediaUrl)) {
return {
success: false,
status: 'invalid_request',
message: '请填写接收者以及文字或图片路径'
}
}
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 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)
return {
success: false,
status: expired ? 'token_expired' : 'send_failed',
message: expired
? '微信登录凭证已失效,请重新扫码登录'
: `发送失败:${body || response.status}`
}
} catch (error) {
return {
success: false,
status: 'connector_offline',
message: `微信连接器不可用:${error instanceof Error ? error.message : String(error)}`
}
}
}
async startLogin(): Promise<AgentHubActionResult> {
if (this.loginChild && this.loginChild.exitCode === null) {
return { success: true, status: this.getStatus() }
}
const executable = resolveWechatConnectorBinaryPath()
if (!existsSync(executable)) {
return this.fail(`微信连接器不存在:${executable}`)
}
this.stopConnector()
this.patchStatus({ connector: 'starting', qrCodeDataUrl: undefined, error: undefined })
const child = spawn(executable, ['login', '--json'], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
this.loginChild = child
this.addLog('wechat-connector', 'info', '已启动扫码登录流程')
let stdoutBuffer = ''
let stderr = ''
child.stdout?.on('data', (data: Buffer) => {
stdoutBuffer += data.toString()
const lines = stdoutBuffer.split(/\r?\n/)
stdoutBuffer = lines.pop() || ''
for (const line of lines) this.handleLoginEvent(line)
})
child.stderr?.on('data', (data: Buffer) => {
stderr += data.toString()
this.addProcessOutput('wechat-connector', 'warn', data.toString())
})
child.once('error', (error) => {
this.addLog('wechat-connector', 'error', `登录进程错误:${error.message}`)
this.patchStatus({ connector: 'error', error: error.message })
})
child.once('exit', (code) => {
if (this.loginChild === child) this.loginChild = null
if (
code !== 0 &&
this.status.connector !== 'online' &&
this.status.connector !== 'disconnected' &&
!this.stopping
) {
this.patchStatus({ connector: 'error', error: stderr.trim() || `登录进程退出:${code}` })
}
})
return { success: true, status: this.getStatus() }
}
cancelLogin(): AgentHubActionResult {
if (this.loginChild && this.loginChild.exitCode === null) this.loginChild.kill()
this.loginChild = null
this.patchStatus({ connector: 'disconnected', qrCodeDataUrl: undefined, error: undefined })
return { success: true, status: this.getStatus() }
}
async reconnect(): Promise<AgentHubActionResult> {
const accounts = await this.loadAccounts()
if (accounts.length === 0) return this.startLogin()
this.startConnector(accounts.at(-1)!)
return { success: true, status: this.getStatus() }
}
disconnect(): AgentHubActionResult {
this.stopConnector()
this.patchStatus({ connector: 'disconnected', error: undefined })
return { success: true, status: this.getStatus() }
}
stop(): void {
this.stopping = true
this.clearHealthCheck()
if (this.loginChild && this.loginChild.exitCode === null) this.loginChild.kill()
this.loginChild = null
this.stopConnector()
const hubServer = this.hubServer
this.hubServer = null
hubServer?.close()
this.patchStatus({ hub: 'offline' })
}
private async startHub(): Promise<boolean> {
if (this.hubServer) return true
this.patchStatus({ hub: 'starting' })
const server = createServer((request, response) => {
void this.handleHubRequest(request, response).catch((error) => {
this.addLog('agent-hub', 'error', `请求处理失败:${this.errorMessage(error)}`)
this.sendHubJson(response, 500, { error: 'internal error' })
})
})
this.hubServer = server
return new Promise((resolve) => {
const fail = (error: Error): void => {
if (this.hubServer === server) this.hubServer = null
this.patchStatus({ hub: 'error', error: error.message })
this.addLog('agent-hub', 'error', `TypeScript 服务启动失败:${error.message}`)
resolve(false)
}
server.once('error', fail)
server.listen(HUB_PORT, HUB_HOST, () => {
server.off('error', fail)
server.on('error', (error) => {
this.patchStatus({ hub: 'error', error: error.message })
this.addLog('agent-hub', 'error', error.message)
})
this.patchStatus({ hub: 'online', error: undefined })
this.addLog('system', 'info', `Agent Hub TypeScript 服务已启动(${HUB_ADDR}`)
this.scheduleHealthCheck()
resolve(true)
})
})
}
private async handleHubRequest(
request: IncomingMessage,
response: ServerResponse
): Promise<void> {
const url = new URL(request.url || '/', `http://${HUB_ADDR}`)
if (request.method === 'GET' && url.pathname === '/health') {
return this.sendHubJson(response, 200, {
status: 'ok',
service: 'agent-hub',
runtime: 'typescript'
})
}
if (request.method !== 'POST' || url.pathname !== '/v1/connectors/wechat/inbound') {
return this.sendHubJson(response, 404, { error: 'not found' })
}
if (!this.authorized(request.headers.authorization)) {
return this.sendHubJson(response, 401, { error: 'unauthorized' })
}
let inbound: InboundMessage
try {
inbound = JSON.parse(await this.readHubBody(request)) as InboundMessage
} catch {
return this.sendHubJson(response, 400, { error: 'invalid request' })
}
const from = String(inbound.from_user_id || '').trim()
if (!from) return this.sendHubJson(response, 400, { error: 'from_user_id is required' })
const messageId = String(inbound.message_id || '')
this.cleanProcessedMessages()
if (messageId && this.processedMessages.has(messageId)) {
return this.sendHubJson(response, 200, { status: 'duplicate' })
}
const text = (inbound.items || [])
.filter((item) => item.type === 1 && item.text?.trim())
.map((item) => item.text!.trim())
.join(' ')
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`)
const reportIntent = this.matchGroupReportIntent(text)
if (reportIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog(
'agent-hub',
'info',
`匹配群聊总结:${reportIntent.group}${reportIntent.range}`
)
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => {
this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(error)}`)
})
void this.generateAndSendReport(inbound, reportIntent)
return this.sendHubJson(response, 202, { status: 'generating' })
}
const limit = this.matchRecentChatIntent(text)
if (limit === null) {
this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图')
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' })
}
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
const items = listRecentChat(limit)
const lines = items.map((item, index) => {
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return `${index + 1}. ${name}${item.type === 'group' ? '群聊' : '联系人'}`
})
const reply = lines.length
? `最近 ${items.length} 个会话:\n${lines.join('\n')}`
: '暂时没有找到最近会话。'
try {
await this.sendConnector(inbound, reply)
} catch (error) {
this.addLog('agent-hub', 'error', `回复发送失败:${this.errorMessage(error)}`)
return this.sendHubJson(response, 502, { error: 'reply delivery failed' })
}
if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
this.sendHubJson(response, 200, { status: 'ok' })
}
private async generateAndSendReport(
inbound: InboundMessage,
intent: GroupReportIntent
): Promise<void> {
try {
const result = await generateAgentGroupReport({ group: intent.group, range: intent.range })
if (!result.success || !result.pngPath) throw new Error(result.error || '群聊总结生成失败')
await this.sendConnector(
inbound,
`已生成${result.groupName || intent.group}的群聊总结(${result.messageCount || 0} 条消息),正在发送图片。`
)
await this.sendConnector(inbound, undefined, result.pngPath)
this.addLog('agent-hub', 'info', `群聊总结图片已发送:${result.groupName || intent.group}`)
} catch (error) {
const message = this.errorMessage(error)
this.addLog('agent-hub', 'error', `群聊总结生成失败:${message}`)
await this.sendConnector(inbound, `群聊总结生成失败:${message}`).catch(() => undefined)
}
}
private async sendConnector(
inbound: InboundMessage,
text?: string,
mediaUrl?: string
): Promise<void> {
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
}),
signal: AbortSignal.timeout(mediaUrl ? 60_000 : 30_000)
})
if (!response.ok) throw new Error((await response.text()) || `HTTP ${response.status}`)
}
private matchRecentChatIntent(text: string): number | null {
const normalized = text.replace(/\s+/g, '')
if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null
const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5)
return Math.max(1, Math.min(20, limit))
}
private matchGroupReportIntent(text: string): GroupReportIntent | null {
const normalized = text.trim()
if (!normalized.includes('群') || !/(总结|日报|报告)/.test(normalized)) return null
const range = /(7天|七天|一周)/.test(normalized)
? '7days'
: /(昨天|昨日)/.test(normalized)
? 'yesterday'
: 'today'
const group = normalized
.replace(
/请|帮我|生成|做一份|做个|今天的|今日的|今天|今日|昨天的|昨日的|昨天|昨日|最近7天的|最近七天的|最近7天|最近七天|近7天的|近七天的|近7天|近七天|消息|聊天记录|聊天|群聊总结|群总结|群日报|群报告|总结|日报|报告|图片|长图/g,
''
)
.replace(/[,。!??:]/g, '')
.trim()
.replace(/成$/, '')
.replace(/群$/, '')
.trim()
return group ? { group, range } : null
}
private authorized(header: string | undefined): boolean {
if (!header?.startsWith('Bearer ')) return false
const expected = Buffer.from(this.inboundToken)
const provided = Buffer.from(header.slice(7))
return expected.length === provided.length && timingSafeEqual(expected, provided)
}
private readHubBody(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
let size = 0
request.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > 1024 * 1024) {
reject(new Error('request too large'))
request.destroy()
return
}
chunks.push(chunk)
})
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
request.on('error', reject)
})
}
private sendHubJson(response: ServerResponse, status: number, payload: unknown): void {
if (response.writableEnded) return
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
response.end(JSON.stringify(payload))
}
private cleanProcessedMessages(): void {
const cutoff = Date.now() - 10 * 60_000
for (const [id, timestamp] of this.processedMessages) {
if (timestamp < cutoff) this.processedMessages.delete(id)
}
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
private async initializeConnector(): Promise<void> {
this.patchStatus({ connector: 'checking' })
try {
const accounts = await this.loadAccounts()
if (accounts.length === 0) {
this.patchStatus({ connector: 'disconnected' })
return
}
this.startConnector(accounts.at(-1)!)
} catch (error) {
this.patchStatus({
connector: 'error',
error: error instanceof Error ? error.message : String(error)
})
}
}
private async loadAccounts(): Promise<{ accountId: string; wechatUserId: string }[]> {
const executable = resolveWechatConnectorBinaryPath()
if (!existsSync(executable)) throw new Error(`微信连接器不存在:${executable}`)
const { stdout } = await execFileAsync(executable, ['accounts', '--json'], {
windowsHide: true,
timeout: 10_000
})
const parsed = JSON.parse(stdout) as {
accounts?: { account_id: string; wechat_user_id: string }[]
}
return (parsed.accounts || []).map((account) => ({
accountId: account.account_id,
wechatUserId: account.wechat_user_id
}))
}
private startConnector(account: { accountId: string; wechatUserId: string }): void {
if (this.connectorChild && this.connectorChild.exitCode === null) return
const executable = resolveWechatConnectorBinaryPath()
this.patchStatus({
connector: 'starting',
accountId: account.accountId,
wechatUserId: account.wechatUserId,
qrCodeDataUrl: undefined,
error: undefined
})
const child = spawn(
executable,
['start', '--foreground', '--api-addr', CONNECTOR_ADDR, '--account-id', account.accountId],
{
env: {
...process.env,
WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL: `http://${HUB_ADDR}/v1/connectors/wechat/inbound`,
WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN: this.inboundToken,
WECHAT_CONNECTOR_INBOUND_WEBHOOK_ONLY: 'true'
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
)
this.connectorChild = child
this.addLog('system', 'info', `正在启动微信连接器(账号 ${account.accountId}`)
child.stdout?.on('data', (data: Buffer) => this.handleConnectorOutput('info', data.toString()))
child.stderr?.on('data', (data: Buffer) => this.handleConnectorOutput('warn', data.toString()))
child.once('spawn', () => {
this.addLog('system', 'info', `微信连接器已启动(PID ${child.pid}`)
this.patchStatus({ connector: 'online' })
})
child.once('error', (error) => {
this.addLog('wechat-connector', 'error', error.message)
this.patchStatus({ connector: 'error', error: error.message })
})
child.once('exit', (code) => {
if (this.connectorChild === child) this.connectorChild = null
this.addLog('system', code === 0 ? 'info' : 'error', `微信连接器已退出(code=${code}`)
if (!this.stopping && this.status.connector !== 'disconnected') {
this.patchStatus({ connector: 'error', error: `微信连接器退出:${code}` })
}
})
}
private stopConnector(): void {
const child = this.connectorChild
this.connectorChild = null
if (child && child.exitCode === null) child.kill()
}
private handleLoginEvent(line: string): void {
if (!line.trim()) return
try {
const event = JSON.parse(line) as {
status: string
qr_code_data_url?: string
account_id?: string
wechat_user_id?: string
}
switch (event.status) {
case 'qrcode':
case 'wait':
this.patchStatus({
connector: 'waiting_scan',
qrCodeDataUrl: event.qr_code_data_url || this.status.qrCodeDataUrl
})
break
case 'scaned':
this.patchStatus({ connector: 'scanned' })
break
case 'confirmed':
this.patchStatus({ connector: 'starting' })
break
case 'expired':
this.patchStatus({ connector: 'error', error: '二维码已过期,请重新获取' })
break
case 'active': {
const account = {
accountId: event.account_id || '',
wechatUserId: event.wechat_user_id || ''
}
this.patchStatus({ ...account, connector: 'starting', qrCodeDataUrl: undefined })
this.startConnector(account)
break
}
}
} catch (error) {
console.warn('[AgentHub] invalid login event:', line, error)
}
}
private patchStatus(patch: Partial<AgentHubStatus>): void {
this.status = { ...this.status, ...patch, updatedAt: Date.now() }
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('agent-hub:status', this.getStatus())
}
}
private addProcessOutput(
source: AgentHubLogSource,
level: AgentHubLogLevel,
output: string
): void {
for (const line of output.split(/\r?\n/)) {
if (line.trim()) this.addLog(source, level, line)
}
}
private handleConnectorOutput(level: AgentHubLogLevel, output: string): void {
this.addProcessOutput('wechat-connector', level, output)
if (/session expired/i.test(output)) {
this.addLog('system', 'error', '当前微信机器人登录已失效,需要重新扫码登录')
this.patchStatus({ connector: 'error', error: '当前登录已失效,请重新扫码登录' })
this.stopConnector()
}
}
private addLog(source: AgentHubLogSource, level: AgentHubLogLevel, rawMessage: string): void {
const message = this.redactLog(rawMessage).trim()
if (!message) return
const entry: AgentHubLogEntry = {
id: this.nextLogId++,
timestamp: Date.now(),
source,
level,
message
}
this.logs.push(entry)
if (this.logs.length > MAX_LOG_ENTRIES) this.logs.splice(0, this.logs.length - MAX_LOG_ENTRIES)
try {
const path = this.logFilePath()
mkdirSync(dirname(path), { recursive: true })
appendFileSync(
path,
`${new Date(entry.timestamp).toISOString()} [${source}] [${level}] ${message}\n`,
'utf8'
)
} catch {
// Do not interrupt message handling because log persistence failed.
}
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('agent-hub:log', entry)
}
}
private redactLog(message: string): string {
return message
.replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer [已隐藏]')
.replace(/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/gi, 'data:image/[二维码已隐藏]')
.replace(/(token[=:\s]+)[^\s,}]+/gi, '$1[已隐藏]')
}
private logFilePath(): string {
return join(app.getPath('logs'), 'agent-hub.log')
}
private fail(error: string): AgentHubActionResult {
this.patchStatus({ connector: 'error', error })
return { success: false, status: this.getStatus(), error }
}
private scheduleHealthCheck(): void {
this.clearHealthCheck()
this.healthTimer = setInterval(() => this.checkDataApi(), HEALTH_INTERVAL_MS)
this.checkDataApi()
}
private checkDataApi(): void {
const ready = isReady()
this.patchStatus({ dataApi: 'online', databaseReady: ready })
}
private clearHealthCheck(): void {
if (this.healthTimer) clearInterval(this.healthTimer)
this.healthTimer = null
}
}
export const agentHubService = new AgentHubService()
+5 -2
View File
@@ -8,6 +8,7 @@ import {
} from '../../shared/local-api-test'
const REQUEST_TIMEOUT_MS = 10_000
const GROUP_REPORT_TIMEOUT_MS = 180_000
const MAX_BODY_SIZE = 512 * 1024
function isEndpointId(value: unknown): value is LocalApiEndpointId {
@@ -122,7 +123,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
})
}
)
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
const timeoutMs =
endpointId === 'agent-group-report' ? GROUP_REPORT_TIMEOUT_MS : REQUEST_TIMEOUT_MS
request.setTimeout(timeoutMs, () => {
request.destroy(new Error('请求超时'))
finish({
ok: false,
@@ -132,7 +135,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
durationMs: Date.now() - startedAt,
responseSize: 0,
errorCode: 'TIMEOUT',
error: '请求超时(10 秒)'
error: `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
})
})
request.on('error', (error: NodeJS.ErrnoException) => {
+15 -1
View File
@@ -12,6 +12,8 @@ export interface AppSettings {
imageXorKey: string
imageAesKey: string
imageKeyFallbackDisabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
}
function getDefaultDbRoot(): string {
@@ -118,7 +120,13 @@ const DEFAULT_SETTINGS: AppSettings = {
imageKeyRoot: defaultDbRoot,
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: false
imageKeyFallbackDisabled: false,
autoLogin: ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim()
.toLowerCase()
),
autoLoginPreferenceSet: false
}
const SETTINGS_FILE = path.join(
@@ -138,6 +146,12 @@ export function loadSettings(): AppSettings {
if (fs.existsSync(SETTINGS_FILE)) {
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
cache = { ...DEFAULT_SETTINGS, ...raw }
if (raw.autoLogin === undefined) {
const hasSavedDatabaseKey = fs.existsSync(
path.join(app.getPath('userData'), 'wechat-db-key.bin')
)
if (hasSavedDatabaseKey) cache.autoLogin = true
}
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
cache.dbRoot = getDefaultDbRoot()
}
+19
View File
@@ -37,6 +37,7 @@ import type {
ImageCandidateQuery,
ImageInsight
} from '../shared/image-insight'
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
export type ParsedContent =
| { type: 'text'; content: string }
@@ -186,6 +187,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}
@@ -210,6 +213,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}
@@ -222,6 +227,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}>
@@ -232,6 +239,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}
@@ -298,6 +307,16 @@ declare global {
sessionId: string,
limit?: number
) => Promise<{ success: boolean; insights: ImageInsight[] }>
getAgentHubStatus: () => Promise<AgentHubStatus>
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
clearAgentHubLogs: () => Promise<void>
startAgentHubLogin: () => Promise<AgentHubActionResult>
cancelAgentHubLogin: () => Promise<AgentHubActionResult>
reconnectAgentHub: () => Promise<AgentHubActionResult>
disconnectAgentHub: () => Promise<AgentHubActionResult>
selectAgentHubTestImage: () => Promise<{ canceled: boolean; path?: string }>
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => () => void
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => () => void
}
}
}
+22 -1
View File
@@ -15,6 +15,7 @@ import type {
ImageCandidateQuery,
ImageInsight
} from '../shared/image-insight'
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
// 渲染器的自定义 API
const api = {
@@ -132,7 +133,27 @@ const api = {
sessionId: string,
limit?: number
): Promise<{ success: boolean; insights: ImageInsight[] }> =>
ipcRenderer.invoke('image:listInsights', sessionId, limit)
ipcRenderer.invoke('image:listInsights', sessionId, limit),
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
startAgentHubLogin: () => ipcRenderer.invoke('agent-hub:startLogin'),
cancelAgentHubLogin: () => ipcRenderer.invoke('agent-hub:cancelLogin'),
reconnectAgentHub: () => ipcRenderer.invoke('agent-hub:reconnect'),
disconnectAgentHub: () => ipcRenderer.invoke('agent-hub:disconnect'),
selectAgentHubTestImage: () => ipcRenderer.invoke('agent-hub:selectTestImage'),
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: AgentHubStatus): void =>
callback(status)
ipcRenderer.on('agent-hub:status', listener)
return () => ipcRenderer.removeListener('agent-hub:status', listener)
},
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => {
const listener = (_event: Electron.IpcRendererEvent, entry: AgentHubLogEntry): void =>
callback(entry)
ipcRenderer.on('agent-hub:log', listener)
return () => ipcRenderer.removeListener('agent-hub:log', listener)
}
}
if (process.contextIsolated) {
+20 -16
View File
@@ -4,6 +4,7 @@ import ChatWindow from './components/ChatWindow'
import { AppShell } from './components/layout/AppShell'
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
import type { SettingsCategoryId } from './features/settings/model/types'
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
import { AppPage } from './components/layout/navigation'
@@ -57,12 +58,6 @@ interface SelfInfo {
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
const VIEW_MESSAGE_LIMIT = 600
const AUTO_LOGIN_ENABLED = ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim()
.toLowerCase()
)
const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
@@ -369,13 +364,12 @@ function App(): React.ReactElement {
React.useEffect(() => {
let active = true
const attemptAutoConnect = async (): Promise<void> => {
const settingsResult = await window.api.getSettings()
// 预填已保存的微信聊天文件路径
try {
const settings = await window.api.getSettings()
if (active && settings?.settings?.dbRoot) setDbRootInput(settings.settings.dbRoot)
} catch {
// 忽略读取设置失败,继续走密钥流程
if (active && settingsResult.settings.dbRoot) {
setDbRootInput(settingsResult.settings.dbRoot)
}
const autoLoginEnabled = settingsResult.settings.autoLogin
// 优先级 1: 构建期环境变量 VITE_DB_KEY(本地开发用)
const envKey = String(import.meta.env.VITE_DB_KEY || '').trim()
// 优先级 2: 上一次保存到 safeStorage 的密钥
@@ -393,7 +387,7 @@ function App(): React.ReactElement {
setDbKey(key)
setAutoConnectSource(envKey ? 'env' : 'saved')
setDbKeyStatus(
AUTO_LOGIN_ENABLED
autoLoginEnabled
? envKey
? '检测到环境变量中的密钥,正在自动连接...'
: '已加载安全保存的密钥,正在自动连接...'
@@ -402,14 +396,17 @@ function App(): React.ReactElement {
: '已加载安全保存的密钥,请手动点击 Connect'
)
setDbKeyStatusKind('normal')
setBootState(AUTO_LOGIN_ENABLED ? 'connecting' : 'login')
setBootState(autoLoginEnabled ? 'connecting' : 'login')
}
if (!AUTO_LOGIN_ENABLED) return
if (!autoLoginEnabled) return
try {
const result = await window.api.initDb(key)
if (!active) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
if (!settingsResult.settings.autoLoginPreferenceSet) {
void window.api.setSettings({ autoLogin: true })
}
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsAuthenticated(true)
setIsDatabaseConnected(true)
@@ -489,6 +486,11 @@ function App(): React.ReactElement {
const hasBootstrapCache = await loadBootstrapCache()
// 持久化手动输入的密钥,供下次启动继续使用
void window.api.saveDbKey(keyToUse).catch(() => undefined)
void window.api.getSettings().then((current) => {
if (!current.settings.autoLoginPreferenceSet) {
void window.api.setSettings({ autoLogin: true })
}
})
setStartupProgress({
title: '正在加载账号信息...',
subtitle: '即将进入 WechatExplorer',
@@ -1036,9 +1038,9 @@ function App(): React.ReactElement {
}
const renderPlaceholderPage = (
page: Exclude<AppPage, 'archive' | 'report'>
page: Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>
): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
const labels: Record<Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>, string> = {
search: '检索',
export: '导出',
api: 'API',
@@ -1168,6 +1170,8 @@ function App(): React.ReactElement {
return renderArchiveWorkspace()
case 'report':
return renderReportWorkspace()
case 'agent-hub':
return <AgentHubWorkspace />
case 'api':
return (
<ApiWorkspace
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,16 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
<path d="M5.5 15.5v3h13v-3" />
</svg>
)
case 'agent-hub':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="5" y="7" width="14" height="11" rx="3" />
<path d="M12 4.5V7" />
<circle cx="9.5" cy="12" r="1" />
<circle cx="14.5" cy="12" r="1" />
<path d="M9.5 15h5" />
</svg>
)
case 'api':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -1,4 +1,4 @@
export type AppPage = 'archive' | 'search' | 'report' | 'export' | 'api' | 'settings'
export type AppPage = 'archive' | 'search' | 'report' | 'agent-hub' | 'export' | 'api' | 'settings'
export interface NavigationItem {
id: AppPage
@@ -9,6 +9,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
{ id: 'archive', label: '档案' },
{ id: 'search', label: '检索' },
{ id: 'report', label: '日报' },
{ id: 'agent-hub', label: 'Agent' },
{ id: 'export', label: '导出' },
{ id: 'api', label: 'API' },
{ id: 'settings', label: '设置' }
@@ -0,0 +1,269 @@
import React from 'react'
import type {
AgentHubLogEntry,
AgentHubLogSource,
AgentHubStatus,
WechatConnectorStatus
} from '../../../../shared/agent-hub'
const STATUS_LABELS: Record<WechatConnectorStatus, string> = {
checking: '正在检查',
disconnected: '未连接',
starting: '正在连接',
waiting_scan: '等待扫码',
scanned: '已扫码,等待手机确认',
online: '在线',
error: '连接异常'
}
const LOG_SOURCE_LABELS: Record<AgentHubLogSource, string> = {
system: '系统',
'agent-hub': 'Agent Hub',
'wechat-connector': '微信连接器'
}
export function AgentHubWorkspace(): React.ReactElement {
const [status, setStatus] = React.useState<AgentHubStatus>({
hub: 'offline',
connector: 'checking',
updatedAt: Date.now()
})
const [busy, setBusy] = React.useState(false)
const [logs, setLogs] = React.useState<AgentHubLogEntry[]>([])
const [logSource, setLogSource] = React.useState<'all' | AgentHubLogSource>('all')
const logBodyRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
let mounted = true
void window.api.getAgentHubStatus().then((next) => {
if (mounted) setStatus(next)
})
void window.api.getAgentHubLogs().then((entries) => {
if (mounted) setLogs(entries)
})
const unsubscribe = window.api.onAgentHubStatus((next) => {
if (mounted) setStatus(next)
})
const unsubscribeLog = window.api.onAgentHubLog((entry) => {
if (mounted) setLogs((current) => [...current.slice(-799), entry])
})
return () => {
mounted = false
unsubscribe()
unsubscribeLog()
}
}, [])
const visibleLogs = logs.filter((entry) => logSource === 'all' || entry.source === logSource)
React.useEffect(() => {
const body = logBodyRef.current
if (body) body.scrollTop = body.scrollHeight
}, [visibleLogs.length])
const copyLogs = async (): Promise<void> => {
const text = visibleLogs
.map(
(entry) =>
`${new Date(entry.timestamp).toLocaleTimeString()} [${LOG_SOURCE_LABELS[entry.source]}] [${entry.level}] ${entry.message}`
)
.join('\n')
await window.api.copyText(text)
}
const clearLogs = async (): Promise<void> => {
await window.api.clearAgentHubLogs()
setLogs([])
}
const runAction = async (
action: () => Promise<{ status: AgentHubStatus; error?: string }>
): Promise<void> => {
setBusy(true)
try {
const result = await action()
setStatus(result.status)
} finally {
setBusy(false)
}
}
const isLoginFlow = ['starting', 'waiting_scan', 'scanned'].includes(status.connector)
const showQRCode = Boolean(status.qrCodeDataUrl) && status.connector !== 'online'
return (
<div className="agent-hub-workspace">
<header className="agent-hub-header">
<div>
<div className="agent-hub-eyebrow">WechatExplorer</div>
<h1>Agent Hub</h1>
<p> AI </p>
</div>
<span className={`agent-hub-runtime ${status.hub}`}>
Agent Hub {status.hub === 'online' ? '运行中' : '未运行'}
</span>
</header>
<div className="agent-hub-grid">
<section className="agent-hub-card agent-hub-login-card">
<div className="agent-hub-card-heading">
<div>
<span className="agent-hub-card-kicker"></span>
<h2></h2>
</div>
<span className={`agent-hub-status ${status.connector}`}>
<i aria-hidden />
{STATUS_LABELS[status.connector]}
</span>
</div>
{showQRCode ? (
<div className="agent-hub-qr-panel">
<div className="agent-hub-qr-frame">
<img src={status.qrCodeDataUrl} alt="微信机器人登录二维码" />
</div>
<div className="agent-hub-qr-copy">
<h3>
{status.connector === 'scanned' ? '请在手机上确认登录' : '使用微信扫描二维码'}
</h3>
<p></p>
<button
type="button"
className="agent-hub-button secondary"
disabled={busy}
onClick={() => void runAction(() => window.api.cancelAgentHubLogin())}
>
</button>
</div>
</div>
) : status.connector === 'online' ? (
<div className="agent-hub-connected">
<div className="agent-hub-connected-icon" aria-hidden>
</div>
<div>
<h3></h3>
<p>{status.accountId || status.wechatUserId || '登录凭据已就绪'}</p>
</div>
</div>
) : (
<div className="agent-hub-empty-login">
<div className="agent-hub-phone" aria-hidden>
<span />
</div>
<h3>{status.connector === 'error' ? '连接遇到问题' : '尚未连接微信机器人'}</h3>
<p>{status.error || '扫码登录后,即可从微信向 Agent Hub 提问。'}</p>
</div>
)}
<div className="agent-hub-actions">
{status.connector === 'online' ? (
<>
<button
type="button"
className="agent-hub-button secondary"
disabled={busy}
onClick={() => void runAction(() => window.api.startAgentHubLogin())}
>
</button>
<button
type="button"
className="agent-hub-button danger"
disabled={busy}
onClick={() => void runAction(() => window.api.disconnectAgentHub())}
>
</button>
</>
) : !isLoginFlow ? (
<button
type="button"
className="agent-hub-button primary"
disabled={busy || status.hub !== 'online'}
onClick={() => void runAction(() => window.api.startAgentHubLogin())}
>
{busy ? '正在获取二维码…' : '扫码登录微信机器人'}
</button>
) : null}
</div>
</section>
<aside className="agent-hub-card agent-hub-capability-card">
<span className="agent-hub-card-kicker"></span>
<h2></h2>
<p> Agent Hub WechatExplorer</p>
<div className="agent-hub-example">
<span></span>
<strong> 5 </strong>
<strong></strong>
</div>
<ul>
<li>
<i />
HTTP
</li>
<li>
<i />
</li>
<li>
<i />
</li>
<li>
<i className={status.dataApi === 'online' ? '' : 'offline'} />
API{status.dataApi === 'online' ? '已连接' : '未连接'}
</li>
<li>
<i className={status.databaseReady ? '' : 'offline'} />
{status.databaseReady ? '可查询' : '未就绪'}
</li>
</ul>
</aside>
</div>
<section className="agent-hub-card agent-hub-log-card">
<div className="agent-hub-log-heading">
<div>
<span className="agent-hub-card-kicker"></span>
<h2></h2>
</div>
<div className="agent-hub-log-actions">
<select
aria-label="筛选日志来源"
value={logSource}
onChange={(event) => setLogSource(event.target.value as 'all' | AgentHubLogSource)}
>
<option value="all"></option>
<option value="system"></option>
<option value="agent-hub">Agent Hub</option>
<option value="wechat-connector"></option>
</select>
<button type="button" onClick={() => void copyLogs()} disabled={visibleLogs.length === 0}>
</button>
<button type="button" onClick={() => void clearLogs()}>
</button>
</div>
</div>
<div className="agent-hub-log-body" ref={logBodyRef}>
{visibleLogs.length === 0 ? (
<div className="agent-hub-log-empty"></div>
) : (
visibleLogs.map((entry) => (
<div className={`agent-hub-log-line ${entry.level}`} key={entry.id}>
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
<span className={`source ${entry.source}`}>{LOG_SOURCE_LABELS[entry.source]}</span>
<code>{entry.message}</code>
</div>
))
)}
</div>
<p className="agent-hub-log-note"> Token </p>
</section>
</div>
)
}
@@ -40,6 +40,17 @@ export function ApiRequestTester({
void onCopyCurl(command)
}
const update = (key: string, value: string): void => onParams({ ...params, [key]: value })
const selectTestImage = async (): Promise<void> => {
const result = await window.api.selectAgentHubTestImage()
if (result.canceled || !result.path) return
let payload: Record<string, unknown> = {}
try {
payload = JSON.parse(body) as Record<string, unknown>
} catch {
// Replace an invalid draft with a valid send-test request.
}
onBody(JSON.stringify({ ...payload, media_url: result.path }, null, 2))
}
return (
<section className="api-request-tester" id="api-request-tester">
<div className="api-section-heading">
@@ -77,6 +88,14 @@ export function ApiRequestTester({
/>
</label>
)}
{endpoint.id === 'agent-send' && (
<div className="api-upload-test-row">
<button type="button" onClick={() => void selectTestImage()}>
</button>
<span></span>
</div>
)}
<div className="api-tester-actions">
<button type="button" onClick={onClear}>
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useReducer } from 'react'
import type { Contact } from '../../../../../shared/types'
import { findEndpoint } from '../model/apiEndpoints'
import { REPORT_REQUEST_PRESET } from '../model/requestPresets'
import {
AGENT_GROUP_REPORT_PRESET,
AGENT_SEND_PRESET,
REPORT_REQUEST_PRESET
} from '../model/requestPresets'
import { type AgentInstallTarget, type SkillInstallSource } from '../model/skillDistribution'
import type {
ApiResponse,
@@ -66,14 +70,23 @@ function reducer(state: State, action: Action): State {
switch (action.type) {
case 'loaded':
return { ...state, settings: action.settings, service: action.service, skill: action.skill }
case 'endpoint':
case 'endpoint': {
const preset =
action.endpointId === 'report'
? REPORT_REQUEST_PRESET
: action.endpointId === 'agent-group-report'
? AGENT_GROUP_REPORT_PRESET
: action.endpointId === 'agent-send'
? AGENT_SEND_PRESET
: state.body
return {
...state,
endpointId: action.endpointId,
params: action.talker && action.endpointId === 'chatlog' ? { talker: action.talker } : {},
body: action.endpointId === 'report' ? state.body || REPORT_REQUEST_PRESET : state.body,
body: preset,
error: ''
}
}
case 'params':
return { ...state, params: action.params }
case 'body':
@@ -62,6 +62,20 @@ export const API_ENDPOINTS: ApiEndpoint[] = [
name: '群聊日报导出',
description: '通过内置模板导出群聊日报 HTML 与 PNG。',
body: true
}),
endpoint('agent-status', {
name: 'Agent Hub 状态',
description: '检查 Agent Hub、微信连接器、本地数据 API 和数据库状态。'
}),
endpoint('agent-group-report', {
name: '生成群聊总结图片',
description: '读取指定群聊并生成今天、昨天或近 7 天的总结长图。',
body: true
}),
endpoint('agent-send', {
name: '微信发送测试',
description: '测试文字或本地图片发送,并区分凭证失效、连接器离线和发送成功。',
body: true
})
]
@@ -29,3 +29,15 @@ export const REPORT_REQUEST_PRESET = JSON.stringify(
null,
2
)
export const AGENT_GROUP_REPORT_PRESET = JSON.stringify(
{ group: '技术交流', range: 'today' },
null,
2
)
export const AGENT_SEND_PRESET = JSON.stringify(
{ to: '', text: 'WechatExplorer Agent Hub 发送测试' },
null,
2
)
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { AccountOverview } from '../account-database/AccountOverview'
import { ConnectionHealthSection } from '../account-database/ConnectionHealthSection'
import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice'
@@ -25,6 +26,26 @@ export function AccountDatabasePage({
onNotice: (message: string) => void
}): React.ReactElement {
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
const [autoLogin, setAutoLogin] = useState(false)
useEffect(() => {
let active = true
void window.api.getSettings().then((result) => {
if (active) setAutoLogin(result.settings.autoLogin)
})
return () => {
active = false
}
}, [])
const changeAutoLogin = async (checked: boolean): Promise<void> => {
const result = await window.api.setSettings({
autoLogin: checked,
autoLoginPreferenceSet: true
})
setAutoLogin(result.settings.autoLogin)
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
}
return (
<div className="settings-page">
<header className="settings-page-header">
@@ -58,6 +79,20 @@ export function AccountDatabasePage({
: undefined
}
/>
<h2 className="settings-section-heading"></h2>
<section className="settings-card settings-auto-login-card">
<label>
<span>
<b></b>
<small>使</small>
</span>
<input
type="checkbox"
checked={autoLogin}
onChange={(event) => void changeAutoLogin(event.target.checked)}
/>
</label>
</section>
</div>
</div>
</div>
+22 -10
View File
@@ -11,6 +11,14 @@ import {
ReportVoiceLeaderboardItem
} from '../../../shared/group-report'
declare const window: {
api: {
imageListCandidates: (...args: unknown[]) => Promise<any>
imageAnalyze: (...args: unknown[]) => Promise<any>
getImage: (...args: unknown[]) => Promise<any>
}
}
export interface GroupReportTranscriptRow {
id: string
datetime: string
@@ -184,6 +192,7 @@ const buildMediaSection = async (
warnings: string[]
}> => {
const warnings: string[] = []
const rendererApi = typeof window === 'undefined' ? null : window.api
const rawImageCandidates = messages
.map((message, index) => {
if (message.contentData?.type !== 'image') return null
@@ -214,6 +223,7 @@ const buildMediaSection = async (
// ============================================================
let visionGallery: ReportVisionGalleryItem[] = []
try {
if (!rendererApi) throw new Error('后台模式不读取 Renderer 图片')
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
const startTime = messages.length ? parseTimestamp(messages[0]) : 0
const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0
@@ -233,7 +243,7 @@ const buildMediaSection = async (
}
})
const candidatesResp = await window.api.imageListCandidates({
const candidatesResp = await rendererApi.imageListCandidates({
sessionId,
startTime,
endTime,
@@ -249,7 +259,7 @@ const buildMediaSection = async (
if (candidate.insight) return candidate.insight
// 未命中:解密图片拿 base64 → 调 AI
try {
const img = await window.api.getImage(
const img = await rendererApi.getImage(
candidate.md5,
candidate.datName,
candidate.sessionId
@@ -260,7 +270,7 @@ const buildMediaSection = async (
)
return null
}
const analyzeResp = await window.api.imageAnalyze({
const analyzeResp = await rendererApi.imageAnalyze({
imageHash: candidate.imageHash,
imageDataUrl: img.data,
messageId: candidate.messageId,
@@ -306,7 +316,7 @@ const buildMediaSection = async (
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
if (!orig) return item
try {
const img = await window.api.getImage(orig.md5, orig.datName, orig.sessionId)
const img = await rendererApi.getImage(orig.md5, orig.datName, orig.sessionId)
if (img.success && img.data?.startsWith('data:image/')) {
return { ...item, imageUrl: img.data }
}
@@ -323,9 +333,10 @@ const buildMediaSection = async (
visionGallery = []
}
const imageCandidates = await Promise.all(
rawImageCandidates.map(async (item) => {
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
const imageCandidates = rendererApi
? await Promise.all(
rawImageCandidates.map(async (item) => {
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId)
if (!result.success || !result.data?.startsWith('data:image/')) return null
return {
sender: item.sender,
@@ -337,9 +348,10 @@ const buildMediaSection = async (
sourceMessageIds: item.sourceMessageIds,
replyCount: item.replyCount,
score: item.score
}
})
)
}
})
)
: []
const gallery: ReportMediaGalleryItem[] = imageCandidates
.filter((item): item is NonNullable<typeof item> => Boolean(item))
+38
View File
@@ -0,0 +1,38 @@
export type AgentHubRuntimeStatus = 'starting' | 'online' | 'offline' | 'error'
export type WechatConnectorStatus =
| 'checking'
| 'disconnected'
| 'starting'
| 'waiting_scan'
| 'scanned'
| 'online'
| 'error'
export interface AgentHubStatus {
hub: AgentHubRuntimeStatus
connector: WechatConnectorStatus
qrCodeDataUrl?: string
accountId?: string
wechatUserId?: string
error?: string
updatedAt: number
dataApi?: 'checking' | 'online' | 'offline'
databaseReady?: boolean
}
export interface AgentHubActionResult {
success: boolean
status: AgentHubStatus
error?: string
}
export type AgentHubLogSource = 'agent-hub' | 'wechat-connector' | 'system'
export type AgentHubLogLevel = 'info' | 'warn' | 'error'
export interface AgentHubLogEntry {
id: number
timestamp: number
source: AgentHubLogSource
level: AgentHubLogLevel
message: string
}
+4 -1
View File
@@ -11,7 +11,10 @@ export const LOCAL_API_ENDPOINTS = {
},
'group-snapshot': { method: 'GET', path: '/api/v1/group_snapshot', queryKeys: ['md5'] },
resolve: { method: 'GET', path: '/api/v1/resolve', queryKeys: ['q'] },
report: { method: 'POST', path: '/api/v1/report', queryKeys: [] }
report: { method: 'POST', path: '/api/v1/report', queryKeys: [] },
'agent-status': { method: 'GET', path: '/api/v1/agent/status', queryKeys: [] },
'agent-group-report': { method: 'POST', path: '/api/v1/agent/group-report', queryKeys: [] },
'agent-send': { method: 'POST', path: '/api/v1/agent/send', queryKeys: [] }
} as const
export type LocalApiEndpointId = keyof typeof LOCAL_API_ENDPOINTS