mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 修改群聊日报生成与群成员信息展示
- 支持结构化 AI 群聊日报及移动端长图导出 - 支持日报时间范围和消息类型筛选 - 支持群成员及当前用户群昵称解析 - 移除消息加载更多并优化图片懒加载 - 增强头像处理与敏感密钥日志保护
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user