mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
fix: 优化群聊展示与群日报生成
This commit is contained in:
@@ -278,6 +278,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
}
|
||||
|
||||
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
|
||||
console.log(`[GroupReport] capture begin html=${htmlPath}`)
|
||||
const reportWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 430,
|
||||
@@ -289,6 +290,7 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise<strin
|
||||
|
||||
try {
|
||||
await reportWindow.loadFile(htmlPath)
|
||||
console.log('[GroupReport] capture loaded html')
|
||||
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
||||
document.fonts.ready,
|
||||
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
|
||||
@@ -296,29 +298,23 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise<strin
|
||||
img.addEventListener('error', resolve, { once: true });
|
||||
}))
|
||||
])`)
|
||||
reportWindow.webContents.debugger.attach('1.3')
|
||||
const metrics = (await reportWindow.webContents.debugger.sendCommand(
|
||||
'Page.getLayoutMetrics'
|
||||
)) as { cssContentSize: { width: number; height: number } }
|
||||
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
|
||||
const height = Math.ceil(metrics.cssContentSize.height)
|
||||
const screenshot = (await reportWindow.webContents.debugger.sendCommand(
|
||||
'Page.captureScreenshot',
|
||||
{
|
||||
format: 'png',
|
||||
captureBeyondViewport: true,
|
||||
fromSurface: true,
|
||||
clip: { x: 0, y: 0, width, height, scale: 1 }
|
||||
}
|
||||
)) as { data: string }
|
||||
const png = Buffer.from(screenshot.data, 'base64')
|
||||
console.log('[GroupReport] capture assets ready')
|
||||
const metrics = (await reportWindow.webContents.executeJavaScript(`({
|
||||
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 430)),
|
||||
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
|
||||
})`)) as { width: number; height: number }
|
||||
const width = Math.max(430, Math.min(1200, Math.ceil(metrics.width)))
|
||||
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
|
||||
reportWindow.setContentSize(width, height)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
|
||||
const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height })
|
||||
const png = image.toPNG()
|
||||
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
||||
await fs.writeFile(pngPath, png)
|
||||
return `data:image/png;base64,${screenshot.data}`
|
||||
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
|
||||
return `data:image/png;base64,${png.toString('base64')}`
|
||||
} finally {
|
||||
if (reportWindow.webContents.debugger.isAttached()) {
|
||||
reportWindow.webContents.debugger.detach()
|
||||
}
|
||||
reportWindow.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -38,6 +38,7 @@ declare global {
|
||||
key: string
|
||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||
getContacts: (filter?: string) => Promise<Contact[]>
|
||||
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
|
||||
getGroupSnapshot: (userMd5: string) => Promise<{
|
||||
roomId: string
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GroupReportExportRequest } from '../shared/group-report'
|
||||
const api = {
|
||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
||||
getMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime),
|
||||
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
||||
|
||||
+120
-41
@@ -12,7 +12,7 @@ interface SelfInfo {
|
||||
}
|
||||
|
||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 250
|
||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
||||
|
||||
const getMessageIdentity = (message: Message): string => {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
@@ -35,6 +35,8 @@ type GroupSnapshot = {
|
||||
members: { wxid: string; nickname: string; avatar: string }[]
|
||||
}
|
||||
|
||||
type GroupMemberMeta = { nickname: string; avatar: string }
|
||||
|
||||
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
||||
member.nickname || member.wxid
|
||||
|
||||
@@ -83,6 +85,8 @@ const buildSyntheticGroupMessages = (
|
||||
return events
|
||||
}
|
||||
|
||||
void buildSyntheticGroupMessages
|
||||
|
||||
const sortMessagesChronologically = (items: Message[]): Message[] =>
|
||||
[...items].sort((left, right) => {
|
||||
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
||||
@@ -111,6 +115,8 @@ function App(): React.ReactElement {
|
||||
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
||||
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
||||
const selectedContactMd5Ref = React.useRef<string>('')
|
||||
|
||||
const refreshSelfInfo = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -130,6 +136,34 @@ function App(): React.ReactElement {
|
||||
const list = await window.api.getContacts()
|
||||
setContacts(list)
|
||||
setFilteredContacts(list)
|
||||
void hydrateContactAvatars(list)
|
||||
}
|
||||
|
||||
const hydrateContactAvatars = async (list: Contact[]): Promise<void> => {
|
||||
const usernames = Array.from(
|
||||
new Set(
|
||||
list
|
||||
.map((contact) => contact.m_nsUsrName)
|
||||
.filter((username) => username && !username.startsWith('Group_') && !username.startsWith('Unknown_'))
|
||||
)
|
||||
)
|
||||
const chunkSize = 60
|
||||
for (let index = 0; index < usernames.length; index += chunkSize) {
|
||||
const chunk = usernames.slice(index, index + chunkSize)
|
||||
if (chunk.length === 0) continue
|
||||
try {
|
||||
const avatars = await window.api.getContactAvatars(chunk)
|
||||
setContacts((current) =>
|
||||
current.map((contact) => avatars[contact.m_nsUsrName] ? { ...contact, avatar: avatars[contact.m_nsUsrName] } : contact)
|
||||
)
|
||||
setFilteredContacts((current) =>
|
||||
current.map((contact) => avatars[contact.m_nsUsrName] ? { ...contact, avatar: avatars[contact.m_nsUsrName] } : contact)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[Contacts] avatar hydrate failed:', error)
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 50))
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -148,6 +182,16 @@ function App(): React.ReactElement {
|
||||
if (active) setBootState('login')
|
||||
return
|
||||
}
|
||||
if (active) {
|
||||
setDbKey(key)
|
||||
setAutoConnectSource(envKey ? 'env' : 'saved')
|
||||
setDbKeyStatus(
|
||||
envKey ? '已加载环境变量中的密钥,请手动点击 Connect' : '已加载安全保存的密钥,请手动点击 Connect'
|
||||
)
|
||||
setDbKeyStatusKind('normal')
|
||||
setBootState('login')
|
||||
}
|
||||
if (true) return
|
||||
if (active) {
|
||||
setBootState('connecting')
|
||||
setDbKey(key)
|
||||
@@ -158,7 +202,7 @@ function App(): React.ReactElement {
|
||||
setDbKeyStatusKind('normal')
|
||||
}
|
||||
try {
|
||||
const result = await window.api.initDb(key)
|
||||
const result: any = await window.api.initDb(key)
|
||||
if (!active) return
|
||||
const success = typeof result === 'boolean' ? result : result.success
|
||||
if (success) {
|
||||
@@ -176,7 +220,7 @@ function App(): React.ReactElement {
|
||||
setDbKeyStatusKind('error')
|
||||
setBootState('login')
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (!active) return
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setDbKeyStatus(`自动连接失败: ${message}`)
|
||||
@@ -255,6 +299,45 @@ function App(): React.ReactElement {
|
||||
[]
|
||||
)
|
||||
|
||||
const applyGroupMemberMeta = React.useCallback(
|
||||
(contact: Contact | null, baseMessages: Message[]): Message[] => {
|
||||
if (!contact || contact.type !== 'group') return baseMessages
|
||||
const memberMap = groupMemberMetaRef.current[contact.md5]
|
||||
if (!memberMap || memberMap.size === 0) return baseMessages
|
||||
|
||||
return baseMessages.map((message) => {
|
||||
const senderId = String(message.senderId || message.name || '').trim()
|
||||
if (!senderId || !senderId.startsWith('wxid_')) return message
|
||||
const member = memberMap.get(senderId)
|
||||
if (!member) return message
|
||||
const nickname = member.nickname && !member.nickname.startsWith('wxid_') ? member.nickname : senderId
|
||||
return {
|
||||
...message,
|
||||
name: nickname,
|
||||
img: message.img || member.avatar
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const loadGroupMemberMeta = React.useCallback(
|
||||
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
|
||||
if (!contact || contact.type !== 'group') return null
|
||||
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
|
||||
if (!snapshot) return null
|
||||
currentGroupSnapshotRef.current = snapshot
|
||||
groupMemberMetaRef.current[contact.md5] = new Map(
|
||||
snapshot.members.map((member) => [
|
||||
member.wxid,
|
||||
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
|
||||
])
|
||||
)
|
||||
return snapshot
|
||||
},
|
||||
[logGroupSnapshot]
|
||||
)
|
||||
|
||||
const handleAutoGetDbKey = async (): Promise<void> => {
|
||||
if (isFetchingDbKey) return
|
||||
setIsFetchingDbKey(true)
|
||||
@@ -338,11 +421,22 @@ function App(): React.ReactElement {
|
||||
|
||||
const handleSelectContact = async (contact: Contact): Promise<void> => {
|
||||
setSelectedContact(contact)
|
||||
selectedContactMd5Ref.current = contact.md5
|
||||
currentGroupSnapshotRef.current = null
|
||||
const { startTime, endTime } = getDateRangeParams(dateRange)
|
||||
const msgs = await window.api.getMessages(contact.md5, startTime, endTime)
|
||||
const snapshot = await logGroupSnapshot(contact, 'select-contact')
|
||||
currentGroupSnapshotRef.current = snapshot
|
||||
setMessages(mergeSyntheticMessages(contact, msgs, snapshot?.roomId))
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
|
||||
setMessages(cachedMessages)
|
||||
if (contact.type === 'group') {
|
||||
void loadGroupMemberMeta(contact).then((snapshot) => {
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
if (!snapshot) return
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDateRangeChange = (range: string): void => {
|
||||
@@ -350,7 +444,7 @@ function App(): React.ReactElement {
|
||||
if (selectedContact) {
|
||||
const { startTime, endTime } = getDateRangeParams(range)
|
||||
window.api.getMessages(selectedContact.md5, startTime, endTime).then((nextMessages) => {
|
||||
setMessages(mergeSyntheticMessages(selectedContact, nextMessages))
|
||||
setMessages(applyGroupMemberMeta(selectedContact, mergeSyntheticMessages(selectedContact, nextMessages)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -360,9 +454,16 @@ function App(): React.ReactElement {
|
||||
|
||||
let disposed = false
|
||||
let refreshTimer: number | null = null
|
||||
let refreshInFlight = false
|
||||
let refreshQueued = false
|
||||
const contactMd5 = selectedContact.md5
|
||||
|
||||
const refreshCurrentConversation = async (): Promise<void> => {
|
||||
if (refreshInFlight) {
|
||||
refreshQueued = true
|
||||
return
|
||||
}
|
||||
refreshInFlight = true
|
||||
try {
|
||||
const range = getDateRangeParams(dateRange)
|
||||
const latestMessages = await window.api.getMessages(
|
||||
@@ -370,41 +471,9 @@ function App(): React.ReactElement {
|
||||
range.startTime,
|
||||
range.endTime
|
||||
)
|
||||
const latestSnapshot = await logGroupSnapshot(selectedContact, 'wcdb-change')
|
||||
const syntheticEvents = buildSyntheticGroupMessages(
|
||||
currentGroupSnapshotRef.current,
|
||||
latestSnapshot,
|
||||
latestMessages
|
||||
)
|
||||
if (latestSnapshot) {
|
||||
currentGroupSnapshotRef.current = latestSnapshot
|
||||
if (syntheticEvents.length) {
|
||||
const existing = syntheticGroupMessagesRef.current[latestSnapshot.roomId] || []
|
||||
const existingIds = new Set(existing.map((message) => message.id))
|
||||
const appended = syntheticEvents.filter((message) => !existingIds.has(message.id))
|
||||
if (appended.length) {
|
||||
syntheticGroupMessagesRef.current[latestSnapshot.roomId] = [...existing, ...appended]
|
||||
console.log(
|
||||
`[GroupMonitor] merged synthetic messages roomId=${latestSnapshot.roomId} total=${syntheticGroupMessagesRef.current[latestSnapshot.roomId].length}`
|
||||
)
|
||||
if (!disposed) {
|
||||
setMessages((current) =>
|
||||
sortMessagesChronologically([
|
||||
...current,
|
||||
...appended.filter(
|
||||
(message) =>
|
||||
!current.some((existingMessage) => existingMessage.id === message.id)
|
||||
)
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const nextMessages = mergeSyntheticMessages(
|
||||
const nextMessages = applyGroupMemberMeta(
|
||||
selectedContact,
|
||||
latestMessages,
|
||||
latestSnapshot?.roomId
|
||||
mergeSyntheticMessages(selectedContact, latestMessages)
|
||||
)
|
||||
if (!disposed) {
|
||||
setMessages((current) =>
|
||||
@@ -414,6 +483,15 @@ function App(): React.ReactElement {
|
||||
} catch (error) {
|
||||
console.warn('[MessageMonitor] 刷新当前会话失败:', error)
|
||||
}
|
||||
refreshInFlight = false
|
||||
if (refreshQueued && !disposed) {
|
||||
refreshQueued = false
|
||||
if (refreshTimer) window.clearTimeout(refreshTimer)
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = null
|
||||
void refreshCurrentConversation()
|
||||
}, MESSAGE_MONITOR_DEBOUNCE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = window.api.onWcdbChange(() => {
|
||||
@@ -435,6 +513,7 @@ function App(): React.ReactElement {
|
||||
isNativeMonitorActive,
|
||||
selectedContact,
|
||||
logGroupSnapshot,
|
||||
applyGroupMemberMeta,
|
||||
mergeSyntheticMessages
|
||||
])
|
||||
|
||||
|
||||
@@ -25,6 +25,25 @@ const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
|
||||
{ value: 'yesterday', label: '昨日' },
|
||||
{ value: '7days', label: '最近 7 天' }
|
||||
]
|
||||
const MAX_RENDERED_MESSAGES = 600
|
||||
const REPORT_STEP_TIMEOUT_MS = 90_000
|
||||
|
||||
const withTimeout = async <T,>(promise: Promise<T>, label: string): Promise<T> => {
|
||||
let timer: number | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), REPORT_STEP_TIMEOUT_MS)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([promise, timeout])
|
||||
} finally {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
const isInternalName = (value?: string): boolean => {
|
||||
const text = String(value || '').trim()
|
||||
return !text || /^wxid_/i.test(text) || /@chatroom$/i.test(text) || /^[a-z0-9_-]{18,}$/i.test(text)
|
||||
}
|
||||
|
||||
const SUMMARY_TYPE_OPTIONS: {
|
||||
value: SummaryMessageType
|
||||
@@ -240,20 +259,47 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
|
||||
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
|
||||
|
||||
const input = buildGroupReportInput(reportMessages, contact, isGroupChat)
|
||||
let memberMap = new Map<string, { nickname: string; avatar: string }>()
|
||||
if (isGroupChat) {
|
||||
try {
|
||||
const snapshot = await withTimeout(window.api.getGroupSnapshot(contact.md5), '读取群成员')
|
||||
memberMap = new Map(
|
||||
(snapshot?.members || []).map((member) => [
|
||||
member.wxid,
|
||||
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
|
||||
])
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[GroupReport] member snapshot failed:', error)
|
||||
}
|
||||
}
|
||||
const namedReportMessages = reportMessages.map((message) => {
|
||||
if (!isGroupChat || !isInternalName(message.name)) return message
|
||||
const senderId = String(message.senderId || message.name || '')
|
||||
const member = memberMap.get(senderId)
|
||||
if (!member?.nickname || isInternalName(member.nickname)) return message
|
||||
return { ...message, name: member.nickname, img: message.img || member.avatar }
|
||||
})
|
||||
const input = buildGroupReportInput(namedReportMessages, contact, isGroupChat)
|
||||
console.log('🚀 ~ AIChat ~ input:', input)
|
||||
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
|
||||
const result = await window.api.aiChat(
|
||||
[
|
||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: input.prompt }
|
||||
],
|
||||
{ apiKey, model, baseURL }
|
||||
const result = await withTimeout(
|
||||
window.api.aiChat(
|
||||
[
|
||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: input.prompt }
|
||||
],
|
||||
{ apiKey, model, baseURL }
|
||||
),
|
||||
'AI 生成日报'
|
||||
)
|
||||
|
||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
|
||||
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
|
||||
const exported = await withTimeout(
|
||||
window.api.exportGroupReport({ report, metadata: input.metadata }),
|
||||
'日报图片导出'
|
||||
)
|
||||
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||
throw new Error(exported.error || '日报文件生成失败')
|
||||
}
|
||||
@@ -285,6 +331,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
return typeMatch && contentMatch
|
||||
})
|
||||
}, [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 (
|
||||
@@ -302,7 +353,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="message-list wechat-message-list">
|
||||
{filteredMessages.map((msg) => {
|
||||
{hiddenMessageCount > 0 && (
|
||||
<div className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">
|
||||
已隐藏较早的 {hiddenMessageCount} 条消息,当前显示最新 {MAX_RENDERED_MESSAGES} 条
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{renderedMessages.map((msg) => {
|
||||
const isMine = msg.from === 'assistant'
|
||||
const isSystem = msg.from === 'system' || msg.type === '系统消息'
|
||||
const displayName = isMine
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Message {
|
||||
isSender: boolean
|
||||
img?: string
|
||||
name?: string
|
||||
senderId?: string
|
||||
contentData?: ParsedContent
|
||||
voiceDataUrl?: string
|
||||
voiceDuration?: number
|
||||
|
||||
Reference in New Issue
Block a user