fix: 优化群聊展示与群日报生成

This commit is contained in:
电摇小子
2026-07-14 10:19:40 +08:00
committed by 电摇小子
parent 267730d59e
commit e8c12696b2
6 changed files with 206 additions and 70 deletions
+16 -20
View File
@@ -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()
}
}
+1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
])
+67 -9
View File
@@ -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
+1
View File
@@ -15,6 +15,7 @@ export interface Message {
isSender: boolean
img?: string
name?: string
senderId?: string
contentData?: ParsedContent
voiceDataUrl?: string
voiceDuration?: number