mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +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> => {
|
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
|
||||||
|
console.log(`[GroupReport] capture begin html=${htmlPath}`)
|
||||||
const reportWindow = new BrowserWindow({
|
const reportWindow = new BrowserWindow({
|
||||||
show: false,
|
show: false,
|
||||||
width: 430,
|
width: 430,
|
||||||
@@ -289,6 +290,7 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise<strin
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await reportWindow.loadFile(htmlPath)
|
await reportWindow.loadFile(htmlPath)
|
||||||
|
console.log('[GroupReport] capture loaded html')
|
||||||
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
||||||
document.fonts.ready,
|
document.fonts.ready,
|
||||||
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
|
...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 });
|
img.addEventListener('error', resolve, { once: true });
|
||||||
}))
|
}))
|
||||||
])`)
|
])`)
|
||||||
reportWindow.webContents.debugger.attach('1.3')
|
console.log('[GroupReport] capture assets ready')
|
||||||
const metrics = (await reportWindow.webContents.debugger.sendCommand(
|
const metrics = (await reportWindow.webContents.executeJavaScript(`({
|
||||||
'Page.getLayoutMetrics'
|
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 430)),
|
||||||
)) as { cssContentSize: { width: number; height: number } }
|
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
|
||||||
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
|
})`)) as { width: number; height: number }
|
||||||
const height = Math.ceil(metrics.cssContentSize.height)
|
const width = Math.max(430, Math.min(1200, Math.ceil(metrics.width)))
|
||||||
const screenshot = (await reportWindow.webContents.debugger.sendCommand(
|
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
|
||||||
'Page.captureScreenshot',
|
reportWindow.setContentSize(width, height)
|
||||||
{
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
format: 'png',
|
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
|
||||||
captureBeyondViewport: true,
|
const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height })
|
||||||
fromSurface: true,
|
const png = image.toPNG()
|
||||||
clip: { x: 0, y: 0, width, height, scale: 1 }
|
|
||||||
}
|
|
||||||
)) as { data: string }
|
|
||||||
const png = Buffer.from(screenshot.data, 'base64')
|
|
||||||
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
||||||
await fs.writeFile(pngPath, png)
|
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 {
|
} finally {
|
||||||
if (reportWindow.webContents.debugger.isAttached()) {
|
|
||||||
reportWindow.webContents.debugger.detach()
|
|
||||||
}
|
|
||||||
reportWindow.destroy()
|
reportWindow.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -38,6 +38,7 @@ declare global {
|
|||||||
key: string
|
key: string
|
||||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||||
getContacts: (filter?: string) => Promise<Contact[]>
|
getContacts: (filter?: string) => Promise<Contact[]>
|
||||||
|
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||||
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
|
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
|
||||||
getGroupSnapshot: (userMd5: string) => Promise<{
|
getGroupSnapshot: (userMd5: string) => Promise<{
|
||||||
roomId: string
|
roomId: string
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { GroupReportExportRequest } from '../shared/group-report'
|
|||||||
const api = {
|
const api = {
|
||||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||||
|
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
||||||
getMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
getMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime),
|
ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime),
|
||||||
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
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 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 => {
|
const getMessageIdentity = (message: Message): string => {
|
||||||
if (message.localId) return `local:${message.localId}`
|
if (message.localId) return `local:${message.localId}`
|
||||||
@@ -35,6 +35,8 @@ type GroupSnapshot = {
|
|||||||
members: { wxid: string; nickname: string; avatar: string }[]
|
members: { wxid: string; nickname: string; avatar: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GroupMemberMeta = { nickname: string; avatar: string }
|
||||||
|
|
||||||
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
||||||
member.nickname || member.wxid
|
member.nickname || member.wxid
|
||||||
|
|
||||||
@@ -83,6 +85,8 @@ const buildSyntheticGroupMessages = (
|
|||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void buildSyntheticGroupMessages
|
||||||
|
|
||||||
const sortMessagesChronologically = (items: Message[]): Message[] =>
|
const sortMessagesChronologically = (items: Message[]): Message[] =>
|
||||||
[...items].sort((left, right) => {
|
[...items].sort((left, right) => {
|
||||||
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
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 [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
||||||
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
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> => {
|
const refreshSelfInfo = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -130,6 +136,34 @@ function App(): React.ReactElement {
|
|||||||
const list = await window.api.getContacts()
|
const list = await window.api.getContacts()
|
||||||
setContacts(list)
|
setContacts(list)
|
||||||
setFilteredContacts(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(() => {
|
React.useEffect(() => {
|
||||||
@@ -148,6 +182,16 @@ function App(): React.ReactElement {
|
|||||||
if (active) setBootState('login')
|
if (active) setBootState('login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (active) {
|
||||||
|
setDbKey(key)
|
||||||
|
setAutoConnectSource(envKey ? 'env' : 'saved')
|
||||||
|
setDbKeyStatus(
|
||||||
|
envKey ? '已加载环境变量中的密钥,请手动点击 Connect' : '已加载安全保存的密钥,请手动点击 Connect'
|
||||||
|
)
|
||||||
|
setDbKeyStatusKind('normal')
|
||||||
|
setBootState('login')
|
||||||
|
}
|
||||||
|
if (true) return
|
||||||
if (active) {
|
if (active) {
|
||||||
setBootState('connecting')
|
setBootState('connecting')
|
||||||
setDbKey(key)
|
setDbKey(key)
|
||||||
@@ -158,7 +202,7 @@ function App(): React.ReactElement {
|
|||||||
setDbKeyStatusKind('normal')
|
setDbKeyStatusKind('normal')
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await window.api.initDb(key)
|
const result: any = await window.api.initDb(key)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const success = typeof result === 'boolean' ? result : result.success
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -176,7 +220,7 @@ function App(): React.ReactElement {
|
|||||||
setDbKeyStatusKind('error')
|
setDbKeyStatusKind('error')
|
||||||
setBootState('login')
|
setBootState('login')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
setDbKeyStatus(`自动连接失败: ${message}`)
|
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> => {
|
const handleAutoGetDbKey = async (): Promise<void> => {
|
||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
@@ -338,11 +421,22 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
const handleSelectContact = async (contact: Contact): Promise<void> => {
|
const handleSelectContact = async (contact: Contact): Promise<void> => {
|
||||||
setSelectedContact(contact)
|
setSelectedContact(contact)
|
||||||
|
selectedContactMd5Ref.current = contact.md5
|
||||||
|
currentGroupSnapshotRef.current = null
|
||||||
const { startTime, endTime } = getDateRangeParams(dateRange)
|
const { startTime, endTime } = getDateRangeParams(dateRange)
|
||||||
const msgs = await window.api.getMessages(contact.md5, startTime, endTime)
|
const msgs = await window.api.getMessages(contact.md5, startTime, endTime)
|
||||||
const snapshot = await logGroupSnapshot(contact, 'select-contact')
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
currentGroupSnapshotRef.current = snapshot
|
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
|
||||||
setMessages(mergeSyntheticMessages(contact, msgs, snapshot?.roomId))
|
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 => {
|
const handleDateRangeChange = (range: string): void => {
|
||||||
@@ -350,7 +444,7 @@ function App(): React.ReactElement {
|
|||||||
if (selectedContact) {
|
if (selectedContact) {
|
||||||
const { startTime, endTime } = getDateRangeParams(range)
|
const { startTime, endTime } = getDateRangeParams(range)
|
||||||
window.api.getMessages(selectedContact.md5, startTime, endTime).then((nextMessages) => {
|
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 disposed = false
|
||||||
let refreshTimer: number | null = null
|
let refreshTimer: number | null = null
|
||||||
|
let refreshInFlight = false
|
||||||
|
let refreshQueued = false
|
||||||
const contactMd5 = selectedContact.md5
|
const contactMd5 = selectedContact.md5
|
||||||
|
|
||||||
const refreshCurrentConversation = async (): Promise<void> => {
|
const refreshCurrentConversation = async (): Promise<void> => {
|
||||||
|
if (refreshInFlight) {
|
||||||
|
refreshQueued = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshInFlight = true
|
||||||
try {
|
try {
|
||||||
const range = getDateRangeParams(dateRange)
|
const range = getDateRangeParams(dateRange)
|
||||||
const latestMessages = await window.api.getMessages(
|
const latestMessages = await window.api.getMessages(
|
||||||
@@ -370,41 +471,9 @@ function App(): React.ReactElement {
|
|||||||
range.startTime,
|
range.startTime,
|
||||||
range.endTime
|
range.endTime
|
||||||
)
|
)
|
||||||
const latestSnapshot = await logGroupSnapshot(selectedContact, 'wcdb-change')
|
const nextMessages = applyGroupMemberMeta(
|
||||||
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(
|
|
||||||
selectedContact,
|
selectedContact,
|
||||||
latestMessages,
|
mergeSyntheticMessages(selectedContact, latestMessages)
|
||||||
latestSnapshot?.roomId
|
|
||||||
)
|
)
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
setMessages((current) =>
|
setMessages((current) =>
|
||||||
@@ -414,6 +483,15 @@ function App(): React.ReactElement {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[MessageMonitor] 刷新当前会话失败:', 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(() => {
|
const unsubscribe = window.api.onWcdbChange(() => {
|
||||||
@@ -435,6 +513,7 @@ function App(): React.ReactElement {
|
|||||||
isNativeMonitorActive,
|
isNativeMonitorActive,
|
||||||
selectedContact,
|
selectedContact,
|
||||||
logGroupSnapshot,
|
logGroupSnapshot,
|
||||||
|
applyGroupMemberMeta,
|
||||||
mergeSyntheticMessages
|
mergeSyntheticMessages
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
|
|||||||
{ value: 'yesterday', label: '昨日' },
|
{ value: 'yesterday', label: '昨日' },
|
||||||
{ value: '7days', label: '最近 7 天' }
|
{ 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: {
|
const SUMMARY_TYPE_OPTIONS: {
|
||||||
value: SummaryMessageType
|
value: SummaryMessageType
|
||||||
@@ -240,20 +259,47 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
|
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
|
||||||
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
|
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:', input)
|
||||||
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
|
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
|
||||||
const result = await window.api.aiChat(
|
const result = await withTimeout(
|
||||||
|
window.api.aiChat(
|
||||||
[
|
[
|
||||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||||
{ role: 'user', content: input.prompt }
|
{ role: 'user', content: input.prompt }
|
||||||
],
|
],
|
||||||
{ apiKey, model, baseURL }
|
{ apiKey, model, baseURL }
|
||||||
|
),
|
||||||
|
'AI 生成日报'
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||||
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
|
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) {
|
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||||
throw new Error(exported.error || '日报文件生成失败')
|
throw new Error(exported.error || '日报文件生成失败')
|
||||||
}
|
}
|
||||||
@@ -285,6 +331,11 @@ 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) {
|
if (!contact) {
|
||||||
return (
|
return (
|
||||||
@@ -302,7 +353,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="message-list wechat-message-list">
|
<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 isMine = msg.from === 'assistant'
|
||||||
const isSystem = msg.from === 'system' || msg.type === '系统消息'
|
const isSystem = msg.from === 'system' || msg.type === '系统消息'
|
||||||
const displayName = isMine
|
const displayName = isMine
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface Message {
|
|||||||
isSender: boolean
|
isSender: boolean
|
||||||
img?: string
|
img?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
senderId?: string
|
||||||
contentData?: ParsedContent
|
contentData?: ParsedContent
|
||||||
voiceDataUrl?: string
|
voiceDataUrl?: string
|
||||||
voiceDuration?: number
|
voiceDuration?: number
|
||||||
|
|||||||
Reference in New Issue
Block a user