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}}
- 数据来源:WechatExplorer · 微信群聊记录
+ 数据来源:微信群聊记录
生成时间:{{GENERATED_AT}}
{{FOOTER_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}} +
+ +
+
问题与解答
+ {{QA_CARDS}} +
+ +
+
群内数据可视化
+ {{HEAT_BARS}} +
+
+ 话唠榜 TOP5(基于已读取记录估算) +
+ {{RANK_ITEMS}} +
+
+

活跃时间线:{{ACTIVITY_TIMELINE}}

+
+
+ +
+
词云/关键词
+
{{CLOUD_TAGS}}
+
+ +
+ 数据来源:WechatExplorer · 微信群聊记录
+ 生成时间:{{GENERATED_AT}}
+ {{FOOTER_NOTE}} +
+
+ + 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}} +
+ +
+ 数据来源:WechatExplorer · 微信群聊记录
+ 生成时间:{{GENERATED_AT}}
+ {{FOOTER_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( + `${label}` + ).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) => `${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) => ``).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) => `` + ) + .join('') + + // AI 图片理解结果板块(ImageInsight) + // 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文 + const visionCards = (report.media?.visionGallery || []) + .filter((item) => item.imageUrl) // 只显示加载成功的图 + .map( + (item) => `
+ AI 识别的图片 +
+
${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) => ( +
+
+
{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 + } + }) + }} + /> + 图片理解 + +