feat: 修改群聊日报生成与群成员信息展示

- 支持结构化 AI 群聊日报及移动端长图导出
- 支持日报时间范围和消息类型筛选
- 支持群成员及当前用户群昵称解析
- 移除消息加载更多并优化图片懒加载
- 增强头像处理与敏感密钥日志保护
This commit is contained in:
电摇小子
2026-06-29 16:43:01 +08:00
parent f6f8bc33f9
commit c00d581d9e
15 changed files with 1531 additions and 175 deletions
+73
View File
@@ -703,6 +703,79 @@ body {
overflow: auto;
}
.ai-settings-modal {
width: min(460px, 90vw);
}
.ai-settings-modal h3 {
margin: 0 0 18px;
}
.ai-filter-section {
margin-bottom: 16px;
}
.ai-filter-label {
margin-bottom: 7px;
color: #333;
font-size: 14px;
font-weight: 600;
}
.ai-date-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.ai-date-options label {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
gap: 5px;
padding: 8px 6px;
border: 1px solid #d8dcdf;
border-radius: 6px;
background: #fff;
color: #4a5257;
cursor: pointer;
font-size: 13px;
}
.ai-date-options label.selected {
border-color: #07c160;
background: #eefaf3;
color: #078f49;
}
.ai-date-options input,
.ai-type-options input {
margin: 0;
accent-color: #07c160;
}
.ai-type-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 10px;
padding: 11px;
border: 1px solid #e1e4e6;
border-radius: 6px;
background: #f8f9fa;
}
.ai-type-options label {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
color: #3f474c;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
.image-preview-modal {
background: transparent;
box-shadow: none;
+128 -135
View File
@@ -1,9 +1,13 @@
import React, { useEffect, useRef, useState } from 'react'
import { toPng } from 'html-to-image'
import { Message, Contact } from '../../../shared/types'
import { VoicePlayer } from './VoicePlayer'
import { RichMessageBubble } from './RichMessageBubble'
import { ImageBubble } from './ImageBubble'
import {
buildGroupReportInput,
GROUP_REPORT_SYSTEM_PROMPT,
parseGroupDailyReport
} from '../utils/group-report'
interface ChatWindowProps {
contact: Contact | null
@@ -13,27 +17,41 @@ interface ChatWindowProps {
onRefreshData?: () => void
}
const systemPrompt = `你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
请注意 不要回复总结除外的内容, 并且不要输出 群友的wxid 微信id 只需要显示群名称
请帮我将给出的群聊内容总结成一个群聊报告,需要你生成7个最重要 最火爆的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容:
- 整体评价
- 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示)
- 参与者(不超过5个人,将重复的人名去重)
- 注意按时间排序,时间段(从日期几点到几点)
- 过程(50到200字左右)
- 评价(50字以下)
- 生成这7天内热度最高的话题,27日到2日一共7天
需要生成27, 28, 29, 30, 31, 1, 2日的话题总结
- 分割线: ------------
type SummaryDateRange = 'today' | 'yesterday' | '7days'
type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system'
另外有以下要求:
1. 每个话题结束使用------------分割
2. 使用中文冒号
3. 无需大标题
4. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类
5. 每个话题详细写出参与者
const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
{ value: 'today', label: '今天' },
{ value: 'yesterday', label: '昨日' },
{ value: '7days', label: '最近 7 天' }
]
最后总结下今日最活跃的前五个发言者`
const SUMMARY_TYPE_OPTIONS: {
value: SummaryMessageType
label: string
messageTypes: string[]
}[] = [
{ value: 'text', label: '文本', messageTypes: ['普通文本'] },
{ value: 'image', label: '图片', messageTypes: ['图片'] },
{ value: 'sticker', label: '表情包', messageTypes: ['表情包'] },
{ value: 'video', label: '视频', messageTypes: ['视频'] },
{ value: 'voice', label: '语音', messageTypes: ['语音'] },
{ value: 'share', label: '分享/引用', messageTypes: ['分享消息', '名片', '位置', '通话'] },
{ value: 'system', label: '系统消息', messageTypes: ['系统消息'] }
]
const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endTime: number } => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
const endTime = Math.floor(Date.now() / 1000)
if (range === 'yesterday') {
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
}
if (range === '7days') {
return { startTime: startOfToday - 6 * 86400, endTime }
}
return { startTime: startOfToday, endTime }
}
const ChatWindow: React.FC<ChatWindowProps> = ({
contact,
@@ -42,10 +60,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onRefresh,
onRefreshData
}) => {
const isGroupChat = contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
)
const messagesEndRef = useRef<HTMLDivElement>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null)
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [imageScale, setImageScale] = useState(0.75)
const [imageRotation, setImageRotation] = useState(0)
@@ -62,8 +82,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
)
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const handleSaveSettings = (): void => {
if (!summaryMessageTypes.length) {
alert('请至少选择一种消息类型')
return
}
localStorage.setItem('ai_api_key', apiKey)
localStorage.setItem('ai_base_url', baseURL)
localStorage.setItem('ai_model', model)
@@ -71,6 +97,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
AIChat()
}
const toggleSummaryMessageType = (type: SummaryMessageType): void => {
setSummaryMessageTypes((current) =>
current.includes(type) ? current.filter((item) => item !== type) : [...current, type]
)
}
const scrollToBottom = (): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
}
@@ -188,86 +220,52 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
document.body.removeChild(link)
}
const [summaryContent, setSummaryContent] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const AIChat = async (): Promise<void> => {
if (!messages || messages.length === 0) {
alert('当前没有消息可供总结')
if (!contact) return
if (!summaryMessageTypes.length) {
alert('请至少选择一种消息类型')
return
}
const filteredMessages = messages
.filter((msg) => !'分享消息,图片,表情包,视频'.split(',').includes(msg.type))
.map((msg) => {
return {
from: msg.from,
type: msg.type,
datetime: msg.datetime,
content: msg.content,
name: msg.name
}
})
const recentMessages = filteredMessages
.map((msg) => {
return `${msg.datetime} ${msg.from}: ${msg.content}`
})
.join('\n')
const prompt = `请总结以下微信聊天记录的核心内容:\n\n${recentMessages}`
setIsLoading(true)
try {
console.log('正在请求AI...')
const { startTime, endTime } = getSummaryDateRange(summaryDateRange)
const rangeMessages = await window.api.getMessages(contact.md5, startTime, endTime)
const allowedTypes = new Set(
SUMMARY_TYPE_OPTIONS.filter((option) => summaryMessageTypes.includes(option.value)).flatMap(
(option) => option.messageTypes
)
)
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
const input = buildGroupReportInput(reportMessages, contact, isGroupChat)
console.log('🚀 ~ AIChat ~ input:', input)
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
const result = await window.api.aiChat(
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt }
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
],
{ apiKey, model, baseURL }
)
if (result.success && result.data) {
console.log('AI Summary:', result.data)
setSummaryContent(result.data)
// 等待状态更新和渲染
setTimeout(() => {
textToImage()
setIsLoading(false) // 图片生成开始后停止加载
}, 500)
} else {
console.error('AI Error:', result.error)
alert(`AI 请求失败: ${result.error}`)
setIsLoading(false)
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 })
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
throw new Error(exported.error || '日报文件生成失败')
}
setGeneratedImage(exported.imageDataUrl)
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
} catch (error) {
console.error('AI Call Failed:', error)
alert('AI 请求发生错误')
alert(`AI 日报生成失败:${error instanceof Error ? error.message : String(error)}`)
} finally {
setIsLoading(false)
}
}
const textToImage = async (): Promise<void> => {
if (imageContainerRef.current) {
try {
const dataUrl = await toPng(imageContainerRef.current, {
cacheBust: true,
backgroundColor: '#ffffff',
style: {
transform: 'scale(1)'
}
})
if (dataUrl && dataUrl.length > 100) {
setGeneratedImage(dataUrl)
} else {
alert('生成图片为空')
}
} catch (err) {
console.error('Failed to generate image', err)
alert('生成图片失败: ' + (err instanceof Error ? err.message : String(err)))
}
}
}
const handleCopyImage = async (): Promise<void> => {
if (!generatedImage) return
const result = await window.api.copyImage(generatedImage)
@@ -276,8 +274,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}
}
const [displayLimit, setDisplayLimit] = useState(100)
const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
@@ -290,8 +286,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
})
}, [messages, contentFilter])
const visibleMessages = filteredMessages.slice(0, displayLimit)
if (!contact) {
return (
<div className="chat-window">
@@ -308,7 +302,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</div>
<div className="message-list wechat-message-list">
{visibleMessages.map((msg) => {
{filteredMessages.map((msg) => {
const isMine = msg.from === 'assistant'
const displayName = isMine
? '我'
@@ -368,22 +362,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</div>
)
})}
{filteredMessages.length > displayLimit && (
<div style={{ textAlign: 'center', padding: '10px' }}>
<button
onClick={() => setDisplayLimit((prev) => prev + 100)}
style={{
padding: '8px 16px',
backgroundColor: '#f0f0f0',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer'
}}
>
({filteredMessages.length - displayLimit} )
</button>
</div>
)}
<div ref={messagesEndRef} />
</div>
@@ -425,42 +403,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</button>
</div>
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: 0,
height: 0,
overflow: 'hidden',
zIndex: -1
}}
>
<div
style={{
width: '820px',
padding: '20px',
backgroundColor: '#fff',
fontSize: '22px',
color: '#000',
whiteSpace: 'pre-wrap',
fontFamily: 'sans-serif',
lineHeight: '1.5'
}}
ref={imageContainerRef}
>
{summaryContent}
</div>
</div>
{/* 加载模态框 */}
{isLoading && (
<div className="modal-overlay">
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
<div style={{ fontSize: '16px', color: '#333' }}> AI ...</div>
<div style={{ fontSize: '16px', color: '#333' }}>...</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
HTML
</div>
</div>
</div>
@@ -496,6 +446,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
>
📋
</button>
{reportPaths && (
<button
onClick={() => window.api.revealGroupReport(reportPaths.pngPath)}
style={{ padding: '5px 10px', cursor: 'pointer' }}
>
</button>
)}
<button
onClick={() => setGeneratedImage(null)}
style={{ padding: '5px 10px', cursor: 'pointer' }}
@@ -559,8 +517,43 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{/* AI Settings Modal */}
{showSettingsModal && (
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-content ai-settings-modal" onClick={(e) => e.stopPropagation()}>
<h3>AI </h3>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-date-options">
{SUMMARY_DATE_OPTIONS.map((option) => (
<label
key={option.value}
className={summaryDateRange === option.value ? 'selected' : ''}
>
<input
type="radio"
name="summary-date-range"
value={option.value}
checked={summaryDateRange === option.value}
onChange={() => setSummaryDateRange(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-type-options">
{SUMMARY_TYPE_OPTIONS.map((option) => (
<label key={option.value}>
<input
type="checkbox"
checked={summaryMessageTypes.includes(option.value)}
onChange={() => toggleSummaryMessageType(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>:</label>
<select
+19 -6
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react'
interface ImageBubbleProps {
@@ -19,6 +19,7 @@ export function ImageBubble({
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const loadImage = useCallback(async () => {
if (imageUrl || loading) return
@@ -50,10 +51,22 @@ export function ImageBubble({
useEffect(() => {
if (imageUrl || loading || error) return
const timer = window.setTimeout(() => {
void loadImage()
}, 0)
return () => window.clearTimeout(timer)
const element = containerRef.current
if (!element || typeof IntersectionObserver === 'undefined') {
const timer = window.setTimeout(() => void loadImage(), 0)
return () => window.clearTimeout(timer)
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return
observer.disconnect()
void loadImage()
},
{ rootMargin: '400px 0px' }
)
observer.observe(element)
return () => observer.disconnect()
}, [error, imageUrl, loadImage, loading])
const handleCopy = async (event: MouseEvent): Promise<void> => {
@@ -88,7 +101,7 @@ export function ImageBubble({
if (!imageUrl) {
return (
<div className="image-bubble image-placeholder">
<div ref={containerRef} className="image-bubble image-placeholder">
<div className="image-placeholder-icon">🖼</div>
<div className="image-placeholder-text"></div>
</div>
+330
View File
@@ -0,0 +1,330 @@
import { Contact, Message } from '../../../shared/types'
import {
GroupDailyReport,
GroupReportMetadata,
ReportHeat,
ReportImportantMessage,
ReportQuestionAnswer,
ReportQuote,
ReportResource,
ReportSpeakerRank,
ReportTopic
} from '../../../shared/group-report'
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
原则:
1. 不得编造聊天中没有的事实、结论、参与者或链接内容。
2. 仅使用输入中的昵称,不输出 wxid、微信 ID、会话 ID 等内部标识。
3. 图片、表情、语音、视频或链接内容不可见时,仅标注消息类型,不要猜测。
4. 摘要说明发生了什么、大家如何回应、最后形成什么结论或氛围。
5. 语气准确、轻巧、有信息密度,避免侮辱性和歧视性评价。
6. 没有实际内容的可选栏目输出空数组,不要凑数。
7. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。
JSON 结构必须为:
{
"overview": "1至2句整体讨论风格与氛围",
"topics": [{
"title": "话题标题",
"timeRange": "HH:mm-HH:mm",
"heat": "高|中|低",
"participants": ["昵称"],
"summary": "话题摘要",
"conclusion": "结论或氛围",
"keywords": ["关键词"]
}],
"resources": [{"title":"资源名","description":"用途或内容","sender":"昵称"}],
"importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要"}],
"quotes": [{"messages":[{"sender":"昵称","content":"简短原话"}],"note":"点评"}],
"qa": [{"question":"问题","answer":"答案与结论","answerer":"昵称"}],
"keywords": ["关键词"]
}
topics 提取 3 至 7 个,参与者最多 5 人,quotes 最多 3 组,keywords 输出 8 至 15 个。`
const isInternalIdentifier = (value: string): boolean =>
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
if (message.from === 'assistant') {
const ownGroupNickname = message.name?.trim()
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
return ownGroupNickname
}
return '我'
}
const candidate = isGroup ? message.name : contact?.m_nsNickName
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
return candidate
}
const summaryContent = (message: Message): string => {
const data = message.contentData
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
switch (data.type) {
case 'image':
return '[图片]'
case 'sticker':
return '[表情]'
case 'voice':
return `[语音${data.duration ? ` ${data.duration}` : ''}]`
case 'share':
return `[分享] ${data.title}${data.des ? `${data.des}` : ''}`
case 'quote': {
const reply = data.title || data.content || message.content || '[回复]'
const quotedSender =
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
return `${reply}(引用 ${quotedSender}${data.quotedContent || `[引用${data.quotedType || '消息'}]`}`
}
case 'location':
return `[位置] ${data.poiname || data.label || '位置消息'}`
case 'card':
return `[名片] ${data.nickname || '微信名片'}`
case 'voip':
return `[通话] ${data.status}${data.duration ? `${data.duration}` : ''}`
case 'system':
case 'text':
return data.content
case 'unknown':
return `[${message.type || '未知消息'}]`
}
return `[${message.type || '消息'}]`
}
const localDate = (timestamp: number): string => {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const localTime = (timestamp: number): string =>
new Date(timestamp).toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit'
})
export interface GroupReportInput {
prompt: string
metadata: GroupReportMetadata
topSpeakers: ReportSpeakerRank[]
activeTimeline: string
}
export const buildGroupReportInput = (
messages: Message[],
contact: Contact | null,
isGroup: boolean
): GroupReportInput => {
const rows = messages.map((message) => ({
datetime: message.datetime,
timestamp: new Date(message.datetime).getTime(),
sender: summarySender(message, contact, isGroup),
content: summaryContent(message),
avatar: message.img
}))
let firstTimestamp = Number.POSITIVE_INFINITY
let lastTimestamp = Number.NEGATIVE_INFINITY
for (const row of rows) {
if (!Number.isFinite(row.timestamp)) continue
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
}
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
const speakerCounts = new Map<string, number>()
const hourCounts = new Map<number, number>()
const avatars: Record<string, string | undefined> = {}
for (const row of rows) {
speakerCounts.set(row.sender, (speakerCounts.get(row.sender) || 0) + 1)
if (Number.isFinite(row.timestamp)) {
const hour = new Date(row.timestamp).getHours()
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
}
if (row.avatar && !avatars[row.sender]) avatars[row.sender] = row.avatar
}
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 5)
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 4)
.sort((left, right) => left.hour - right.hour)
.map(
({ hour, count }) =>
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59${count}条)`
)
.join('、')
const startDate = localDate(firstTimestamp)
const endDate = localDate(lastTimestamp)
const sameDay = startDate === endDate
const dateRange = sameDay
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
: `${startDate} ${localTime(firstTimestamp)}${endDate} ${localTime(lastTimestamp)}`
const timeSpan = sameDay
? `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 3600000))}小时`
: `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 86400000))}`
const contactName = contact?.m_nsNickName || ''
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
const metadata: GroupReportMetadata = {
groupName,
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
dateRange,
messageCount: rows.length,
activeUsers: speakerCounts.size,
timeSpan,
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
recordNote: `基于当前已加载的 ${rows.length} 条记录`,
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容仅按类型统计。',
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
avatars
}
const transcript = rows.map((row) => `${row.datetime} ${row.sender}${row.content}`).join('\n')
const prompt = `请为以下微信${isGroup ? '群聊' : '对话'}记录生成日报 JSON。
会话名:${groupName}
时间范围:${dateRange}
消息数:${rows.length}
活跃人数:${speakerCounts.size}
完整性:仅基于当前应用已加载的记录。
聊天记录:
${transcript}`
return { prompt, metadata, topSpeakers, activeTimeline }
}
const asObject = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
const asString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const asName = (value: unknown): string => {
const name = asString(value)
return name && !isInternalIdentifier(name) ? name : '未命名群成员'
}
const asNumber = (value: unknown): number => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
const asStrings = (value: unknown, limit = 20): string[] =>
asArray(value).map(asString).filter(Boolean).slice(0, limit)
const normalizeHeat = (value: unknown): ReportHeat => {
const heat = asString(value)
if (heat.includes('高')) return '高'
if (heat.includes('低')) return '低'
return '中'
}
const extractJson = (raw: string): unknown => {
const cleaned = raw
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
const start = cleaned.indexOf('{')
const end = cleaned.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('AI 未返回可解析的日报 JSON')
return JSON.parse(cleaned.slice(start, end + 1))
}
export const parseGroupDailyReport = (
raw: string,
topSpeakers: ReportSpeakerRank[],
activeTimeline: string
): GroupDailyReport => {
const root = asObject(extractJson(raw))
const topics: ReportTopic[] = asArray(root.topics)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
timeRange: asString(item.timeRange),
heat: normalizeHeat(item.heat),
participants: asArray(item.participants).map(asName).filter(Boolean).slice(0, 5),
summary: asString(item.summary),
conclusion: asString(item.conclusion),
keywords: asStrings(item.keywords, 8)
}
})
.filter((topic) => topic.title && topic.summary)
.slice(0, 7)
if (!topics.length) throw new Error('AI 日报中没有有效话题')
const resources: ReportResource[] = asArray(root.resources)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
description: asString(item.description),
sender: item.sender ? asName(item.sender) : undefined
}
})
.filter((item) => item.title && item.description)
const importantMessages: ReportImportantMessage[] = asArray(root.importantMessages)
.map((value) => {
const item = asObject(value)
return {
sender: asName(item.sender),
time: asString(item.time),
content: asString(item.content),
note: asString(item.note)
}
})
.filter((item) => item.sender && item.content)
const quotes: ReportQuote[] = asArray(root.quotes)
.map((value) => {
const item = asObject(value)
return {
messages: asArray(item.messages)
.map((messageValue) => {
const message = asObject(messageValue)
return { sender: asName(message.sender), content: asString(message.content) }
})
.filter((message) => message.sender && message.content),
note: asString(item.note)
}
})
.filter((quote) => quote.messages.length)
.slice(0, 3)
const qa: ReportQuestionAnswer[] = asArray(root.qa)
.map((value) => {
const item = asObject(value)
return {
question: asString(item.question),
answer: asString(item.answer),
answerer: item.answerer ? asName(item.answerer) : undefined
}
})
.filter((item) => item.question && item.answer)
return {
overview: asString(root.overview) || '基于已读取记录生成的群聊日报。',
topics,
resources,
importantMessages,
quotes,
qa,
analytics: {
topicHeat: topics.map((topic) => ({
topic: topic.title,
score: topic.heat === '高' ? 100 : topic.heat === '中' ? 65 : 35
})),
activeTimeline: activeTimeline || '暂无可用时间统计',
topSpeakers: topSpeakers.map((speaker) => ({
name: speaker.name,
count: Math.max(0, asNumber(speaker.count))
}))
},
keywords: asStrings(root.keywords, 15)
}
}