diff --git a/.env.example b/.env.example
index 285378e..5436001 100644
--- a/.env.example
+++ b/.env.example
@@ -8,7 +8,7 @@ VITE_AUTO_LOGIN=false
# AI API Configuration (Optional, can be entered in UI)
VITE_DEEPSEEK_API_KEY=
VITE_AI_BASE_URL=https://api.deepseek.com
-VITE_AI_MODEL=deepseek-chat
+VITE_AI_MODEL=deepseek-v4-flash
# Message types to filter out (comma separated). Empty means show all message types.
VITE_FILTER_MSG_TYPES=
diff --git a/.gitignore b/.gitignore
index 5312024..d196c22 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
node_modules
+*.tsbuildinfo
dist
out
.env
diff --git a/README.md b/README.md
index f727874..83a07f1 100644
--- a/README.md
+++ b/README.md
@@ -18,10 +18,13 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
+ - **一句话速览**: 首屏突出今日核心结论与待跟进事项。
- **实用信息与资源**: 提取分享的链接、资源等信息。
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
- **有趣对话或金句**: 收录群内的精彩对话。
- **问题与解答**: 整理群内的问答内容。
+ - **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
+ - **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
- **词云/关键词**: 可视化展示群聊关键词。
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
diff --git a/docs/skill/wechatexplorer-reader/SKILL.md b/docs/skill/wechatexplorer-reader/SKILL.md
index 09c925f..78632e3 100644
--- a/docs/skill/wechatexplorer-reader/SKILL.md
+++ b/docs/skill/wechatexplorer-reader/SKILL.md
@@ -141,12 +141,31 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图
"qa": [
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
],
+ "unresolved": [
+ { "question": "待跟进问题", "owner": "相关人(可选)", "status": "待跟进", "note": "为什么还没结束" }
+ ],
+ "storylines": [
+ { "title": "剧情线", "stages": [{ "time": "10:12", "event": "提出问题" }], "result": "可选结果" }
+ ],
+ "reversals": [
+ { "topic": "某话题", "initialView": "最初判断", "finalView": "最终判断", "note": "可选说明" }
+ ],
+ "participantChains": [
+ { "topic": "某话题", "chain": ["A 提出", "B 补充", "C 收尾"], "note": "可选说明" }
+ ],
"analytics": {
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
"activeTimeline": "10:00-12:00 为最活跃时段",
- "topSpeakers": [{ "name": "张三", "count": 58 }]
+ "topSpeakers": [{ "name": "张三", "count": 58 }],
+ "voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
},
- "keywords": ["高频词1", "高频词2"]
+ "keywords": ["高频词1", "高频词2"],
+ "hero": {
+ "headline": "一句抓重点的日报标题",
+ "summary": "一句概览",
+ "keyTakeaway": "最重要结论",
+ "pendingNote": "待跟进事项"
+ }
},
"metadata": {
"groupName": "技术交流",
diff --git a/resources/mobile_daily_report.html b/resources/mobile_daily_report.html
index 784ff69..9962a6c 100644
--- a/resources/mobile_daily_report.html
+++ b/resources/mobile_daily_report.html
@@ -27,13 +27,13 @@
.report {
width: 430px;
margin: 0 auto;
- padding: 22px 14px 34px;
+ padding: 20px 14px 34px;
}
.hero,
- .card,
- .section {
+ .section,
+ .card {
background: #fff;
- border-radius: 16px;
+ border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
@@ -41,32 +41,35 @@
}
.hero-top {
display: flex;
- align-items: center;
+ align-items: flex-start;
justify-content: space-between;
gap: 14px;
- min-width: 0;
}
.hero-top > div:first-child {
min-width: 0;
flex: 1 1 auto;
}
.hero h1 {
+ margin: 0;
font-size: 23px;
line-height: 1.2;
- margin: 0 0 8px;
font-weight: 900;
}
.sub {
+ margin-top: 8px;
color: #667085;
font-size: 13px;
- line-height: 1.5;
+ line-height: 1.55;
}
- .record-note {
- color: #485465;
- font-weight: 650;
- }
- .overview {
- margin-top: 2px;
+ .mode-tag {
+ display: inline-flex;
+ margin-top: 10px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: #eef8f2;
+ color: #07a352;
+ font-size: 11px;
+ font-weight: 800;
}
.avatar-grid {
width: 58px;
@@ -83,6 +86,49 @@
border-radius: 50%;
object-fit: cover;
}
+ .hero-headline {
+ margin-top: 14px;
+ padding: 14px;
+ border-radius: 16px;
+ background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
+ }
+ .hero-headline b {
+ display: block;
+ font-size: 17px;
+ color: #076c39;
+ }
+ .hero-headline p {
+ margin: 8px 0 0;
+ font-size: 13px;
+ line-height: 1.65;
+ color: #1f2933;
+ }
+ .hero-inline-notes {
+ display: grid;
+ gap: 8px;
+ margin-top: 10px;
+ }
+ .hero-note,
+ .hero-status {
+ padding: 10px 12px;
+ border-radius: 12px;
+ font-size: 12px;
+ line-height: 1.5;
+ }
+ .hero-status {
+ margin-top: 10px;
+ background: #f7faf9;
+ color: #076c39;
+ font-weight: 700;
+ }
+ .hero-note.takeaway {
+ background: #eef8f2;
+ color: #076c39;
+ }
+ .hero-note.pending {
+ background: #fff8e8;
+ color: #8a5a00;
+ }
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -94,14 +140,11 @@
border-radius: 12px;
padding: 10px 6px;
text-align: center;
- min-width: 0;
- overflow: hidden;
}
.stat b {
display: block;
font-size: 18px;
color: #07a352;
- white-space: nowrap;
line-height: 1.25;
}
.stat span {
@@ -109,16 +152,16 @@
color: #667085;
}
.section {
- margin-top: 14px;
+ margin-top: 18px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
- font-size: 18px;
+ margin-bottom: 14px;
+ font-size: 19px;
font-weight: 900;
- margin-bottom: 12px;
}
.section-title::before {
content: '';
@@ -127,11 +170,23 @@
border-radius: 99px;
background: #07c160;
}
+ .section-subtitle {
+ margin: 10px 0 6px;
+ color: #667085;
+ font-size: 12px;
+ font-weight: 700;
+ }
+ .section-more {
+ margin-top: 10px;
+ color: #98a2b3;
+ font-size: 11px;
+ text-align: right;
+ }
.card {
padding: 14px;
margin-top: 10px;
- box-shadow: none;
border: 1px solid #edf0f2;
+ box-shadow: none;
}
.topic-title-row {
display: flex;
@@ -140,17 +195,17 @@
gap: 8px;
}
.topic-title-row h3 {
+ margin: 0;
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;
+ border-radius: 999px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
@@ -165,19 +220,44 @@
background: #eef5ff;
color: #1677ff;
}
- .red {
- background: #fff1f0;
- color: #ff4d4f;
- }
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
+ margin: 10px 0 0;
font-size: 13px;
line-height: 1.65;
- margin: 10px 0 0;
+ }
+ .topic-conclusions {
+ display: grid;
+ gap: 8px;
+ margin-top: 10px;
+ }
+ .topic-conclusion {
+ padding: 8px 10px;
+ border-radius: 10px;
+ background: #edf9f1;
+ color: #076c39;
+ font-size: 12px;
+ line-height: 1.5;
+ font-weight: 700;
+ }
+ .topic-inline-image {
+ display: grid;
+ grid-template-columns: 76px 1fr;
+ gap: 10px;
+ margin-top: 10px;
+ padding: 10px;
+ border-radius: 12px;
+ background: #f7faf9;
+ }
+ .topic-inline-image img {
+ width: 76px;
+ height: 76px;
+ border-radius: 10px;
+ object-fit: cover;
}
.participants {
display: flex;
@@ -189,7 +269,6 @@
display: inline-flex;
align-items: center;
gap: 5px;
- min-width: 0;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
@@ -198,7 +277,6 @@
width: 24px;
height: 24px;
border-radius: 50%;
- object-fit: cover;
}
.person-chip b {
max-width: 58px;
@@ -220,17 +298,6 @@
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;
@@ -255,9 +322,9 @@
color: #1f2933;
}
.important-text {
+ margin-top: 5px;
font-size: 13px;
line-height: 1.55;
- margin-top: 5px;
}
.important-note {
margin-top: 8px;
@@ -269,6 +336,34 @@
font-size: 12px;
line-height: 1.45;
}
+ .action-grid {
+ display: grid;
+ gap: 10px;
+ }
+ .action-card {
+ border-radius: 14px;
+ padding: 12px;
+ }
+ .todo-card {
+ background: #eef5ff;
+ }
+ .unresolved-card {
+ background: #fff8e8;
+ }
+ .action-card b {
+ display: block;
+ color: #1f2933;
+ font-size: 14px;
+ }
+ .action-card div {
+ margin-top: 6px;
+ font-size: 12px;
+ line-height: 1.55;
+ color: #485465;
+ }
+ .action-note {
+ color: #667085;
+ }
.chat-block {
background: #f0f2f5;
border-radius: 14px;
@@ -299,49 +394,180 @@
line-height: 1.5;
}
.quote-note {
- background: #fff8e1;
- border-radius: 10px;
- padding: 9px 10px;
margin-top: 10px;
+ padding: 9px 10px;
+ border-radius: 10px;
+ background: #fff8e1;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
- .qa-card {
- background: #f8fafc;
- border-radius: 14px;
- padding: 12px;
+ .qa-card,
+ .resource {
margin-top: 10px;
+ padding: 12px;
+ border-radius: 14px;
+ background: #f8fafc;
}
- .qa-card b {
+ .qa-card b,
+ .resource b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
- .qa-card div {
+ .qa-card div,
+ .resource {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
- .bar-row {
+ .storyline-card,
+ .chain-card {
+ background: #f8fafc;
+ }
+ .storyline-steps {
display: grid;
- grid-template-columns: 82px 1fr;
gap: 8px;
- align-items: center;
- margin-top: 9px;
+ margin-top: 10px;
+ }
+ .storyline-step {
+ display: grid;
+ grid-template-columns: 50px 1fr;
+ gap: 10px;
+ }
+ .storyline-step span {
+ color: #8a94a6;
font-size: 12px;
}
- .bar {
- height: 10px;
- background: #edf1f5;
- border-radius: 999px;
- overflow: hidden;
+ .storyline-step b {
+ font-size: 13px;
+ line-height: 1.5;
}
- .bar i {
- display: block;
- height: 100%;
- background: #07c160;
+ .chain-flow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ margin-top: 10px;
+ }
+ .chain-flow span {
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 9px;
border-radius: 999px;
+ background: #eef8f2;
+ color: #076c39;
+ font-size: 12px;
+ font-weight: 700;
+ }
+ .chain-flow i {
+ font-style: normal;
+ color: #98a2b3;
+ }
+ .gallery-card {
+ display: grid;
+ grid-template-columns: 112px 1fr;
+ gap: 12px;
+ margin-top: 10px;
+ padding: 12px;
+ background: #f7faf9;
+ border-radius: 14px;
+ }
+ .gallery-image {
+ width: 112px;
+ height: 112px;
+ border-radius: 12px;
+ object-fit: cover;
+ background: #e5e7eb;
+ }
+ .gallery-stats {
+ display: inline-flex;
+ margin-top: 7px;
+ padding: 4px 8px;
+ border-radius: 999px;
+ background: #eef5ff;
+ color: #1677ff;
+ font-size: 11px;
+ font-weight: 700;
+ }
+ .badge-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ }
+ /* AI 图片识别板块 */
+ .vision-card {
+ display: grid;
+ grid-template-columns: 132px 1fr;
+ gap: 14px;
+ margin-top: 10px;
+ padding: 12px;
+ background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
+ border: 1px solid #d6efde;
+ border-radius: 14px;
+ }
+ .vision-image {
+ width: 132px;
+ height: 132px;
+ border-radius: 12px;
+ object-fit: cover;
+ background: #e5e7eb;
+ }
+ .vision-description {
+ margin-top: 6px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: #1f2933;
+ }
+ .vision-ocr {
+ margin-top: 6px;
+ padding: 6px 10px;
+ background: #eef5ff;
+ color: #1677ff;
+ font-size: 11px;
+ border-radius: 8px;
+ word-break: break-all;
+ }
+ .vision-tags {
+ margin-top: 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ }
+ .vision-tag {
+ display: inline-block;
+ padding: 3px 8px;
+ background: #07c160;
+ color: #fff;
+ font-size: 10px;
+ font-weight: 700;
+ border-radius: 999px;
+ }
+ .vision-label {
+ margin-top: 6px;
+ font-size: 10px;
+ color: #07a352;
+ font-weight: 600;
+ }
+ .badge-card {
+ background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
+ border: 1px solid #edf0f2;
+ border-radius: 14px;
+ padding: 12px;
+ }
+ .badge-card b {
+ display: block;
+ margin-top: 8px;
+ font-size: 15px;
+ }
+ .badge-card p {
+ margin: 8px 0 0;
+ font-size: 12px;
+ line-height: 1.55;
+ }
+ .data-grid {
+ display: grid;
+ gap: 12px;
}
.rank {
display: flex;
@@ -350,6 +576,9 @@
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
+ .rank:last-child {
+ border-bottom: none;
+ }
.rank img {
width: 30px;
height: 30px;
@@ -402,7 +631,58 @@
color: #8a94a6;
}
.empty-section {
- display: none;
+ display: none !important;
+ }
+ .compact .report {
+ padding-top: 18px;
+ }
+ .compact .section {
+ margin-top: 12px;
+ padding: 14px;
+ }
+ .compact .card {
+ padding: 11px;
+ }
+ .compact .important-card,
+ .compact .gallery-card,
+ .compact .chat-block {
+ padding: 10px;
+ }
+ .compact .section-title {
+ margin-bottom: 9px;
+ font-size: 17px;
+ }
+ .compact .hero-headline p,
+ .compact .card p,
+ .compact .important-text,
+ .compact .chat-bubble {
+ line-height: 1.5;
+ }
+ .compact .participants,
+ .compact .keywords,
+ .compact .hero-inline-notes {
+ gap: 6px;
+ }
+ .compact .topic-conclusions {
+ gap: 6px;
+ }
+ .compact .topic-inline-image {
+ grid-template-columns: 64px 1fr;
+ padding: 8px;
+ }
+ .compact .topic-inline-image img {
+ width: 64px;
+ height: 64px;
+ }
+ .compact .stats {
+ gap: 6px;
+ margin-top: 14px;
+ }
+ .compact .stat {
+ padding: 8px 6px;
+ }
+ .compact .stat b {
+ font-size: 17px;
}
@media (max-width: 430px) {
html,
@@ -414,83 +694,144 @@
padding-left: 12px;
padding-right: 12px;
}
- .stats {
- gap: 6px;
- }
- .stat b {
- font-size: 16px;
- }
}
-
+
{{GROUP_NAME}}日报
-
-
{{DATE_RANGE}}
-
{{RECORD_NOTE}}
-
{{OVERVIEW}}
-
+
{{DATE_RANGE}}
{{RECORD_NOTE}}
+
{{REPORT_MODE_LABEL}}
{{HERO_AVATARS}}
+
+
{{HERO_HEADLINE}}
+
{{HERO_SUMMARY}}
+
+ {{HERO_STATUS_LINE}}
+
+
{{HERO_TAKEAWAY}}
+
{{HERO_PENDING}}
+
{{MESSAGE_COUNT}}消息数
{{ACTIVE_USERS}}活跃人数
-
{{TIME_SPAN}}持续时长
-
{{TOPIC_COUNT}}主要话题
+
{{TOPIC_COUNT}}话题数
+
{{MEDIA_COUNT}}媒体消息
-
+
今日讨论热点
{{TOPIC_CARDS}}
+ {{TOPICS_MORE_NOTE}}
-
+
+ 重要消息
+ {{IMPORTANT_MESSAGES}}
+ {{MESSAGES_MORE_NOTE}}
+
+
+
+ 待办事项和未解决问题
+ 待办事项
+ {{TODO_CARDS}}
+ 尚未解决
+ {{UNRESOLVED_CARDS}}
+ {{ACTIONS_MORE_NOTE}}
+
+
+
+ 今日名场面
+ {{QUOTE_BLOCKS}}
+ {{QUOTES_MORE_NOTE}}
+
+
+
+ 今日群数据
+
+
+
话唠榜 TOP5
+ {{RANK_ITEMS}}
+
+
+
最活跃时段:{{ACTIVITY_TIMELINE}}
+
今日状态:形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。
+
+
+
+
+
+ 关键词
+ {{CLOUD_TAGS}}
+ {{KEYWORDS_MORE_NOTE}}
+
+
+
实用信息与资源
{{RESOURCE_ITEMS}}
+ {{RESOURCES_MORE_NOTE}}
-
- 重要消息汇总
- {{IMPORTANT_MESSAGES}}
-
-
-
- 有趣对话或金句
- {{QUOTE_BLOCKS}}
-
-
-
+
问题与解答
{{QA_CARDS}}
+ {{QA_MORE_NOTE}}
-
- 群内数据可视化
- {{HEAT_BARS}}
-
-
- 话唠榜 TOP5(基于已读取记录估算)
-
- {{RANK_ITEMS}}
-
-
-
活跃时间线:{{ACTIVITY_TIMELINE}}
-
+
+ 今日剧情时间线
+ {{STORYLINE_CARDS}}
+ {{STORYLINES_MORE_NOTE}}
-
- 词云/关键词
- {{CLOUD_TAGS}}
+
+ 群聊反转现场
+ {{REVERSAL_CARDS}}
+ {{REVERSALS_MORE_NOTE}}
+
+
+
+ {{VISION_TITLE}}
+ {{VISION_CARDS}}
+
+
+
+ 今日群相册
+ {{GALLERY_CARDS}}
+ {{GALLERY_MORE_NOTE}}
+
+
+
+ 语音之最
+ {{VOICE_CARDS}}
+ {{VOICE_MORE_NOTE}}
+
+
+
+ 语音时长榜
+ {{VOICE_RANK_CARDS}}
+
+
+
+ 今日临时人设
+ {{BADGE_CARDS}}
+ {{BADGES_MORE_NOTE}}
+
+
+
+ 话题参与链路
+ {{CHAIN_CARDS}}
+ {{CHAINS_MORE_NOTE}}
diff --git a/resources/mobile_daily_report_v1.html b/resources/mobile_daily_report_v1.html
new file mode 100644
index 0000000..e80e5cb
--- /dev/null
+++ b/resources/mobile_daily_report_v1.html
@@ -0,0 +1,592 @@
+
+
+
+
+
+ {{REPORT_TITLE}}
+
+
+
+
+
+
+
+
{{GROUP_NAME}}日报
+
+
{{DATE_RANGE}}
+
{{RECORD_NOTE}}
+
{{OVERVIEW}}
+
+
+
{{HERO_AVATARS}}
+
+
+
{{MESSAGE_COUNT}}消息数
+
{{ACTIVE_USERS}}活跃人数
+
{{TIME_SPAN}}持续时长
+
{{TOPIC_COUNT}}主要话题
+
+
+
+
+ 今日讨论热点
+ {{TOPIC_CARDS}}
+
+
+
+ {{VISION_TITLE}}
+ {{VISION_CARDS}}
+
+
+
+ 实用信息与资源
+ {{RESOURCE_ITEMS}}
+
+
+
+ 重要消息汇总
+ {{IMPORTANT_MESSAGES}}
+
+
+
+ 有趣对话或金句
+ {{QUOTE_BLOCKS}}
+
+
+
+
+
+ 群内数据可视化
+ {{HEAT_BARS}}
+
+
+ 话唠榜 TOP5(基于已读取记录估算)
+
+ {{RANK_ITEMS}}
+
+
+
活跃时间线:{{ACTIVITY_TIMELINE}}
+
+
+
+
+ 词云/关键词
+ {{CLOUD_TAGS}}
+
+
+
+
+
+
diff --git a/resources/mobile_daily_report_v2.html b/resources/mobile_daily_report_v2.html
new file mode 100644
index 0000000..38d173d
--- /dev/null
+++ b/resources/mobile_daily_report_v2.html
@@ -0,0 +1,829 @@
+
+
+
+
+
+ {{REPORT_TITLE}}
+
+
+
+
+
+
+
+
{{GROUP_NAME}}日报
+
{{DATE_RANGE}}
{{RECORD_NOTE}}
+
+
{{HERO_AVATARS}}
+
+
+
{{HERO_HEADLINE}}
+
{{HERO_SUMMARY}}
+
+ {{HERO_STATUS_LINE}}
+
+
{{HERO_TAKEAWAY}}
+
{{HERO_PENDING}}
+
+
+
{{MESSAGE_COUNT}}消息数
+
{{ACTIVE_USERS}}活跃人数
+
{{TOPIC_COUNT}}话题数
+
{{MEDIA_COUNT}}媒体消息
+
+
+
+
+ 今日讨论热点
+ {{TOPIC_CARDS}}
+ {{TOPICS_MORE_NOTE}}
+
+
+
+ 重要消息
+ {{IMPORTANT_MESSAGES}}
+ {{MESSAGES_MORE_NOTE}}
+
+
+
+ 待办事项和未解决问题
+ 待办事项
+ {{TODO_CARDS}}
+ 尚未解决
+ {{UNRESOLVED_CARDS}}
+ {{ACTIONS_MORE_NOTE}}
+
+
+
+ 今日名场面
+ {{QUOTE_BLOCKS}}
+ {{QUOTES_MORE_NOTE}}
+
+
+
+ 今日群数据
+
+
+
话唠榜 TOP5
+ {{RANK_ITEMS}}
+
+
+
最活跃时段:{{ACTIVITY_TIMELINE}}
+
今日状态:形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。
+
+
+
+
+
+ 关键词
+ {{CLOUD_TAGS}}
+ {{KEYWORDS_MORE_NOTE}}
+
+
+
+ 实用信息与资源
+ {{RESOURCE_ITEMS}}
+ {{RESOURCES_MORE_NOTE}}
+
+
+
+ 问题与解答
+ {{QA_CARDS}}
+ {{QA_MORE_NOTE}}
+
+
+
+ 今日剧情时间线
+ {{STORYLINE_CARDS}}
+ {{STORYLINES_MORE_NOTE}}
+
+
+
+ 群聊反转现场
+ {{REVERSAL_CARDS}}
+ {{REVERSALS_MORE_NOTE}}
+
+
+
+ {{VISION_TITLE}}
+ {{VISION_CARDS}}
+
+
+
+ 今日群相册
+ {{GALLERY_CARDS}}
+ {{GALLERY_MORE_NOTE}}
+
+
+
+ 语音之最
+ {{VOICE_CARDS}}
+ {{VOICE_MORE_NOTE}}
+
+
+
+ 语音时长榜
+ {{VOICE_RANK_CARDS}}
+
+
+
+ 今日临时人设
+ {{BADGE_CARDS}}
+ {{BADGES_MORE_NOTE}}
+
+
+
+ 话题参与链路
+ {{CHAIN_CARDS}}
+ {{CHAINS_MORE_NOTE}}
+
+
+
+
+
+
diff --git a/scripts/render-report-fixtures.mjs b/scripts/render-report-fixtures.mjs
new file mode 100644
index 0000000..c7b9333
--- /dev/null
+++ b/scripts/render-report-fixtures.mjs
@@ -0,0 +1,409 @@
+import { app, BrowserWindow } from 'electron'
+import fs from 'fs-extra'
+import path from 'path'
+import os from 'os'
+import { fileURLToPath } from 'url'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const root = path.resolve(__dirname, '..')
+const templatePath = path.join(root, 'resources', 'mobile_daily_report.html')
+const outputDir = path.join(os.tmpdir(), 'wechatexplorer-report-fixtures')
+
+const escapeHtml = (value) =>
+ String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+
+const replacePlaceholder = (html, key, value) => html.replaceAll(`{{${key}}}`, value)
+
+const avatarSvg = (label, color) =>
+ `data:image/svg+xml;base64,${Buffer.from(
+ ``
+ ).toString('base64')}`
+
+const localImagePath = '/Users/Wxw_/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/a969409112_d784/temp/RWTemp/2026-07/94ce24699a5a1d539c00a37ec8ace755.png'
+const sampleImage = fs.existsSync(localImagePath)
+ ? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
+ : avatarSvg('图', '#dbeafe')
+
+const avatars = {
+ 阿宇: avatarSvg('宇', '#dcfce7'),
+ 老周: avatarSvg('周', '#e0f2fe'),
+ 小李: avatarSvg('李', '#fef3c7'),
+ 'we water': avatarSvg('W', '#ede9fe'),
+ 佩佩: avatarSvg('佩', '#fee2e2')
+}
+
+const heroNames = ['阿宇', '老周', '小李', 'we water']
+const heroAvatars = heroNames
+ .map((name) => `
`)
+ .join('')
+
+const compactRequest = {
+ metadata: {
+ groupName: '技术交流',
+ reportDate: '2026-07-10',
+ dateRange: '2026-07-10 09:12-19:48',
+ messageCount: 382,
+ activeUsers: 47,
+ imageCount: 9,
+ voiceCount: 5,
+ stickerCount: 14,
+ mediaMessageCount: 28,
+ timeSpan: '11 h',
+ generatedAt: '2026-07-10 22:18',
+ recordNote: '基于示例数据生成的精简版日报',
+ footerNote: '精简版默认面向长图转发;无内容模块自动隐藏。',
+ heroParticipants: heroNames,
+ avatars,
+ reportMode: 'compact'
+ },
+ report: {
+ hero: {
+ headline: '接口排查和版本升级是今天主线',
+ summary: '白天主要围绕接口异常、升级节奏和上线安排展开,结论比争论更多,待跟进事项也比较集中。',
+ keyTakeaway: '大家确认本次异常更像缓存与配置问题,而不是服务端挂掉。',
+ pendingNote: '测试环境接口文档和回滚方案仍需补齐。',
+ statusLine: '今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决'
+ },
+ summaryStats: {
+ messageCount: 382,
+ activeUsers: 47,
+ topicCount: 4,
+ mediaCount: 28,
+ imageCount: 9,
+ voiceCount: 5,
+ stickerCount: 14,
+ conclusionCount: 3,
+ todoCount: 2,
+ unresolvedCount: 1
+ },
+ sectionMeta: {
+ hero: { enabled: true, importance: 1, confidence: 0.95, totalCount: 1, displayedCount: 1 },
+ topics: { enabled: true, importance: 0.98, confidence: 0.86, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
+ importantMessages: { enabled: true, importance: 0.95, confidence: 0.84, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
+ actions: { enabled: true, importance: 0.97, confidence: 0.81, totalCount: 5, displayedCount: 3, hiddenCount: 2 },
+ moments: { enabled: true, importance: 0.75, confidence: 0.75, totalCount: 3, displayedCount: 1, hiddenCount: 2 },
+ analytics: { enabled: true, importance: 0.8, confidence: 0.95, totalCount: 1, displayedCount: 1 },
+ keywords: { enabled: true, importance: 0.68, confidence: 0.92, totalCount: 16, displayedCount: 12, hiddenCount: 4 }
+ },
+ topics: [
+ {
+ title: 'GPT 接口异常排查',
+ timeRange: '09:20-11:05',
+ heat: '高',
+ summary: '上午先从接口超时和返回结构异常入手,几轮排查后,大家逐步把问题收敛到缓存与环境配置,而不是后端服务不可用。',
+ conclusions: [
+ { text: '接口本身可用,异常更像本地缓存与环境变量冲突。' },
+ { text: '先清缓存再复测,避免把旧响应误判成线上事故。' }
+ ],
+ participants: ['阿宇', '老周', '小李'],
+ keywords: ['接口', '缓存', '环境变量'],
+ image: {
+ imageUrl: sampleImage,
+ note: '该图片引发 12 条回复。根据图片前后对话推断,这是一张帮助定位问题的截图。'
+ }
+ },
+ {
+ title: '版本升级节奏',
+ timeRange: '11:40-12:20',
+ heat: '中',
+ summary: '关于 React 版本是否立刻升级,讨论从“能不能升”转成“这周值不值得升”,最终倾向先补兼容性清单再动。',
+ conclusions: [{ text: '先列旧组件兼容清单,再决定升级窗口。' }],
+ participants: ['阿宇', 'we water', '佩佩'],
+ keywords: ['React', '升级', '兼容性']
+ },
+ {
+ title: '上线节奏与回滚准备',
+ timeRange: '15:10-16:05',
+ heat: '中',
+ summary: '下午讨论上线方案时,大家更关注回滚准备是否充分,最后把重点放在文档、监控和回滚路径补齐上。',
+ conclusions: [{ text: '上线前需要补一版简短回滚说明。' }],
+ participants: ['老周', '小李'],
+ keywords: ['上线', '回滚', '监控']
+ },
+ ],
+ importantMessages: [
+ { sender: '阿宇', time: '10:41', content: '先别回滚,接口能通。', note: '稳定了排查方向。' },
+ { sender: '老周', time: '11:02', content: '像是缓存没清掉。', note: '把问题从服务端收敛到本地环境。' },
+ { sender: '小李', time: '15:36', content: '上线前把回滚文档补一下。', note: '明确形成待办。' }
+ ],
+ todos: [
+ { task: '补测试环境接口文档', owner: '小李', topic: 'GPT 接口异常排查', note: '方便明天复测。' },
+ { task: '整理回滚说明', owner: '老周', deadline: '今晚', topic: '上线节奏与回滚准备' }
+ ],
+ unresolved: [
+ { question: '缓存问题的根因是不是插件残留?', owner: '阿宇', status: '待跟进', lastDiscussedAt: '18:26', note: '目前只有推断,还没有最终证据。' }
+ ],
+ quotes: [
+ {
+ messages: [
+ { sender: '阿宇', content: '我以为接口炸了。' },
+ { sender: '老周', content: '先别慌,先清缓存。' },
+ { sender: '小李', content: '清完它居然真好了。' }
+ ],
+ note: '从“要不要回滚”迅速切到“先做最小验证”,很像今天的群聊节奏。'
+ }
+ ],
+ analytics: {
+ topSpeakers: [
+ { name: '阿宇', count: 112 },
+ { name: '老周', count: 78 },
+ { name: '小李', count: 61 },
+ { name: 'we water', count: 49 },
+ { name: '佩佩', count: 33 }
+ ],
+ activeTimeline: '09:00-09:59、10:00-10:59、15:00-15:59',
+ voiceLeaderboard: []
+ },
+ keywords: ['GPT', '接口', '缓存', '升级', '上线', '回滚', '监控', '兼容性', '截图', '文档', '测试环境', '复测'],
+ media: {
+ gallery: [],
+ voiceHighlights: [],
+ funBadges: []
+ },
+ resources: [],
+ qa: [],
+ storylines: [],
+ reversals: [],
+ participantChains: []
+ }
+}
+
+const fullRequest = JSON.parse(JSON.stringify(compactRequest))
+fullRequest.metadata.recordNote = '基于示例数据生成的完整版日报'
+fullRequest.metadata.reportMode = 'full'
+fullRequest.report.sectionMeta = {
+ ...fullRequest.report.sectionMeta,
+ resources: { enabled: true, importance: 0.58, confidence: 0.76, totalCount: 2, displayedCount: 2 },
+ qa: { enabled: true, importance: 0.62, confidence: 0.8, totalCount: 2, displayedCount: 2 },
+ storylines: { enabled: true, importance: 0.68, confidence: 0.74, totalCount: 2, displayedCount: 2 },
+ reversals: { enabled: true, importance: 0.55, confidence: 0.72, totalCount: 1, displayedCount: 1 },
+ gallery: { enabled: true, importance: 0.64, confidence: 0.82, totalCount: 2, displayedCount: 2 },
+ voices: { enabled: true, importance: 0.6, confidence: 0.83, totalCount: 2, displayedCount: 2 },
+ badges: { enabled: true, importance: 0.45, confidence: 0.68, totalCount: 2, displayedCount: 2 },
+ chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 }
+}
+fullRequest.report.resources = [
+ { title: '测试环境接口文档', description: '明天复测会直接用到的说明。', sender: '小李' },
+ { title: '回滚说明草稿', description: '上线前确认回滚路径与负责人。', sender: '老周' }
+]
+fullRequest.report.qa = [
+ { question: '今晚要不要升级 React?', answer: '先不升级,先补兼容清单。', answerer: 'we water' },
+ { question: '接口是不是服务端挂了?', answer: '不是,当前更像缓存与环境问题。', answerer: '老周' }
+]
+fullRequest.report.storylines = [
+ {
+ title: '接口异常排查线',
+ stages: [
+ { time: '09:20', event: '阿宇提出接口异常。' },
+ { time: '09:46', event: '老周建议先清缓存。' },
+ { time: '10:41', event: '确认接口本身可通。' }
+ ],
+ result: '初步定位为缓存与配置冲突。'
+ },
+ {
+ title: '版本升级讨论线',
+ stages: [
+ { time: '11:40', event: '开始讨论是否本周升级。' },
+ { time: '12:05', event: '补充兼容性与工期顾虑。' }
+ ],
+ result: '今晚不升,先补兼容清单。'
+ }
+]
+fullRequest.report.reversals = [
+ { topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' }
+]
+fullRequest.report.media = {
+ gallery: [
+ {
+ sender: '阿宇',
+ time: '09:52',
+ imageUrl: sampleImage,
+ note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。',
+ stats: '12 条后续消息 · 6 人接话',
+ inferenceLabel: '基于图片后的聊天上下文推断'
+ },
+ {
+ sender: '佩佩',
+ time: '17:14',
+ imageUrl: sampleImage,
+ note: '第二张图带起一轮轻松但有效的快速确认。',
+ stats: '5 条后续消息 · 3 人接话',
+ inferenceLabel: '基于图片后的聊天上下文推断'
+ }
+ ],
+ voiceHighlights: [
+ { title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
+ { title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
+ ],
+ funBadges: [
+ { title: '高能输出王', owner: '阿宇', note: '今天一共发了 112 条消息。' },
+ { title: '语音麦霸', owner: '老周', note: '语音总时长位列第一。' }
+ ]
+}
+fullRequest.report.participantChains = [
+ { topic: '接口异常排查', chain: ['阿宇 提出', '老周 收敛方向', '小李 验证', 'we water 定结论'], note: '比较典型的一条技术讨论链路。' }
+]
+fullRequest.report.analytics.voiceLeaderboard = [
+ { sender: '老周', count: 3, durationSec: 97 },
+ { sender: '阿宇', count: 2, durationSec: 54 }
+]
+
+async function renderRequest(request, targetBase) {
+ let html = await fs.readFile(templatePath, 'utf8')
+ const report = request.report
+ const metadata = request.metadata
+ const topicCards = report.topics
+ .map((topic) => {
+ const conclusions = (topic.conclusions || [])
+ .slice(0, 2)
+ .map((entry) => `${escapeHtml(entry.text)}
`)
+ .join('')
+ const image = topic.image?.imageUrl
+ ? `
${escapeHtml(topic.image.note)}
`
+ : ''
+ return `${escapeHtml(topic.title)}
${escapeHtml(topic.heat)}热${escapeHtml(topic.timeRange)}
${escapeHtml(topic.summary)}
${conclusions ? `
${conclusions}
` : ''}${image}
${topic.participants.map((name) => `
${escapeHtml(name)}`).join('')}
${topic.keywords.map((word) => `${escapeHtml(word)}`).join('')}
`
+ })
+ .join('')
+ const importantMessages = report.importantMessages
+ .map((message) => `})
${escapeHtml(message.sender)}${escapeHtml(message.time)}
${escapeHtml(message.content)}
${escapeHtml(message.note)}
`)
+ .join('')
+ const todoCards = (report.todos || []).map((item) => `${escapeHtml(item.task)}${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}
${item.note ? `
${escapeHtml(item.note)}
` : ''}
`).join('')
+ const unresolvedCards = (report.unresolved || []).map((item) => `${escapeHtml(item.question)}${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}
${escapeHtml(item.note)}
`).join('')
+ const quoteBlocks = report.quotes.map((quote) => `${quote.messages.map((message) => `
})
${escapeHtml(message.sender)}
${escapeHtml(message.content)}
`).join('')}
${escapeHtml(quote.note)}
`).join('')
+ const rankItems = report.analytics.topSpeakers.map((speaker, index) => `
${index + 1}. ${escapeHtml(speaker.name)}${speaker.count} 条 `).join('')
+ const cloudTags = report.keywords.map((word, index) => `${escapeHtml(word)}`).join('')
+ const resourceItems = (report.resources || []).map((resource) => `${escapeHtml(resource.title)}${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}
${escapeHtml(resource.description)}
`).join('')
+ const qaCards = (report.qa || []).map((item) => `Q:${escapeHtml(item.question)}A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
`).join('')
+ const storylineCards = (report.storylines || []).map((item) => `${escapeHtml(item.title)}
${item.stages.map((stage) => `
${escapeHtml(stage.time)}${escapeHtml(stage.event)}
`).join('')}
${item.result ? `
${escapeHtml(item.result)}
` : ''}
`).join('')
+ const reversalCards = (report.reversals || []).map((item) => `${escapeHtml(item.topic)}最初:${escapeHtml(item.initialView)}
后来:${escapeHtml(item.finalView)}
${item.note ? `
${escapeHtml(item.note)}
` : ''}
`).join('')
+ const galleryCards = (report.media.gallery || []).map((item) => `
${escapeHtml(item.sender)}${escapeHtml(item.time)}
${item.stats ? `
${escapeHtml(item.stats)}
` : ''}
${escapeHtml(item.note)}
${item.inferenceLabel ? `
${escapeHtml(item.inferenceLabel)}
` : ''}
`).join('')
+ const voiceCards = (report.media.voiceHighlights || []).map((item) => `${escapeHtml(item.title)} · ${escapeHtml(item.sender)}${escapeHtml(item.note)}
`).join('')
+ const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `
${index + 1}. ${escapeHtml(item.sender)}${item.count} 条 · ${item.durationSec} 秒 `).join('')
+ const badgeCards = (report.media.funBadges || []).map((item) => `${escapeHtml(item.title)}${escapeHtml(item.owner)}${escapeHtml(item.note)}
`).join('')
+ const chainCards = (report.participantChains || []).map((item) => `${escapeHtml(item.topic)}
${item.chain.map((node) => `${escapeHtml(node)}`).join('→')}
${item.note ? `
${escapeHtml(item.note)}
` : ''}
`).join('')
+
+ const replaceMap = {
+ REPORT_TITLE: `${metadata.groupName}日报`,
+ REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
+ GROUP_NAME: metadata.groupName,
+ DATE_RANGE: metadata.dateRange,
+ RECORD_NOTE: metadata.recordNote,
+ REPORT_MODE_LABEL: metadata.reportMode === 'full' ? '完整版' : '精简版',
+ HERO_HEADLINE: report.hero.headline,
+ HERO_SUMMARY: report.hero.summary,
+ HERO_STATUS_LINE: report.hero.statusLine || '',
+ HERO_STATUS_EMPTY_CLASS: report.hero.statusLine ? '' : 'empty-section',
+ HERO_TAKEAWAY: report.hero.keyTakeaway || '',
+ HERO_TAKEAWAY_EMPTY_CLASS: report.hero.keyTakeaway ? '' : 'empty-section',
+ HERO_PENDING: report.hero.pendingNote || '',
+ HERO_PENDING_EMPTY_CLASS: report.hero.pendingNote ? '' : 'empty-section',
+ HERO_AVATARS: heroAvatars,
+ MESSAGE_COUNT: String(report.summaryStats.messageCount),
+ ACTIVE_USERS: String(report.summaryStats.activeUsers),
+ TOPIC_COUNT: String(report.summaryStats.topicCount),
+ MEDIA_COUNT: String(report.summaryStats.mediaCount),
+ TOPICS_EMPTY_CLASS: report.sectionMeta.topics?.enabled ? '' : 'empty-section',
+ TOPIC_CARDS: topicCards,
+ TOPICS_MORE_NOTE: report.sectionMeta.topics?.hiddenCount ? `另有 ${report.sectionMeta.topics.hiddenCount} 条内容,请在完整版中查看
` : '',
+ MESSAGES_EMPTY_CLASS: report.sectionMeta.importantMessages?.enabled ? '' : 'empty-section',
+ IMPORTANT_MESSAGES: importantMessages,
+ MESSAGES_MORE_NOTE: report.sectionMeta.importantMessages?.hiddenCount ? `另有 ${report.sectionMeta.importantMessages.hiddenCount} 条内容,请在完整版中查看
` : '',
+ ACTIONS_EMPTY_CLASS: report.sectionMeta.actions?.enabled ? '' : 'empty-section',
+ TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
+ TODO_CARDS: todoCards,
+ UNRESOLVED_EMPTY_CLASS: report.unresolved.length ? '' : 'empty-section',
+ UNRESOLVED_CARDS: unresolvedCards,
+ ACTIONS_MORE_NOTE: report.sectionMeta.actions?.hiddenCount ? `另有 ${report.sectionMeta.actions.hiddenCount} 条内容,请在完整版中查看
` : '',
+ QUOTES_EMPTY_CLASS: report.sectionMeta.moments?.enabled ? '' : 'empty-section',
+ QUOTE_BLOCKS: quoteBlocks,
+ QUOTES_MORE_NOTE: report.sectionMeta.moments?.hiddenCount ? `另有 ${report.sectionMeta.moments.hiddenCount} 条内容,请在完整版中查看
` : '',
+ ANALYTICS_EMPTY_CLASS: '',
+ RANK_ITEMS: rankItems,
+ ACTIVITY_TIMELINE: report.analytics.activeTimeline,
+ CONCLUSION_COUNT: String(report.summaryStats.conclusionCount),
+ TODO_COUNT: String(report.summaryStats.todoCount),
+ UNRESOLVED_COUNT: String(report.summaryStats.unresolvedCount),
+ KEYWORDS_EMPTY_CLASS: report.sectionMeta.keywords?.enabled ? '' : 'empty-section',
+ CLOUD_TAGS: cloudTags,
+ KEYWORDS_MORE_NOTE: report.sectionMeta.keywords?.hiddenCount ? `另有 ${report.sectionMeta.keywords.hiddenCount} 个关键词,请在完整版中查看
` : '',
+ RESOURCES_EMPTY_CLASS: report.sectionMeta.resources?.enabled ? '' : 'empty-section',
+ RESOURCE_ITEMS: resourceItems,
+ RESOURCES_MORE_NOTE: '',
+ QA_EMPTY_CLASS: report.sectionMeta.qa?.enabled ? '' : 'empty-section',
+ QA_CARDS: qaCards,
+ QA_MORE_NOTE: '',
+ STORYLINES_EMPTY_CLASS: report.sectionMeta.storylines?.enabled ? '' : 'empty-section',
+ STORYLINE_CARDS: storylineCards,
+ STORYLINES_MORE_NOTE: '',
+ REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section',
+ REVERSAL_CARDS: reversalCards,
+ REVERSALS_MORE_NOTE: '',
+ GALLERY_EMPTY_CLASS: report.sectionMeta.gallery?.enabled ? '' : 'empty-section',
+ GALLERY_CARDS: galleryCards,
+ GALLERY_MORE_NOTE: '',
+ VOICE_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
+ VOICE_CARDS: voiceCards,
+ VOICE_MORE_NOTE: '',
+ VOICE_RANK_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
+ VOICE_RANK_CARDS: voiceRankCards,
+ BADGES_EMPTY_CLASS: report.sectionMeta.badges?.enabled ? '' : 'empty-section',
+ BADGE_CARDS: badgeCards,
+ BADGES_MORE_NOTE: '',
+ CHAINS_EMPTY_CLASS: report.sectionMeta.chains?.enabled ? '' : 'empty-section',
+ CHAIN_CARDS: chainCards,
+ CHAINS_MORE_NOTE: '',
+ GENERATED_AT: metadata.generatedAt,
+ FOOTER_NOTE: metadata.footerNote
+ }
+ for (const [key, value] of Object.entries(replaceMap)) html = replacePlaceholder(html, key, value)
+ const htmlPath = path.join(outputDir, `${targetBase}.html`)
+ const pngPath = path.join(outputDir, `${targetBase}.png`)
+ await fs.ensureDir(outputDir)
+ await fs.writeFile(htmlPath, html, 'utf8')
+
+ const win = new BrowserWindow({
+ show: false,
+ width: 430,
+ height: 800,
+ frame: false,
+ backgroundColor: '#f3f5f7',
+ webPreferences: { sandbox: true }
+ })
+ await win.loadFile(htmlPath)
+ await win.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 });
+ }))
+ ])`)
+ win.webContents.debugger.attach('1.3')
+ const metrics = await win.webContents.debugger.sendCommand('Page.getLayoutMetrics')
+ const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
+ const height = Math.ceil(metrics.cssContentSize.height)
+ const screenshot = await win.webContents.debugger.sendCommand('Page.captureScreenshot', {
+ format: 'png',
+ captureBeyondViewport: true,
+ fromSurface: true,
+ clip: { x: 0, y: 0, width, height, scale: 1 }
+ })
+ await fs.writeFile(pngPath, Buffer.from(screenshot.data, 'base64'))
+ if (win.webContents.debugger.isAttached()) win.webContents.debugger.detach()
+ win.destroy()
+ return { htmlPath, pngPath, height }
+}
+
+app.whenReady().then(async () => {
+ const mode = process.argv.includes('--full') ? 'full' : 'compact'
+ const result =
+ mode === 'full'
+ ? await renderRequest(fullRequest, 'full-fixture')
+ : await renderRequest(compactRequest, 'compact-fixture')
+ console.log(JSON.stringify({ mode, result, outputDir }, null, 2))
+ app.quit()
+})
diff --git a/src/main/db/image-insights-store.ts b/src/main/db/image-insights-store.ts
new file mode 100644
index 0000000..c9220d1
--- /dev/null
+++ b/src/main/db/image-insights-store.ts
@@ -0,0 +1,101 @@
+// src/main/db/image-insights-store.ts
+// 持久化 ImageInsight 到 JSON 文件(userData/image-insights.json)
+// 跟项目现有风格一致(ai-provider-service 用 ai-providers.json)
+
+import { app } from 'electron'
+import fs from 'fs-extra'
+import path from 'path'
+import type { ImageInsight } from '../../shared/image-insight'
+
+interface ImageInsightsFile {
+ version: 1
+ /** imageHash -> ImageInsight 索引(缓存查询 O(1)) */
+ byHash: Record
+ /** messageId -> imageHash 反向索引(防止同一 message 重复入库) */
+ byMessageId: Record
+}
+
+const EMPTY_FILE: ImageInsightsFile = {
+ version: 1,
+ byHash: {},
+ byMessageId: {}
+}
+
+class ImageInsightsStore {
+ private cache: ImageInsightsFile | null = null
+
+ private get filePath(): string {
+ return path.join(app.getPath('userData'), 'image-insights.json')
+ }
+
+ private ensureLoaded(): ImageInsightsFile {
+ if (this.cache) return this.cache
+ try {
+ if (fs.existsSync(this.filePath)) {
+ const raw = fs.readJsonSync(this.filePath) as Partial
+ this.cache = {
+ version: 1,
+ byHash: raw.byHash || {},
+ byMessageId: raw.byMessageId || {}
+ }
+ return this.cache
+ }
+ } catch (error) {
+ console.warn('[ImageInsightsStore] failed to load, fallback to empty:', error)
+ }
+ this.cache = { ...EMPTY_FILE }
+ return this.cache
+ }
+
+ private persist(): void {
+ if (!this.cache) return
+ try {
+ fs.ensureDirSync(path.dirname(this.filePath))
+ fs.writeJsonSync(this.filePath, this.cache, { spaces: 2 })
+ } catch (error) {
+ console.error('[ImageInsightsStore] failed to persist:', error)
+ }
+ }
+
+ /** 通过 imageHash 查询缓存 */
+ getByHash(imageHash: string): ImageInsight | null {
+ return this.ensureLoaded().byHash[imageHash] || null
+ }
+
+ /** 列出某会话的所有 insights(按时间倒序) */
+ listBySession(sessionId: string, limit?: number): ImageInsight[] {
+ const data = this.ensureLoaded()
+ const items = Object.values(data.byHash)
+ .filter((it) => it.sessionId === sessionId)
+ .sort((a, b) => b.sentAt - a.sentAt)
+ return typeof limit === 'number' ? items.slice(0, limit) : items
+ }
+
+ /**
+ * 写入或更新 Insight。
+ * - 同 imageHash 已存在:更新 description/ocrText/tags/category/importance/provider/model/updatedAt(保留 createdAt)
+ * - 新 hash:插入
+ * 同步维护 byMessageId 反向索引。
+ */
+ upsert(insight: ImageInsight): void {
+ const data = this.ensureLoaded()
+ const existing = data.byHash[insight.imageHash]
+ const now = Date.now()
+ if (existing) {
+ data.byHash[insight.imageHash] = {
+ ...existing,
+ ...insight,
+ id: existing.id, // 保留 id
+ createdAt: existing.createdAt, // 保留首次分析时间
+ updatedAt: now
+ }
+ } else {
+ data.byHash[insight.imageHash] = { ...insight, createdAt: now, updatedAt: now }
+ data.byMessageId[insight.messageId] = insight.imageHash
+ }
+ this.persist()
+ }
+
+}
+
+export const imageInsightsStore = new ImageInsightsStore()
diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts
index f431b9b..8f79818 100644
--- a/src/main/group-report-service.ts
+++ b/src/main/group-report-service.ts
@@ -6,11 +6,29 @@ import {
GroupReportExportRequest,
GroupReportExportResult,
GroupReportMetadata,
- ReportHeat
+ ReportHeat,
+ ReportSectionMeta
} from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
+import { imageInsightService } from './services/image-insight-service'
-const TEMPLATE_NAME = 'mobile_daily_report.html'
+const TEMPLATE_FILES: Record = {
+ v1: 'mobile_daily_report_v1.html',
+ v2: 'mobile_daily_report_v2.html'
+}
+const DEFAULT_TEMPLATE = TEMPLATE_FILES.v1
+
+const templatePath = (templateId?: string): string => {
+ const name = TEMPLATE_FILES[templateId || ''] || DEFAULT_TEMPLATE
+ const candidates = [
+ path.join(process.resourcesPath, 'resources', name),
+ path.join(app.getAppPath(), 'resources', name),
+ path.join(process.cwd(), 'resources', name)
+ ]
+ const found = candidates.find((candidate) => fs.existsSync(candidate))
+ if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
+ return found
+}
const escapeHtml = (value: unknown): string =>
String(value ?? '')
@@ -75,17 +93,6 @@ const embedAvatar = async (source: string | undefined, name: string): Promise {
- 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
-}
-
/**
* 从群成员快照反推真头像,填进 metadata.avatars。
* - 没传 talker → 跳过(向后兼容)
@@ -138,6 +145,26 @@ const heatClass = (heat: ReportHeat): string => {
const replacePlaceholder = (html: string, key: string, value: string): string =>
html.replaceAll(`{{${key}}}`, value)
+const sectionMeta = (
+ request: GroupReportExportRequest,
+ key: keyof NonNullable
+): ReportSectionMeta | undefined => request.report.sectionMeta?.[key]
+
+const sectionClass = (
+ request: GroupReportExportRequest,
+ key: keyof NonNullable,
+ hasContent: boolean
+): string => (sectionMeta(request, key)?.enabled && hasContent ? '' : 'empty-section')
+
+const overflowNote = (
+ request: GroupReportExportRequest,
+ key: keyof NonNullable
+): string => {
+ const meta = sectionMeta(request, key)
+ if (request.metadata.reportMode !== 'compact' || !meta?.hiddenCount) return ''
+ return `另有 ${meta.hiddenCount} 条内容,请在完整版中查看
`
+}
+
const renderReportHtml = async (request: GroupReportExportRequest): Promise => {
const { report, metadata } = request
const avatarNames = new Set(metadata.heroParticipants)
@@ -147,6 +174,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise avatarNames.add(message.sender))
)
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
+ report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
+ report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
+ report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
const avatars = new Map()
await Promise.all(
@@ -168,7 +198,38 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise${escapeHtml(topic.title)}
${escapeHtml(topic.heat)}热
${escapeHtml(topic.timeRange)}
${escapeHtml(topic.summary)}
- ${topic.conclusion ? `${escapeHtml(topic.conclusion)}
` : ''}
+ ${
+ topic.conclusions?.length
+ ? `${topic.conclusions
+ .slice(0, 2)
+ .map((entry) => `
${escapeHtml(entry.text)}
`)
+ .join('')}
`
+ : topic.conclusion
+ ? `${escapeHtml(topic.conclusion)}
`
+ : ''
+ }
+ ${
+ topic.image
+ ? (() => {
+ // 优先用已有 imageUrl;若有 imageHash(来自 visionGallery),按 hash 取原图
+ let imageUrl = topic.image.imageUrl
+ if (!imageUrl && topic.image.imageHash) {
+ const insight = imageInsightService.getInsight(topic.image.imageHash)
+ if (insight) {
+ // insight 不含 imageUrl,需要按 md5/datName 重新拿;这里通过 ImageDecryptService 间接获取
+ // 走 ImageDecryptService.findImageFile + decryptImageToBase64
+ const decryptService = (globalThis as { __imageDecrypt?: { findImageFile: (md5?: string, dat?: string) => string | null; decryptImageToBase64: (p: string) => string | null } }).__imageDecrypt
+ if (decryptService) {
+ const filePath = decryptService.findImageFile(insight.md5, insight.datName)
+ if (filePath) imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
+ }
+ }
+ }
+ if (!imageUrl) return ''
+ return `
${escapeHtml(topic.image.note)}
`
+ })()
+ : ''
+ }
${topic.participants
.slice(0, 5)
.map(
@@ -213,18 +274,122 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise
- `Q:${escapeHtml(item.question)}A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
`
+ (item) => `
+
${escapeHtml(item.task)}
+
${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}
+ ${item.note ? `
${escapeHtml(item.note)}
` : ''}
+
`
)
.join('')
- const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
- const heatBars = report.analytics.topicHeat
+ const unresolvedCards = (report.unresolved || [])
.map(
- (item) =>
- `${escapeHtml(item.topic)}
`
+ (item) => `
+
${escapeHtml(item.question)}
+
${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}
+
${escapeHtml(item.note)}
+
`
+ )
+ .join('')
+
+ const storylineCards = (report.storylines || [])
+ .map(
+ (item) => `
+
${escapeHtml(item.title)}
+
${item.stages
+ .map(
+ (stage) => `
+ ${escapeHtml(stage.time || '--:--')}
+ ${escapeHtml(stage.event)}
+
`
+ )
+ .join('')}
+ ${item.result ? `
${escapeHtml(item.result)}
` : ''}
+
`
+ )
+ .join('')
+
+ const reversalCards = (report.reversals || [])
+ .map(
+ (item) => `
+
${escapeHtml(item.topic)}
+
最初:${escapeHtml(item.initialView)}
+
后来:${escapeHtml(item.finalView)}
+ ${item.note ? `
${escapeHtml(item.note)}
` : ''}
+
`
+ )
+ .join('')
+
+ const chainCards = (report.participantChains || [])
+ .map(
+ (item) => `
+
${escapeHtml(item.topic)}
+
${item.chain.map((node) => `${escapeHtml(node)}`).join('→')}
+ ${item.note ? `
${escapeHtml(item.note)}
` : ''}
+
`
+ )
+ .join('')
+
+ const galleryCards = (report.media?.gallery || [])
+ .map(
+ (item) => `
+

+
+
${escapeHtml(item.sender)}${escapeHtml(item.time)}
+ ${item.stats ? `
${escapeHtml(item.stats)}
` : ''}
+
${escapeHtml(item.note)}
+ ${item.inferenceLabel ? `
${escapeHtml(item.inferenceLabel)}
` : ''}
+
+
`
+ )
+ .join('')
+
+ // AI 图片理解结果板块(ImageInsight)
+ // 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文
+ const visionCards = (report.media?.visionGallery || [])
+ .filter((item) => item.imageUrl) // 只显示加载成功的图
+ .map(
+ (item) => `
+

+
+
${escapeHtml(item.sender)}${escapeHtml(item.time)}
+
${escapeHtml(item.description)}
+ ${item.ocrText ? `
📝 ${escapeHtml(item.ocrText)}
` : ''}
+ ${item.tags.length ? `
${item.tags.map((t) => `${escapeHtml(t)}`).join('')}
` : ''}
+
AI 图片识别
+
+
`
+ )
+ .join('')
+
+ const voiceCards = (report.media?.voiceHighlights || [])
+ .map(
+ (item) => `
+
${escapeHtml(item.title)} · ${escapeHtml(item.sender)}
+
${escapeHtml(item.note)}
+
`
+ )
+ .join('')
+
+ const voiceRankCards = (report.analytics.voiceLeaderboard || [])
+ .map(
+ (item, index) => `
+
})
+
${index + 1}. ${escapeHtml(item.sender)}
+
${item.count} 条 · ${item.durationSec} 秒
+
`
+ )
+ .join('')
+
+ const badgeCards = (report.media?.funBadges || [])
+ .map(
+ (item) => `
+
${escapeHtml(item.title)}
+
${escapeHtml(item.owner)}
+
${escapeHtml(item.note)}
+
`
)
.join('')
@@ -236,6 +401,20 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise {
+ const count = Math.max(0, speaker.count)
+ const width = Math.min(100, count * 12)
+ return `
+ ${escapeHtml(speaker.name)}
+
+ ${count}
+
`
+ })
+ .join('')
+
const cloudTags = report.keywords
.slice(0, 15)
.map(
@@ -244,35 +423,116 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise
+ `Q:${escapeHtml(item.question)}A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
`
+ )
+ .join('')
+
+ const summaryStats = report.summaryStats || {
+ messageCount: metadata.messageCount,
+ activeUsers: metadata.activeUsers,
+ topicCount: report.topics.length,
+ mediaCount: metadata.mediaMessageCount || 0,
+ imageCount: metadata.imageCount || 0,
+ voiceCount: metadata.voiceCount || 0,
+ stickerCount: metadata.stickerCount || 0,
+ conclusionCount: 0,
+ todoCount: report.todos.length,
+ unresolvedCount: report.unresolved.length
+ }
+
+ let html = await fs.readFile(templatePath(request.templateId), 'utf8')
const values: Record = {
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
+ REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
GROUP_NAME: escapeHtml(metadata.groupName),
DATE_RANGE: escapeHtml(metadata.dateRange),
- RECORD_NOTE: escapeHtml(`基于 WechatExplorer 加载的 ${metadata.messageCount} 条记录`),
- OVERVIEW: escapeHtml(report.overview),
+ RECORD_NOTE: escapeHtml(metadata.recordNote),
+ // v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
+ OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
+ // v2 模板使用的 hero-*
+ HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
+ HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
+ HERO_TAKEAWAY: escapeHtml(report.hero?.keyTakeaway || ''),
+ HERO_PENDING: escapeHtml(report.hero?.pendingNote || ''),
+ HERO_STATUS_LINE: escapeHtml(report.hero?.statusLine || ''),
+ HERO_STATUS_EMPTY_CLASS: report.hero?.statusLine ? '' : 'empty-section',
+ HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
+ HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
HERO_AVATARS: heroAvatars,
- MESSAGE_COUNT: String(metadata.messageCount),
- ACTIVE_USERS: String(metadata.activeUsers),
+ MESSAGE_COUNT: String(summaryStats.messageCount),
+ ACTIVE_USERS: String(summaryStats.activeUsers),
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
- TOPIC_COUNT: String(report.topics.length),
+ TOPIC_COUNT: String(summaryStats.topicCount),
+ MEDIA_COUNT: String(summaryStats.mediaCount),
TOPIC_CARDS: topicCards,
- RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
+ TOPICS_EMPTY_CLASS: sectionClass(request, 'topics', report.topics.length > 0),
+ TOPICS_MORE_NOTE: overflowNote(request, 'topics'),
+ RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
RESOURCE_ITEMS: resourceItems,
- MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
+ RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
+ MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
IMPORTANT_MESSAGES: importantMessages,
- QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
+ MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
+ QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
QUOTE_BLOCKS: quoteBlocks,
- QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
+ QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
+ ACTIONS_EMPTY_CLASS: sectionClass(request, 'actions', report.todos.length + report.unresolved.length > 0),
+ TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
+ TODO_CARDS: todoCards,
+ UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section',
+ UNRESOLVED_CARDS: unresolvedCards,
+ ACTIONS_MORE_NOTE: overflowNote(request, 'actions'),
+ QA_EMPTY_CLASS: sectionClass(request, 'qa', report.qa.length > 0),
QA_CARDS: qaCards,
- HEAT_BARS: heatBars,
+ QA_MORE_NOTE: overflowNote(request, 'qa'),
+ STORYLINES_EMPTY_CLASS: sectionClass(request, 'storylines', report.storylines?.length > 0),
+ STORYLINE_CARDS: storylineCards,
+ STORYLINES_MORE_NOTE: overflowNote(request, 'storylines'),
+ REVERSALS_EMPTY_CLASS: sectionClass(request, 'reversals', report.reversals?.length > 0),
+ REVERSAL_CARDS: reversalCards,
+ REVERSALS_MORE_NOTE: overflowNote(request, 'reversals'),
+ CHAINS_EMPTY_CLASS: sectionClass(request, 'chains', report.participantChains?.length > 0),
+ CHAIN_CARDS: chainCards,
+ CHAINS_MORE_NOTE: overflowNote(request, 'chains'),
+ // AI 图片识别板块
+ VISION_EMPTY_CLASS: sectionClass(
+ request,
+ 'vision',
+ (report.media?.visionGallery?.length ?? 0) > 0
+ ),
+ VISION_CARDS: visionCards,
+ VISION_TITLE: '📸 AI 识别的图片精选',
+ GALLERY_EMPTY_CLASS: sectionClass(request, 'gallery', report.media?.gallery?.length > 0),
+ GALLERY_CARDS: galleryCards,
+ GALLERY_MORE_NOTE: overflowNote(request, 'gallery'),
+ VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
+ VOICE_CARDS: voiceCards,
+ VOICE_MORE_NOTE: overflowNote(request, 'voices'),
+ VOICE_RANK_EMPTY_CLASS: sectionClass(request, 'voices', report.analytics.voiceLeaderboard?.length > 0),
+ VOICE_RANK_CARDS: voiceRankCards,
+ BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
+ BADGE_CARDS: badgeCards,
+ BADGES_MORE_NOTE: overflowNote(request, 'badges'),
RANK_ITEMS: rankItems,
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
+ CONCLUSION_COUNT: String(summaryStats.conclusionCount),
+ TODO_COUNT: String(summaryStats.todoCount),
+ UNRESOLVED_COUNT: String(summaryStats.unresolvedCount),
CLOUD_TAGS: cloudTags,
+ KEYWORDS_EMPTY_CLASS: sectionClass(request, 'keywords', report.keywords.length > 0),
+ KEYWORDS_MORE_NOTE: overflowNote(request, 'keywords'),
+ ANALYTICS_EMPTY_CLASS: sectionClass(request, 'analytics', true),
GENERATED_AT: escapeHtml(metadata.generatedAt),
- FOOTER_NOTE: escapeHtml(metadata.footerNote)
+ FOOTER_NOTE: escapeHtml(metadata.footerNote),
+ // v1 模板独有:从 analytics.topSpeakers 渲染水平条形热度图
+ HEAT_BARS: heatBarsHtml
}
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
+ // 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
+ html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
return html
}
@@ -327,7 +587,8 @@ export const exportGroupReport = async (
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
- const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
+ const templateLabel = request.templateId === 'v1' ? '经典版' : '模板2'
+ const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
const htmlStartedAt = new Date()
diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts
index c1b2ab3..44d51a1 100644
--- a/src/main/image-decrypt-service.ts
+++ b/src/main/image-decrypt-service.ts
@@ -317,6 +317,39 @@ export class ImageDecryptService {
return `data:${mimeType};base64,${unwrapped.toString('base64')}`
}
+ /**
+ * 首选 DAT 无法解密时,继续尝试同目录下属于同一图片的其他清晰度变体。
+ * 微信可能只保留 base/_h/_hd/_t 中的一部分,不能把首个文件失败等同于整张图失败。
+ */
+ decryptImageToBase64WithFallback(
+ datPath: string,
+ allowThumbnail = true
+ ): { data: string; filePath: string } | null {
+ const candidates = [datPath]
+ if (extname(datPath).toLowerCase().includes('dat')) {
+ const dir = dirname(datPath)
+ const base = this.normalizeDatBase(basename(datPath))
+ const siblings = this.buildPreferredDatNames(base)
+ .filter((name) => allowThumbnail || !this.isThumbnailName(name))
+ .map((name) => join(dir, name))
+ .filter((candidate) => existsSync(candidate))
+ .sort((left, right) => {
+ const leftThumb = this.isThumbnailName(basename(left)) ? 1 : 0
+ const rightThumb = this.isThumbnailName(basename(right)) ? 1 : 0
+ if (leftThumb !== rightThumb) return leftThumb - rightThumb
+ return statSync(right).size - statSync(left).size
+ })
+ candidates.push(...siblings)
+ }
+
+ for (const candidate of this.uniq(candidates)) {
+ const data = this.decryptImageToBase64(candidate)
+ if (data) return { data, filePath: candidate }
+ }
+ console.warn('[ImageDecrypt] all variants failed:', this.uniq(candidates))
+ return null
+ }
+
/**
* 检测 DAT 文件版本
*/
@@ -485,7 +518,9 @@ export class ImageDecryptService {
})
.sort((left, right) => right.size - left.size)
- const nonThumb = toSized(paths.filter((candidate) => !this.isThumbnailName(basename(candidate))))
+ const nonThumb = toSized(
+ paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
+ )
if (nonThumb[0]) return nonThumb[0].candidate
if (!allowThumbnail) return null
diff --git a/src/main/index.ts b/src/main/index.ts
index 8965673..a39a1b9 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -37,6 +37,14 @@ import type {
import { DatabaseKeyStore } from './database-key-store'
import { ImageKeyConfigService } from './services/image-key-config-service'
import { AIProviderService } from './services/ai-provider-service'
+import { imageInsightService } from './services/image-insight-service'
+import type {
+ ImageAnalysisRequest,
+ ImageAnalysisResponse,
+ ImageCandidate,
+ ImageCandidateQuery,
+ ImageInsight
+} from '../shared/image-insight'
import { KeyServiceMac } from './key-service-mac'
import { KeyService as KeyServiceWin } from './key-service-win'
import * as chat from './services/chat-service'
@@ -475,20 +483,108 @@ app.whenReady().then(async () => {
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
}
- const base64 = imageDecryptService.decryptImageToBase64(filePath)
- if (!base64) {
+ const decrypted = imageDecryptService.decryptImageToBase64WithFallback(filePath, true)
+ if (!decrypted) {
return { success: false, error: '图片解密失败' }
}
return {
success: true,
- data: base64,
- isThumb: imageDecryptService.isThumbnailFile(filePath),
- filePath
+ data: decrypted.data,
+ isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
+ filePath: decrypted.filePath
}
}
)
+ // ============================================================
+ // AI 图片理解基础设施(ImageInsightService)
+ // ============================================================
+ // 注入依赖(用闭包捕获当前 db:getImage 已经初始化过的 imageDecryptService)
+ // 同时把 imageDecryptService 暴露到 globalThis,供 group-report-service 渲染时按 imageHash 取图
+ ;(globalThis as { __imageDecrypt?: typeof imageDecryptService }).__imageDecrypt =
+ imageDecryptService
+ imageInsightService.bind({
+ providerService: aiProviderService,
+ decryptService: {
+ findImageFile: (md5, datName, opts) =>
+ imageDecryptService?.findImageFile(md5, datName, opts) ?? null,
+ decryptImageToBase64: (filePath) =>
+ imageDecryptService?.decryptImageToBase64(filePath) ?? null
+ }
+ })
+
+ /** 日报入口:取会话 Top N 热点图片 + 已缓存的 Insight */
+ ipcMain.handle(
+ 'image:listCandidates',
+ async (
+ _,
+ query: ImageCandidateQuery
+ ): Promise<{ success: boolean; candidates: ImageCandidate[]; error?: string }> => {
+ console.log('[IPC] image:listCandidates query=%j', query)
+ try {
+ const inputs = (query as ImageCandidateQuery & { inputs?: unknown[] }).inputs || []
+ console.log('[IPC] image:listCandidates received %d inputs', inputs.length)
+ const candidates = await imageInsightService.listTopHotImages(query, inputs as never)
+ console.log('[IPC] image:listCandidates returned %d candidates', candidates.length)
+ return { success: true, candidates }
+ } catch (error) {
+ console.warn('[IPC] image:listCandidates failed:', error)
+ return {
+ success: false,
+ candidates: [],
+ error: error instanceof Error ? error.message : String(error)
+ }
+ }
+ }
+ )
+
+ /** 单图分析:缓存命中即返回,未命中调 AI;失败不抛 */
+ ipcMain.handle(
+ 'image:analyze',
+ async (_, request: ImageAnalysisRequest): Promise => {
+ console.log('[IPC] image:analyze hash=%s messageId=%s', request.imageHash, request.messageId)
+ // 校验 provider 是否支持 vision
+ const runtime = aiProviderService.getRuntimeConfig()
+ if (!runtime.configured) {
+ return { success: false, error: '尚未配置 AI Provider' }
+ }
+ const list = aiProviderService.list()
+ const provider = list.providers.find((p) => p.id === runtime.providerId)
+ const model = provider?.models.find((m) => m.id === runtime.model)
+ if (!provider || !model) {
+ return { success: false, error: '当前 AI 模型不存在' }
+ }
+ if (!model.capabilities.vision) {
+ return { success: false, error: '当前模型不支持图片理解' }
+ }
+ // request 来自 renderer,imageHash 是 md5(优先)或 sha256(...),dataUrl 在内部算出
+ // 这里直接调 service,dataUrl 由 renderer 通过 window.api.getImage 拿到再传进来
+ return imageInsightService.analyze(request)
+ }
+ )
+
+ /** 单图查询缓存 */
+ ipcMain.handle(
+ 'image:getInsight',
+ async (_, imageHash: string): Promise<{ success: boolean; insight?: ImageInsight }> => {
+ const insight = imageInsightService.getInsight(imageHash)
+ return { success: true, insight: insight || undefined }
+ }
+ )
+
+ /** 列出某会话所有已分析的 insights */
+ ipcMain.handle(
+ 'image:listInsights',
+ async (
+ _,
+ sessionId: string,
+ limit?: number
+ ): Promise<{ success: boolean; insights: ImageInsight[] }> => {
+ return { success: true, insights: imageInsightService.listBySession(sessionId, limit) }
+ }
+ )
+
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
if (!stickerService) {
stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client())
diff --git a/src/main/services/ai-provider-service.ts b/src/main/services/ai-provider-service.ts
index 5c93aaf..14a5c10 100644
--- a/src/main/services/ai-provider-service.ts
+++ b/src/main/services/ai-provider-service.ts
@@ -69,7 +69,8 @@ export class AIProviderService {
configured: Boolean(
provider && provider.models.length && (provider.hasApiKey || !needsApiKey(provider))
),
- status: provider?.status || 'untested'
+ status: provider?.status || 'untested',
+ timeoutMs: provider?.advanced.timeoutMs
}
}
@@ -169,6 +170,37 @@ export class AIProviderService {
}
}
+ /**
+ * 多模态图片理解。
+ * 输入:text + image parts 的 messages,返回 AI 文本响应。
+ * 与 testVision 区别:不校验 prompt,不写入 capability marker(供 ImageInsightService 复用)。
+ */
+ async analyzeImage(
+ messages: Array<{
+ role: string
+ content: string | Array<{ type: 'text'; text: string } | { type: 'image'; dataUrl: string }>
+ }>,
+ options?: AIChatRequestOptions
+ ): Promise<{
+ success: boolean
+ data?: string
+ usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
+ error?: string
+ }> {
+ try {
+ const imagePart = messages
+ .flatMap((message) => (typeof message.content === 'string' ? [] : message.content))
+ .find((part) => part.type === 'image')
+ if (!imagePart || imagePart.type !== 'image') throw new Error('图片识别请求缺少图片数据')
+ const imageError = validateVisionImage(imagePart.dataUrl)
+ if (imageError) throw new Error(imageError)
+ const result = await this.request(messages as AIMessage[], options)
+ return { success: true, ...result }
+ } catch (error) {
+ return { success: false, error: safeAIError(error) }
+ }
+ }
+
async testVision(request: AIVisionTestRequest): Promise {
const startedAt = Date.now()
const imageError = validateVisionImage(request.imageDataUrl)
@@ -215,7 +247,13 @@ export class AIProviderService {
}> {
if (options?.apiKey) return this.requestLegacy(messages, options)
const resolved = this.resolveProvider(options)
- return requestProvider(resolved.provider, resolved.key, resolved.model, messages, testing)
+ const provider = options?.timeoutMs
+ ? {
+ ...resolved.provider,
+ advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
+ }
+ : resolved.provider
+ return requestProvider(provider, resolved.key, resolved.model, messages, testing)
}
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
@@ -263,12 +301,38 @@ export class AIProviderService {
}
private markVisionCapability(providerId: string, modelId: string): void {
+ this.markCapabilities(providerId, modelId, { vision: true, ocr: true })
+ }
+
+ /**
+ * 标记模型已验证的 capabilities(已存在则跳过)。
+ * OCR 跟随 vision:几乎所有 vision 模型都能 OCR,标记 vision 时同步标记 ocr。
+ */
+ private markCapabilities(
+ providerId: string,
+ modelId: string,
+ caps: { vision?: boolean; ocr?: boolean }
+ ): void {
const data = this.readMetadata()
const provider = data.providers.find((item) => item.id === providerId)
const model = provider?.models.find((item) => item.id === modelId)
- if (!provider || !model || model.capabilities.vision) return
- model.capabilities.vision = true
- this.writeMetadata(data)
+ if (!provider || !model) return
+ // 老配置可能没有 ocr 字段,补默认 false
+ if (typeof model.capabilities.ocr !== 'boolean') model.capabilities.ocr = false
+ let changed = false
+ if (caps.vision === true && !model.capabilities.vision) {
+ model.capabilities.vision = true
+ // vision 开启默认带 ocr(派生能力)
+ if (!model.capabilities.ocr) {
+ model.capabilities.ocr = true
+ }
+ changed = true
+ }
+ if (caps.ocr === true && !model.capabilities.ocr) {
+ model.capabilities.ocr = true
+ changed = true
+ }
+ if (changed) this.writeMetadata(data)
}
private ensureEnvironmentMigration(): void {
@@ -300,6 +364,14 @@ export class AIProviderService {
const data = fs.readJsonSync(filePath) as AIProviderMetadataFile
if (data.version !== 1 || !Array.isArray(data.providers))
throw new Error('invalid provider metadata')
+ // 老配置兼容:补 capabilities.ocr 默认值(vision 派生 OCR)
+ for (const provider of data.providers) {
+ for (const model of provider.models) {
+ if (typeof model.capabilities.ocr !== 'boolean') {
+ model.capabilities.ocr = model.capabilities.vision === true
+ }
+ }
+ }
return data
}
@@ -325,7 +397,7 @@ function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary {
{
name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId,
id: modelId,
- capabilities: { chat: true, vision: false, longContext: true }
+ capabilities: { chat: true, vision: false, ocr: false, longContext: true }
}
],
defaultModel: modelId,
@@ -454,7 +526,7 @@ async function requestOpenAICompatible(
},
provider.advanced.timeoutMs
)
- const payload = (await response.json()) as OpenAIResponsePayload
+ const payload = await parseJsonResponse(response)
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
return {
data: String(payload.choices?.[0]?.message?.content || ''),
@@ -508,7 +580,7 @@ async function requestAnthropic(
},
provider.advanced.timeoutMs
)
- const payload = (await response.json()) as AnthropicResponsePayload
+ const payload = await parseJsonResponse(response)
if (!response.ok)
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
return {
@@ -543,6 +615,20 @@ async function fetchWithTimeout(
}
}
+async function parseJsonResponse(response: Response): Promise {
+ const body = await response.text()
+ try {
+ return JSON.parse(body) as T
+ } catch {
+ const looksLikeHtml = /^\s*(?: = {
@@ -292,6 +299,9 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
+ groupNickname: member.groupNickname || '',
+ wechatNickname: member.wechatNickname || '',
+ remark: member.remark || '',
avatar: member.m_nsHeadImgUrl || ''
}))
diff --git a/src/main/services/image-insight-prompt.ts b/src/main/services/image-insight-prompt.ts
new file mode 100644
index 0000000..d7e4163
--- /dev/null
+++ b/src/main/services/image-insight-prompt.ts
@@ -0,0 +1,94 @@
+// src/main/services/image-insight-prompt.ts
+// 图片理解 prompt 模板 — 输出严格的 JSON,便于程序化解析
+
+export const IMAGE_ANALYSIS_SYSTEM_PROMPT = `你是微信群聊的图片分析助手。
+请根据用户提供的图片和图片前后的聊天上下文,生成对该图片的结构化理解。
+
+输出要求(严格遵守):
+1. 必须输出 JSON,不要用 markdown 代码块包裹
+2. description:1-2 句中文,30-80 字,描述图片核心内容
+3. ocrText:如果图片含文字(截图、文档、票据等),提取出来;纯风景/表情包可填空字符串
+4. tags:3-6 个中文关键词标签
+5. category:screenshot / photo / meme / document / chart / other 之一
+6. importance:low / medium / high — 根据图片的信息密度和后续讨论热度判断
+
+禁止:
+- 不要猜测图片中未明确可见的内容
+- 不要复述聊天上下文本身(那是 description 之外的事)
+- 不要输出 markdown 标记`
+
+export interface ImageAnalysisContext {
+ sender: string
+ sentAt: number
+ contextBefore: string[] // 图片前 1-3 条消息
+ contextAfter: string[] // 图片后 1-3 条消息
+}
+
+export function buildImageAnalysisUserText(ctx: ImageAnalysisContext): string {
+ const before = ctx.contextBefore.length
+ ? ctx.contextBefore.map((m, i) => ` ${i + 1}. ${m}`).join('\n')
+ : ' (无前文)'
+ const after = ctx.contextAfter.length
+ ? ctx.contextAfter.map((m, i) => ` ${i + 1}. ${m}`).join('\n')
+ : ' (无后续讨论)'
+ const time = new Date(ctx.sentAt * 1000).toLocaleString('zh-CN', { hour12: false })
+
+ return `发送者:${ctx.sender}
+时间:${time}
+
+图片前的聊天:
+${before}
+
+图片后的聊天:
+${after}
+
+请输出 JSON(严格遵守 system 要求):
+{"description":"...","ocrText":"...","tags":["..."],"category":"...","importance":"..."}`
+}
+
+/**
+ * 把 AI 文本响应解析成结构化字段。
+ * 容忍:无 markdown 包裹、有 markdown 包裹、尾部有杂质等。
+ */
+export function parseImageAnalysisResponse(raw: string): {
+ description: string
+ ocrText: string
+ tags: string[]
+ category: 'screenshot' | 'photo' | 'meme' | 'document' | 'chart' | 'other'
+ importance: 'low' | 'medium' | 'high'
+} {
+ const text = raw.trim()
+ // 提取 JSON 段
+ const jsonMatch = text.match(/\{[\s\S]*\}/)
+ if (!jsonMatch) {
+ throw new Error('AI 未返回合法 JSON')
+ }
+ let parsed: Record
+ try {
+ parsed = JSON.parse(jsonMatch[0])
+ } catch {
+ throw new Error('AI 返回的 JSON 无法解析')
+ }
+
+ const description = String(parsed.description || '').trim()
+ if (!description) throw new Error('AI 未返回 description')
+
+ const ocrText = String(parsed.ocrText || '').trim()
+ const tagsRaw = parsed.tags
+ const tags = Array.isArray(tagsRaw)
+ ? tagsRaw.map((t) => String(t).trim()).filter(Boolean).slice(0, 8)
+ : []
+
+ const categoryRaw = String(parsed.category || 'other').toLowerCase()
+ const category: 'screenshot' | 'photo' | 'meme' | 'document' | 'chart' | 'other' =
+ ['screenshot', 'photo', 'meme', 'document', 'chart'].includes(categoryRaw)
+ ? (categoryRaw as 'screenshot' | 'photo' | 'meme' | 'document' | 'chart')
+ : 'other'
+
+ const importanceRaw = String(parsed.importance || 'medium').toLowerCase()
+ const importance: 'low' | 'medium' | 'high' = ['low', 'medium', 'high'].includes(importanceRaw)
+ ? (importanceRaw as 'low' | 'medium' | 'high')
+ : 'medium'
+
+ return { description, ocrText, tags, category, importance }
+}
\ No newline at end of file
diff --git a/src/main/services/image-insight-service.ts b/src/main/services/image-insight-service.ts
new file mode 100644
index 0000000..39b0836
--- /dev/null
+++ b/src/main/services/image-insight-service.ts
@@ -0,0 +1,279 @@
+// src/main/services/image-insight-service.ts
+// WechatExplorer AI 图片理解基础设施
+//
+// 设计原则:
+// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
+// 2. 同图(imageHash)走缓存,绝不重复调 AI
+// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
+// 4. 第一阶段:Top 3 热点图 + 缓存命中即返回,未命中并发调 AI
+
+import crypto from 'crypto'
+import { randomUUID } from 'crypto'
+import { imageInsightsStore } from '../db/image-insights-store'
+import {
+ buildImageAnalysisUserText,
+ IMAGE_ANALYSIS_SYSTEM_PROMPT,
+ parseImageAnalysisResponse
+} from './image-insight-prompt'
+import type {
+ ImageAnalysisRequest,
+ ImageAnalysisResponse,
+ ImageCandidate,
+ ImageCandidateQuery,
+ ImageInsight
+} from '../../shared/image-insight'
+
+/**
+ * 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。
+ * 这样可以避免 ImageInsightService 自己重新查询消息,且参数语义清晰。
+ */
+export interface ImageCandidateInput {
+ messageId: string
+ md5?: string
+ datName?: string
+ sessionId: string
+ sender: string
+ sentAt: number
+ /** 图片发出后 8 条消息内、不同发言人的回复数(由 renderer 计算) */
+ responseCount: number
+ /** 表情/语音互动条数 */
+ interactionCount: number
+}
+
+interface ProviderServiceLike {
+ list(): ProviderSummaryLike
+ analyzeImage(
+ messages: Array<{
+ role: string
+ content: string | Array<{ type: 'text'; text: string } | { type: 'image'; dataUrl: string }>
+ }>,
+ options?: { providerId?: string; modelId?: string }
+ ): Promise<{
+ success: boolean
+ data?: string
+ error?: string
+ }>
+}
+
+interface DecryptServiceLike {
+ findImageFile(
+ md5?: string,
+ imageDatName?: string,
+ options?: { allowThumbnail?: boolean }
+ ): string | null
+ decryptImageToBase64(datPath: string): string | null
+}
+
+interface ProviderSummaryLike {
+ providers: Array<{
+ id: string
+ isDefault: boolean
+ defaultModel: string
+ models: Array<{ id: string; capabilities: { vision: boolean; ocr: boolean } }>
+ }>
+ defaultProviderId?: string
+}
+
+class ImageInsightService {
+ private providerService: ProviderServiceLike | null = null
+ private decryptService: DecryptServiceLike | null = null
+ /** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */
+ private runtimeProviderId: string | undefined = undefined
+ private runtimeModelId: string | undefined = undefined
+
+ /** 注入依赖(由 main/index.ts 在 app ready 后调用) */
+ bind(deps: {
+ providerService: ProviderServiceLike & { list(): ProviderSummaryLike }
+ decryptService: DecryptServiceLike
+ }): void {
+ this.providerService = deps.providerService
+ this.decryptService = deps.decryptService
+ console.log(
+ '[ImageInsightService] bind ok, default provider=%s model=%s',
+ this.runtimeProviderId,
+ this.runtimeModelId
+ )
+ // 读取默认 provider/model(后续 analyze 时使用)
+ try {
+ const list = deps.providerService.list()
+ const provider =
+ list.providers.find((p) => p.id === list.defaultProviderId) || list.providers[0]
+ this.runtimeProviderId = provider?.id
+ this.runtimeModelId = provider?.defaultModel
+ console.log(
+ '[ImageInsightService] bind loaded default provider=%s model=%s',
+ this.runtimeProviderId,
+ this.runtimeModelId
+ )
+ } catch (error) {
+ console.warn('[ImageInsightService] bind list failed:', error)
+ }
+ }
+
+ /**
+ * 计算图片缓存 key:imageHash。
+ * 策略:优先微信原始 md5,无 md5 才用 sha256(rawBytes).slice(0, 32)
+ */
+ private async computeImageHash(
+ md5: string | undefined,
+ datName: string | undefined
+ ): Promise {
+ if (md5 && md5.trim()) return md5.trim().toLowerCase()
+ if (!this.decryptService) return null
+ const filePath = this.decryptService.findImageFile(undefined, datName, { allowThumbnail: true })
+ if (!filePath) return null
+ // 一次性读盘 + sha256(只在没有 md5 时才付出 IO)
+ try {
+ const fs = await import('fs-extra')
+ const buf = await fs.readFile(filePath)
+ const sha = crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)
+ return `sha256:${sha}`
+ } catch (error) {
+ console.warn('[ImageInsightService] computeImageHash failed:', error)
+ return null
+ }
+ }
+
+ /** 通过 hash 拿 Insight(只读缓存,无 AI 调用) */
+ getInsight(imageHash: string): ImageInsight | null {
+ return imageInsightsStore.getByHash(imageHash)
+ }
+
+ /**
+ * 主入口:分析一张图片。
+ * 1. 通过 imageHash 查缓存,命中即返回
+ * 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回
+ * 3. 任意步骤失败:记录日志,返回 success=false,**不抛**
+ */
+ async analyze(request: ImageAnalysisRequest): Promise {
+ try {
+ if (!request.force) {
+ const cached = imageInsightsStore.getByHash(request.imageHash)
+ if (cached) {
+ return { success: true, insight: cached, fromCache: true }
+ }
+ }
+
+ if (!this.providerService) {
+ return { success: false, error: 'AI Provider 未初始化' }
+ }
+
+ const messages = [
+ {
+ role: 'system',
+ content: IMAGE_ANALYSIS_SYSTEM_PROMPT
+ },
+ {
+ role: 'user',
+ content: [
+ {
+ type: 'text' as const,
+ text: buildImageAnalysisUserText({
+ sender: request.sender,
+ sentAt: request.sentAt,
+ contextBefore: [],
+ contextAfter: []
+ })
+ },
+ { type: 'image' as const, dataUrl: request.imageDataUrl }
+ ]
+ }
+ ]
+
+ const list = this.providerService.list()
+ const provider =
+ list.providers.find((item) => item.id === list.defaultProviderId) || list.providers[0]
+ this.runtimeProviderId = provider?.id
+ this.runtimeModelId = provider?.defaultModel
+ const result = await this.providerService.analyzeImage(messages, {
+ providerId: this.runtimeProviderId,
+ modelId: this.runtimeModelId
+ })
+ if (!result.success || !result.data) {
+ console.warn('[ImageInsightService] analyze vision failed: %s', result.error || 'no data')
+ return { success: false, error: result.error || 'AI 未返回内容' }
+ }
+ console.log('[ImageInsightService] analyze ok, description=%s', result.data.slice(0, 80))
+
+ const parsed = parseImageAnalysisResponse(result.data)
+ const insight: ImageInsight = {
+ id: randomUUID(),
+ messageId: request.messageId,
+ imageHash: request.imageHash,
+ md5: undefined,
+ datName: undefined,
+ description: parsed.description,
+ ocrText: parsed.ocrText || undefined,
+ tags: parsed.tags,
+ category: parsed.category,
+ importance: parsed.importance,
+ provider: this.runtimeProviderId || '',
+ model: this.runtimeModelId || '',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ sender: request.sender,
+ sentAt: request.sentAt,
+ sessionId: request.sessionId
+ }
+ imageInsightsStore.upsert(insight)
+ return { success: true, insight, fromCache: false }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ console.warn('[ImageInsightService] analyze failed:', message)
+ return { success: false, error: message }
+ }
+ }
+
+ /**
+ * 日报入口:从 renderer 传入的图片消息候选中挑 Top N + 命中缓存的 Insight。
+ *
+ * 设计:不自己查 chat-service(参数语义不清),而是由 renderer 从已加载的 messages 中
+ * 提取图片消息 + 计算热度后传入。这样既复用现有数据,又避免 userMd5/sessionId 混淆。
+ */
+ async listTopHotImages(
+ query: ImageCandidateQuery,
+ inputs: ImageCandidateInput[] = []
+ ): Promise {
+ const limit = query.limit ?? 3
+ const candidates: ImageCandidate[] = []
+ console.log('[ImageInsightService] listTopHotImages received %d inputs', inputs.length)
+ for (const input of inputs) {
+ const hash = await this.computeImageHash(input.md5, input.datName)
+ if (!hash) {
+ console.log(
+ '[ImageInsightService] skip %s: hash empty (md5=%s datName=%s)',
+ input.messageId,
+ input.md5,
+ input.datName
+ )
+ continue
+ }
+ const heatScore = input.responseCount * 3 + input.interactionCount * 2 + 1
+ const candidate: ImageCandidate = {
+ messageId: input.messageId,
+ imageHash: hash,
+ md5: input.md5,
+ datName: input.datName,
+ sessionId: input.sessionId,
+ sender: input.sender,
+ sentAt: input.sentAt,
+ heatScore
+ }
+ const cached = imageInsightsStore.getByHash(hash)
+ if (cached) candidate.insight = cached
+ candidates.push(candidate)
+ }
+ candidates.sort((a, b) => b.heatScore - a.heatScore)
+ return candidates.slice(0, limit)
+ }
+
+ /**
+ * 列出会话所有 insights(按时间倒序,供未来 UI 复用)
+ */
+ listBySession(sessionId: string, limit?: number): ImageInsight[] {
+ return imageInsightsStore.listBySession(sessionId, limit)
+ }
+
+}
+
+export const imageInsightService = new ImageInsightService()
diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts
index 6388b0f..e943752 100644
--- a/src/main/wcdb4-client.ts
+++ b/src/main/wcdb4-client.ts
@@ -32,6 +32,9 @@ export interface Wcdb4MessageQueryOptions {
export interface Wcdb4GroupMember {
m_nsUsrName: string
nickname: string
+ groupNickname: string
+ wechatNickname: string
+ remark: string
m_nsHeadImgUrl: string
}
@@ -930,17 +933,24 @@ export class Wcdb4Client {
'member_username',
'm_nsUsrName'
])
- const memberNickname = this.pickString(row, [
+ const wechatNickname = this.pickString(row, [
'nickname',
'nickName',
+ 'wechatNickname',
+ 'wechat_nickname',
+ 'm_nsNickName'
+ ])
+ const remark = this.pickString(row, [
+ 'remark',
+ 'remarkName',
+ 'remark_name',
+ 'contactRemark',
+ 'contact_remark'
+ ])
+ const memberNickname = this.pickString(row, [
'displayName',
'display_name',
- 'groupNickname',
- 'group_nickname',
- 'roomNickname',
- 'room_nickname',
- 'remark',
- 'm_nsNickName'
+ 'name'
])
const avatar = this.pickString(row, [
'avatarUrl',
@@ -955,7 +965,11 @@ export class Wcdb4Client {
return {
m_nsUsrName: username,
- nickname: groupNicknames.get(username) || memberNickname,
+ nickname:
+ groupNicknames.get(username) || remark || wechatNickname || memberNickname,
+ groupNickname: groupNicknames.get(username) || '',
+ wechatNickname: wechatNickname || memberNickname,
+ remark,
m_nsHeadImgUrl: avatar
}
})
@@ -975,6 +989,8 @@ export class Wcdb4Client {
...member,
nickname:
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
+ wechatNickname:
+ member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
}))
} catch {
diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts
index 8301c11..a914428 100644
--- a/src/preload/index.d.ts
+++ b/src/preload/index.d.ts
@@ -30,6 +30,13 @@ import type {
AIVisionTestResult,
LegacyAIConfig
} from '../shared/ai-provider'
+import type {
+ ImageAnalysisRequest,
+ ImageAnalysisResponse,
+ ImageCandidate,
+ ImageCandidateQuery,
+ ImageInsight
+} from '../shared/image-insight'
export type ParsedContent =
| { type: 'text'; content: string }
@@ -87,7 +94,14 @@ declare global {
getGroupSnapshot: (userMd5: string) => Promise<{
roomId: string
memberCount: number
- members: { wxid: string; nickname: string; avatar: string }[]
+ members: {
+ wxid: string
+ nickname: string
+ groupNickname: string
+ wechatNickname: string
+ remark: string
+ avatar: string
+ }[]
} | null>
search: (keyword: string) => Promise
aiChat: (
@@ -270,6 +284,20 @@ declare global {
openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }>
testLocalApiRequest: (request: LocalApiTestRequest) => Promise
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
+ // ============================================================
+ // AI 图片理解基础设施(ImageInsightService)
+ // ============================================================
+ imageListCandidates: (query: ImageCandidateQuery) => Promise<{
+ success: boolean
+ candidates: ImageCandidate[]
+ error?: string
+ }>
+ imageAnalyze: (request: ImageAnalysisRequest) => Promise
+ getImageInsight: (imageHash: string) => Promise<{ success: boolean; insight?: ImageInsight }>
+ listImageInsights: (
+ sessionId: string,
+ limit?: number
+ ) => Promise<{ success: boolean; insights: ImageInsight[] }>
}
}
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index a4b3a07..b7e9f96 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -8,6 +8,13 @@ import type {
AIVisionTestRequest,
LegacyAIConfig
} from '../shared/ai-provider'
+import type {
+ ImageAnalysisRequest,
+ ImageAnalysisResponse,
+ ImageCandidate,
+ ImageCandidateQuery,
+ ImageInsight
+} from '../shared/image-insight'
// 渲染器的自定义 API
const api = {
@@ -108,7 +115,24 @@ const api = {
revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'),
openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'),
testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request),
- copyText: (text: string) => ipcRenderer.invoke('api:copyText', text)
+ copyText: (text: string) => ipcRenderer.invoke('api:copyText', text),
+ // ============================================================
+ // AI 图片理解基础设施(ImageInsightService)
+ // ============================================================
+ imageListCandidates: (query: ImageCandidateQuery): Promise<{
+ success: boolean
+ candidates: ImageCandidate[]
+ error?: string
+ }> => ipcRenderer.invoke('image:listCandidates', query),
+ imageAnalyze: (request: ImageAnalysisRequest): Promise =>
+ ipcRenderer.invoke('image:analyze', request),
+ getImageInsight: (imageHash: string): Promise<{ success: boolean; insight?: ImageInsight }> =>
+ ipcRenderer.invoke('image:getInsight', imageHash),
+ listImageInsights: (
+ sessionId: string,
+ limit?: number
+ ): Promise<{ success: boolean; insights: ImageInsight[] }> =>
+ ipcRenderer.invoke('image:listInsights', sessionId, limit)
}
if (process.contextIsolated) {
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 53d751f..77a5640 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -81,7 +81,14 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
type GroupSnapshot = {
roomId: string
memberCount: number
- members: { wxid: string; nickname: string; avatar: string }[]
+ members: {
+ wxid: string
+ nickname: string
+ groupNickname: string
+ wechatNickname: string
+ remark: string
+ avatar: string
+ }[]
}
type GroupMemberMeta = { nickname: string; avatar: string }
@@ -1137,6 +1144,12 @@ function App(): React.ReactElement {
onRevealReport={reportGeneration.revealReport}
onViewResult={openReportResult}
hasReportResult={generatedReports.length > 0}
+ templateId={reportGeneration.templateId}
+ onTemplateIdChange={reportGeneration.setTemplateId}
+ memberNamePreference={reportGeneration.memberNamePreference}
+ onMemberNamePreferenceChange={reportGeneration.setMemberNamePreference}
+ reportTimeoutSeconds={reportGeneration.reportTimeoutSeconds}
+ onReportTimeoutSecondsChange={reportGeneration.setReportTimeoutSeconds}
/>
.report-section + .report-section {
+ margin-top: 28px;
+}
+
.report-empty-state,
.report-config-section,
.report-privacy-note,
@@ -3040,6 +3044,39 @@ body {
font: 700 15px/20px var(--wxex-font);
}
+.report-timeout-section {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.report-timeout-section p {
+ margin: 6px 0 0;
+ color: var(--wxex-text-secondary);
+ font-size: 12px;
+}
+
+.report-timeout-section label {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 8px;
+ color: var(--wxex-text-secondary);
+ font-size: 13px;
+}
+
+.report-timeout-section input {
+ width: 96px;
+ height: 36px;
+ padding: 0 10px;
+ border: 1px solid var(--wxex-border);
+ border-radius: var(--wxex-radius-md);
+ background: #fff;
+ color: var(--wxex-text-primary);
+ font: 500 13px var(--wxex-font);
+}
+
.report-empty-state p,
.report-privacy-note p {
margin: 6px 0 0;
@@ -4390,3 +4427,185 @@ body {
flex-direction: column;
}
}
+
+/* ============================================================ */
+/* 日报模板选择器 + 预览器 */
+/* ============================================================ */
+.report-section-desc {
+ margin: 0 0 12px;
+ color: var(--wxex-text-secondary);
+ font-size: 12px;
+ line-height: 1.6;
+}
+
+.report-template-list {
+ display: grid;
+ gap: 10px;
+}
+
+.report-template-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ border: 1.5px solid var(--wxex-border);
+ border-radius: 12px;
+ background: #fff;
+ transition: border-color 0.2s, background 0.2s;
+}
+
+.report-template-item.active {
+ border-color: var(--wxex-primary, #07c160);
+ background: #f0fbf3;
+}
+
+.report-template-item.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.report-template-item > label {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ min-width: 0;
+}
+
+.report-template-item input[type='radio'] {
+ accent-color: var(--wxex-primary, #07c160);
+}
+
+.report-template-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.report-template-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--wxex-text-primary, #1f2933);
+}
+
+.report-template-tagline {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--wxex-text-secondary, #485465);
+ line-height: 1.5;
+}
+
+.report-template-preview-btn {
+ padding: 6px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ border-radius: 8px;
+ border: 1px solid var(--wxex-border);
+ background: #fff;
+ color: var(--wxex-text-primary, #1f2933);
+ cursor: pointer;
+ transition: background 0.2s, color 0.2s, border-color 0.2s;
+}
+
+.report-template-preview-btn:hover {
+ background: var(--wxex-primary, #07c160);
+ color: #fff;
+ border-color: var(--wxex-primary, #07c160);
+}
+
+/* 预览遮罩 */
+.report-template-preview-mask {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 999;
+ padding: 20px;
+}
+
+.report-template-preview-card {
+ background: #f3f5f7;
+ border-radius: 18px;
+ padding: 18px;
+ width: min(380px, 100%);
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: 0 16px 60px rgba(15, 23, 42, 0.25);
+}
+
+.report-template-preview-card h4 {
+ margin: 0 0 6px;
+ font-size: 17px;
+ font-weight: 800;
+}
+
+.report-template-preview-card p.muted {
+ margin: 0 0 14px;
+ font-size: 12px;
+ color: var(--wxex-text-secondary, #485465);
+ line-height: 1.5;
+}
+
+.report-template-preview-frame {
+ display: grid;
+ gap: 8px;
+ background: #fff;
+ border-radius: 14px;
+ padding: 12px;
+ border: 1px solid var(--wxex-border);
+}
+
+.fake-card {
+ background: #f7faf9;
+ border-radius: 10px;
+ padding: 10px 12px;
+}
+
+.fake-hero {
+ background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
+}
+
+.fake-title {
+ font-size: 14px;
+ font-weight: 800;
+ color: #076c39;
+}
+
+.fake-sub {
+ margin-top: 2px;
+ font-size: 11px;
+ color: #485465;
+}
+
+.fake-section {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.fake-bar {
+ width: 4px;
+ height: 14px;
+ border-radius: 2px;
+ background: var(--wxex-primary, #07c160);
+}
+
+.fake-section-title {
+ font-size: 12px;
+ font-weight: 700;
+ color: #1f2933;
+}
+
+.report-template-preview-close {
+ margin-top: 14px;
+ width: 100%;
+ padding: 10px;
+ border-radius: 10px;
+ border: none;
+ background: var(--wxex-primary, #07c160);
+ color: #fff;
+ font-weight: 700;
+ cursor: pointer;
+}
diff --git a/src/renderer/src/components/reports/AiReportWorkspace.tsx b/src/renderer/src/components/reports/AiReportWorkspace.tsx
index 8e8782b..863dba0 100644
--- a/src/renderer/src/components/reports/AiReportWorkspace.tsx
+++ b/src/renderer/src/components/reports/AiReportWorkspace.tsx
@@ -3,6 +3,7 @@ import { Contact } from '../../../../shared/types'
import {
AiModelConfig,
RangeMessageState,
+ ReportMemberNamePreference,
ReportGenerationPhase,
ReportPaths
} from '../../hooks/useGroupReportGeneration'
@@ -11,7 +12,9 @@ import { MessageTypeSelector } from './MessageTypeSelector'
import { ModelSummary } from './ModelSummary'
import { ReportDensitySelector } from './ReportDensitySelector'
import { ReportRangeSelector } from './ReportRangeSelector'
+import { ReportMemberNameSelector } from './ReportMemberNameSelector'
import { ReportSectionSelector } from './ReportSectionSelector'
+import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
interface AiReportWorkspaceProps {
sourceContact: Contact | null
@@ -36,6 +39,12 @@ interface AiReportWorkspaceProps {
onRevealReport: () => Promise<{ success: boolean; error?: string }>
onViewResult: () => void
hasReportResult: boolean
+ templateId: ReportTemplateId
+ onTemplateIdChange: (value: ReportTemplateId) => void
+ memberNamePreference: ReportMemberNamePreference
+ onMemberNamePreferenceChange: (value: ReportMemberNamePreference) => void
+ reportTimeoutSeconds: number
+ onReportTimeoutSecondsChange: (value: number) => void
}
const rangeLabel = (range: SummaryDateRange): string => {
@@ -76,7 +85,13 @@ export function AiReportWorkspace({
onCopyImage,
onRevealReport,
onViewResult,
- hasReportResult
+ hasReportResult,
+ templateId,
+ onTemplateIdChange,
+ memberNamePreference,
+ onMemberNamePreferenceChange,
+ reportTimeoutSeconds,
+ onReportTimeoutSecondsChange
}: AiReportWorkspaceProps): React.ReactElement {
const [actionStatus, setActionStatus] = useState('')
const groupName = sourceContact?.m_nsNickName || sourceContact?.m_nsUsrName || '未选择群聊'
@@ -144,7 +159,35 @@ export function AiReportWorkspace({
/>
+
+
+
+
+
日报生成超时
+
大群聊和图片识别耗时较长,可单独设置本次日报最多等待时间。
+
+
+
隐私说明
微信数据库和聊天记录默认从本机读取。
diff --git a/src/renderer/src/components/reports/ReportMemberNameSelector.tsx b/src/renderer/src/components/reports/ReportMemberNameSelector.tsx
new file mode 100644
index 0000000..40ac94b
--- /dev/null
+++ b/src/renderer/src/components/reports/ReportMemberNameSelector.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import { ReportMemberNamePreference } from '../../hooks/useGroupReportGeneration'
+
+const OPTIONS: {
+ value: ReportMemberNamePreference
+ label: string
+ description: string
+}[] = [
+ {
+ value: 'groupNickname',
+ label: '群昵称',
+ description: '优先使用成员在当前群设置的昵称。'
+ },
+ {
+ value: 'wechatNickname',
+ label: '微信昵称',
+ description: '优先使用对方公开的微信昵称。'
+ },
+ {
+ value: 'remark',
+ label: '通讯录备注',
+ description: '优先使用你为对方设置的备注。'
+ }
+]
+
+export function ReportMemberNameSelector({
+ value,
+ disabled,
+ onChange
+}: {
+ value: ReportMemberNamePreference
+ disabled?: boolean
+ onChange: (value: ReportMemberNamePreference) => void
+}): React.ReactElement {
+ return (
+
+ 成员名称
+ 选择日报中群成员的显示名称,默认使用群昵称。
+
+ {OPTIONS.map((option) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/renderer/src/components/reports/ReportTemplateSelector.tsx b/src/renderer/src/components/reports/ReportTemplateSelector.tsx
new file mode 100644
index 0000000..05977d4
--- /dev/null
+++ b/src/renderer/src/components/reports/ReportTemplateSelector.tsx
@@ -0,0 +1,139 @@
+import React, { useState } from 'react'
+
+export type ReportTemplateId = 'v1' | 'v2'
+
+interface TemplateMeta {
+ id: ReportTemplateId
+ label: string
+ tagline: string
+ preview: { title: string; sections: string[] }
+}
+
+const TEMPLATES: TemplateMeta[] = [
+ {
+ id: 'v1',
+ label: '模板1 · 经典日报',
+ tagline: '实用信息 / 重要消息 / 金句 / 问答 / 数据可视化',
+ preview: {
+ title: '经典日报',
+ sections: [
+ '今日讨论热点',
+ 'AI 识别的图片精选',
+ '实用信息与资源',
+ '重要消息汇总',
+ '有趣对话或金句',
+ '问题与解答',
+ '群内数据可视化',
+ '词云/关键词'
+ ]
+ }
+ },
+ {
+ id: 'v2',
+ label: '模板2 · 支持图片板块',
+ tagline: '包含热点图片与上下文;需要模型服务商支持图片识别',
+ preview: {
+ title: '支持图片板块',
+ sections: [
+ '今日讨论热点',
+ '重要消息',
+ '待办事项和未解决问题',
+ '今日名场面',
+ '今日群数据',
+ '关键词',
+ '实用信息与资源',
+ '问题与解答',
+ '今日剧情时间线',
+ '群聊反转现场',
+ 'AI 识别的图片精选',
+ '今日群相册',
+ '语音之最',
+ '语音时长榜',
+ '今日临时人设',
+ '话题参与链路'
+ ]
+ }
+ }
+]
+
+interface ReportTemplateSelectorProps {
+ value: ReportTemplateId
+ onChange: (value: ReportTemplateId) => void
+ disabled?: boolean
+}
+
+export const ReportTemplateSelector: React.FC = ({
+ value,
+ onChange,
+ disabled
+}) => {
+ const [previewing, setPreviewing] = useState(null)
+ return (
+
+ 日报模板
+
+ 选择日报呈现风格。模板2 支持图片板块,但需要 AI 模型服务商支持图片识别。
+
+
+ {TEMPLATES.map((tpl) => {
+ const active = value === tpl.id
+ return (
+
+
+
+
+ )
+ })}
+
+ {previewing && (
+ setPreviewing(null)}>
+
e.stopPropagation()}>
+
{previewing.preview.title}
+
{previewing.tagline}
+
+
+
群聊日报 · 预览
+
2026-xx-xx · 基于已加载记录
+
+ {previewing.preview.sections.map((s) => (
+
+ ))}
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx b/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx
index 0e98c5b..8906017 100644
--- a/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx
+++ b/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx
@@ -174,13 +174,31 @@ export function AIProviderEditor({
{
+ const vision = event.target.checked
+ // OCR 是 vision 的派生能力:勾 vision 时自动带 OCR
+ patchModel(index, {
+ capabilities: {
+ ...model.capabilities,
+ vision,
+ ocr: vision ? true : model.capabilities.ocr
+ }
+ })
+ }}
+ />
+ 图片理解
+
+