mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 修改群聊日报生成与群成员信息展示
- 支持结构化 AI 群聊日报及移动端长图导出 - 支持日报时间范围和消息类型筛选 - 支持群成员及当前用户群昵称解析 - 移除消息加载更多并优化图片懒加载 - 增强头像处理与敏感密钥日志保护
This commit is contained in:
@@ -34,7 +34,6 @@
|
||||
"better-sqlite3-multiple-ciphers": "^12.5.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"koffi": "^2.9.0",
|
||||
"openai": "^6.10.0",
|
||||
"silk-wasm": "^3.7.1"
|
||||
|
||||
Generated
-6
@@ -26,7 +26,6 @@ specifiers:
|
||||
eslint-plugin-react-refresh: ^0.4.24
|
||||
fs-extra: ^11.3.2
|
||||
fzstd: ^0.1.1
|
||||
html-to-image: ^1.11.13
|
||||
koffi: ^2.9.0
|
||||
openai: ^6.10.0
|
||||
prettier: ^3.7.4
|
||||
@@ -42,7 +41,6 @@ dependencies:
|
||||
better-sqlite3-multiple-ciphers: 12.5.0
|
||||
fs-extra: 11.3.2
|
||||
fzstd: 0.1.1
|
||||
html-to-image: 1.11.13
|
||||
koffi: 2.16.2
|
||||
openai: 6.10.0
|
||||
silk-wasm: 3.7.1
|
||||
@@ -2946,10 +2944,6 @@ packages:
|
||||
lru-cache: 6.0.0
|
||||
dev: true
|
||||
|
||||
/html-to-image/1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
dev: false
|
||||
|
||||
/http-cache-semantics/4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>{{REPORT_TITLE}}</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
html {
|
||||
width: 430px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
width: 430px;
|
||||
background: #f3f5f7;
|
||||
color: #1f2933;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
.report {
|
||||
width: 430px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 14px 34px;
|
||||
}
|
||||
.hero,
|
||||
.card,
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.hero {
|
||||
padding: 20px;
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 23px;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 8px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.sub {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.avatar-grid {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.avatar-grid img,
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.stat {
|
||||
background: #f7faf9;
|
||||
border-radius: 12px;
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat b {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
color: #07a352;
|
||||
}
|
||||
.stat span {
|
||||
font-size: 11px;
|
||||
color: #667085;
|
||||
}
|
||||
.section {
|
||||
margin-top: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 20px;
|
||||
border-radius: 99px;
|
||||
background: #07c160;
|
||||
}
|
||||
.card {
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
box-shadow: none;
|
||||
border: 1px solid #edf0f2;
|
||||
}
|
||||
.topic-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.topic-title-row h3 {
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
font-weight: 850;
|
||||
}
|
||||
.heat,
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
background: #eef8f2;
|
||||
color: #07a352;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hot {
|
||||
background: #fff4e5;
|
||||
color: #d46b08;
|
||||
}
|
||||
.blue {
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.red {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
.topic-meta {
|
||||
margin-top: 6px;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.card p {
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.participants {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.person-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
background: #f6f8fa;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px 3px 3px;
|
||||
}
|
||||
.person-chip img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.person-chip b {
|
||||
max-width: 58px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.keywords span {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f2f4f7;
|
||||
color: #667085;
|
||||
}
|
||||
.resource {
|
||||
padding: 11px 12px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.resource b {
|
||||
color: #1677ff;
|
||||
}
|
||||
.important-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background: #f7faf9;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.important-card > .avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.important-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
}
|
||||
.important-meta b {
|
||||
color: #1f2933;
|
||||
}
|
||||
.important-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.important-note {
|
||||
margin-top: 8px;
|
||||
padding: 7px 9px;
|
||||
border-left: 3px solid #07c160;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
color: #07a352;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.chat-block {
|
||||
background: #f0f2f5;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.chat-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.chat-name {
|
||||
font-size: 11px;
|
||||
color: #667085;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chat-bubble {
|
||||
background: #fff;
|
||||
border-radius: 4px 12px 12px 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.quote-note {
|
||||
background: #fff8e1;
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
margin-top: 10px;
|
||||
color: #8a5a00;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.qa-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.qa-card b {
|
||||
display: block;
|
||||
color: #1f2933;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.qa-card div {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #485465;
|
||||
}
|
||||
.bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 82px 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bar {
|
||||
height: 10px;
|
||||
background: #edf1f5;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #07c160;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid #eef0f2;
|
||||
}
|
||||
.rank img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.rank b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.rank span {
|
||||
margin-left: auto;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cloud-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.cloud-tags span {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f2f4f7;
|
||||
color: #485465;
|
||||
font-weight: 800;
|
||||
}
|
||||
.cloud-tags .xl {
|
||||
font-size: 20px;
|
||||
color: #07a352;
|
||||
background: #e9f8ef;
|
||||
}
|
||||
.cloud-tags .lg {
|
||||
font-size: 17px;
|
||||
color: #1677ff;
|
||||
background: #eef5ff;
|
||||
}
|
||||
.cloud-tags .md {
|
||||
font-size: 15px;
|
||||
color: #d46b08;
|
||||
background: #fff4e5;
|
||||
}
|
||||
.footer {
|
||||
padding: 16px 4px 0;
|
||||
color: #98a2b3;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.muted {
|
||||
color: #8a94a6;
|
||||
}
|
||||
.empty-section {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
}
|
||||
.report {
|
||||
width: 100%;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.stats {
|
||||
gap: 6px;
|
||||
}
|
||||
.stat b {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="report">
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<div>
|
||||
<h1>{{GROUP_NAME}}日报</h1>
|
||||
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||
<div class="stat"><b>{{TIME_SPAN}}</b><span>时间跨度</span></div>
|
||||
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="section topics">
|
||||
<div class="section-title">今日讨论热点</div>
|
||||
{{TOPIC_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
|
||||
<div class="section-title">实用信息与资源</div>
|
||||
{{RESOURCE_ITEMS}}
|
||||
</section>
|
||||
|
||||
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
|
||||
<div class="section-title">重要消息汇总</div>
|
||||
{{IMPORTANT_MESSAGES}}
|
||||
</section>
|
||||
|
||||
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
|
||||
<div class="section-title">有趣对话或金句</div>
|
||||
{{QUOTE_BLOCKS}}
|
||||
</section>
|
||||
|
||||
<section class="section qa {{QA_EMPTY_CLASS}}">
|
||||
<div class="section-title">问题与解答</div>
|
||||
{{QA_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section analytics">
|
||||
<div class="section-title">群内数据可视化</div>
|
||||
{{HEAT_BARS}}
|
||||
<div class="card">
|
||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
|
||||
话唠榜 TOP5(基于已读取记录估算)
|
||||
</div>
|
||||
{{RANK_ITEMS}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section cloud">
|
||||
<div class="section-title">词云/关键词</div>
|
||||
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
数据来源:微信群聊记录<br />
|
||||
生成时间:{{GENERATED_AT}}<br />
|
||||
{{FOOTER_NOTE}}
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,296 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
ReportHeat
|
||||
} from '../shared/group-report'
|
||||
|
||||
const TEMPLATE_NAME = 'mobile_daily_report.html'
|
||||
|
||||
const escapeHtml = (value: unknown): string =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const sanitizeFileName = (value: string): string =>
|
||||
value
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || '未命名群聊'
|
||||
|
||||
const hashName = (name: string): number => {
|
||||
let hash = 0
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
||||
return hash
|
||||
}
|
||||
|
||||
const fallbackAvatar = (name: string): string => {
|
||||
const hue = hashName(name) % 360
|
||||
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||||
}
|
||||
|
||||
const imageMimeType = (contentType: string | null, source: string): string => {
|
||||
if (contentType?.startsWith('image/')) return contentType.split(';')[0]
|
||||
const extension = path.extname(source).toLowerCase()
|
||||
if (extension === '.png') return 'image/png'
|
||||
if (extension === '.webp') return 'image/webp'
|
||||
if (extension === '.gif') return 'image/gif'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
|
||||
if (!source) return fallbackAvatar(name)
|
||||
if (/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const response = await fetch(source, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 WechatExplorer',
|
||||
Referer: 'https://weixin.qq.com/'
|
||||
},
|
||||
signal: AbortSignal.timeout(8000)
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const mime = imageMimeType(response.headers.get('content-type'), source)
|
||||
return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`
|
||||
}
|
||||
|
||||
const localPath = source.startsWith('file://') ? new URL(source) : source
|
||||
const buffer = await fs.readFile(localPath)
|
||||
return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`
|
||||
} catch (error) {
|
||||
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
|
||||
return fallbackAvatar(name)
|
||||
}
|
||||
}
|
||||
|
||||
const templatePath = (): string => {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
|
||||
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
|
||||
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
|
||||
]
|
||||
const found = candidates.find((candidate) => fs.existsSync(candidate))
|
||||
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
|
||||
return found
|
||||
}
|
||||
|
||||
const heatClass = (heat: ReportHeat): string => {
|
||||
if (heat === '高') return 'hot'
|
||||
if (heat === '低') return 'blue'
|
||||
return ''
|
||||
}
|
||||
|
||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||
html.replaceAll(`{{${key}}}`, value)
|
||||
|
||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||
const { report, metadata } = request
|
||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
|
||||
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
|
||||
report.quotes.forEach((quote) =>
|
||||
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||
)
|
||||
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
||||
|
||||
const avatars = new Map<string, string>()
|
||||
await Promise.all(
|
||||
Array.from(avatarNames).map(async (name) => {
|
||||
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
|
||||
})
|
||||
)
|
||||
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
|
||||
|
||||
const heroNames = metadata.heroParticipants.slice(0, 4)
|
||||
while (heroNames.length < 4) heroNames.push(metadata.groupName)
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
||||
.join('')
|
||||
|
||||
const topicCards = report.topics
|
||||
.map(
|
||||
(topic) => `<div class="card topic-card">
|
||||
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||
<p>${escapeHtml(topic.summary)}</p>
|
||||
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
|
||||
<div class="participants">${topic.participants
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(name) =>
|
||||
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
|
||||
)
|
||||
.join('')}</div>
|
||||
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const resourceItems = report.resources
|
||||
.map(
|
||||
(resource) =>
|
||||
`<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const importantMessages = report.importantMessages
|
||||
.map(
|
||||
(message) => `<div class="important-card">
|
||||
<img class="avatar" src="${avatar(message.sender)}" alt="">
|
||||
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div>
|
||||
<div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const quoteBlocks = report.quotes
|
||||
.map(
|
||||
(quote) =>
|
||||
`<div class="chat-block">${quote.messages
|
||||
.map(
|
||||
(
|
||||
message
|
||||
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><div>
|
||||
<div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div>
|
||||
</div></div>`
|
||||
)
|
||||
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const qaCards = report.qa
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
|
||||
const heatBars = report.analytics.topicHeat
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const rankItems = report.analytics.topSpeakers
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(speaker, index) =>
|
||||
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const cloudTags = report.keywords
|
||||
.slice(0, 15)
|
||||
.map(
|
||||
(word, index) =>
|
||||
`<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
let html = await fs.readFile(templatePath(), 'utf8')
|
||||
const values: Record<string, string> = {
|
||||
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||
RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()),
|
||||
HERO_AVATARS: heroAvatars,
|
||||
MESSAGE_COUNT: String(metadata.messageCount),
|
||||
ACTIVE_USERS: String(metadata.activeUsers),
|
||||
TIME_SPAN: escapeHtml(metadata.timeSpan),
|
||||
TOPIC_COUNT: String(report.topics.length),
|
||||
TOPIC_CARDS: topicCards,
|
||||
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
|
||||
RESOURCE_ITEMS: resourceItems,
|
||||
MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
|
||||
IMPORTANT_MESSAGES: importantMessages,
|
||||
QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
|
||||
QUOTE_BLOCKS: quoteBlocks,
|
||||
QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
|
||||
QA_CARDS: qaCards,
|
||||
HEAT_BARS: heatBars,
|
||||
RANK_ITEMS: rankItems,
|
||||
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
||||
CLOUD_TAGS: cloudTags,
|
||||
GENERATED_AT: escapeHtml(metadata.generatedAt),
|
||||
FOOTER_NOTE: escapeHtml(metadata.footerNote)
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
return html
|
||||
}
|
||||
|
||||
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
|
||||
const reportWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 430,
|
||||
height: 800,
|
||||
frame: false,
|
||||
backgroundColor: '#f3f5f7',
|
||||
webPreferences: { sandbox: true }
|
||||
})
|
||||
|
||||
try {
|
||||
await reportWindow.loadFile(htmlPath)
|
||||
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
||||
document.fonts.ready,
|
||||
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
|
||||
img.addEventListener('load', resolve, { once: true });
|
||||
img.addEventListener('error', resolve, { once: true });
|
||||
}))
|
||||
])`)
|
||||
reportWindow.webContents.debugger.attach('1.3')
|
||||
const metrics = (await reportWindow.webContents.debugger.sendCommand(
|
||||
'Page.getLayoutMetrics'
|
||||
)) as { cssContentSize: { width: number; height: number } }
|
||||
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
|
||||
const height = Math.ceil(metrics.cssContentSize.height)
|
||||
const screenshot = (await reportWindow.webContents.debugger.sendCommand(
|
||||
'Page.captureScreenshot',
|
||||
{
|
||||
format: 'png',
|
||||
captureBeyondViewport: true,
|
||||
fromSurface: true,
|
||||
clip: { x: 0, y: 0, width, height, scale: 1 }
|
||||
}
|
||||
)) as { data: string }
|
||||
const png = Buffer.from(screenshot.data, 'base64')
|
||||
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
||||
await fs.writeFile(pngPath, png)
|
||||
return `data:image/png;base64,${screenshot.data}`
|
||||
} finally {
|
||||
if (reportWindow.webContents.debugger.isAttached()) {
|
||||
reportWindow.webContents.debugger.detach()
|
||||
}
|
||||
reportWindow.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export const exportGroupReport = async (
|
||||
request: GroupReportExportRequest
|
||||
): Promise<GroupReportExportResult> => {
|
||||
try {
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
|
||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||
const html = await renderReportHtml(request)
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
|
||||
return { success: true, htmlPath, pngPath, imageDataUrl }
|
||||
} catch (error) {
|
||||
console.error('[GroupReport] export failed:', error)
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
@@ -236,8 +236,6 @@ export class ImageDecryptService {
|
||||
version,
|
||||
'file:',
|
||||
datPath,
|
||||
'xorKey:',
|
||||
this.xorKey,
|
||||
'aesKey present:',
|
||||
!!this.aesKey
|
||||
)
|
||||
@@ -258,7 +256,6 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||
console.log('[ImageDecrypt] AES key bytes:', key.toString('hex'), 'length:', key.length)
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
}
|
||||
|
||||
|
||||
+28
-12
@@ -11,6 +11,8 @@ import {
|
||||
parseStickerMessageFromRow
|
||||
} from './message-parser'
|
||||
import { ImageDecryptService } from './image-decrypt-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
|
||||
let wechatDb: WechatDb | null = null
|
||||
let voiceService: VoiceService | null = null
|
||||
@@ -53,8 +55,8 @@ function normalizeMsgType(value: string | number | undefined): number {
|
||||
function createWindow(): void {
|
||||
// 创建浏览器窗口
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 670,
|
||||
width: 1400,
|
||||
height: 800,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
@@ -102,9 +104,7 @@ app.whenReady().then(() => {
|
||||
ipcMain.handle('db:init', (_, key: string) => {
|
||||
try {
|
||||
const trimmedKey = String(key || '').trim()
|
||||
console.log(
|
||||
`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length} keyPreview=${trimmedKey.slice(0, 6)}...${trimmedKey.slice(-6)}`
|
||||
)
|
||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||
wechatDb = new WechatDb(key)
|
||||
const wcdb4Client = wechatDb.getWcdb4Client()
|
||||
if (wcdb4Client) {
|
||||
@@ -175,6 +175,10 @@ app.whenReady().then(() => {
|
||||
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
|
||||
const groupMembers = wechatDb.getGroupMembersForChat(userMd5)
|
||||
const myAvatar = wechatDb.getMyAvatarUrl()
|
||||
const myGroupNickname =
|
||||
username?.endsWith('@chatroom') && wcdb4Client
|
||||
? wcdb4Client.getMyGroupNickname(username)
|
||||
: undefined
|
||||
|
||||
return rawMessages.map((msg: WechatMessage) => {
|
||||
const rawMsgType = parseInt(msg.messageType)
|
||||
@@ -187,13 +191,12 @@ app.whenReady().then(() => {
|
||||
let content = msg.msgContent
|
||||
let img = ''
|
||||
let name = ''
|
||||
if (isMine && myAvatar) {
|
||||
img = myAvatar
|
||||
} else if (typeof msg.senderAvatar === 'string') {
|
||||
img = msg.senderAvatar
|
||||
}
|
||||
if (typeof msg.senderNickname === 'string') {
|
||||
name = msg.senderNickname
|
||||
if (isMine) {
|
||||
if (myAvatar) img = myAvatar
|
||||
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
|
||||
} else {
|
||||
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
|
||||
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
|
||||
}
|
||||
// 检查内容是否以 wxid 开头并包含冒号
|
||||
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
|
||||
@@ -343,6 +346,19 @@ app.whenReady().then(() => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
|
||||
return exportGroupReport(request)
|
||||
})
|
||||
|
||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||
try {
|
||||
shell.showItemInFolder(filePath)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getVoiceData',
|
||||
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
|
||||
|
||||
+77
-10
@@ -65,6 +65,7 @@ export class Wcdb4Client {
|
||||
private initialized = false
|
||||
private displayNameCache = new Map<string, string>()
|
||||
private avatarCache = new Map<string, string>()
|
||||
private groupNicknameCache = new Map<string, Map<string, string>>()
|
||||
private cachedSessions: Wcdb4Session[] | null = null
|
||||
|
||||
private wcdbInit: (() => number) | null = null
|
||||
@@ -96,6 +97,9 @@ export class Wcdb4Client {
|
||||
private wcdbGetGroupMembers:
|
||||
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbGetGroupNicknames:
|
||||
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbOpenMessageCursor:
|
||||
| ((
|
||||
handle: number,
|
||||
@@ -234,6 +238,7 @@ export class Wcdb4Client {
|
||||
this.cachedSessions = null
|
||||
this.displayNameCache.clear()
|
||||
this.avatarCache.clear()
|
||||
this.groupNicknameCache.clear()
|
||||
}
|
||||
|
||||
getSessions(): Wcdb4Session[] {
|
||||
@@ -291,12 +296,7 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
getMyAvatarUrl(): string | undefined {
|
||||
const rawAccountName = path.basename(this.accountRoot)
|
||||
const candidates = this.uniq([
|
||||
this.wxid,
|
||||
rawAccountName,
|
||||
Wcdb4Client.cleanAccountDirName(rawAccountName)
|
||||
])
|
||||
const candidates = this.getMyUsernameCandidates()
|
||||
this.hydrateAvatarUrls(candidates)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -307,6 +307,15 @@ export class Wcdb4Client {
|
||||
return undefined
|
||||
}
|
||||
|
||||
getMyGroupNickname(chatroomId: string): string | undefined {
|
||||
const groupNicknames = this.getGroupNicknames(chatroomId)
|
||||
for (const candidate of this.getMyUsernameCandidates()) {
|
||||
const nickname = groupNicknames.get(candidate)
|
||||
if (nickname) return nickname
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private getMessagesByCursor(
|
||||
username: string,
|
||||
startTime?: number,
|
||||
@@ -413,11 +422,12 @@ export class Wcdb4Client {
|
||||
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
||||
|
||||
try {
|
||||
const groupNicknames = this.getGroupNicknames(chatroomId)
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
|
||||
)
|
||||
|
||||
return (Array.isArray(rows) ? rows : []).map((row) => {
|
||||
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
||||
const username = this.pickString(row, [
|
||||
'username',
|
||||
'userName',
|
||||
@@ -425,10 +435,15 @@ export class Wcdb4Client {
|
||||
'member_username',
|
||||
'm_nsUsrName'
|
||||
])
|
||||
const nickname = this.pickString(row, [
|
||||
const memberNickname = this.pickString(row, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'groupNickname',
|
||||
'group_nickname',
|
||||
'roomNickname',
|
||||
'room_nickname',
|
||||
'remark',
|
||||
'm_nsNickName'
|
||||
])
|
||||
@@ -440,21 +455,60 @@ export class Wcdb4Client {
|
||||
])
|
||||
|
||||
if (username) {
|
||||
if (nickname) this.displayNameCache.set(username, nickname)
|
||||
if (avatar) this.avatarCache.set(username, avatar)
|
||||
}
|
||||
|
||||
return {
|
||||
m_nsUsrName: username,
|
||||
nickname: nickname || username,
|
||||
nickname: groupNicknames.get(username) || memberNickname,
|
||||
m_nsHeadImgUrl: avatar
|
||||
}
|
||||
})
|
||||
|
||||
const missingDisplayNames = members
|
||||
.filter((member) => !member.nickname)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
this.hydrateDisplayNames(missingDisplayNames)
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
nickname:
|
||||
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
getGroupNicknames(chatroomId: string): Map<string, string> {
|
||||
const cached = this.groupNicknameCache.get(chatroomId)
|
||||
if (cached) return cached
|
||||
|
||||
const nicknames = new Map<string, string>()
|
||||
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
||||
|
||||
try {
|
||||
const rows = this.callJson<Record<string, string> | Record<string, unknown>[]>(
|
||||
(handle, outJson) => this.wcdbGetGroupNicknames!(handle, chatroomId, outJson)
|
||||
)
|
||||
this.readStringMap(rows, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'groupNickname',
|
||||
'group_nickname',
|
||||
'name'
|
||||
]).forEach((nickname, username) => nicknames.set(username, nickname))
|
||||
this.groupNicknameCache.set(chatroomId, nicknames)
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] failed to get group nicknames for ${chatroomId}:`, error)
|
||||
}
|
||||
|
||||
return nicknames
|
||||
}
|
||||
|
||||
async getVoiceData(
|
||||
sessionId: string,
|
||||
createTime: number,
|
||||
@@ -650,6 +704,14 @@ export class Wcdb4Client {
|
||||
this.wcdbGetGroupMembers = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetGroupNicknames = lib.func(
|
||||
'int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)'
|
||||
) as (handle: number, chatroomId: string, outJson: WcdbVoidOut) => number
|
||||
} catch {
|
||||
this.wcdbGetGroupNicknames = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbOpenMessageCursor = lib.func(
|
||||
'int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)'
|
||||
@@ -1168,6 +1230,11 @@ export class Wcdb4Client {
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
private getMyUsernameCandidates(): string[] {
|
||||
const rawAccountName = path.basename(this.accountRoot)
|
||||
return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)])
|
||||
}
|
||||
|
||||
private normalizeTimestamp(input: number): number {
|
||||
if (!input || input <= 0) return 0
|
||||
const normalized = input > 1e12 ? Math.floor(input / 1000) : Math.floor(input)
|
||||
|
||||
@@ -53,7 +53,7 @@ export class WechatDb {
|
||||
|
||||
constructor(rawKey: string) {
|
||||
this.rawKey = rawKey
|
||||
console.log(`Initializing WechatDb with key: ${rawKey}`)
|
||||
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||
|
||||
if (this.tryOpenWechat4()) {
|
||||
this.chatDb = this.getChatDbNumber()
|
||||
|
||||
Vendored
+3
@@ -1,5 +1,6 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import { Contact, Message } from '../shared/types'
|
||||
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
|
||||
|
||||
export type ParsedContent =
|
||||
| { type: 'text'; content: string }
|
||||
@@ -58,6 +59,8 @@ declare global {
|
||||
cdnUrl?: string,
|
||||
md5?: string
|
||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
@@ -19,7 +20,10 @@ const api = {
|
||||
ipcRenderer.invoke('db:parseMessage', content, messageType),
|
||||
getImage: (imageMd5?: string, imageDatNameOrThumb?: string | boolean, sessionId?: string) =>
|
||||
ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId),
|
||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5)
|
||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||
ipcRenderer.invoke('report:export', request),
|
||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath)
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export type ReportHeat = '高' | '中' | '低'
|
||||
|
||||
export interface ReportTopic {
|
||||
title: string
|
||||
timeRange: string
|
||||
heat: ReportHeat
|
||||
participants: string[]
|
||||
summary: string
|
||||
conclusion?: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export interface ReportResource {
|
||||
title: string
|
||||
description: string
|
||||
sender?: string
|
||||
}
|
||||
|
||||
export interface ReportImportantMessage {
|
||||
sender: string
|
||||
time: string
|
||||
content: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface ReportQuoteMessage {
|
||||
sender: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ReportQuote {
|
||||
messages: ReportQuoteMessage[]
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface ReportQuestionAnswer {
|
||||
question: string
|
||||
answer: string
|
||||
answerer?: string
|
||||
}
|
||||
|
||||
export interface ReportTopicHeat {
|
||||
topic: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface ReportSpeakerRank {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface GroupDailyReport {
|
||||
overview: string
|
||||
topics: ReportTopic[]
|
||||
resources: ReportResource[]
|
||||
importantMessages: ReportImportantMessage[]
|
||||
quotes: ReportQuote[]
|
||||
qa: ReportQuestionAnswer[]
|
||||
analytics: {
|
||||
topicHeat: ReportTopicHeat[]
|
||||
activeTimeline: string
|
||||
topSpeakers: ReportSpeakerRank[]
|
||||
}
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export interface GroupReportMetadata {
|
||||
groupName: string
|
||||
reportDate: string
|
||||
dateRange: string
|
||||
messageCount: number
|
||||
activeUsers: number
|
||||
timeSpan: string
|
||||
generatedAt: string
|
||||
recordNote: string
|
||||
footerNote: string
|
||||
heroParticipants: string[]
|
||||
avatars: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export interface GroupReportExportRequest {
|
||||
report: GroupDailyReport
|
||||
metadata: GroupReportMetadata
|
||||
}
|
||||
|
||||
export interface GroupReportExportResult {
|
||||
success: boolean
|
||||
htmlPath?: string
|
||||
pngPath?: string
|
||||
imageDataUrl?: string
|
||||
error?: string
|
||||
}
|
||||
Reference in New Issue
Block a user