mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
fix: 优化群聊展示与群日报生成
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user