mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 更换日报功能模板
- ChatWindow 模型下拉框新增 deepseek-v4-pro / deepseek-v4-flash - 主进程 model 兜底值改为 deepseek-v4-flash - 同步更新 .env.example 默认模型 - 修复旧 localStorage 中 gpt-5.5 等不支持的模型导致的 400 报错
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
GROUP_REPORT_SYSTEM_PROMPT,
|
||||
parseGroupDailyReport
|
||||
} from '../utils/group-report'
|
||||
import type { ReportMode } from '../../../shared/group-report'
|
||||
|
||||
interface ChatWindowProps {
|
||||
contact: Contact | null
|
||||
@@ -81,9 +82,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const [baseURL, setBaseURL] = useState(
|
||||
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
|
||||
)
|
||||
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat')
|
||||
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-v4-flash')
|
||||
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
||||
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
||||
const [reportMode, setReportMode] = useState<ReportMode>(
|
||||
() => (localStorage.getItem('group_report_mode') as ReportMode) || 'compact'
|
||||
)
|
||||
|
||||
const handleSaveSettings = (): void => {
|
||||
if (!summaryMessageTypes.length) {
|
||||
@@ -93,6 +97,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
localStorage.setItem('ai_api_key', apiKey)
|
||||
localStorage.setItem('ai_base_url', baseURL)
|
||||
localStorage.setItem('ai_model', model)
|
||||
localStorage.setItem('group_report_mode', reportMode)
|
||||
setShowSettingsModal(false)
|
||||
AIChat()
|
||||
}
|
||||
@@ -240,7 +245,7 @@ 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)
|
||||
const input = await buildGroupReportInput(reportMessages, contact, isGroupChat, reportMode)
|
||||
console.log('🚀 ~ AIChat ~ input:', input)
|
||||
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
|
||||
const result = await window.api.aiChat(
|
||||
@@ -252,7 +257,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
)
|
||||
|
||||
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,
|
||||
input.voiceLeaderboard,
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
|
||||
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||
throw new Error(exported.error || '日报文件生成失败')
|
||||
@@ -548,6 +560,31 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-filter-section">
|
||||
<div className="ai-filter-label">报告模式</div>
|
||||
<div className="ai-date-options">
|
||||
<label className={reportMode === 'compact' ? 'selected' : ''}>
|
||||
<input
|
||||
type="radio"
|
||||
name="report-mode"
|
||||
value="compact"
|
||||
checked={reportMode === 'compact'}
|
||||
onChange={() => setReportMode('compact')}
|
||||
/>
|
||||
精简版(推荐)
|
||||
</label>
|
||||
<label className={reportMode === 'full' ? 'selected' : ''}>
|
||||
<input
|
||||
type="radio"
|
||||
name="report-mode"
|
||||
value="full"
|
||||
checked={reportMode === 'full'}
|
||||
onChange={() => setReportMode('full')}
|
||||
/>
|
||||
完整版
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-filter-section">
|
||||
<div className="ai-filter-label">消息类型</div>
|
||||
<div className="ai-type-options">
|
||||
@@ -570,7 +607,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
>
|
||||
<option value="deepseek-chat">DeepSeek Chat</option>
|
||||
<option value="deepseek-v4-pro">DeepSeek V4 Pro</option>
|
||||
<option value="deepseek-v4-flash">DeepSeek V4 Flash</option>
|
||||
<option value="gpt-4o">GPT-4o</option>
|
||||
<option value="gpt-4o-mini">GPT-4o Mini</option>
|
||||
<option value="gpt-4-turbo">GPT-4 Turbo</option>
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import { Contact, Message } from '../../../shared/types'
|
||||
import {
|
||||
GroupDailyReport,
|
||||
GroupReportMetadata,
|
||||
ReportFunBadge,
|
||||
ReportMediaGalleryItem,
|
||||
ReportMode,
|
||||
ReportSpeakerRank,
|
||||
ReportVoiceHighlight,
|
||||
ReportVoiceLeaderboardItem
|
||||
} from '../../../shared/group-report'
|
||||
|
||||
export interface GroupReportTranscriptRow {
|
||||
id: string
|
||||
datetime: string
|
||||
timestamp: number
|
||||
sender: string
|
||||
content: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface GroupReportFactsSnapshot {
|
||||
metadata: GroupReportMetadata
|
||||
transcriptRows: GroupReportTranscriptRow[]
|
||||
topSpeakers: ReportSpeakerRank[]
|
||||
activeTimeline: string
|
||||
media: GroupDailyReport['media']
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||
factsPrompt: string
|
||||
}
|
||||
|
||||
export const isInternalIdentifier = (value: string): boolean =>
|
||||
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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 || '消息'}]`
|
||||
}
|
||||
|
||||
export const parseTimestamp = (message: Message): number => {
|
||||
const value = new Date(message.datetime).getTime()
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
export 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}`
|
||||
}
|
||||
|
||||
export const localTime = (timestamp: number): string =>
|
||||
new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
export const resolveVoiceDuration = (message: Message): number => {
|
||||
const fromData = message.contentData?.type === 'voice' ? message.contentData.duration : undefined
|
||||
return Math.max(0, Number(fromData ?? message.voiceDuration ?? 0) || 0)
|
||||
}
|
||||
|
||||
const truncate = (value: string, max = 48): string =>
|
||||
value.length > max ? `${value.slice(0, max - 1)}…` : value
|
||||
|
||||
const buildImageContext = (
|
||||
messages: Message[],
|
||||
index: number,
|
||||
contact: Contact | null,
|
||||
isGroup: boolean
|
||||
): {
|
||||
note: string
|
||||
stats: string
|
||||
responseCount: number
|
||||
participantCount: number
|
||||
snippets: string[]
|
||||
} => {
|
||||
const baseTime = parseTimestamp(messages[index])
|
||||
const participants = new Set<string>()
|
||||
const snippets: string[] = []
|
||||
let responseCount = 0
|
||||
|
||||
for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) {
|
||||
const candidate = messages[offset]
|
||||
const candidateTime = parseTimestamp(candidate)
|
||||
if (baseTime && candidateTime && candidateTime - baseTime > 20 * 60 * 1000) break
|
||||
if (candidate.type === '系统消息' || candidate.from === 'system') continue
|
||||
|
||||
const sender = summarySender(candidate, contact, isGroup)
|
||||
const sameSender = sender === summarySender(messages[index], contact, isGroup)
|
||||
const content = summaryContent(candidate)
|
||||
if (!sameSender) {
|
||||
responseCount += 1
|
||||
participants.add(sender)
|
||||
}
|
||||
if (
|
||||
snippets.length < 3 &&
|
||||
!content.startsWith('[图片]') &&
|
||||
!content.startsWith('[表情]') &&
|
||||
!content.startsWith('[语音')
|
||||
) {
|
||||
snippets.push(truncate(content, 28))
|
||||
}
|
||||
}
|
||||
|
||||
const note = snippets.length
|
||||
? `图片发出后,群里接着聊到:${snippets.join(' / ')}`
|
||||
: responseCount > 0
|
||||
? '图片发出后引发了一波接续讨论。'
|
||||
: '这张图片更多像是一次轻量分享,没有形成长链路讨论。'
|
||||
|
||||
const statsParts: string[] = []
|
||||
if (responseCount > 0) statsParts.push(`${responseCount} 条后续消息`)
|
||||
if (participants.size > 0) statsParts.push(`${participants.size} 人接话`)
|
||||
if (!statsParts.length) statsParts.push('讨论热度较低')
|
||||
|
||||
return {
|
||||
note,
|
||||
stats: statsParts.join(' · '),
|
||||
responseCount,
|
||||
participantCount: participants.size,
|
||||
snippets
|
||||
}
|
||||
}
|
||||
|
||||
const buildMediaSection = async (
|
||||
messages: Message[],
|
||||
contact: Contact | null,
|
||||
isGroup: boolean,
|
||||
topSpeakersMap: Map<string, number>
|
||||
): Promise<{
|
||||
media: GroupDailyReport['media']
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||
}> => {
|
||||
const rawImageCandidates = messages
|
||||
.map((message, index) => {
|
||||
if (message.contentData?.type !== 'image') return null
|
||||
const sender = summarySender(message, contact, isGroup)
|
||||
const context = buildImageContext(messages, index, contact, isGroup)
|
||||
return {
|
||||
sourceMessageIds: [message.id],
|
||||
md5: message.contentData.md5,
|
||||
datName: message.contentData.datName,
|
||||
sessionId: message.sessionId,
|
||||
sender,
|
||||
time: localTime(parseTimestamp(message)),
|
||||
note: context.note,
|
||||
stats: context.stats,
|
||||
replyCount: context.responseCount,
|
||||
score: context.responseCount * 3 + context.participantCount * 2 + 1
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 6)
|
||||
|
||||
const imageCandidates = await Promise.all(
|
||||
rawImageCandidates.map(async (item) => {
|
||||
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
|
||||
if (!result.success || !result.data?.startsWith('data:image/')) return null
|
||||
return {
|
||||
sender: item.sender,
|
||||
time: item.time,
|
||||
imageUrl: result.data,
|
||||
note: item.note,
|
||||
stats: item.stats,
|
||||
inferenceLabel: '基于图片后的聊天上下文推断',
|
||||
sourceMessageIds: item.sourceMessageIds,
|
||||
replyCount: item.replyCount,
|
||||
score: item.score
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const gallery: ReportMediaGalleryItem[] = imageCandidates
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 4)
|
||||
.map(({ score: _score, ...item }) => item)
|
||||
|
||||
const voiceMessages = messages
|
||||
.filter((message) => message.contentData?.type === 'voice')
|
||||
.map((message) => ({
|
||||
sender: summarySender(message, contact, isGroup),
|
||||
duration: resolveVoiceDuration(message),
|
||||
time: localTime(parseTimestamp(message))
|
||||
}))
|
||||
|
||||
const voiceTotals = new Map<string, { count: number; duration: number }>()
|
||||
for (const item of voiceMessages) {
|
||||
const current = voiceTotals.get(item.sender) || { count: 0, duration: 0 }
|
||||
current.count += 1
|
||||
current.duration += item.duration
|
||||
voiceTotals.set(item.sender, current)
|
||||
}
|
||||
|
||||
const voiceLeaderboard: ReportVoiceLeaderboardItem[] = Array.from(voiceTotals.entries())
|
||||
.map(([sender, value]) => ({
|
||||
sender,
|
||||
count: value.count,
|
||||
durationSec: value.duration
|
||||
}))
|
||||
.sort((left, right) => right.durationSec - left.durationSec || right.count - left.count)
|
||||
.slice(0, 5)
|
||||
|
||||
let bestStreak: { sender: string; count: number; duration: number; time: string } | null = null
|
||||
let currentStreak: { sender: string; count: number; duration: number; time: string } | null = null
|
||||
for (const message of messages) {
|
||||
if (message.contentData?.type !== 'voice') {
|
||||
currentStreak = null
|
||||
continue
|
||||
}
|
||||
const sender = summarySender(message, contact, isGroup)
|
||||
const duration = resolveVoiceDuration(message)
|
||||
const time = localTime(parseTimestamp(message))
|
||||
if (currentStreak && currentStreak.sender === sender) {
|
||||
currentStreak.count += 1
|
||||
currentStreak.duration += duration
|
||||
} else {
|
||||
currentStreak = { sender, count: 1, duration, time }
|
||||
}
|
||||
if (!bestStreak || currentStreak.count > bestStreak.count) {
|
||||
bestStreak = { ...currentStreak }
|
||||
}
|
||||
}
|
||||
|
||||
const voiceHighlights: ReportVoiceHighlight[] = []
|
||||
if (voiceLeaderboard[0]) {
|
||||
voiceHighlights.push({
|
||||
title: '语音输出王',
|
||||
sender: voiceLeaderboard[0].sender,
|
||||
note: `共发送 ${voiceLeaderboard[0].count} 条语音,累计 ${voiceLeaderboard[0].durationSec} 秒。`
|
||||
})
|
||||
}
|
||||
if (bestStreak && bestStreak.count >= 2) {
|
||||
voiceHighlights.push({
|
||||
title: '连续发言时刻',
|
||||
sender: bestStreak.sender,
|
||||
note: `${bestStreak.time} 连发 ${bestStreak.count} 条语音,共 ${bestStreak.duration} 秒。`
|
||||
})
|
||||
}
|
||||
|
||||
const funBadges: ReportFunBadge[] = []
|
||||
const topSpeaker = Array.from(topSpeakersMap.entries()).sort((left, right) => right[1] - left[1])[0]
|
||||
if (topSpeaker) {
|
||||
funBadges.push({
|
||||
title: '高能输出王',
|
||||
owner: topSpeaker[0],
|
||||
note: `今天一共发了 ${topSpeaker[1]} 条消息。`
|
||||
})
|
||||
}
|
||||
if (gallery[0]) {
|
||||
funBadges.push({
|
||||
title: '图片话题王',
|
||||
owner: gallery[0].sender,
|
||||
note: `${gallery[0].time} 的图片带动了最明显的一轮讨论。`
|
||||
})
|
||||
}
|
||||
if (voiceLeaderboard[0]) {
|
||||
funBadges.push({
|
||||
title: '语音麦霸',
|
||||
owner: voiceLeaderboard[0].sender,
|
||||
note: `语音总时长暂居第一,适合放进“今日声音档案”。`
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
media: {
|
||||
gallery,
|
||||
voiceHighlights: voiceHighlights.slice(0, 2),
|
||||
funBadges: funBadges.slice(0, 3)
|
||||
},
|
||||
voiceLeaderboard
|
||||
}
|
||||
}
|
||||
|
||||
const collectQuestionCandidates = (
|
||||
messages: Message[],
|
||||
contact: Contact | null,
|
||||
isGroup: boolean
|
||||
): string[] =>
|
||||
messages
|
||||
.map((message) => ({
|
||||
id: message.id,
|
||||
sender: summarySender(message, contact, isGroup),
|
||||
content: summaryContent(message)
|
||||
}))
|
||||
.filter((item) => /[??]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么'))
|
||||
.slice(-6)
|
||||
.map((item) => `${item.sender}(${item.id}):${truncate(item.content, 32)}`)
|
||||
|
||||
const collectReplyFacts = (messages: Message[], contact: Contact | null, isGroup: boolean): string[] =>
|
||||
messages
|
||||
.filter((message) => message.contentData?.type === 'quote' && message.contentData.quotedSender)
|
||||
.slice(0, 10)
|
||||
.map((message) => {
|
||||
const sender = summarySender(message, contact, isGroup)
|
||||
const quotedSender =
|
||||
message.contentData?.type === 'quote' && message.contentData.quotedSender
|
||||
? message.contentData.quotedSender
|
||||
: '群成员'
|
||||
return `${sender} 回复了 ${quotedSender}`
|
||||
})
|
||||
|
||||
export const buildGroupReportFacts = async (
|
||||
messages: Message[],
|
||||
contact: Contact | null,
|
||||
isGroup: boolean,
|
||||
reportMode: ReportMode
|
||||
): Promise<GroupReportFactsSnapshot> => {
|
||||
const transcriptRows = messages.map((message) => ({
|
||||
id: message.id,
|
||||
datetime: message.datetime,
|
||||
timestamp: parseTimestamp(message),
|
||||
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 transcriptRows) {
|
||||
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> = {}
|
||||
let imageCount = 0
|
||||
let stickerCount = 0
|
||||
let voiceCount = 0
|
||||
let voiceDurationSec = 0
|
||||
|
||||
for (const message of messages) {
|
||||
const sender = summarySender(message, contact, isGroup)
|
||||
const timestamp = parseTimestamp(message)
|
||||
speakerCounts.set(sender, (speakerCounts.get(sender) || 0) + 1)
|
||||
if (Number.isFinite(timestamp)) {
|
||||
const hour = new Date(timestamp).getHours()
|
||||
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
|
||||
}
|
||||
if (message.img && !avatars[sender]) avatars[sender] = message.img
|
||||
if (message.contentData?.type === 'image') imageCount += 1
|
||||
if (message.contentData?.type === 'sticker') stickerCount += 1
|
||||
if (message.contentData?.type === 'voice') {
|
||||
voiceCount += 1
|
||||
voiceDurationSec += resolveVoiceDuration(message)
|
||||
}
|
||||
}
|
||||
|
||||
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 durationMs = Math.max(0, lastTimestamp - firstTimestamp)
|
||||
const durationHours = durationMs / 3600000
|
||||
const timeSpan = (() => {
|
||||
if (sameDay) {
|
||||
if (durationHours < 1) {
|
||||
const minutes = Math.max(1, Math.round(durationMs / 60000))
|
||||
return `${minutes} min`
|
||||
}
|
||||
const hours = Math.max(1, Math.ceil(durationHours))
|
||||
return `${hours} h`
|
||||
}
|
||||
const days = Math.max(1, Math.ceil(durationMs / 86400000))
|
||||
return `${days} d`
|
||||
})()
|
||||
|
||||
const contactName = contact?.m_nsNickName || ''
|
||||
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
|
||||
const metadata: GroupReportMetadata = {
|
||||
groupName,
|
||||
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
|
||||
dateRange,
|
||||
messageCount: transcriptRows.length,
|
||||
activeUsers: speakerCounts.size,
|
||||
imageCount,
|
||||
voiceCount,
|
||||
stickerCount,
|
||||
mediaMessageCount: imageCount + voiceCount + stickerCount,
|
||||
timeSpan,
|
||||
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
recordNote: `基于当前已加载的 ${transcriptRows.length} 条记录`,
|
||||
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。',
|
||||
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
|
||||
avatars,
|
||||
reportMode
|
||||
}
|
||||
|
||||
const { media, voiceLeaderboard } = await buildMediaSection(messages, contact, isGroup, speakerCounts)
|
||||
|
||||
const factsPrompt = [
|
||||
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
|
||||
`消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`,
|
||||
activeTimeline ? `活跃时段:${activeTimeline}` : '',
|
||||
media.gallery.length
|
||||
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats})`).join(';')}`
|
||||
: '',
|
||||
voiceLeaderboard.length
|
||||
? `语音榜:${voiceLeaderboard
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.sender} ${item.count} 条 / ${item.durationSec} 秒`)
|
||||
.join(';')}`
|
||||
: '',
|
||||
collectQuestionCandidates(messages, contact, isGroup).length
|
||||
? `疑似待跟进问题:${collectQuestionCandidates(messages, contact, isGroup).join(';')}`
|
||||
: '',
|
||||
collectReplyFacts(messages, contact, isGroup).length
|
||||
? `回复关系样本:${collectReplyFacts(messages, contact, isGroup).join(';')}`
|
||||
: ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return {
|
||||
metadata,
|
||||
transcriptRows,
|
||||
topSpeakers,
|
||||
activeTimeline,
|
||||
media,
|
||||
voiceLeaderboard,
|
||||
factsPrompt
|
||||
}
|
||||
}
|
||||
@@ -1,216 +1,206 @@
|
||||
import { Contact, Message } from '../../../shared/types'
|
||||
import {
|
||||
GroupDailyReport,
|
||||
GroupReportMetadata,
|
||||
ReportHeat,
|
||||
ReportImportantMessage,
|
||||
ReportMode,
|
||||
ReportParticipantChain,
|
||||
ReportQuestionAnswer,
|
||||
ReportQuote,
|
||||
ReportResource,
|
||||
ReportReversal,
|
||||
ReportSectionKey,
|
||||
ReportSectionMeta,
|
||||
ReportSpeakerRank,
|
||||
ReportTopic
|
||||
ReportStoryline,
|
||||
ReportTodoItem,
|
||||
ReportTopic,
|
||||
ReportUnresolvedItem,
|
||||
ReportVoiceLeaderboardItem
|
||||
} from '../../../shared/group-report'
|
||||
import { Contact, Message } from '../../../shared/types'
|
||||
import { buildGroupReportFacts } from './group-report-facts'
|
||||
|
||||
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
|
||||
|
||||
原则:
|
||||
总原则:
|
||||
1. 不得编造聊天中没有的事实、结论、参与者或链接内容。
|
||||
2. 仅使用输入中的昵称,不输出 wxid、微信 ID、会话 ID 等内部标识。
|
||||
3. 图片、表情、语音、视频或链接内容不可见时,仅标注消息类型,不要猜测。
|
||||
4. 摘要说明发生了什么、大家如何回应、最后形成什么结论或氛围。
|
||||
5. 语气准确、轻巧、有信息密度,避免侮辱性和歧视性评价。
|
||||
6. 没有实际内容的可选栏目输出空数组,不要凑数。
|
||||
7. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。
|
||||
3. 图片、表情、语音、视频或链接内容不可见时,只能描述消息类型、互动效果和上下文,不能猜图片具体内容。
|
||||
4. 先合并同一事件,再按重要性排序,避免一个事件在多个模块大段重复出现。
|
||||
5. 优先保留结论、待办、未解决问题、明确通知,再保留趣味内容。
|
||||
6. 负责人、截止日期、图片内容必须有聊天证据;没有证据就返回 null 或省略。
|
||||
7. 不为了填满模板而生成内容,没有就输出空数组。
|
||||
8. 所有候选条目尽量返回 sourceMessageIds,便于程序去重和追溯。
|
||||
9. 精简版面向 30 秒阅读,摘要必须短;完整版可以保留更多候选项。
|
||||
10. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。
|
||||
|
||||
JSON 结构必须为:
|
||||
{
|
||||
"overview": "1至2句整体讨论风格与氛围",
|
||||
"hero": {
|
||||
"headline": "一句抓重点的日报标题",
|
||||
"summary": "一句总结今天发生了什么,最多3行",
|
||||
"keyTakeaway": "最重要结论,没有可空",
|
||||
"pendingNote": "最值得跟进的一项,没有可空",
|
||||
"statusLine": "例如:今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决"
|
||||
},
|
||||
"topics": [{
|
||||
"title": "话题标题",
|
||||
"timeRange": "HH:mm-HH:mm",
|
||||
"heat": "高|中|低",
|
||||
"participants": ["昵称"],
|
||||
"summary": "话题摘要",
|
||||
"conclusion": "结论或氛围",
|
||||
"keywords": ["关键词"]
|
||||
"summary": "100字以内",
|
||||
"conclusion": "可选简短结论",
|
||||
"conclusions": [{"text":"关键结论","sourceMessageIds":["消息ID"]}],
|
||||
"keywords": ["关键词"],
|
||||
"sourceMessageIds": ["消息ID"]
|
||||
}],
|
||||
"resources": [{"title":"资源名","description":"用途或内容","sender":"昵称"}],
|
||||
"importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要"}],
|
||||
"quotes": [{"messages":[{"sender":"昵称","content":"简短原话"}],"note":"点评"}],
|
||||
"qa": [{"question":"问题","answer":"答案与结论","answerer":"昵称"}],
|
||||
"resources": [{"title":"资源名","description":"用途或内容","sender":"昵称","sourceMessageIds":["消息ID"]}],
|
||||
"importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要","sourceMessageIds":["消息ID"],"importance":0.95,"confidence":0.9}],
|
||||
"quotes": [{"messages":[{"sender":"昵称","content":"简短原话","sourceMessageId":"消息ID"}],"note":"为什么好笑或值得看","sourceMessageIds":["消息ID"],"importance":0.8,"confidence":0.8}],
|
||||
"qa": [{"question":"问题","answer":"答案与结论","answerer":"昵称","sourceMessageIds":["消息ID"]}],
|
||||
"todos": [{"task":"待办事项","owner":"负责人或null","deadline":"截止时间或null","topic":"来源话题或null","note":"补充说明","sourceMessageIds":["消息ID"],"importance":0.95,"confidence":0.85}],
|
||||
"unresolved": [{"question":"待跟进的问题","owner":"提问者或相关人","status":"待跟进|暂未回答|进行中","note":"为什么还没结束","lastDiscussedAt":"HH:mm 或 null","sourceMessageIds":["消息ID"],"importance":0.9,"confidence":0.8}],
|
||||
"storylines": [{"title":"剧情线标题","stages":[{"time":"HH:mm","event":"发生了什么","sourceMessageIds":["消息ID"]}],"result":"结果","sourceMessageIds":["消息ID"]}],
|
||||
"reversals": [{"topic":"发生反转的话题","initialView":"最初判断","finalView":"最终判断","note":"为什么反转","sourceMessageIds":["消息ID"]}],
|
||||
"participantChains": [{"topic":"话题名","chain":["A 提出","B 补充","C 收尾"],"note":"链路说明","sourceMessageIds":["消息ID"]}],
|
||||
"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'
|
||||
})
|
||||
额外要求:
|
||||
- 精简版:热点最多4个、重要消息最多4条、待办最多5条、未解决最多3条、名场面最多2组、每组最多4条、关键词最多12个。
|
||||
- 完整版:可以保留更多候选项,但仍要去重和排序。
|
||||
- 如果某个字段不确定,请返回 null 或空数组,不要猜。`
|
||||
|
||||
export interface GroupReportInput {
|
||||
prompt: string
|
||||
metadata: GroupReportMetadata
|
||||
topSpeakers: ReportSpeakerRank[]
|
||||
activeTimeline: string
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||
media: GroupDailyReport['media']
|
||||
}
|
||||
|
||||
export const buildGroupReportInput = (
|
||||
const REPORT_MODE_LABEL: Record<ReportMode, string> = {
|
||||
compact: '精简版(推荐)',
|
||||
full: '完整版'
|
||||
}
|
||||
|
||||
interface ReportModeConfig {
|
||||
maxTopics: number
|
||||
maxImportantMessages: number
|
||||
maxTodos: number
|
||||
maxUnresolved: number
|
||||
maxQuotes: number
|
||||
maxQuoteMessages: number
|
||||
maxKeywords: number
|
||||
maxStorylines: number
|
||||
maxReversals: number
|
||||
maxChains: number
|
||||
maxGallery: number
|
||||
maxVoiceHighlights: number
|
||||
maxBadges: number
|
||||
maxResources: number
|
||||
maxQa: number
|
||||
topicSummaryLength: number
|
||||
noteLength: number
|
||||
enabledSections: ReportSectionKey[]
|
||||
}
|
||||
|
||||
const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
|
||||
compact: {
|
||||
maxTopics: 3,
|
||||
maxImportantMessages: 3,
|
||||
maxTodos: 4,
|
||||
maxUnresolved: 3,
|
||||
maxQuotes: 1,
|
||||
maxQuoteMessages: 4,
|
||||
maxKeywords: 12,
|
||||
maxStorylines: 0,
|
||||
maxReversals: 0,
|
||||
maxChains: 0,
|
||||
maxGallery: 0,
|
||||
maxVoiceHighlights: 0,
|
||||
maxBadges: 0,
|
||||
maxResources: 0,
|
||||
maxQa: 0,
|
||||
topicSummaryLength: 100,
|
||||
noteLength: 72,
|
||||
enabledSections: ['hero', 'topics', 'importantMessages', 'actions', 'moments', 'analytics', 'keywords']
|
||||
},
|
||||
full: {
|
||||
maxTopics: 6,
|
||||
maxImportantMessages: 6,
|
||||
maxTodos: 8,
|
||||
maxUnresolved: 5,
|
||||
maxQuotes: 3,
|
||||
maxQuoteMessages: 5,
|
||||
maxKeywords: 15,
|
||||
maxStorylines: 2,
|
||||
maxReversals: 2,
|
||||
maxChains: 3,
|
||||
maxGallery: 4,
|
||||
maxVoiceHighlights: 2,
|
||||
maxBadges: 3,
|
||||
maxResources: 4,
|
||||
maxQa: 4,
|
||||
topicSummaryLength: 140,
|
||||
noteLength: 96,
|
||||
enabledSections: [
|
||||
'hero',
|
||||
'topics',
|
||||
'importantMessages',
|
||||
'actions',
|
||||
'moments',
|
||||
'analytics',
|
||||
'keywords',
|
||||
'resources',
|
||||
'qa',
|
||||
'storylines',
|
||||
'reversals',
|
||||
'gallery',
|
||||
'voices',
|
||||
'badges',
|
||||
'chains'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export const buildGroupReportInput = async (
|
||||
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
|
||||
}))
|
||||
isGroup: boolean,
|
||||
reportMode: ReportMode
|
||||
): Promise<GroupReportInput> => {
|
||||
const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode)
|
||||
const transcript = facts.transcriptRows
|
||||
.map((row) => `[${row.id}] ${row.datetime} ${row.sender}:${row.content}`)
|
||||
.join('\n')
|
||||
|
||||
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 durationMs = Math.max(0, lastTimestamp - firstTimestamp)
|
||||
const durationHours = durationMs / 3600000
|
||||
const timeSpan = (() => {
|
||||
if (sameDay) {
|
||||
if (durationHours < 1) {
|
||||
const minutes = Math.max(1, Math.round(durationMs / 60000))
|
||||
return `${minutes} min`
|
||||
}
|
||||
const hours = Math.max(1, Math.ceil(durationHours))
|
||||
return `${hours} h`
|
||||
}
|
||||
const days = Math.max(1, Math.ceil(durationMs / 86400000))
|
||||
return `${days} d`
|
||||
})()
|
||||
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}
|
||||
会话名:${facts.metadata.groupName}
|
||||
时间范围:${facts.metadata.dateRange}
|
||||
消息数:${facts.metadata.messageCount}
|
||||
活跃人数:${facts.metadata.activeUsers}
|
||||
目标模式:${REPORT_MODE_LABEL[reportMode]}
|
||||
完整性:仅基于当前应用已加载的记录。
|
||||
|
||||
媒体与结构化事实(可引用行为,不可猜测图片内容):
|
||||
${facts.factsPrompt || '无'}
|
||||
|
||||
聊天记录:
|
||||
${transcript}`
|
||||
return { prompt, metadata, topSpeakers, activeTimeline }
|
||||
|
||||
return {
|
||||
prompt,
|
||||
metadata: facts.metadata,
|
||||
topSpeakers: facts.topSpeakers,
|
||||
activeTimeline: facts.activeTimeline,
|
||||
voiceLeaderboard: facts.voiceLeaderboard,
|
||||
media: facts.media
|
||||
}
|
||||
}
|
||||
|
||||
const asObject = (value: unknown): Record<string, unknown> =>
|
||||
@@ -219,9 +209,9 @@ const asObject = (value: unknown): 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 asNullableString = (value: unknown): string | null => {
|
||||
const result = asString(value)
|
||||
return result || null
|
||||
}
|
||||
const asNumber = (value: unknown): number => {
|
||||
const parsed = Number(value)
|
||||
@@ -231,6 +221,8 @@ 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 normalizeName = (value: unknown): string => asString(value) || '未命名群成员'
|
||||
|
||||
const normalizeHeat = (value: unknown): ReportHeat => {
|
||||
const heat = asString(value)
|
||||
if (heat.includes('高')) return '高'
|
||||
@@ -238,6 +230,9 @@ const normalizeHeat = (value: unknown): ReportHeat => {
|
||||
return '中'
|
||||
}
|
||||
|
||||
const truncate = (value: string, max: number): string =>
|
||||
value.length > max ? `${value.slice(0, Math.max(1, max - 1))}…` : value
|
||||
|
||||
const extractJson = (raw: string): unknown => {
|
||||
const cleaned = raw
|
||||
.trim()
|
||||
@@ -249,83 +244,476 @@ const extractJson = (raw: string): unknown => {
|
||||
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)
|
||||
const createSignature = (...parts: Array<string | null | undefined>): string =>
|
||||
parts
|
||||
.map((part) => (part || '').replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
.join('|')
|
||||
.toLowerCase()
|
||||
|
||||
const parseTopics = (root: Record<string, unknown>): 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),
|
||||
participants: asArray(item.participants).map(normalizeName).filter(Boolean).slice(0, 5),
|
||||
summary: asString(item.summary),
|
||||
conclusion: asString(item.conclusion),
|
||||
keywords: asStrings(item.keywords, 8)
|
||||
conclusions: asArray(item.conclusions)
|
||||
.map((entry) => {
|
||||
const conclusion = asObject(entry)
|
||||
return {
|
||||
text: asString(conclusion.text),
|
||||
sourceMessageIds: asStrings(conclusion.sourceMessageIds, 6)
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.text)
|
||||
.slice(0, 4),
|
||||
keywords: asStrings(item.keywords, 8),
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 12)
|
||||
}
|
||||
})
|
||||
.filter((topic) => topic.title && topic.summary)
|
||||
.slice(0, 7)
|
||||
if (!topics.length) throw new Error('AI 日报中没有有效话题')
|
||||
|
||||
const resources: ReportResource[] = asArray(root.resources)
|
||||
const parseResources = (root: Record<string, unknown>): 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
|
||||
sender: item.sender ? normalizeName(item.sender) : undefined,
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 6)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.title && item.description)
|
||||
const importantMessages: ReportImportantMessage[] = asArray(root.importantMessages)
|
||||
|
||||
const parseImportantMessages = (root: Record<string, unknown>): ReportImportantMessage[] =>
|
||||
asArray(root.importantMessages)
|
||||
.map((value) => {
|
||||
const item = asObject(value)
|
||||
return {
|
||||
sender: asName(item.sender),
|
||||
sender: normalizeName(item.sender),
|
||||
time: asString(item.time),
|
||||
content: asString(item.content),
|
||||
note: asString(item.note)
|
||||
note: asString(item.note),
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 6),
|
||||
importance: asNumber(item.importance),
|
||||
confidence: asNumber(item.confidence)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.sender && item.content)
|
||||
const quotes: ReportQuote[] = asArray(root.quotes)
|
||||
|
||||
const parseQuotes = (root: Record<string, unknown>): 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) }
|
||||
return {
|
||||
sender: normalizeName(message.sender),
|
||||
content: asString(message.content),
|
||||
sourceMessageId: asString(message.sourceMessageId)
|
||||
}
|
||||
})
|
||||
.filter((message) => message.sender && message.content),
|
||||
note: asString(item.note)
|
||||
note: asString(item.note),
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
|
||||
importance: asNumber(item.importance),
|
||||
confidence: asNumber(item.confidence)
|
||||
}
|
||||
})
|
||||
.filter((quote) => quote.messages.length)
|
||||
.slice(0, 3)
|
||||
const qa: ReportQuestionAnswer[] = asArray(root.qa)
|
||||
|
||||
const parseQa = (root: Record<string, unknown>): 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
|
||||
answerer: item.answerer ? normalizeName(item.answerer) : undefined,
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.question && item.answer)
|
||||
|
||||
const parseTodos = (root: Record<string, unknown>): ReportTodoItem[] =>
|
||||
asArray(root.todos)
|
||||
.map((value) => {
|
||||
const item = asObject(value)
|
||||
return {
|
||||
task: asString(item.task),
|
||||
owner: item.owner === null ? null : item.owner ? normalizeName(item.owner) : undefined,
|
||||
deadline: item.deadline === null ? null : asNullableString(item.deadline),
|
||||
topic: item.topic === null ? null : asNullableString(item.topic),
|
||||
note: asString(item.note),
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
|
||||
importance: asNumber(item.importance),
|
||||
confidence: asNumber(item.confidence)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.task)
|
||||
|
||||
const parseUnresolved = (root: Record<string, unknown>): ReportUnresolvedItem[] =>
|
||||
asArray(root.unresolved)
|
||||
.map((value) => {
|
||||
const item = asObject(value)
|
||||
const status = asString(item.status)
|
||||
const normalizedStatus: ReportUnresolvedItem['status'] =
|
||||
status === '暂未回答' || status === '进行中' || status === '待跟进' ? status : '待跟进'
|
||||
return {
|
||||
question: asString(item.question),
|
||||
owner: item.owner ? normalizeName(item.owner) : undefined,
|
||||
status: normalizedStatus,
|
||||
note: asString(item.note),
|
||||
lastDiscussedAt: item.lastDiscussedAt === null ? null : asNullableString(item.lastDiscussedAt),
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
|
||||
importance: asNumber(item.importance),
|
||||
confidence: asNumber(item.confidence)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.question && item.note)
|
||||
|
||||
const parseStorylines = (root: Record<string, unknown>): ReportStoryline[] =>
|
||||
asArray(root.storylines)
|
||||
.map((value) => {
|
||||
const item = asObject(value)
|
||||
return {
|
||||
title: asString(item.title),
|
||||
stages: asArray(item.stages)
|
||||
.map((stageValue) => {
|
||||
const stage = asObject(stageValue)
|
||||
return {
|
||||
time: asString(stage.time),
|
||||
event: asString(stage.event),
|
||||
sourceMessageIds: asStrings(stage.sourceMessageIds, 6)
|
||||
}
|
||||
})
|
||||
.filter((stage) => stage.event),
|
||||
result: asString(item.result),
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.title && item.stages.length)
|
||||
|
||||
const parseReversals = (root: Record<string, unknown>): ReportReversal[] =>
|
||||
asArray(root.reversals)
|
||||
.map((value) => {
|
||||
const item = asObject(value)
|
||||
return {
|
||||
topic: asString(item.topic),
|
||||
initialView: asString(item.initialView),
|
||||
finalView: asString(item.finalView),
|
||||
note: asString(item.note),
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.topic && item.initialView && item.finalView)
|
||||
|
||||
const parseParticipantChains = (root: Record<string, unknown>): ReportParticipantChain[] =>
|
||||
asArray(root.participantChains)
|
||||
.map((value) => {
|
||||
const item = asObject(value)
|
||||
return {
|
||||
topic: asString(item.topic),
|
||||
chain: asStrings(item.chain, 5),
|
||||
note: asString(item.note),
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 8)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.topic && item.chain.length)
|
||||
|
||||
const scoreByHeat = (heat: ReportHeat): number => (heat === '高' ? 0.95 : heat === '中' ? 0.75 : 0.55)
|
||||
|
||||
const scoreByCount = (count: number, max = 5): number => Math.min(1, Math.max(0.3, count / max))
|
||||
|
||||
const dedupeItems = <T>(
|
||||
items: T[],
|
||||
getSources: (item: T) => string[],
|
||||
getSignature: (item: T) => string
|
||||
): T[] => {
|
||||
const seenSources = new Set<string>()
|
||||
const seenSignatures = new Set<string>()
|
||||
const result: T[] = []
|
||||
for (const item of items) {
|
||||
const signature = getSignature(item)
|
||||
const sources = getSources(item).filter(Boolean)
|
||||
const sourceOverlap = sources.some((source) => seenSources.has(source))
|
||||
if (signature && seenSignatures.has(signature)) continue
|
||||
if (sourceOverlap && sources.length) continue
|
||||
result.push(item)
|
||||
if (signature) seenSignatures.add(signature)
|
||||
for (const source of sources) seenSources.add(source)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const sortByScore = <T>(items: T[], scorer: (item: T) => number): T[] =>
|
||||
[...items].sort((left, right) => scorer(right) - scorer(left))
|
||||
|
||||
const buildSectionMeta = (
|
||||
enabled: boolean,
|
||||
displayedCount: number,
|
||||
totalCount: number,
|
||||
importance: number,
|
||||
confidence: number
|
||||
): ReportSectionMeta => ({
|
||||
enabled: enabled && displayedCount > 0,
|
||||
importance,
|
||||
confidence,
|
||||
totalCount,
|
||||
displayedCount,
|
||||
hiddenCount: Math.max(0, totalCount - displayedCount)
|
||||
})
|
||||
|
||||
const clampTopics = (topics: ReportTopic[], config: ReportModeConfig): ReportTopic[] =>
|
||||
topics.slice(0, config.maxTopics).map((topic) => ({
|
||||
...topic,
|
||||
summary: truncate(topic.summary, config.topicSummaryLength),
|
||||
conclusions: (topic.conclusions || [])
|
||||
.slice(0, 2)
|
||||
.map((entry) => ({ ...entry, text: truncate(entry.text, config.noteLength) })),
|
||||
conclusion: topic.conclusion ? truncate(topic.conclusion, config.noteLength) : topic.conclusion,
|
||||
keywords: topic.keywords.slice(0, 3)
|
||||
}))
|
||||
|
||||
const attachHighImpactImage = (
|
||||
topics: ReportTopic[],
|
||||
gallery: GroupDailyReport['media']['gallery']
|
||||
): ReportTopic[] => {
|
||||
if (!gallery.length) return topics
|
||||
const [firstImage, ...rest] = gallery
|
||||
const nextTopics = topics.map((topic, index) =>
|
||||
index === 0 && firstImage.replyCount && firstImage.replyCount >= 3
|
||||
? {
|
||||
...topic,
|
||||
image: {
|
||||
imageUrl: firstImage.imageUrl,
|
||||
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`,
|
||||
sourceMessageIds: firstImage.sourceMessageIds
|
||||
}
|
||||
}
|
||||
: topic
|
||||
)
|
||||
gallery.splice(0, rest.length >= 0 ? 1 : 0)
|
||||
return nextTopics
|
||||
}
|
||||
|
||||
const postProcessReport = (
|
||||
report: GroupDailyReport,
|
||||
mode: ReportMode,
|
||||
metadata: GroupReportMetadata
|
||||
): GroupDailyReport => {
|
||||
const config = REPORT_MODE_CONFIG[mode]
|
||||
|
||||
const topicsScored = sortByScore(report.topics, (item) => scoreByHeat(item.heat))
|
||||
const topicsDeduped = dedupeItems(
|
||||
topicsScored,
|
||||
(item) => item.sourceMessageIds || [],
|
||||
(item) => createSignature(item.title, item.summary)
|
||||
)
|
||||
let gallery = [...report.media.gallery]
|
||||
const topics = attachHighImpactImage(clampTopics(topicsDeduped, config), gallery)
|
||||
|
||||
const importantMessagesRaw = sortByScore(report.importantMessages, (item) =>
|
||||
Math.max(item.importance || 0, item.confidence || 0.6)
|
||||
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.55 : true))
|
||||
const importantMessages = dedupeItems(
|
||||
importantMessagesRaw,
|
||||
(item) => item.sourceMessageIds || [],
|
||||
(item) => createSignature(item.content, item.note)
|
||||
)
|
||||
.slice(0, config.maxImportantMessages)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
content: truncate(item.content, config.noteLength),
|
||||
note: truncate(item.note, config.noteLength)
|
||||
}))
|
||||
|
||||
const todosRaw = sortByScore(report.todos, (item) =>
|
||||
Math.max(item.importance || 0.6, item.confidence || 0.5)
|
||||
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.5 : true))
|
||||
const todos = dedupeItems(
|
||||
todosRaw,
|
||||
(item) => item.sourceMessageIds || [],
|
||||
(item) => createSignature(item.task, item.owner || '', item.deadline || '')
|
||||
)
|
||||
.slice(0, config.maxTodos)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
task: truncate(item.task, config.noteLength),
|
||||
note: item.note ? truncate(item.note, config.noteLength) : item.note
|
||||
}))
|
||||
|
||||
const unresolvedRaw = sortByScore(report.unresolved, (item) =>
|
||||
Math.max(item.importance || 0.6, item.confidence || 0.5)
|
||||
).filter((item) => (mode === 'compact' ? (item.confidence || 0) >= 0.45 : true))
|
||||
const unresolved = dedupeItems(
|
||||
unresolvedRaw,
|
||||
(item) => item.sourceMessageIds || [],
|
||||
(item) => createSignature(item.question, item.note)
|
||||
)
|
||||
.slice(0, config.maxUnresolved)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
question: truncate(item.question, config.noteLength),
|
||||
note: truncate(item.note, config.noteLength)
|
||||
}))
|
||||
|
||||
const quotesRaw = sortByScore(report.quotes, (item) =>
|
||||
Math.max(item.importance || 0.55, item.confidence || 0.55, scoreByCount(item.messages.length, 4))
|
||||
)
|
||||
const quotes = dedupeItems(
|
||||
quotesRaw,
|
||||
(item) => item.sourceMessageIds || item.messages.map((message) => message.sourceMessageId || ''),
|
||||
(item) => createSignature(item.note, item.messages.map((message) => message.content).join('|'))
|
||||
)
|
||||
.slice(0, config.maxQuotes)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
messages: item.messages.slice(0, config.maxQuoteMessages).map((message) => ({
|
||||
...message,
|
||||
content: truncate(message.content, Math.min(config.noteLength, 38))
|
||||
})),
|
||||
note: truncate(item.note, config.noteLength)
|
||||
}))
|
||||
|
||||
const resources = report.resources.slice(0, config.maxResources)
|
||||
const qa = report.qa.slice(0, config.maxQa)
|
||||
const storylines = report.storylines.slice(0, config.maxStorylines)
|
||||
const reversals = report.reversals.slice(0, config.maxReversals)
|
||||
const participantChains = report.participantChains.slice(0, config.maxChains)
|
||||
const voiceHighlights = report.media.voiceHighlights.slice(0, config.maxVoiceHighlights)
|
||||
const funBadges = report.media.funBadges.slice(0, config.maxBadges)
|
||||
const keywords = report.keywords.slice(0, config.maxKeywords)
|
||||
|
||||
const conclusionCount =
|
||||
topics.reduce((count, topic) => count + (topic.conclusions?.length || 0), 0) +
|
||||
importantMessages.length
|
||||
const summaryStats = {
|
||||
messageCount: metadata.messageCount,
|
||||
activeUsers: metadata.activeUsers,
|
||||
topicCount: topics.length,
|
||||
mediaCount: metadata.mediaMessageCount || 0,
|
||||
imageCount: metadata.imageCount || 0,
|
||||
voiceCount: metadata.voiceCount || 0,
|
||||
stickerCount: metadata.stickerCount || 0,
|
||||
conclusionCount,
|
||||
todoCount: todos.length,
|
||||
unresolvedCount: unresolved.length
|
||||
}
|
||||
|
||||
const hero = {
|
||||
headline:
|
||||
report.hero?.headline || topics[0]?.title || `${metadata.groupName}${mode === 'compact' ? '速览' : '日报'}`,
|
||||
summary: truncate(
|
||||
report.hero?.summary || report.overview || '今天群里有新的讨论进展。',
|
||||
mode === 'compact' ? 84 : 120
|
||||
),
|
||||
keyTakeaway: report.hero?.keyTakeaway ? truncate(report.hero.keyTakeaway, config.noteLength) : undefined,
|
||||
pendingNote: report.hero?.pendingNote ? truncate(report.hero.pendingNote, config.noteLength) : undefined,
|
||||
statusLine:
|
||||
report.hero?.statusLine ||
|
||||
`今日形成 ${summaryStats.conclusionCount} 个结论 · ${summaryStats.todoCount} 个待办 · ${summaryStats.unresolvedCount} 个问题尚未解决`
|
||||
}
|
||||
|
||||
const sectionMeta: Partial<Record<ReportSectionKey, ReportSectionMeta>> = {
|
||||
hero: buildSectionMeta(true, 1, 1, 1, 0.95),
|
||||
topics: buildSectionMeta(config.enabledSections.includes('topics'), topics.length, report.topics.length, 0.98, 0.85),
|
||||
importantMessages: buildSectionMeta(
|
||||
config.enabledSections.includes('importantMessages'),
|
||||
importantMessages.length,
|
||||
report.importantMessages.length,
|
||||
0.95,
|
||||
0.82
|
||||
),
|
||||
actions: buildSectionMeta(
|
||||
config.enabledSections.includes('actions'),
|
||||
todos.length + unresolved.length,
|
||||
report.todos.length + report.unresolved.length,
|
||||
0.97,
|
||||
0.8
|
||||
),
|
||||
moments: buildSectionMeta(config.enabledSections.includes('moments'), quotes.length, report.quotes.length, 0.75, 0.72),
|
||||
analytics: buildSectionMeta(config.enabledSections.includes('analytics'), 1, 1, 0.8, 0.95),
|
||||
keywords: buildSectionMeta(config.enabledSections.includes('keywords'), keywords.length, report.keywords.length, 0.68, 0.9),
|
||||
resources: buildSectionMeta(config.enabledSections.includes('resources'), resources.length, report.resources.length, 0.55, 0.75),
|
||||
qa: buildSectionMeta(config.enabledSections.includes('qa'), qa.length, report.qa.length, 0.62, 0.78),
|
||||
storylines: buildSectionMeta(config.enabledSections.includes('storylines'), storylines.length, report.storylines.length, 0.63, 0.74),
|
||||
reversals: buildSectionMeta(config.enabledSections.includes('reversals'), reversals.length, report.reversals.length, 0.54, 0.72),
|
||||
gallery: buildSectionMeta(config.enabledSections.includes('gallery'), gallery.length, report.media.gallery.length, 0.6, 0.8),
|
||||
voices: buildSectionMeta(config.enabledSections.includes('voices'), voiceHighlights.length, report.media.voiceHighlights.length, 0.56, 0.84),
|
||||
badges: buildSectionMeta(config.enabledSections.includes('badges'), funBadges.length, report.media.funBadges.length, 0.45, 0.65),
|
||||
chains: buildSectionMeta(config.enabledSections.includes('chains'), participantChains.length, report.participantChains.length, 0.58, 0.71)
|
||||
}
|
||||
|
||||
return {
|
||||
overview: asString(root.overview) || '基于已读取记录生成的群聊日报。',
|
||||
...report,
|
||||
mode,
|
||||
hero,
|
||||
topics,
|
||||
resources,
|
||||
importantMessages,
|
||||
quotes,
|
||||
qa,
|
||||
todos,
|
||||
unresolved,
|
||||
storylines,
|
||||
reversals,
|
||||
participantChains,
|
||||
keywords,
|
||||
media: {
|
||||
gallery,
|
||||
voiceHighlights,
|
||||
funBadges
|
||||
},
|
||||
summaryStats,
|
||||
sectionMeta
|
||||
}
|
||||
}
|
||||
|
||||
export const parseGroupDailyReport = (
|
||||
raw: string,
|
||||
topSpeakers: ReportSpeakerRank[],
|
||||
activeTimeline: string,
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[],
|
||||
metadata: GroupReportMetadata,
|
||||
media: GroupDailyReport['media']
|
||||
): GroupDailyReport => {
|
||||
const root = asObject(extractJson(raw))
|
||||
const topics = parseTopics(root)
|
||||
if (!topics.length) throw new Error('AI 日报中没有有效话题')
|
||||
|
||||
const heroRoot = asObject(root.hero)
|
||||
const report: GroupDailyReport = {
|
||||
overview: asString(root.overview) || '基于已读取记录生成的群聊日报。',
|
||||
mode: metadata.reportMode || 'compact',
|
||||
hero:
|
||||
heroRoot && Object.keys(heroRoot).length
|
||||
? {
|
||||
headline: asString(heroRoot.headline) || topics[0]?.title || '今日群聊速览',
|
||||
summary: asString(heroRoot.summary) || asString(root.overview) || '今天群里有新的讨论进展。',
|
||||
keyTakeaway: asString(heroRoot.keyTakeaway),
|
||||
pendingNote: asString(heroRoot.pendingNote),
|
||||
statusLine: asString(heroRoot.statusLine)
|
||||
}
|
||||
: undefined,
|
||||
topics,
|
||||
resources: parseResources(root),
|
||||
importantMessages: parseImportantMessages(root),
|
||||
quotes: parseQuotes(root),
|
||||
qa: parseQa(root),
|
||||
todos: parseTodos(root),
|
||||
unresolved: parseUnresolved(root),
|
||||
storylines: parseStorylines(root),
|
||||
reversals: parseReversals(root),
|
||||
participantChains: parseParticipantChains(root),
|
||||
analytics: {
|
||||
topicHeat: topics.map((topic) => ({
|
||||
topic: topic.title,
|
||||
@@ -335,8 +723,12 @@ export const parseGroupDailyReport = (
|
||||
topSpeakers: topSpeakers.map((speaker) => ({
|
||||
name: speaker.name,
|
||||
count: Math.max(0, asNumber(speaker.count))
|
||||
}))
|
||||
})),
|
||||
voiceLeaderboard
|
||||
},
|
||||
keywords: asStrings(root.keywords, 15)
|
||||
keywords: asStrings(root.keywords, 15),
|
||||
media
|
||||
}
|
||||
|
||||
return postProcessReport(report, metadata.reportMode || 'compact', metadata)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user