diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts index 037d085..a7e9548 100644 --- a/src/main/group-report-service.ts +++ b/src/main/group-report-service.ts @@ -278,6 +278,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise => { + 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 img.complete ? Promise.resolve() : new Promise((resolve) => { @@ -296,29 +298,23 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise 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() } } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index dcfbcc6..bdc10ec 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -38,6 +38,7 @@ declare global { key: string ) => Promise getContacts: (filter?: string) => Promise + getContactAvatars: (usernames: string[]) => Promise> getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise getGroupSnapshot: (userMd5: string) => Promise<{ roomId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 65c74dd..b5cc046 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7ae9f40..935baaf 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(null) const syntheticGroupMessagesRef = React.useRef>({}) + const groupMemberMetaRef = React.useRef>>({}) + const selectedContactMd5Ref = React.useRef('') const refreshSelfInfo = async (): Promise => { 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 => { + 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 => { + 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 => { if (isFetchingDbKey) return setIsFetchingDbKey(true) @@ -338,11 +421,22 @@ function App(): React.ReactElement { const handleSelectContact = async (contact: Contact): Promise => { 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 => { + 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 ]) diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index 7b76cd4..7c928a1 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -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 (promise: Promise, label: string): Promise => { + let timer: number | undefined + const timeout = new Promise((_, 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 = ({ 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() + 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 = ({ 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 = ({
- {filteredMessages.map((msg) => { + {hiddenMessageCount > 0 && ( +
+
+ 已隐藏较早的 {hiddenMessageCount} 条消息,当前显示最新 {MAX_RENDERED_MESSAGES} 条 +
+
+ )} + {renderedMessages.map((msg) => { const isMine = msg.from === 'assistant' const isSystem = msg.from === 'system' || msg.type === '系统消息' const displayName = isMine diff --git a/src/shared/types.ts b/src/shared/types.ts index 18f8016..2bf8c61 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -15,6 +15,7 @@ export interface Message { isSender: boolean img?: string name?: string + senderId?: string contentData?: ParsedContent voiceDataUrl?: string voiceDuration?: number