perf: 优化聊天渲染与后台任务性能

This commit is contained in:
Wxw-Gu
2026-07-17 11:37:02 +08:00
parent 8e1e166856
commit 1f693aa3d7
10 changed files with 588 additions and 74 deletions
+2 -1
View File
@@ -44,9 +44,10 @@
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux" "build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
}, },
"dependencies": { "dependencies": {
"@koromix/koffi-win32-x64": "3.1.0",
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0",
"@tanstack/react-virtual": "^3.14.6",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"fzstd": "^0.1.1", "fzstd": "^0.1.1",
"koffi": "^3.1.0", "koffi": "^3.1.0",
+17 -3
View File
@@ -12,6 +12,7 @@ specifiers:
'@electron-toolkit/utils': ^4.0.0 '@electron-toolkit/utils': ^4.0.0
'@koromix/koffi-win32-x64': 3.1.0 '@koromix/koffi-win32-x64': 3.1.0
'@rollup/rollup-darwin-arm64': ^4.62.2 '@rollup/rollup-darwin-arm64': ^4.62.2
'@tanstack/react-virtual': ^3.14.6
'@types/fs-extra': ^11.0.4 '@types/fs-extra': ^11.0.4
'@types/node': ^22.19.1 '@types/node': ^22.19.1
'@types/react': ^19.2.7 '@types/react': ^19.2.7
@@ -40,6 +41,7 @@ dependencies:
'@electron-toolkit/preload': 3.0.2_electron@43.1.0 '@electron-toolkit/preload': 3.0.2_electron@43.1.0
'@electron-toolkit/utils': 4.0.0_electron@43.1.0 '@electron-toolkit/utils': 4.0.0_electron@43.1.0
'@koromix/koffi-win32-x64': 3.1.0 '@koromix/koffi-win32-x64': 3.1.0
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
fs-extra: 11.3.2 fs-extra: 11.3.2
fzstd: 0.1.1 fzstd: 0.1.1
koffi: 3.1.0 koffi: 3.1.0
@@ -1124,6 +1126,21 @@ packages:
defer-to-connect: 2.0.1 defer-to-connect: 2.0.1
dev: true dev: true
/@tanstack/react-virtual/3.14.6_bokjwhiew3ov3ffvbmafuwoalq:
resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
'@tanstack/virtual-core': 3.17.4
react: 19.2.1
react-dom: 19.2.1_react@19.2.1
dev: false
/@tanstack/virtual-core/3.17.4:
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
dev: false
/@tootallnate/once/2.0.0: /@tootallnate/once/2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@@ -4037,7 +4054,6 @@ packages:
dependencies: dependencies:
react: 19.2.1 react: 19.2.1
scheduler: 0.27.0 scheduler: 0.27.0
dev: true
/react-is/16.13.1: /react-is/16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -4051,7 +4067,6 @@ packages:
/react/19.2.1: /react/19.2.1:
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true
/read-binary-file-arch/1.0.6: /read-binary-file-arch/1.0.6:
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
@@ -4234,7 +4249,6 @@ packages:
/scheduler/0.27.0: /scheduler/0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
dev: true
/semver/5.7.2: /semver/5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
+24 -19
View File
@@ -4,6 +4,11 @@ import crypto from 'crypto'
import os from 'os' import os from 'os'
import { Wcdb4Client } from './wcdb4-client' import { Wcdb4Client } from './wcdb4-client'
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
const imageDecryptLog = (...args: unknown[]): void => {
if (imageDecryptDebugEnabled) console.log(...args)
}
export class ImageDecryptService { export class ImageDecryptService {
private readonly defaultV1AesKey = 'cfcd208495d565ef' private readonly defaultV1AesKey = 'cfcd208495d565ef'
@@ -41,7 +46,7 @@ export class ImageDecryptService {
) )
if (!existsSync(accountRoot)) { if (!existsSync(accountRoot)) {
console.log('[ImageDecrypt] account root not found:', accountRoot) imageDecryptLog('[ImageDecrypt] account root not found:', accountRoot)
return null return null
} }
@@ -61,7 +66,7 @@ export class ImageDecryptService {
.sort((a, b) => b.mtime - a.mtime) .sort((a, b) => b.mtime - a.mtime)
if (accounts.length === 0) { if (accounts.length === 0) {
console.log('[ImageDecrypt] no accounts found') imageDecryptLog('[ImageDecrypt] no accounts found')
return null return null
} }
@@ -83,7 +88,7 @@ export class ImageDecryptService {
const normalizedMd5 = this.normalizeDatBase(md5 || '') const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '') const normalizedDatName = this.normalizeDatBase(imageDatName || '')
console.log('[ImageDecrypt] findImageFile:', { imageDecryptLog('[ImageDecrypt] findImageFile:', {
md5: normalizedMd5, md5: normalizedMd5,
imageDatName: normalizedDatName, imageDatName: normalizedDatName,
accountDir, accountDir,
@@ -96,7 +101,7 @@ export class ImageDecryptService {
if (fullPath && existsSync(fullPath)) { if (fullPath && existsSync(fullPath)) {
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail) const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
if (allowThumbnail || !this.isThumbnailName(basename(selected))) { if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
console.log('[ImageDecrypt] hardlink hit:', selected) imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
return selected return selected
} }
} }
@@ -105,7 +110,7 @@ export class ImageDecryptService {
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/ // 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
const attachDir = join(accountDir, 'msg', 'attach') const attachDir = join(accountDir, 'msg', 'attach')
if (!existsSync(attachDir)) { if (!existsSync(attachDir)) {
console.log('[ImageDecrypt] attach dir not found:', attachDir) imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName) return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
} }
@@ -120,7 +125,7 @@ export class ImageDecryptService {
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail) const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
if (legacyHit) return legacyHit if (legacyHit) return legacyHit
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys) imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
return null return null
} }
@@ -146,7 +151,7 @@ export class ImageDecryptService {
] ]
const found = this.getLargestExistingPath(candidates, allowThumbnail) const found = this.getLargestExistingPath(candidates, allowThumbnail)
if (found) { if (found) {
console.log('[ImageDecrypt] prefix path hit:', found) imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
return found return found
} }
} }
@@ -175,14 +180,14 @@ export class ImageDecryptService {
allowThumbnail allowThumbnail
) )
if (found) { if (found) {
console.log('[ImageDecrypt] found at:', found) imageDecryptLog('[ImageDecrypt] found at:', found)
return found return found
} }
} }
} }
} }
} catch (e) { } catch (e) {
console.log('[ImageDecrypt]遍历目录失败:', e) imageDecryptLog('[ImageDecrypt]遍历目录失败:', e)
} }
return null return null
@@ -229,7 +234,7 @@ export class ImageDecryptService {
const fullPath = join(dir, entry) const fullPath = join(dir, entry)
const stat = statSync(fullPath) const stat = statSync(fullPath)
if (stat.isFile() && variants.has(entry.toLowerCase())) { if (stat.isFile() && variants.has(entry.toLowerCase())) {
console.log('[ImageDecrypt] legacy path hit:', fullPath) imageDecryptLog('[ImageDecrypt] legacy path hit:', fullPath)
return fullPath return fullPath
} }
} }
@@ -252,13 +257,13 @@ export class ImageDecryptService {
*/ */
decryptImage(datPath: string): Buffer | null { decryptImage(datPath: string): Buffer | null {
if (!existsSync(datPath)) { if (!existsSync(datPath)) {
console.log('[ImageDecrypt] file not found:', datPath) imageDecryptLog('[ImageDecrypt] file not found:', datPath)
return null return null
} }
try { try {
const version = this.getDatVersion(datPath) const version = this.getDatVersion(datPath)
console.log( imageDecryptLog(
'[ImageDecrypt] dat version:', '[ImageDecrypt] dat version:',
version, version,
'file:', 'file:',
@@ -269,25 +274,25 @@ export class ImageDecryptService {
let decrypted: Buffer let decrypted: Buffer
if (version === 1) { if (version === 1) {
console.log('[ImageDecrypt] using V1 (default AES key)') imageDecryptLog('[ImageDecrypt] using V1 (default AES key)')
const key = Buffer.from(this.defaultV1AesKey, 'ascii') const key = Buffer.from(this.defaultV1AesKey, 'ascii')
decrypted = this.decryptDatV4(datPath, key) decrypted = this.decryptDatV4(datPath, key)
} else if (version === 2) { } else if (version === 2) {
console.log('[ImageDecrypt] using V2 (user AES key)') imageDecryptLog('[ImageDecrypt] using V2 (user AES key)')
if (!this.aesKey) { if (!this.aesKey) {
console.log('[ImageDecrypt] no AES key configured') imageDecryptLog('[ImageDecrypt] no AES key configured')
return null return null
} }
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16) const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
decrypted = this.decryptDatV4(datPath, key) decrypted = this.decryptDatV4(datPath, key)
} else { } else {
console.log('[ImageDecrypt] unsupported dat version:', version) imageDecryptLog('[ImageDecrypt] unsupported dat version:', version)
return null return null
} }
return decrypted return decrypted
} catch (error) { } catch (error) {
console.error('[ImageDecrypt] decrypt error:', error) imageDecryptLog('[ImageDecrypt] decrypt error:', error)
return null return null
} }
} }
@@ -309,7 +314,7 @@ export class ImageDecryptService {
const unwrapped = this.unwrapWxgf(decrypted) const unwrapped = this.unwrapWxgf(decrypted)
const ext = this.detectImageExtension(unwrapped) const ext = this.detectImageExtension(unwrapped)
if (!ext) { if (!ext) {
console.log('[ImageDecrypt] unknown image format') imageDecryptLog('[ImageDecrypt] unknown image format')
return null return null
} }
@@ -346,7 +351,7 @@ export class ImageDecryptService {
const data = this.decryptImageToBase64(candidate) const data = this.decryptImageToBase64(candidate)
if (data) return { data, filePath: candidate } if (data) return { data, filePath: candidate }
} }
console.warn('[ImageDecrypt] all variants failed:', this.uniq(candidates)) imageDecryptLog('[ImageDecrypt] all variants failed:', this.uniq(candidates))
return null return null
} }
+421 -5
View File
@@ -14,7 +14,15 @@ import type {
} from '../../shared/agent-hub' } from '../../shared/agent-hub'
import type { AppSettings } from './settings-store' import type { AppSettings } from './settings-store'
import { generateAgentGroupReport } from './agent-group-report-service' import { generateAgentGroupReport } from './agent-group-report-service'
import { isReady, listMessages, listRecentChat, resolveMd5 } from './chat-service' import { AIProviderService } from './ai-provider-service'
import {
getGroupSnapshot,
isReady,
listContacts,
listMessages,
listRecentChat,
resolveMd5
} from './chat-service'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const HEALTH_INTERVAL_MS = 5_000 const HEALTH_INTERVAL_MS = 5_000
@@ -39,8 +47,24 @@ interface GroupReportIntent {
interface ContactChatIntent { interface ContactChatIntent {
contact: string contact: string
limit: number limit: number
summarize: boolean
} }
interface GroupMemberChatIntent {
group: string
member: string
range: 'today' | 'yesterday' | '7days'
days: number
goal: string
}
interface NaturalLanguageResult {
command?: string
reply?: string
}
const agentAIProvider = new AIProviderService()
function resolveBundledBinary( function resolveBundledBinary(
resourceSegments: string[], resourceSegments: string[],
executable: string, executable: string,
@@ -314,6 +338,7 @@ class AgentHubService {
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`) this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`)
const reportIntent = this.matchGroupReportIntent(text) const reportIntent = this.matchGroupReportIntent(text)
if (reportIntent) { if (reportIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now()) if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog( this.addLog(
@@ -328,20 +353,39 @@ class AgentHubService {
return this.sendHubJson(response, 202, { status: 'generating' }) return this.sendHubJson(response, 202, { status: 'generating' })
} }
const groupMemberIntent = this.matchGroupMemberChatIntent(text)
if (groupMemberIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now())
void this.summarizeGroupMemberChat(inbound, groupMemberIntent)
return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' })
}
const contactChatIntent = this.matchContactChatIntent(text) const contactChatIntent = this.matchContactChatIntent(text)
if (contactChatIntent) { if (contactChatIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now()) if (messageId) this.processedMessages.set(messageId, Date.now())
if (contactChatIntent.summarize) {
void this.summarizeContactChat(inbound, contactChatIntent)
return this.sendHubJson(response, 202, { status: 'generating', mode: 'contact-summary' })
}
await this.replyContactChat(inbound, contactChatIntent) await this.replyContactChat(inbound, contactChatIntent)
return this.sendHubJson(response, 200, { status: 'ok' }) return this.sendHubJson(response, 200, { status: 'ok' })
} }
const limit = this.matchRecentChatIntent(text) const recentChatLimit = this.matchRecentChatIntent(text)
if (limit === null) { if (recentChatLimit === null && text.trim()) {
if (messageId) this.processedMessages.set(messageId, Date.now())
void this.handleNaturalLanguage(inbound, text)
return this.sendHubJson(response, 202, {
status: 'processing',
mode: 'natural-language'
})
}
if (recentChatLimit === null) {
this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图') this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图')
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' }) return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' })
} }
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' }) if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
const items = listRecentChat(limit) const items = listRecentChat(recentChatLimit)
const lines = items.map((item, index) => { const lines = items.map((item, index) => {
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim() const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return `${index + 1}. ${name}${item.type === 'group' ? '群聊' : '联系人'}` return `${index + 1}. ${name}${item.type === 'group' ? '群聊' : '联系人'}`
@@ -360,6 +404,140 @@ class AgentHubService {
this.sendHubJson(response, 200, { status: 'ok' }) this.sendHubJson(response, 200, { status: 'ok' })
} }
private async handleNaturalLanguage(inbound: InboundMessage, text: string): Promise<void> {
try {
const result = await this.resolveNaturalLanguage(text)
if (result.reply) {
await this.sendConnector(inbound, this.formatAIReply(result.reply))
this.addLog('agent-hub', 'info', '自然语言回复已发送')
return
}
if (!result.command) {
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
return
}
this.addLog('agent-hub', 'info', `自然语言已理解为:${result.command}`)
const reportIntent = this.matchGroupReportIntent(result.command)
if (reportIntent) {
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch(() => undefined)
await this.generateAndSendReport(inbound, reportIntent)
return
}
const groupMemberIntent = this.matchGroupMemberChatIntent(result.command)
if (groupMemberIntent) {
await this.summarizeGroupMemberChat(inbound, groupMemberIntent)
return
}
const contactIntent = this.matchContactChatIntent(result.command)
if (contactIntent) {
if (contactIntent.summarize) await this.summarizeContactChat(inbound, contactIntent)
else await this.replyContactChat(inbound, contactIntent)
return
}
const recentLimit = this.matchRecentChatIntent(result.command)
if (recentLimit !== null) {
await this.replyRecentChats(inbound, recentLimit)
return
}
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
} catch (error) {
this.addLog('agent-hub', 'error', `自然语言处理失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `处理失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
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' ? '群聊' : '联系人'}`
})
await this.sendConnector(
inbound,
lines.length ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` : '暂时没有找到最近会话。'
)
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
}
private async resolveNaturalLanguage(text: string): Promise<NaturalLanguageResult> {
const result = await agentAIProvider.chat([
{
role: 'system',
content: `你是 WechatExplorer 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
支持的工具:
1. recent:查看最近会话,参数 limit 为 1-20。
2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。
3. report:生成某个群的群聊总结图片,参数 group 和 rangetoday、yesterday、7days)。
4. group_member:分析某个群里某位成员的发言,参数 group、member、rangetoday、yesterday、7days)、days1-30)和 goal(保留用户希望总结、研究人物、提取观点等完整目标)。只要用户同时提到群聊和群成员,应优先使用 group_member,不能识别成 contact。
5. chat:不需要工具的普通对话,reply 用简洁中文直接回答。
输出格式:{"type":"recent|contact|report|group_member|chat","limit":5,"contact":"","group":"","member":"","range":"today","days":3,"goal":"","reply":""}
不要声称已经读取未调用的聊天记录,不要执行电脑控制、文件操作、付款或发送给其他联系人。`
},
{ role: 'user', content: text.slice(0, 1000) }
])
if (!result.success || !result.data) {
this.addLog('agent-hub', 'warn', `自然语言理解不可用:${result.error || 'AI 未返回内容'}`)
return {}
}
try {
const json = result.data.match(/\{[\s\S]*\}/)?.[0]
if (!json) return {}
const parsed = JSON.parse(json) as Record<string, unknown>
const limit = Math.max(1, Math.min(20, Number(parsed['limit']) || 5))
if (parsed['type'] === 'recent') return { command: `最近${limit}条消息` }
if (parsed['type'] === 'contact' && String(parsed['contact'] || '').trim()) {
return { command: `我和${String(parsed['contact']).trim()}最近${limit}条聊了什么` }
}
if (parsed['type'] === 'report' && String(parsed['group'] || '').trim()) {
const range =
parsed['range'] === '7days'
? '最近7天'
: parsed['range'] === 'yesterday'
? '昨天'
: '今天'
return { command: `生成${String(parsed['group']).trim()}${range}的群聊总结图片` }
}
if (
parsed['type'] === 'group_member' &&
String(parsed['group'] || '').trim() &&
String(parsed['member'] || '').trim()
) {
const range =
parsed['range'] === 'yesterday'
? '昨天'
: parsed['range'] === 'today'
? '今天'
: '最近7天'
const group = String(parsed['group'] || '')
.trim()
.replace(/(?:群聊|群)+$/g, '')
const days = Math.max(1, Math.min(30, Number(parsed['days']) || 7))
const goal = String(parsed['goal'] || '总结发言').trim()
return {
command: `看看${group}群里${String(parsed['member']).trim()}${range === '最近7天' ? `最近${days}` : range}说了什么,${goal}`
}
}
if (parsed['type'] === 'chat') {
const reply = String(parsed['reply'] || '').trim()
return reply ? { reply: reply.slice(0, 1500) } : {}
}
} catch (error) {
this.addLog('agent-hub', 'warn', `自然语言结果解析失败:${this.errorMessage(error)}`)
}
return {}
}
private async replyContactChat( private async replyContactChat(
inbound: InboundMessage, inbound: InboundMessage,
intent: ContactChatIntent intent: ContactChatIntent
@@ -395,6 +573,180 @@ class AgentHubService {
this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`) this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`)
} }
private async summarizeContactChat(
inbound: InboundMessage,
intent: ContactChatIntent
): Promise<void> {
try {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
const contact = resolveMd5(intent.contact)
if (!contact || contact.type !== 'user') {
await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`)
return
}
await this.sendConnector(
inbound,
`收到!正在整理和${contact.m_nsNickName}的近期聊天,请等待…`
)
const endTime = Math.floor(Date.now() / 1000)
const startTime = endTime - 7 * 24 * 60 * 60
const messages = listMessages(contact.md5, startTime, endTime, { limit: 300 }).slice(-300)
if (!messages.length) {
await this.sendConnector(inbound, `最近 7 天没有找到和${contact.m_nsNickName}的聊天记录。`)
return
}
const transcript = messages
.map((message) => {
const speaker = message.isSender ? '我' : contact.m_nsNickName
return `[${message.datetime}] ${speaker}${this.describeChatMessage(message.content, message.type)}`
})
.join('\n')
const summary = await agentAIProvider.chat([
{
role: 'system',
content:
'你是私人聊天记录总结助手。仅根据提供的记录总结,不编造。按日期或主题整理关键进展、双方观点、决定、待办和未解决问题;忽略无意义表情,保留重要数字与事实。使用适合微信阅读的简洁中文。'
},
{
role: 'user',
content: `请总结我和“${contact.m_nsNickName}”最近 7 天聊了什么。\n\n聊天记录:\n${transcript}`
}
])
if (!summary.success || !summary.data?.trim()) {
throw new Error(summary.error || 'AI 未返回总结')
}
await this.sendConnector(
inbound,
this.formatAIReply(
`${contact.m_nsNickName}最近聊天总结(近 7 天,共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
)
)
this.addLog('agent-hub', 'info', `联系人聊天总结已发送(${messages.length} 条)`)
} catch (error) {
this.addLog('agent-hub', 'error', `联系人聊天总结失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `聊天总结失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private async summarizeGroupMemberChat(
inbound: InboundMessage,
intent: GroupMemberChatIntent
): Promise<void> {
try {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
const group = this.resolveGroup(intent.group)
if (!group) {
await this.sendConnector(inbound, `没有找到群聊“${intent.group}”。`)
return
}
const snapshot = getGroupSnapshot(group.md5)
const memberQuery = intent.member.trim().toLowerCase()
const member = snapshot?.members.find((item) =>
[item.groupNickname, item.wechatNickname, item.remark, item.nickname, item.wxid].some(
(name) =>
String(name || '')
.trim()
.toLowerCase() === memberQuery
)
)
if (!member) {
await this.sendConnector(
inbound,
`没有在“${group.m_nsNickName}”找到成员“${intent.member}”。`
)
return
}
const displayName =
member.groupNickname || member.wechatNickname || member.remark || member.nickname
await this.sendConnector(
inbound,
`收到!正在整理${displayName}在“${group.m_nsNickName}”的近期发言,请等待…`
)
const now = new Date()
const todayStart = Math.floor(
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
)
const startTime =
intent.range === 'today'
? todayStart
: intent.range === 'yesterday'
? todayStart - 24 * 60 * 60
: Math.floor(Date.now() / 1000) - intent.days * 24 * 60 * 60
const endTime = intent.range === 'yesterday' ? todayStart - 1 : Math.floor(Date.now() / 1000)
const aliases = new Set(
[member.groupNickname, member.wechatNickname, member.remark, member.nickname]
.map((name) =>
String(name || '')
.trim()
.toLowerCase()
)
.filter(Boolean)
)
const messages = listMessages(group.md5, startTime, endTime, { limit: 10_000 })
.filter(
(message) =>
String(message.senderId || '').trim() === member.wxid ||
aliases.has(
String(message.name || '')
.trim()
.toLowerCase()
)
)
.slice(-1000)
if (!messages.length) {
await this.sendConnector(
inbound,
`所选时间范围没有找到${displayName}在“${group.m_nsNickName}”的发言。`
)
return
}
const transcript = messages
.map(
(message) =>
`[${message.datetime}] ${this.describeChatMessage(message.content, message.type)}`
)
.join('\n')
const summary = await agentAIProvider.chat([
{
role: 'system',
content:
'你是擅长分析微信群聊的助手。严格依据提供的发言完成用户的原始要求,输出结构和侧重点由内容决定,不套固定模板。可以归纳人物特征、兴趣、表达习惯和群内角色,但必须区分事实与推测,为推测说明依据和不确定性,不得编造。使用适合微信阅读的中文。'
},
{
role: 'user',
content: `用户原始要求:${intent.goal}\n分析对象:“${displayName}”在群聊“${group.m_nsNickName}”中的发言。\n时间范围:${intent.range === 'today' ? '今天' : intent.range === 'yesterday' ? '昨天' : `最近 ${intent.days}`}\n共提供 ${messages.length} 条发言。\n\n发言记录:\n${transcript}`
}
])
if (!summary.success || !summary.data?.trim()) {
throw new Error(summary.error || 'AI 未返回总结')
}
await this.sendConnector(
inbound,
this.formatAIReply(
`${displayName}在“${group.m_nsNickName}”的发言总结(共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
)
)
this.addLog('agent-hub', 'info', `群成员发言总结已发送(${messages.length} 条)`)
} catch (error) {
this.addLog('agent-hub', 'error', `群成员发言总结失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `群成员发言总结失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private describeChatMessage(content: string, type: string): string { private describeChatMessage(content: string, type: string): string {
const normalized = String(content || '') const normalized = String(content || '')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
@@ -404,6 +756,15 @@ class AgentHubService {
return `[${label}]` return `[${label}]`
} }
private formatAIReply(content: string): string {
return content
.replace(/\r\n?/g, '\n')
.replace(/[ \t]*•[ \t]*/g, '\n• ')
.replace(/[ \t]+(?=\d+[.、][ \t])/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
private async generateAndSendReport( private async generateAndSendReport(
inbound: InboundMessage, inbound: InboundMessage,
intent: GroupReportIntent intent: GroupReportIntent
@@ -465,7 +826,62 @@ class AgentHubService {
if (!contact) return null if (!contact) return null
const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10) const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10)
return { contact, limit: Math.max(1, Math.min(20, limit)) } const summarize = /(聊了什么|聊什么|说了什么|谈了什么|总结)/.test(normalized)
return { contact, limit: Math.max(1, Math.min(20, limit)), summarize }
}
private matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null {
const normalized = text.trim().replace(/[,。!??:]/g, '')
const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)'
const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)'
const patterns = [
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,]+(.+?)${timePattern}${actionPattern}`
),
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}`
)
]
for (const pattern of patterns) {
const match = normalized.match(pattern)
if (match?.[1]?.trim() && match[2]?.trim()) {
const range = /昨天|昨日/.test(match[3] || '')
? 'yesterday'
: /今天|今日/.test(match[3] || '')
? 'today'
: '7days'
const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7))
return {
group: match[1].trim(),
member: match[2].trim(),
range,
days,
goal: normalized
}
}
}
return null
}
private resolveGroup(query: string): ReturnType<typeof resolveMd5> {
const normalize = (value: string): string =>
value
.trim()
.toLowerCase()
.replace(/[\s,。!??:、“”'‘’]/g, '')
.replace(/(?:群聊|群)+$/g, '')
const target = normalize(query)
if (!target) return null
const groups = listContacts().filter((contact) => contact.type === 'group')
return (
groups.find((contact) => normalize(contact.m_nsNickName) === target) ||
groups.find((contact) => {
const name = normalize(contact.m_nsNickName)
return name.includes(target) || target.includes(name)
}) ||
null
)
} }
private matchGroupReportIntent(text: string): GroupReportIntent | null { private matchGroupReportIntent(text: string): GroupReportIntent | null {
+49 -12
View File
@@ -31,6 +31,10 @@ interface BootstrapCacheFile {
const CACHE_VERSION = 1 const CACHE_VERSION = 1
const MAX_MESSAGE_BUCKETS = 24 const MAX_MESSAGE_BUCKETS = 24
const MAX_MESSAGES_PER_BUCKET = 1200 const MAX_MESSAGES_PER_BUCKET = 1200
const WRITE_DEBOUNCE_MS = 300
const memoryCache = new Map<string, BootstrapCacheFile>()
const writeTimers = new Map<string, NodeJS.Timeout>()
const writeQueues = new Map<string, Promise<void>>()
function normalizeRoot(accountRoot?: string): string { function normalizeRoot(accountRoot?: string): string {
return String(accountRoot || '').trim() return String(accountRoot || '').trim()
@@ -43,19 +47,26 @@ function getCacheFile(accountRoot?: string): string {
.update(`${process.platform}:${normalizedRoot}`) .update(`${process.platform}:${normalizedRoot}`)
.digest('hex') .digest('hex')
.slice(0, 16) .slice(0, 16)
return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`) return path.join(
app.getPath('userData'),
'cache',
'bootstrap',
`${process.platform}-${hash}.json`
)
} }
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null { function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot) const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null if (!normalizedRoot) return null
const file = getCacheFile(normalizedRoot) const file = getCacheFile(normalizedRoot)
const cached = memoryCache.get(file)
if (cached) return cached
try { try {
if (!fs.existsSync(file)) return null if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile> const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
return { const result: BootstrapCacheFile = {
version: CACHE_VERSION, version: CACHE_VERSION,
platform: process.platform, platform: process.platform,
accountRoot: normalizedRoot, accountRoot: normalizedRoot,
@@ -64,6 +75,8 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
contacts: Array.isArray(raw.contacts) ? raw.contacts : [], contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {} messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
} }
memoryCache.set(file, result)
return result
} catch (error) { } catch (error) {
console.warn('[BootstrapCache] read failed:', error) console.warn('[BootstrapCache] read failed:', error)
return null return null
@@ -71,20 +84,39 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
} }
function writeCacheFile(cache: BootstrapCacheFile): void { function writeCacheFile(cache: BootstrapCacheFile): void {
try {
const file = getCacheFile(cache.accountRoot) const file = getCacheFile(cache.accountRoot)
fs.ensureDirSync(path.dirname(file)) memoryCache.set(file, cache)
fs.writeJsonSync(file, cache, { spaces: 2 }) const existingTimer = writeTimers.get(file)
} catch (error) { if (existingTimer) clearTimeout(existingTimer)
writeTimers.set(
file,
setTimeout(() => {
writeTimers.delete(file)
const serialized = JSON.stringify(memoryCache.get(file) || cache)
const previous = writeQueues.get(file) || Promise.resolve()
const next = previous
.catch(() => undefined)
.then(async () => {
await fs.ensureDir(path.dirname(file))
await fs.writeFile(file, serialized, 'utf8')
})
.catch((error) => {
console.warn('[BootstrapCache] write failed:', error) console.warn('[BootstrapCache] write failed:', error)
} })
.finally(() => {
if (writeQueues.get(file) === next) writeQueues.delete(file)
})
writeQueues.set(file, next)
}, WRITE_DEBOUNCE_MS)
)
} }
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null { function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot) const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null if (!normalizedRoot) return null
return ( const existing = readCacheFile(normalizedRoot)
readCacheFile(normalizedRoot) || { if (existing) return existing
const created: BootstrapCacheFile = {
version: CACHE_VERSION, version: CACHE_VERSION,
platform: process.platform, platform: process.platform,
accountRoot: normalizedRoot, accountRoot: normalizedRoot,
@@ -92,7 +124,8 @@ function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
contacts: [], contacts: [],
messages: {} messages: {}
} }
) memoryCache.set(getCacheFile(normalizedRoot), created)
return created
} }
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string { function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
@@ -144,7 +177,9 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
) )
const nameByUsername = new Map( const nameByUsername = new Map(
cache.contacts cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)) .filter(
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
)
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName]) .map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
) )
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
@@ -179,7 +214,9 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
) )
const nameByUsername = new Map( const nameByUsername = new Map(
(cache.contacts || []) (cache.contacts || [])
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)) .filter(
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
)
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName]) .map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
) )
cache.contacts = contacts.map((contact) => ({ cache.contacts = contacts.map((contact) => ({
+2 -2
View File
@@ -330,7 +330,7 @@ function App(): React.ReactElement {
usernames.length ? 55 : 90 usernames.length ? 55 : 90
) )
let loadedCount = 0 let loadedCount = 0
const chunkSize = 8 const chunkSize = 32
for (let index = 0; index < usernames.length; index += chunkSize) { for (let index = 0; index < usernames.length; index += chunkSize) {
if (runId !== contactAvatarHydrationRunRef.current) return if (runId !== contactAvatarHydrationRunRef.current) return
const chunk = usernames.slice(index, index + chunkSize) const chunk = usernames.slice(index, index + chunkSize)
@@ -361,7 +361,7 @@ function App(): React.ReactElement {
} catch (error) { } catch (error) {
console.warn('[Contacts] avatar hydrate failed:', error) console.warn('[Contacts] avatar hydrate failed:', error)
} }
await new Promise((resolve) => window.setTimeout(resolve, 150)) await new Promise((resolve) => window.setTimeout(resolve, 50))
} }
if (usernames.length) onProgress?.('头像加载完成', 90) if (usernames.length) onProgress?.('头像加载完成', 90)
} }
+21 -2
View File
@@ -1143,6 +1143,20 @@ body {
background: #f0f2ef; background: #f0f2ef;
} }
.virtual-message-list {
position: relative;
flex: 0 0 auto;
width: 100%;
}
.virtual-message-group {
position: absolute;
top: 0;
left: 0;
display: flow-root;
width: 100%;
}
.wechat-message-row { .wechat-message-row {
margin-bottom: 14px; margin-bottom: 14px;
} }
@@ -7061,7 +7075,9 @@ body {
border: 1.5px solid var(--wxex-border); border: 1.5px solid var(--wxex-border);
border-radius: 12px; border-radius: 12px;
background: #fff; background: #fff;
transition: border-color 0.2s, background 0.2s; transition:
border-color 0.2s,
background 0.2s;
} }
.report-template-item.active { .report-template-item.active {
@@ -7114,7 +7130,10 @@ body {
background: #fff; background: #fff;
color: var(--wxex-text-primary, #1f2933); color: var(--wxex-text-primary, #1f2933);
cursor: pointer; cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s; transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
} }
.report-template-preview-btn:hover { .report-template-preview-btn:hover {
+3 -10
View File
@@ -20,7 +20,6 @@ interface ChatWindowProps {
isAiLoading?: boolean isAiLoading?: boolean
} }
const MAX_RENDERED_MESSAGES = 600
const DATE_RANGE_LABELS: Record<string, string> = { const DATE_RANGE_LABELS: Record<string, string> = {
today: '今天', today: '今天',
yesterday: '昨日', yesterday: '昨日',
@@ -242,12 +241,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
return typeMatch && contentMatch return typeMatch && contentMatch
}) })
}, [messages, contentFilter]) }, [messages, contentFilter])
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
const renderedMessages = React.useMemo(
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
[filteredMessages]
)
if (!contact) return <EmptyConversationState /> if (!contact) return <EmptyConversationState />
const dateRangeLabel = getChatHeaderRangeLabel(dateRange) const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
@@ -272,8 +265,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<DataTrustBar messageCount={messages.length} /> <DataTrustBar messageCount={messages.length} />
<MessageList <MessageList
contact={contact} contact={contact}
messages={renderedMessages} messages={filteredMessages}
hiddenMessageCount={hiddenMessageCount} hiddenMessageCount={0}
isLoadingMessages={isLoadingMessages} isLoadingMessages={isLoadingMessages}
isGroupChat={isGroupChat} isGroupChat={isGroupChat}
showAvatar={showAvatar} showAvatar={showAvatar}
@@ -283,7 +276,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onImageClick={openImagePreview} onImageClick={openImagePreview}
/> />
<ChatStatusBar <ChatStatusBar
count={renderedMessages.length} count={filteredMessages.length}
showAvatar={showAvatar} showAvatar={showAvatar}
isAtLatest={isAtLatest} isAtLatest={isAtLatest}
onShowAvatarChange={setShowAvatar} onShowAvatarChange={setShowAvatar}
@@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Contact, Message } from '../../../../shared/types' import { Contact, Message } from '../../../../shared/types'
import { MessageGroup } from './MessageGroup' import { MessageGroup } from './MessageGroup'
import { buildMessageGroups } from './messageGrouping' import { buildMessageGroups } from './messageGrouping'
@@ -29,6 +30,16 @@ export function MessageList({
onImageClick onImageClick
}: MessageListProps): React.ReactElement { }: MessageListProps): React.ReactElement {
const groups = React.useMemo(() => buildMessageGroups(messages), [messages]) const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
// TanStack Virtual intentionally exposes mutable measurement methods.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: groups.length,
getScrollElement: () => listRef.current,
estimateSize: () => 96,
getItemKey: (index) => groups[index]?.id || index,
overscan: 8
})
const virtualItems = virtualizer.getVirtualItems()
return ( return (
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}> <div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
@@ -40,16 +51,29 @@ export function MessageList({
</div> </div>
</div> </div>
)} )}
{groups.map((group) => ( <div className="virtual-message-list" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualItems.map((virtualItem) => {
const group = groups[virtualItem.index]
if (!group) return null
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="virtual-message-group"
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<MessageGroup <MessageGroup
key={group.id}
group={group} group={group}
contact={contact} contact={contact}
isGroupChat={isGroupChat} isGroupChat={isGroupChat}
showAvatar={showAvatar} showAvatar={showAvatar}
onImageClick={onImageClick} onImageClick={onImageClick}
/> />
))} </div>
)
})}
</div>
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
) )
@@ -195,8 +195,9 @@ export function AgentHubWorkspace(): React.ReactElement {
<h2></h2> <h2></h2>
<p> Agent Hub WechatExplorer</p> <p> Agent Hub WechatExplorer</p>
<div className="agent-hub-example"> <div className="agent-hub-example">
<span></span> <span></span>
<strong> 5 </strong> <strong> 5 </strong>
<strong>xx聊了些什么</strong>
<strong></strong> <strong></strong>
</div> </div>
<ul> <ul>
@@ -212,6 +213,10 @@ export function AgentHubWorkspace(): React.ReactElement {
<i /> <i />
</li> </li>
<li>
<i />
使 AI
</li>
<li> <li>
<i className={status.dataApi === 'online' ? '' : 'offline'} /> <i className={status.dataApi === 'online' ? '' : 'offline'} />
API{status.dataApi === 'online' ? '已连接' : '未连接'} API{status.dataApi === 'online' ? '已连接' : '未连接'}