mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
merge: 合并群聊日报与图片理解功能
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ VITE_AUTO_LOGIN=false
|
|||||||
# AI API Configuration (Optional, can be entered in UI)
|
# AI API Configuration (Optional, can be entered in UI)
|
||||||
VITE_DEEPSEEK_API_KEY=
|
VITE_DEEPSEEK_API_KEY=
|
||||||
VITE_AI_BASE_URL=https://api.deepseek.com
|
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.
|
# Message types to filter out (comma separated). Empty means show all message types.
|
||||||
VITE_FILTER_MSG_TYPES=
|
VITE_FILTER_MSG_TYPES=
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
*.tsbuildinfo
|
||||||
dist
|
dist
|
||||||
out
|
out
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
|||||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||||
|
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
|
||||||
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||||
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||||
- **有趣对话或金句**: 收录群内的精彩对话。
|
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||||
- **问题与解答**: 整理群内的问答内容。
|
- **问题与解答**: 整理群内的问答内容。
|
||||||
|
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
|
||||||
|
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
|
||||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||||
- **词云/关键词**: 可视化展示群聊关键词。
|
- **词云/关键词**: 可视化展示群聊关键词。
|
||||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||||
|
|||||||
@@ -141,12 +141,31 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图
|
|||||||
"qa": [
|
"qa": [
|
||||||
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
|
{ "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": {
|
"analytics": {
|
||||||
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
|
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
|
||||||
"activeTimeline": "10:00-12:00 为最活跃时段",
|
"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": {
|
"metadata": {
|
||||||
"groupName": "技术交流",
|
"groupName": "技术交流",
|
||||||
|
|||||||
+445
-104
@@ -27,13 +27,13 @@
|
|||||||
.report {
|
.report {
|
||||||
width: 430px;
|
width: 430px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 22px 14px 34px;
|
padding: 20px 14px 34px;
|
||||||
}
|
}
|
||||||
.hero,
|
.hero,
|
||||||
.card,
|
.section,
|
||||||
.section {
|
.card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
@@ -41,32 +41,35 @@
|
|||||||
}
|
}
|
||||||
.hero-top {
|
.hero-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.hero-top > div:first-child {
|
.hero-top > div:first-child {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
font-size: 23px;
|
font-size: 23px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin: 0 0 8px;
|
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
.sub {
|
.sub {
|
||||||
|
margin-top: 8px;
|
||||||
color: #667085;
|
color: #667085;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
.record-note {
|
.mode-tag {
|
||||||
color: #485465;
|
display: inline-flex;
|
||||||
font-weight: 650;
|
margin-top: 10px;
|
||||||
}
|
padding: 4px 10px;
|
||||||
.overview {
|
border-radius: 999px;
|
||||||
margin-top: 2px;
|
background: #eef8f2;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.avatar-grid {
|
.avatar-grid {
|
||||||
width: 58px;
|
width: 58px;
|
||||||
@@ -83,6 +86,49 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
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 {
|
.stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
@@ -94,14 +140,11 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px 6px;
|
padding: 10px 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.stat b {
|
.stat b {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #07a352;
|
color: #07a352;
|
||||||
white-space: nowrap;
|
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
.stat span {
|
.stat span {
|
||||||
@@ -109,16 +152,16 @@
|
|||||||
color: #667085;
|
color: #667085;
|
||||||
}
|
}
|
||||||
.section {
|
.section {
|
||||||
margin-top: 14px;
|
margin-top: 18px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
.section-title {
|
.section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 18px;
|
margin-bottom: 14px;
|
||||||
|
font-size: 19px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
.section-title::before {
|
.section-title::before {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -127,11 +170,23 @@
|
|||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
background: #07c160;
|
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 {
|
.card {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
box-shadow: none;
|
|
||||||
border: 1px solid #edf0f2;
|
border: 1px solid #edf0f2;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.topic-title-row {
|
.topic-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -140,17 +195,17 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.topic-title-row h3 {
|
.topic-title-row h3 {
|
||||||
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
margin: 0;
|
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
.heat,
|
.heat,
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 999px;
|
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
background: #eef8f2;
|
background: #eef8f2;
|
||||||
color: #07a352;
|
color: #07a352;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -165,19 +220,44 @@
|
|||||||
background: #eef5ff;
|
background: #eef5ff;
|
||||||
color: #1677ff;
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
.red {
|
|
||||||
background: #fff1f0;
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
.topic-meta {
|
.topic-meta {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
color: #8a94a6;
|
color: #8a94a6;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.card p {
|
.card p {
|
||||||
|
margin: 10px 0 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
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 {
|
.participants {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -189,7 +269,6 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
min-width: 0;
|
|
||||||
background: #f6f8fa;
|
background: #f6f8fa;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 3px 8px 3px 3px;
|
padding: 3px 8px 3px 3px;
|
||||||
@@ -198,7 +277,6 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
.person-chip b {
|
.person-chip b {
|
||||||
max-width: 58px;
|
max-width: 58px;
|
||||||
@@ -220,17 +298,6 @@
|
|||||||
background: #f2f4f7;
|
background: #f2f4f7;
|
||||||
color: #667085;
|
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 {
|
.important-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -255,9 +322,9 @@
|
|||||||
color: #1f2933;
|
color: #1f2933;
|
||||||
}
|
}
|
||||||
.important-text {
|
.important-text {
|
||||||
|
margin-top: 5px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
margin-top: 5px;
|
|
||||||
}
|
}
|
||||||
.important-note {
|
.important-note {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -269,6 +336,34 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
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 {
|
.chat-block {
|
||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -299,49 +394,180 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.quote-note {
|
.quote-note {
|
||||||
background: #fff8e1;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff8e1;
|
||||||
color: #8a5a00;
|
color: #8a5a00;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.qa-card {
|
.qa-card,
|
||||||
background: #f8fafc;
|
.resource {
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
.qa-card b {
|
.qa-card b,
|
||||||
|
.resource b {
|
||||||
display: block;
|
display: block;
|
||||||
color: #1f2933;
|
color: #1f2933;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
.qa-card div {
|
.qa-card div,
|
||||||
|
.resource {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: #485465;
|
color: #485465;
|
||||||
}
|
}
|
||||||
.bar-row {
|
.storyline-card,
|
||||||
|
.chain-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.storyline-steps {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 82px 1fr;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
margin-top: 10px;
|
||||||
margin-top: 9px;
|
}
|
||||||
|
.storyline-step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.storyline-step span {
|
||||||
|
color: #8a94a6;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.bar {
|
.storyline-step b {
|
||||||
height: 10px;
|
font-size: 13px;
|
||||||
background: #edf1f5;
|
line-height: 1.5;
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.bar i {
|
.chain-flow {
|
||||||
display: block;
|
display: flex;
|
||||||
height: 100%;
|
flex-wrap: wrap;
|
||||||
background: #07c160;
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.chain-flow span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 9px;
|
||||||
border-radius: 999px;
|
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 {
|
.rank {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -350,6 +576,9 @@
|
|||||||
padding: 9px 0;
|
padding: 9px 0;
|
||||||
border-bottom: 1px solid #eef0f2;
|
border-bottom: 1px solid #eef0f2;
|
||||||
}
|
}
|
||||||
|
.rank:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
.rank img {
|
.rank img {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
@@ -402,7 +631,58 @@
|
|||||||
color: #8a94a6;
|
color: #8a94a6;
|
||||||
}
|
}
|
||||||
.empty-section {
|
.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) {
|
@media (max-width: 430px) {
|
||||||
html,
|
html,
|
||||||
@@ -414,83 +694,144 @@
|
|||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
.stats {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.stat b {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="{{REPORT_MODE_CLASS}}">
|
||||||
<main class="report">
|
<main class="report">
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="hero-top">
|
<div class="hero-top">
|
||||||
<div>
|
<div>
|
||||||
<h1>{{GROUP_NAME}}日报</h1>
|
<h1>{{GROUP_NAME}}日报</h1>
|
||||||
<div class="sub">
|
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||||
<div>{{DATE_RANGE}}</div>
|
<div class="mode-tag">{{REPORT_MODE_LABEL}}</div>
|
||||||
<div class="record-note">{{RECORD_NOTE}}</div>
|
|
||||||
<div class="overview">{{OVERVIEW}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hero-headline">
|
||||||
|
<b>{{HERO_HEADLINE}}</b>
|
||||||
|
<p>{{HERO_SUMMARY}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
|
||||||
|
<div class="hero-inline-notes">
|
||||||
|
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
|
||||||
|
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
|
||||||
|
</div>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||||
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||||
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
|
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
|
||||||
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="section topics">
|
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||||
<div class="section-title">今日讨论热点</div>
|
<div class="section-title">今日讨论热点</div>
|
||||||
{{TOPIC_CARDS}}
|
{{TOPIC_CARDS}}
|
||||||
|
{{TOPICS_MORE_NOTE}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
|
<section class="section {{MESSAGES_EMPTY_CLASS}}">
|
||||||
<div class="section-title">实用信息与资源</div>
|
<div class="section-title">重要消息</div>
|
||||||
{{RESOURCE_ITEMS}}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
|
|
||||||
<div class="section-title">重要消息汇总</div>
|
|
||||||
{{IMPORTANT_MESSAGES}}
|
{{IMPORTANT_MESSAGES}}
|
||||||
|
{{MESSAGES_MORE_NOTE}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
|
<section class="section {{ACTIONS_EMPTY_CLASS}}">
|
||||||
<div class="section-title">有趣对话或金句</div>
|
<div class="section-title">待办事项和未解决问题</div>
|
||||||
|
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
|
||||||
|
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
|
||||||
|
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
|
||||||
|
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
|
||||||
|
{{ACTIONS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{QUOTES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日名场面</div>
|
||||||
{{QUOTE_BLOCKS}}
|
{{QUOTE_BLOCKS}}
|
||||||
|
{{QUOTES_MORE_NOTE}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section qa {{QA_EMPTY_CLASS}}">
|
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
|
||||||
<div class="section-title">问题与解答</div>
|
<div class="section-title">今日群数据</div>
|
||||||
{{QA_CARDS}}
|
<div class="data-grid">
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section analytics">
|
|
||||||
<div class="section-title">群内数据可视化</div>
|
|
||||||
{{HEAT_BARS}}
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
|
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
|
||||||
话唠榜 TOP5(基于已读取记录估算)
|
|
||||||
</div>
|
|
||||||
{{RANK_ITEMS}}
|
{{RANK_ITEMS}}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
|
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||||
|
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section cloud">
|
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
|
||||||
<div class="section-title">词云/关键词</div>
|
<div class="section-title">关键词</div>
|
||||||
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||||
|
{{KEYWORDS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{RESOURCES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">实用信息与资源</div>
|
||||||
|
{{RESOURCE_ITEMS}}
|
||||||
|
{{RESOURCES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{QA_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">问题与解答</div>
|
||||||
|
{{QA_CARDS}}
|
||||||
|
{{QA_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{STORYLINES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日剧情时间线</div>
|
||||||
|
{{STORYLINE_CARDS}}
|
||||||
|
{{STORYLINES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{REVERSALS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">群聊反转现场</div>
|
||||||
|
{{REVERSAL_CARDS}}
|
||||||
|
{{REVERSALS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{VISION_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">{{VISION_TITLE}}</div>
|
||||||
|
{{VISION_CARDS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{GALLERY_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日群相册</div>
|
||||||
|
{{GALLERY_CARDS}}
|
||||||
|
{{GALLERY_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{VOICE_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">语音之最</div>
|
||||||
|
{{VOICE_CARDS}}
|
||||||
|
{{VOICE_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">语音时长榜</div>
|
||||||
|
<div class="card">{{VOICE_RANK_CARDS}}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{BADGES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日临时人设</div>
|
||||||
|
<div class="badge-grid">{{BADGE_CARDS}}</div>
|
||||||
|
{{BADGES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{CHAINS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">话题参与链路</div>
|
||||||
|
{{CHAIN_CARDS}}
|
||||||
|
{{CHAINS_MORE_NOTE}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
数据来源:微信群聊记录<br />
|
||||||
生成时间:{{GENERATED_AT}}<br />
|
生成时间:{{GENERATED_AT}}<br />
|
||||||
{{FOOTER_NOTE}}
|
{{FOOTER_NOTE}}
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -0,0 +1,592 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>{{REPORT_TITLE}}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
width: 430px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 430px;
|
||||||
|
background: #f3f5f7;
|
||||||
|
color: #1f2933;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
.report {
|
||||||
|
width: 430px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 22px 14px 34px;
|
||||||
|
}
|
||||||
|
.hero,
|
||||||
|
.card,
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.hero-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.hero-top > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 23px;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.record-note {
|
||||||
|
color: #485465;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.overview {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.avatar-grid {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.avatar-grid img,
|
||||||
|
.avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat b {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #07a352;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.stat span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 5px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #07c160;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid #edf0f2;
|
||||||
|
}
|
||||||
|
.topic-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.topic-title-row h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
.heat,
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #eef8f2;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hot {
|
||||||
|
background: #fff4e5;
|
||||||
|
color: #d46b08;
|
||||||
|
}
|
||||||
|
.blue {
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
.red {
|
||||||
|
background: #fff1f0;
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
.topic-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.card p {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
.participants {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.person-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px 3px 3px;
|
||||||
|
}
|
||||||
|
.person-chip img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.person-chip b {
|
||||||
|
max-width: 58px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.keywords {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.keywords span {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.resource {
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.resource b {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
.important-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.important-card > .avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.important-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.important-meta b {
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
.important-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.important-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-left: 3px solid #07c160;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.chat-block {
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.chat-msg {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.chat-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.chat-name {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.chat-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px 12px 12px 12px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.quote-note {
|
||||||
|
background: #fff8e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #8a5a00;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.qa-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.qa-card b {
|
||||||
|
display: block;
|
||||||
|
color: #1f2933;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.qa-card div {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #485465;
|
||||||
|
}
|
||||||
|
.bar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 82px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
height: 10px;
|
||||||
|
background: #edf1f5;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: #07c160;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.rank {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px solid #eef0f2;
|
||||||
|
}
|
||||||
|
.rank img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.rank b {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.rank span {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.cloud-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cloud-tags span {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #485465;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.cloud-tags .xl {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #07a352;
|
||||||
|
background: #e9f8ef;
|
||||||
|
}
|
||||||
|
.cloud-tags .lg {
|
||||||
|
font-size: 17px;
|
||||||
|
color: #1677ff;
|
||||||
|
background: #eef5ff;
|
||||||
|
}
|
||||||
|
.cloud-tags .md {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #d46b08;
|
||||||
|
background: #fff4e5;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 16px 4px 0;
|
||||||
|
color: #98a2b3;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #8a94a6;
|
||||||
|
}
|
||||||
|
.empty-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.report {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.stat b {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* AI 图片识别板块(v1 模板) */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
/* 热度条形图(v1 模板) */
|
||||||
|
.heat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr 40px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.heat-name {
|
||||||
|
color: #1f2933;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.heat-bar {
|
||||||
|
background: #f3f5f7;
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.heat-bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #07c160 0%, #34d399 100%);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.heat-val {
|
||||||
|
color: #485465;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="report">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1>{{GROUP_NAME}}日报</h1>
|
||||||
|
<div class="sub">
|
||||||
|
<div>{{DATE_RANGE}}</div>
|
||||||
|
<div class="record-note">{{RECORD_NOTE}}</div>
|
||||||
|
<div class="overview">{{OVERVIEW}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||||
|
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||||
|
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
|
||||||
|
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section topics">
|
||||||
|
<div class="section-title">今日讨论热点</div>
|
||||||
|
{{TOPIC_CARDS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section vision {{VISION_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">{{VISION_TITLE}}</div>
|
||||||
|
{{VISION_CARDS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">实用信息与资源</div>
|
||||||
|
{{RESOURCE_ITEMS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">重要消息汇总</div>
|
||||||
|
{{IMPORTANT_MESSAGES}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">有趣对话或金句</div>
|
||||||
|
{{QUOTE_BLOCKS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section qa {{QA_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">问题与解答</div>
|
||||||
|
{{QA_CARDS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section analytics">
|
||||||
|
<div class="section-title">群内数据可视化</div>
|
||||||
|
{{HEAT_BARS}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
|
||||||
|
话唠榜 TOP5(基于已读取记录估算)
|
||||||
|
</div>
|
||||||
|
{{RANK_ITEMS}}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section cloud">
|
||||||
|
<div class="section-title">词云/关键词</div>
|
||||||
|
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||||
|
生成时间:{{GENERATED_AT}}<br />
|
||||||
|
{{FOOTER_NOTE}}
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>{{REPORT_TITLE}}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
width: 430px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 430px;
|
||||||
|
background: #f3f5f7;
|
||||||
|
color: #1f2933;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
.report {
|
||||||
|
width: 430px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 14px 34px;
|
||||||
|
}
|
||||||
|
.hero,
|
||||||
|
.section,
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.hero-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.hero-top > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 23px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.avatar-grid {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.avatar-grid img,
|
||||||
|
.avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat b {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #07a352;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.stat span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 5px;
|
||||||
|
height: 20px;
|
||||||
|
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;
|
||||||
|
border: 1px solid #edf0f2;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.topic-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.topic-title-row h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
.heat,
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef8f2;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hot {
|
||||||
|
background: #fff4e5;
|
||||||
|
color: #d46b08;
|
||||||
|
}
|
||||||
|
.blue {
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
.topic-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.card p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.person-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px 3px 3px;
|
||||||
|
}
|
||||||
|
.person-chip img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.person-chip b {
|
||||||
|
max-width: 58px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.keywords {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.keywords span {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.important-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.important-card > .avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.important-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.important-meta b {
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
.important-text {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.important-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-left: 3px solid #07c160;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.chat-msg {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.chat-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.chat-name {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.chat-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px 12px 12px 12px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.quote-note {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff8e1;
|
||||||
|
color: #8a5a00;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.qa-card,
|
||||||
|
.resource {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.qa-card b,
|
||||||
|
.resource b {
|
||||||
|
display: block;
|
||||||
|
color: #1f2933;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.qa-card div,
|
||||||
|
.resource {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #485465;
|
||||||
|
}
|
||||||
|
.storyline-card,
|
||||||
|
.chain-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.storyline-steps {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.storyline-step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.storyline-step span {
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.storyline-step b {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px solid #eef0f2;
|
||||||
|
}
|
||||||
|
.rank:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.rank img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.rank b {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.rank span {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.cloud-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cloud-tags span {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #485465;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.cloud-tags .xl {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #07a352;
|
||||||
|
background: #e9f8ef;
|
||||||
|
}
|
||||||
|
.cloud-tags .lg {
|
||||||
|
font-size: 17px;
|
||||||
|
color: #1677ff;
|
||||||
|
background: #eef5ff;
|
||||||
|
}
|
||||||
|
.cloud-tags .md {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #d46b08;
|
||||||
|
background: #fff4e5;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 16px 4px 0;
|
||||||
|
color: #98a2b3;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #8a94a6;
|
||||||
|
}
|
||||||
|
.empty-section {
|
||||||
|
display: none !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,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.report {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="{{REPORT_MODE_CLASS}}">
|
||||||
|
<main class="report">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1>{{GROUP_NAME}}日报</h1>
|
||||||
|
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-headline">
|
||||||
|
<b>{{HERO_HEADLINE}}</b>
|
||||||
|
<p>{{HERO_SUMMARY}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
|
||||||
|
<div class="hero-inline-notes">
|
||||||
|
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
|
||||||
|
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||||
|
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||||
|
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
|
||||||
|
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日讨论热点</div>
|
||||||
|
{{TOPIC_CARDS}}
|
||||||
|
{{TOPICS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{MESSAGES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">重要消息</div>
|
||||||
|
{{IMPORTANT_MESSAGES}}
|
||||||
|
{{MESSAGES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{ACTIONS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">待办事项和未解决问题</div>
|
||||||
|
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
|
||||||
|
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
|
||||||
|
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
|
||||||
|
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
|
||||||
|
{{ACTIONS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{QUOTES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日名场面</div>
|
||||||
|
{{QUOTE_BLOCKS}}
|
||||||
|
{{QUOTES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日群数据</div>
|
||||||
|
<div class="data-grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
|
||||||
|
{{RANK_ITEMS}}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||||
|
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">关键词</div>
|
||||||
|
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||||
|
{{KEYWORDS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{RESOURCES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">实用信息与资源</div>
|
||||||
|
{{RESOURCE_ITEMS}}
|
||||||
|
{{RESOURCES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{QA_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">问题与解答</div>
|
||||||
|
{{QA_CARDS}}
|
||||||
|
{{QA_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{STORYLINES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日剧情时间线</div>
|
||||||
|
{{STORYLINE_CARDS}}
|
||||||
|
{{STORYLINES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{REVERSALS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">群聊反转现场</div>
|
||||||
|
{{REVERSAL_CARDS}}
|
||||||
|
{{REVERSALS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{VISION_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">{{VISION_TITLE}}</div>
|
||||||
|
{{VISION_CARDS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{GALLERY_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日群相册</div>
|
||||||
|
{{GALLERY_CARDS}}
|
||||||
|
{{GALLERY_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{VOICE_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">语音之最</div>
|
||||||
|
{{VOICE_CARDS}}
|
||||||
|
{{VOICE_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">语音时长榜</div>
|
||||||
|
<div class="card">{{VOICE_RANK_CARDS}}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{BADGES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">今日临时人设</div>
|
||||||
|
<div class="badge-grid">{{BADGE_CARDS}}</div>
|
||||||
|
{{BADGES_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section {{CHAINS_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">话题参与链路</div>
|
||||||
|
{{CHAIN_CARDS}}
|
||||||
|
{{CHAINS_MORE_NOTE}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||||
|
生成时间:{{GENERATED_AT}}<br />
|
||||||
|
{{FOOTER_NOTE}}
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
const replacePlaceholder = (html, key, value) => html.replaceAll(`{{${key}}}`, value)
|
||||||
|
|
||||||
|
const avatarSvg = (label, color) =>
|
||||||
|
`data:image/svg+xml;base64,${Buffer.from(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="${color}"/><text x="48" y="58" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="36" fill="#0f172a">${label}</text></svg>`
|
||||||
|
).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) => `<img src="${avatars[name]}" alt="${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) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
|
||||||
|
.join('')
|
||||||
|
const image = topic.image?.imageUrl
|
||||||
|
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||||
|
: ''
|
||||||
|
return `<div class="card topic-card"><div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${topic.heat === '高' ? 'hot' : topic.heat === '低' ? 'blue' : ''}">${escapeHtml(topic.heat)}热</span></div><div class="topic-meta">${escapeHtml(topic.timeRange)}</div><p>${escapeHtml(topic.summary)}</p>${conclusions ? `<div class="topic-conclusions">${conclusions}</div>` : ''}${image}<div class="participants">${topic.participants.map((name) => `<span class="person-chip"><img src="${avatars[name] || avatarSvg(name[0], '#e5e7eb')}" alt=""><b>${escapeHtml(name)}</b></span>`).join('')}</div><div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div></div>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
const importantMessages = report.importantMessages
|
||||||
|
.map((message) => `<div class="important-card"><img class="avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div><div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div></div>`)
|
||||||
|
.join('')
|
||||||
|
const todoCards = (report.todos || []).map((item) => `<div class="action-card todo-card"><b>${escapeHtml(item.task)}</b><div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
|
||||||
|
const unresolvedCards = (report.unresolved || []).map((item) => `<div class="action-card unresolved-card"><b>${escapeHtml(item.question)}</b><div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div><div class="action-note">${escapeHtml(item.note)}</div></div>`).join('')
|
||||||
|
const quoteBlocks = report.quotes.map((quote) => `<div class="chat-block">${quote.messages.map((message) => `<div class="chat-msg"><img class="chat-avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div><div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div></div></div>`).join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`).join('')
|
||||||
|
const rankItems = report.analytics.topSpeakers.map((speaker, index) => `<div class="rank"><img src="${avatars[speaker.name] || avatarSvg(speaker.name[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${speaker.count} 条</span></div>`).join('')
|
||||||
|
const cloudTags = report.keywords.map((word, index) => `<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`).join('')
|
||||||
|
const resourceItems = (report.resources || []).map((resource) => `<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`).join('')
|
||||||
|
const qaCards = (report.qa || []).map((item) => `<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`).join('')
|
||||||
|
const storylineCards = (report.storylines || []).map((item) => `<div class="card storyline-card"><div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div><div class="storyline-steps">${item.stages.map((stage) => `<div class="storyline-step"><span>${escapeHtml(stage.time)}</span><b>${escapeHtml(stage.event)}</b></div>`).join('')}</div>${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}</div>`).join('')
|
||||||
|
const reversalCards = (report.reversals || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.topic)}</b><div>最初:${escapeHtml(item.initialView)}</div><div>后来:${escapeHtml(item.finalView)}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
|
||||||
|
const galleryCards = (report.media.gallery || []).map((item) => `<div class="gallery-card"><img class="gallery-image" src="${item.imageUrl}" alt=""><div class="gallery-body"><div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}<div class="important-text">${escapeHtml(item.note)}</div>${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}</div></div>`).join('')
|
||||||
|
const voiceCards = (report.media.voiceHighlights || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b><div>${escapeHtml(item.note)}</div></div>`).join('')
|
||||||
|
const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `<div class="rank"><img src="${avatars[item.sender] || avatarSvg(item.sender[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(item.sender)}</b><span>${item.count} 条 · ${item.durationSec} 秒</span></div>`).join('')
|
||||||
|
const badgeCards = (report.media.funBadges || []).map((item) => `<div class="badge-card"><span class="tag">${escapeHtml(item.title)}</span><b>${escapeHtml(item.owner)}</b><p>${escapeHtml(item.note)}</p></div>`).join('')
|
||||||
|
const chainCards = (report.participantChains || []).map((item) => `<div class="card chain-card"><div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div><div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}</div>`).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 ? `<div class="section-more">另有 ${report.sectionMeta.topics.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||||
|
MESSAGES_EMPTY_CLASS: report.sectionMeta.importantMessages?.enabled ? '' : 'empty-section',
|
||||||
|
IMPORTANT_MESSAGES: importantMessages,
|
||||||
|
MESSAGES_MORE_NOTE: report.sectionMeta.importantMessages?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.importantMessages.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||||
|
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 ? `<div class="section-more">另有 ${report.sectionMeta.actions.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||||
|
QUOTES_EMPTY_CLASS: report.sectionMeta.moments?.enabled ? '' : 'empty-section',
|
||||||
|
QUOTE_BLOCKS: quoteBlocks,
|
||||||
|
QUOTES_MORE_NOTE: report.sectionMeta.moments?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.moments.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||||
|
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 ? `<div class="section-more">另有 ${report.sectionMeta.keywords.hiddenCount} 个关键词,请在完整版中查看</div>` : '',
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -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<string, ImageInsight>
|
||||||
|
/** messageId -> imageHash 反向索引(防止同一 message 重复入库) */
|
||||||
|
byMessageId: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ImageInsightsFile>
|
||||||
|
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()
|
||||||
@@ -6,11 +6,29 @@ import {
|
|||||||
GroupReportExportRequest,
|
GroupReportExportRequest,
|
||||||
GroupReportExportResult,
|
GroupReportExportResult,
|
||||||
GroupReportMetadata,
|
GroupReportMetadata,
|
||||||
ReportHeat
|
ReportHeat,
|
||||||
|
ReportSectionMeta
|
||||||
} from '../shared/group-report'
|
} from '../shared/group-report'
|
||||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
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<string, string> = {
|
||||||
|
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 =>
|
const escapeHtml = (value: unknown): string =>
|
||||||
String(value ?? '')
|
String(value ?? '')
|
||||||
@@ -75,17 +93,6 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const templatePath = (): string => {
|
|
||||||
const candidates = [
|
|
||||||
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
|
|
||||||
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
|
|
||||||
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
|
|
||||||
]
|
|
||||||
const found = candidates.find((candidate) => fs.existsSync(candidate))
|
|
||||||
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从群成员快照反推真头像,填进 metadata.avatars。
|
* 从群成员快照反推真头像,填进 metadata.avatars。
|
||||||
* - 没传 talker → 跳过(向后兼容)
|
* - 没传 talker → 跳过(向后兼容)
|
||||||
@@ -138,6 +145,26 @@ const heatClass = (heat: ReportHeat): string => {
|
|||||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||||
html.replaceAll(`{{${key}}}`, value)
|
html.replaceAll(`{{${key}}}`, value)
|
||||||
|
|
||||||
|
const sectionMeta = (
|
||||||
|
request: GroupReportExportRequest,
|
||||||
|
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||||
|
): ReportSectionMeta | undefined => request.report.sectionMeta?.[key]
|
||||||
|
|
||||||
|
const sectionClass = (
|
||||||
|
request: GroupReportExportRequest,
|
||||||
|
key: keyof NonNullable<typeof request.report.sectionMeta>,
|
||||||
|
hasContent: boolean
|
||||||
|
): string => (sectionMeta(request, key)?.enabled && hasContent ? '' : 'empty-section')
|
||||||
|
|
||||||
|
const overflowNote = (
|
||||||
|
request: GroupReportExportRequest,
|
||||||
|
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||||
|
): string => {
|
||||||
|
const meta = sectionMeta(request, key)
|
||||||
|
if (request.metadata.reportMode !== 'compact' || !meta?.hiddenCount) return ''
|
||||||
|
return `<div class="section-more">另有 ${meta.hiddenCount} 条内容,请在完整版中查看</div>`
|
||||||
|
}
|
||||||
|
|
||||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||||
const { report, metadata } = request
|
const { report, metadata } = request
|
||||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||||
@@ -147,6 +174,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||||
)
|
)
|
||||||
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
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<string, string>()
|
const avatars = new Map<string, string>()
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -168,7 +198,38 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||||
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||||
<p>${escapeHtml(topic.summary)}</p>
|
<p>${escapeHtml(topic.summary)}</p>
|
||||||
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
|
${
|
||||||
|
topic.conclusions?.length
|
||||||
|
? `<div class="topic-conclusions">${topic.conclusions
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
|
||||||
|
.join('')}</div>`
|
||||||
|
: topic.conclusion
|
||||||
|
? `<div class="topic-conclusions"><div class="topic-conclusion">${escapeHtml(topic.conclusion)}</div></div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${
|
||||||
|
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 `<div class="topic-inline-image"><img src="${imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||||
|
})()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
<div class="participants">${topic.participants
|
<div class="participants">${topic.participants
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(
|
.map(
|
||||||
@@ -213,18 +274,122 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
const qaCards = report.qa
|
const todoCards = (report.todos || [])
|
||||||
.map(
|
.map(
|
||||||
(item) =>
|
(item) => `<div class="action-card todo-card">
|
||||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
<b>${escapeHtml(item.task)}</b>
|
||||||
|
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||||
|
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
|
||||||
|
</div>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
|
const unresolvedCards = (report.unresolved || [])
|
||||||
const heatBars = report.analytics.topicHeat
|
|
||||||
.map(
|
.map(
|
||||||
(item) =>
|
(item) => `<div class="action-card unresolved-card">
|
||||||
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
|
<b>${escapeHtml(item.question)}</b>
|
||||||
|
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||||
|
<div class="action-note">${escapeHtml(item.note)}</div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const storylineCards = (report.storylines || [])
|
||||||
|
.map(
|
||||||
|
(item) => `<div class="card storyline-card">
|
||||||
|
<div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div>
|
||||||
|
<div class="storyline-steps">${item.stages
|
||||||
|
.map(
|
||||||
|
(stage) => `<div class="storyline-step">
|
||||||
|
<span>${escapeHtml(stage.time || '--:--')}</span>
|
||||||
|
<b>${escapeHtml(stage.event)}</b>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')}</div>
|
||||||
|
${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const reversalCards = (report.reversals || [])
|
||||||
|
.map(
|
||||||
|
(item) => `<div class="qa-card">
|
||||||
|
<b>${escapeHtml(item.topic)}</b>
|
||||||
|
<div>最初:${escapeHtml(item.initialView)}</div>
|
||||||
|
<div>后来:${escapeHtml(item.finalView)}</div>
|
||||||
|
${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const chainCards = (report.participantChains || [])
|
||||||
|
.map(
|
||||||
|
(item) => `<div class="card chain-card">
|
||||||
|
<div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div>
|
||||||
|
<div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>
|
||||||
|
${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const galleryCards = (report.media?.gallery || [])
|
||||||
|
.map(
|
||||||
|
(item) => `<div class="gallery-card">
|
||||||
|
<img class="gallery-image" src="${item.imageUrl}" alt="群聊图片">
|
||||||
|
<div class="gallery-body">
|
||||||
|
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||||
|
${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}
|
||||||
|
<div class="important-text">${escapeHtml(item.note)}</div>
|
||||||
|
${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
// AI 图片理解结果板块(ImageInsight)
|
||||||
|
// 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文
|
||||||
|
const visionCards = (report.media?.visionGallery || [])
|
||||||
|
.filter((item) => item.imageUrl) // 只显示加载成功的图
|
||||||
|
.map(
|
||||||
|
(item) => `<div class="vision-card">
|
||||||
|
<img class="vision-image" src="${item.imageUrl}" alt="AI 识别的图片">
|
||||||
|
<div class="vision-body">
|
||||||
|
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||||
|
<div class="vision-description">${escapeHtml(item.description)}</div>
|
||||||
|
${item.ocrText ? `<div class="vision-ocr">📝 ${escapeHtml(item.ocrText)}</div>` : ''}
|
||||||
|
${item.tags.length ? `<div class="vision-tags">${item.tags.map((t) => `<span class="vision-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
|
||||||
|
<div class="vision-label">AI 图片识别</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const voiceCards = (report.media?.voiceHighlights || [])
|
||||||
|
.map(
|
||||||
|
(item) => `<div class="qa-card">
|
||||||
|
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
|
||||||
|
<div>${escapeHtml(item.note)}</div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
|
||||||
|
.map(
|
||||||
|
(item, index) => `<div class="rank">
|
||||||
|
<img src="${avatar(item.sender)}" alt="">
|
||||||
|
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
|
||||||
|
<span>${item.count} 条 · ${item.durationSec} 秒</span>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const badgeCards = (report.media?.funBadges || [])
|
||||||
|
.map(
|
||||||
|
(item) => `<div class="badge-card">
|
||||||
|
<span class="tag">${escapeHtml(item.title)}</span>
|
||||||
|
<b>${escapeHtml(item.owner)}</b>
|
||||||
|
<p>${escapeHtml(item.note)}</p>
|
||||||
|
</div>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
@@ -236,6 +401,20 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
|
// v1 模板使用的水平条形热度图,渲染 top speakers 排行
|
||||||
|
const heatBarsHtml = report.analytics.topSpeakers
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((speaker) => {
|
||||||
|
const count = Math.max(0, speaker.count)
|
||||||
|
const width = Math.min(100, count * 12)
|
||||||
|
return `<div class="heat-row">
|
||||||
|
<span class="heat-name">${escapeHtml(speaker.name)}</span>
|
||||||
|
<span class="heat-bar"><i style="width:${width}%"></i></span>
|
||||||
|
<span class="heat-val">${count}</span>
|
||||||
|
</div>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
const cloudTags = report.keywords
|
const cloudTags = report.keywords
|
||||||
.slice(0, 15)
|
.slice(0, 15)
|
||||||
.map(
|
.map(
|
||||||
@@ -244,35 +423,116 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
let html = await fs.readFile(templatePath(), 'utf8')
|
const qaCards = report.qa
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const 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<string, string> = {
|
const values: Record<string, string> = {
|
||||||
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||||
|
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||||
GROUP_NAME: escapeHtml(metadata.groupName),
|
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||||
RECORD_NOTE: escapeHtml(`基于 WechatExplorer 加载的 ${metadata.messageCount} 条记录`),
|
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
||||||
OVERVIEW: escapeHtml(report.overview),
|
// 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,
|
HERO_AVATARS: heroAvatars,
|
||||||
MESSAGE_COUNT: String(metadata.messageCount),
|
MESSAGE_COUNT: String(summaryStats.messageCount),
|
||||||
ACTIVE_USERS: String(metadata.activeUsers),
|
ACTIVE_USERS: String(summaryStats.activeUsers),
|
||||||
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
||||||
TOPIC_COUNT: String(report.topics.length),
|
TOPIC_COUNT: String(summaryStats.topicCount),
|
||||||
|
MEDIA_COUNT: String(summaryStats.mediaCount),
|
||||||
TOPIC_CARDS: topicCards,
|
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,
|
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,
|
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,
|
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,
|
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,
|
RANK_ITEMS: rankItems,
|
||||||
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
||||||
|
CONCLUSION_COUNT: String(summaryStats.conclusionCount),
|
||||||
|
TODO_COUNT: String(summaryStats.todoCount),
|
||||||
|
UNRESOLVED_COUNT: String(summaryStats.unresolvedCount),
|
||||||
CLOUD_TAGS: cloudTags,
|
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),
|
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)
|
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||||
|
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||||
|
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +587,8 @@ export const exportGroupReport = async (
|
|||||||
|
|
||||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||||
await fs.ensureDir(outputDir)
|
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 htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||||
const htmlStartedAt = new Date()
|
const htmlStartedAt = new Date()
|
||||||
|
|||||||
@@ -317,6 +317,39 @@ export class ImageDecryptService {
|
|||||||
return `data:${mimeType};base64,${unwrapped.toString('base64')}`
|
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 文件版本
|
* 检测 DAT 文件版本
|
||||||
*/
|
*/
|
||||||
@@ -485,7 +518,9 @@ export class ImageDecryptService {
|
|||||||
})
|
})
|
||||||
.sort((left, right) => right.size - left.size)
|
.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 (nonThumb[0]) return nonThumb[0].candidate
|
||||||
if (!allowThumbnail) return null
|
if (!allowThumbnail) return null
|
||||||
|
|
||||||
|
|||||||
+101
-5
@@ -37,6 +37,14 @@ import type {
|
|||||||
import { DatabaseKeyStore } from './database-key-store'
|
import { DatabaseKeyStore } from './database-key-store'
|
||||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||||
import { AIProviderService } from './services/ai-provider-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 { KeyServiceMac } from './key-service-mac'
|
||||||
import { KeyService as KeyServiceWin } from './key-service-win'
|
import { KeyService as KeyServiceWin } from './key-service-win'
|
||||||
import * as chat from './services/chat-service'
|
import * as chat from './services/chat-service'
|
||||||
@@ -475,20 +483,108 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
|
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64 = imageDecryptService.decryptImageToBase64(filePath)
|
const decrypted = imageDecryptService.decryptImageToBase64WithFallback(filePath, true)
|
||||||
if (!base64) {
|
if (!decrypted) {
|
||||||
return { success: false, error: '图片解密失败' }
|
return { success: false, error: '图片解密失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: base64,
|
data: decrypted.data,
|
||||||
isThumb: imageDecryptService.isThumbnailFile(filePath),
|
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
||||||
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<ImageAnalysisResponse> => {
|
||||||
|
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) => {
|
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
|
||||||
if (!stickerService) {
|
if (!stickerService) {
|
||||||
stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client())
|
stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client())
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ export class AIProviderService {
|
|||||||
configured: Boolean(
|
configured: Boolean(
|
||||||
provider && provider.models.length && (provider.hasApiKey || !needsApiKey(provider))
|
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<AIVisionTestResult> {
|
async testVision(request: AIVisionTestRequest): Promise<AIVisionTestResult> {
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
const imageError = validateVisionImage(request.imageDataUrl)
|
const imageError = validateVisionImage(request.imageDataUrl)
|
||||||
@@ -215,7 +247,13 @@ export class AIProviderService {
|
|||||||
}> {
|
}> {
|
||||||
if (options?.apiKey) return this.requestLegacy(messages, options)
|
if (options?.apiKey) return this.requestLegacy(messages, options)
|
||||||
const resolved = this.resolveProvider(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 }): {
|
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
|
||||||
@@ -263,12 +301,38 @@ export class AIProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private markVisionCapability(providerId: string, modelId: string): void {
|
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 data = this.readMetadata()
|
||||||
const provider = data.providers.find((item) => item.id === providerId)
|
const provider = data.providers.find((item) => item.id === providerId)
|
||||||
const model = provider?.models.find((item) => item.id === modelId)
|
const model = provider?.models.find((item) => item.id === modelId)
|
||||||
if (!provider || !model || model.capabilities.vision) return
|
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
|
model.capabilities.vision = true
|
||||||
this.writeMetadata(data)
|
// 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 {
|
private ensureEnvironmentMigration(): void {
|
||||||
@@ -300,6 +364,14 @@ export class AIProviderService {
|
|||||||
const data = fs.readJsonSync(filePath) as AIProviderMetadataFile
|
const data = fs.readJsonSync(filePath) as AIProviderMetadataFile
|
||||||
if (data.version !== 1 || !Array.isArray(data.providers))
|
if (data.version !== 1 || !Array.isArray(data.providers))
|
||||||
throw new Error('invalid provider metadata')
|
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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +397,7 @@ function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary {
|
|||||||
{
|
{
|
||||||
name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId,
|
name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId,
|
||||||
id: modelId,
|
id: modelId,
|
||||||
capabilities: { chat: true, vision: false, longContext: true }
|
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
defaultModel: modelId,
|
defaultModel: modelId,
|
||||||
@@ -454,7 +526,7 @@ async function requestOpenAICompatible(
|
|||||||
},
|
},
|
||||||
provider.advanced.timeoutMs
|
provider.advanced.timeoutMs
|
||||||
)
|
)
|
||||||
const payload = (await response.json()) as OpenAIResponsePayload
|
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||||
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
||||||
return {
|
return {
|
||||||
data: String(payload.choices?.[0]?.message?.content || ''),
|
data: String(payload.choices?.[0]?.message?.content || ''),
|
||||||
@@ -508,7 +580,7 @@ async function requestAnthropic(
|
|||||||
},
|
},
|
||||||
provider.advanced.timeoutMs
|
provider.advanced.timeoutMs
|
||||||
)
|
)
|
||||||
const payload = (await response.json()) as AnthropicResponsePayload
|
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
||||||
return {
|
return {
|
||||||
@@ -543,6 +615,20 @@ async function fetchWithTimeout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||||
|
const body = await response.text()
|
||||||
|
try {
|
||||||
|
return JSON.parse(body) as T
|
||||||
|
} catch {
|
||||||
|
const looksLikeHtml = /^\s*(?:<!doctype\s+html|<html\b)/i.test(body)
|
||||||
|
const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`
|
||||||
|
if (looksLikeHtml) {
|
||||||
|
throw new Error(`模型服务返回了网页而不是 JSON(HTTP ${status}),请稍后重试或检查中转服务`)
|
||||||
|
}
|
||||||
|
throw new Error(`模型服务返回格式异常(HTTP ${status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function safeAIError(error: unknown): string {
|
function safeAIError(error: unknown): string {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') return 'AI 请求超时'
|
if (error instanceof DOMException && error.name === 'AbortError') return 'AI 请求超时'
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
|||||||
@@ -56,7 +56,14 @@ export interface FormattedMessage {
|
|||||||
export interface GroupSnapshot {
|
export interface GroupSnapshot {
|
||||||
roomId: string
|
roomId: string
|
||||||
memberCount: number
|
memberCount: number
|
||||||
members: { wxid: string; nickname: string; avatar: string }[]
|
members: {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const MSG_TYPE_DICT: Record<number, string> = {
|
const MSG_TYPE_DICT: Record<number, string> = {
|
||||||
@@ -292,6 +299,9 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
|||||||
.map((member) => ({
|
.map((member) => ({
|
||||||
wxid: member.m_nsUsrName,
|
wxid: member.m_nsUsrName,
|
||||||
nickname: member.nickname || '',
|
nickname: member.nickname || '',
|
||||||
|
groupNickname: member.groupNickname || '',
|
||||||
|
wechatNickname: member.wechatNickname || '',
|
||||||
|
remark: member.remark || '',
|
||||||
avatar: member.m_nsHeadImgUrl || ''
|
avatar: member.m_nsHeadImgUrl || ''
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, unknown>
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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<string | null> {
|
||||||
|
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<ImageAnalysisResponse> {
|
||||||
|
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<ImageCandidate[]> {
|
||||||
|
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()
|
||||||
@@ -32,6 +32,9 @@ export interface Wcdb4MessageQueryOptions {
|
|||||||
export interface Wcdb4GroupMember {
|
export interface Wcdb4GroupMember {
|
||||||
m_nsUsrName: string
|
m_nsUsrName: string
|
||||||
nickname: string
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
m_nsHeadImgUrl: string
|
m_nsHeadImgUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,17 +933,24 @@ export class Wcdb4Client {
|
|||||||
'member_username',
|
'member_username',
|
||||||
'm_nsUsrName'
|
'm_nsUsrName'
|
||||||
])
|
])
|
||||||
const memberNickname = this.pickString(row, [
|
const wechatNickname = this.pickString(row, [
|
||||||
'nickname',
|
'nickname',
|
||||||
'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',
|
'displayName',
|
||||||
'display_name',
|
'display_name',
|
||||||
'groupNickname',
|
'name'
|
||||||
'group_nickname',
|
|
||||||
'roomNickname',
|
|
||||||
'room_nickname',
|
|
||||||
'remark',
|
|
||||||
'm_nsNickName'
|
|
||||||
])
|
])
|
||||||
const avatar = this.pickString(row, [
|
const avatar = this.pickString(row, [
|
||||||
'avatarUrl',
|
'avatarUrl',
|
||||||
@@ -955,7 +965,11 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
m_nsUsrName: username,
|
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
|
m_nsHeadImgUrl: avatar
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -975,6 +989,8 @@ export class Wcdb4Client {
|
|||||||
...member,
|
...member,
|
||||||
nickname:
|
nickname:
|
||||||
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
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) || ''
|
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Vendored
+29
-1
@@ -30,6 +30,13 @@ import type {
|
|||||||
AIVisionTestResult,
|
AIVisionTestResult,
|
||||||
LegacyAIConfig
|
LegacyAIConfig
|
||||||
} from '../shared/ai-provider'
|
} from '../shared/ai-provider'
|
||||||
|
import type {
|
||||||
|
ImageAnalysisRequest,
|
||||||
|
ImageAnalysisResponse,
|
||||||
|
ImageCandidate,
|
||||||
|
ImageCandidateQuery,
|
||||||
|
ImageInsight
|
||||||
|
} from '../shared/image-insight'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
@@ -87,7 +94,14 @@ declare global {
|
|||||||
getGroupSnapshot: (userMd5: string) => Promise<{
|
getGroupSnapshot: (userMd5: string) => Promise<{
|
||||||
roomId: string
|
roomId: string
|
||||||
memberCount: number
|
memberCount: number
|
||||||
members: { wxid: string; nickname: string; avatar: string }[]
|
members: {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}[]
|
||||||
} | null>
|
} | null>
|
||||||
search: (keyword: string) => Promise<string | null>
|
search: (keyword: string) => Promise<string | null>
|
||||||
aiChat: (
|
aiChat: (
|
||||||
@@ -270,6 +284,20 @@ declare global {
|
|||||||
openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }>
|
openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }>
|
||||||
testLocalApiRequest: (request: LocalApiTestRequest) => Promise<LocalApiTestResponse>
|
testLocalApiRequest: (request: LocalApiTestRequest) => Promise<LocalApiTestResponse>
|
||||||
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
|
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
// ============================================================
|
||||||
|
// AI 图片理解基础设施(ImageInsightService)
|
||||||
|
// ============================================================
|
||||||
|
imageListCandidates: (query: ImageCandidateQuery) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
candidates: ImageCandidate[]
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
imageAnalyze: (request: ImageAnalysisRequest) => Promise<ImageAnalysisResponse>
|
||||||
|
getImageInsight: (imageHash: string) => Promise<{ success: boolean; insight?: ImageInsight }>
|
||||||
|
listImageInsights: (
|
||||||
|
sessionId: string,
|
||||||
|
limit?: number
|
||||||
|
) => Promise<{ success: boolean; insights: ImageInsight[] }>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -8,6 +8,13 @@ import type {
|
|||||||
AIVisionTestRequest,
|
AIVisionTestRequest,
|
||||||
LegacyAIConfig
|
LegacyAIConfig
|
||||||
} from '../shared/ai-provider'
|
} from '../shared/ai-provider'
|
||||||
|
import type {
|
||||||
|
ImageAnalysisRequest,
|
||||||
|
ImageAnalysisResponse,
|
||||||
|
ImageCandidate,
|
||||||
|
ImageCandidateQuery,
|
||||||
|
ImageInsight
|
||||||
|
} from '../shared/image-insight'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
@@ -108,7 +115,24 @@ const api = {
|
|||||||
revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'),
|
revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'),
|
||||||
openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'),
|
openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'),
|
||||||
testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request),
|
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<ImageAnalysisResponse> =>
|
||||||
|
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) {
|
if (process.contextIsolated) {
|
||||||
|
|||||||
@@ -81,7 +81,14 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
|||||||
type GroupSnapshot = {
|
type GroupSnapshot = {
|
||||||
roomId: string
|
roomId: string
|
||||||
memberCount: number
|
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 }
|
type GroupMemberMeta = { nickname: string; avatar: string }
|
||||||
@@ -1137,6 +1144,12 @@ function App(): React.ReactElement {
|
|||||||
onRevealReport={reportGeneration.revealReport}
|
onRevealReport={reportGeneration.revealReport}
|
||||||
onViewResult={openReportResult}
|
onViewResult={openReportResult}
|
||||||
hasReportResult={generatedReports.length > 0}
|
hasReportResult={generatedReports.length > 0}
|
||||||
|
templateId={reportGeneration.templateId}
|
||||||
|
onTemplateIdChange={reportGeneration.setTemplateId}
|
||||||
|
memberNamePreference={reportGeneration.memberNamePreference}
|
||||||
|
onMemberNamePreferenceChange={reportGeneration.setMemberNamePreference}
|
||||||
|
reportTimeoutSeconds={reportGeneration.reportTimeoutSeconds}
|
||||||
|
onReportTimeoutSecondsChange={reportGeneration.setReportTimeoutSeconds}
|
||||||
/>
|
/>
|
||||||
<ReportTaskStatusPanel
|
<ReportTaskStatusPanel
|
||||||
phase={reportGeneration.phase}
|
phase={reportGeneration.phase}
|
||||||
|
|||||||
@@ -3020,6 +3020,10 @@ body {
|
|||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-report-body > .report-section + .report-section {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.report-empty-state,
|
.report-empty-state,
|
||||||
.report-config-section,
|
.report-config-section,
|
||||||
.report-privacy-note,
|
.report-privacy-note,
|
||||||
@@ -3040,6 +3044,39 @@ body {
|
|||||||
font: 700 15px/20px var(--wxex-font);
|
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-empty-state p,
|
||||||
.report-privacy-note p {
|
.report-privacy-note p {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
@@ -4390,3 +4427,185 @@ body {
|
|||||||
flex-direction: column;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Contact } from '../../../../shared/types'
|
|||||||
import {
|
import {
|
||||||
AiModelConfig,
|
AiModelConfig,
|
||||||
RangeMessageState,
|
RangeMessageState,
|
||||||
|
ReportMemberNamePreference,
|
||||||
ReportGenerationPhase,
|
ReportGenerationPhase,
|
||||||
ReportPaths
|
ReportPaths
|
||||||
} from '../../hooks/useGroupReportGeneration'
|
} from '../../hooks/useGroupReportGeneration'
|
||||||
@@ -11,7 +12,9 @@ import { MessageTypeSelector } from './MessageTypeSelector'
|
|||||||
import { ModelSummary } from './ModelSummary'
|
import { ModelSummary } from './ModelSummary'
|
||||||
import { ReportDensitySelector } from './ReportDensitySelector'
|
import { ReportDensitySelector } from './ReportDensitySelector'
|
||||||
import { ReportRangeSelector } from './ReportRangeSelector'
|
import { ReportRangeSelector } from './ReportRangeSelector'
|
||||||
|
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
|
||||||
import { ReportSectionSelector } from './ReportSectionSelector'
|
import { ReportSectionSelector } from './ReportSectionSelector'
|
||||||
|
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
|
||||||
|
|
||||||
interface AiReportWorkspaceProps {
|
interface AiReportWorkspaceProps {
|
||||||
sourceContact: Contact | null
|
sourceContact: Contact | null
|
||||||
@@ -36,6 +39,12 @@ interface AiReportWorkspaceProps {
|
|||||||
onRevealReport: () => Promise<{ success: boolean; error?: string }>
|
onRevealReport: () => Promise<{ success: boolean; error?: string }>
|
||||||
onViewResult: () => void
|
onViewResult: () => void
|
||||||
hasReportResult: boolean
|
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 => {
|
const rangeLabel = (range: SummaryDateRange): string => {
|
||||||
@@ -76,7 +85,13 @@ export function AiReportWorkspace({
|
|||||||
onCopyImage,
|
onCopyImage,
|
||||||
onRevealReport,
|
onRevealReport,
|
||||||
onViewResult,
|
onViewResult,
|
||||||
hasReportResult
|
hasReportResult,
|
||||||
|
templateId,
|
||||||
|
onTemplateIdChange,
|
||||||
|
memberNamePreference,
|
||||||
|
onMemberNamePreferenceChange,
|
||||||
|
reportTimeoutSeconds,
|
||||||
|
onReportTimeoutSecondsChange
|
||||||
}: AiReportWorkspaceProps): React.ReactElement {
|
}: AiReportWorkspaceProps): React.ReactElement {
|
||||||
const [actionStatus, setActionStatus] = useState('')
|
const [actionStatus, setActionStatus] = useState('')
|
||||||
const groupName = sourceContact?.m_nsNickName || sourceContact?.m_nsUsrName || '未选择群聊'
|
const groupName = sourceContact?.m_nsNickName || sourceContact?.m_nsUsrName || '未选择群聊'
|
||||||
@@ -144,7 +159,35 @@ export function AiReportWorkspace({
|
|||||||
/>
|
/>
|
||||||
<ReportSectionSelector />
|
<ReportSectionSelector />
|
||||||
<ReportDensitySelector />
|
<ReportDensitySelector />
|
||||||
|
<ReportTemplateSelector
|
||||||
|
value={templateId}
|
||||||
|
onChange={onTemplateIdChange}
|
||||||
|
disabled={configDisabled}
|
||||||
|
/>
|
||||||
|
<ReportMemberNameSelector
|
||||||
|
value={memberNamePreference}
|
||||||
|
onChange={onMemberNamePreferenceChange}
|
||||||
|
disabled={configDisabled}
|
||||||
|
/>
|
||||||
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
|
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
|
||||||
|
<section className="report-config-section report-timeout-section">
|
||||||
|
<div>
|
||||||
|
<h3>日报生成超时</h3>
|
||||||
|
<p>大群聊和图片识别耗时较长,可单独设置本次日报最多等待时间。</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
max={1800}
|
||||||
|
step={30}
|
||||||
|
value={reportTimeoutSeconds}
|
||||||
|
disabled={configDisabled}
|
||||||
|
onChange={(event) => onReportTimeoutSecondsChange(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
<span>秒</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
<section className="report-privacy-note">
|
<section className="report-privacy-note">
|
||||||
<h3>隐私说明</h3>
|
<h3>隐私说明</h3>
|
||||||
<p>微信数据库和聊天记录默认从本机读取。</p>
|
<p>微信数据库和聊天记录默认从本机读取。</p>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<section className="report-section">
|
||||||
|
<h3>成员名称</h3>
|
||||||
|
<p className="report-section-desc">选择日报中群成员的显示名称,默认使用群昵称。</p>
|
||||||
|
<div className="report-template-list">
|
||||||
|
{OPTIONS.map((option) => (
|
||||||
|
<label
|
||||||
|
className={`report-template-item ${value === option.value ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
|
||||||
|
key={option.value}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="report-member-name"
|
||||||
|
checked={value === option.value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={() => onChange(option.value)}
|
||||||
|
/>
|
||||||
|
<div className="report-template-body">
|
||||||
|
<div className="report-template-title">{option.label}</div>
|
||||||
|
<div className="report-template-tagline">{option.description}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<ReportTemplateSelectorProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled
|
||||||
|
}) => {
|
||||||
|
const [previewing, setPreviewing] = useState<TemplateMeta | null>(null)
|
||||||
|
return (
|
||||||
|
<section className="report-section">
|
||||||
|
<h3>日报模板</h3>
|
||||||
|
<p className="report-section-desc">
|
||||||
|
选择日报呈现风格。模板2 支持图片板块,但需要 AI 模型服务商支持图片识别。
|
||||||
|
</p>
|
||||||
|
<div className="report-template-list">
|
||||||
|
{TEMPLATES.map((tpl) => {
|
||||||
|
const active = value === tpl.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tpl.id}
|
||||||
|
className={`report-template-item ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="report-template"
|
||||||
|
value={tpl.id}
|
||||||
|
checked={active}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={() => onChange(tpl.id)}
|
||||||
|
/>
|
||||||
|
<div className="report-template-body">
|
||||||
|
<div className="report-template-title">{tpl.label}</div>
|
||||||
|
<div className="report-template-tagline">{tpl.tagline}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="report-template-preview-btn"
|
||||||
|
onClick={() => setPreviewing(tpl)}
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{previewing && (
|
||||||
|
<div className="report-template-preview-mask" onClick={() => setPreviewing(null)}>
|
||||||
|
<div className="report-template-preview-card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h4>{previewing.preview.title}</h4>
|
||||||
|
<p className="muted">{previewing.tagline}</p>
|
||||||
|
<div className="report-template-preview-frame">
|
||||||
|
<div className="fake-card fake-hero">
|
||||||
|
<div className="fake-title">群聊日报 · 预览</div>
|
||||||
|
<div className="fake-sub">2026-xx-xx · 基于已加载记录</div>
|
||||||
|
</div>
|
||||||
|
{previewing.preview.sections.map((s) => (
|
||||||
|
<div className="fake-card fake-section" key={s}>
|
||||||
|
<div className="fake-bar" />
|
||||||
|
<div className="fake-section-title">{s}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="report-template-preview-close"
|
||||||
|
onClick={() => setPreviewing(null)}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -174,13 +174,31 @@ export function AIProviderEditor({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={model.capabilities.vision}
|
checked={model.capabilities.vision}
|
||||||
|
onChange={(event) => {
|
||||||
|
const vision = event.target.checked
|
||||||
|
// OCR 是 vision 的派生能力:勾 vision 时自动带 OCR
|
||||||
|
patchModel(index, {
|
||||||
|
capabilities: {
|
||||||
|
...model.capabilities,
|
||||||
|
vision,
|
||||||
|
ocr: vision ? true : model.capabilities.ocr
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
图片理解
|
||||||
|
</label>
|
||||||
|
<label title="图片文字识别,跟随图片理解能力">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={model.capabilities.ocr}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
patchModel(index, {
|
patchModel(index, {
|
||||||
capabilities: { ...model.capabilities, vision: event.target.checked }
|
capabilities: { ...model.capabilities, ocr: event.target.checked }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
图片理解
|
图片文字识别
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
@@ -296,5 +314,5 @@ export function AIProviderEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyModel(): AIModelDefinition {
|
function emptyModel(): AIModelDefinition {
|
||||||
return { name: '', id: '', capabilities: { chat: true, vision: false, longContext: false } }
|
return { name: '', id: '', capabilities: { chat: true, vision: false, ocr: false, longContext: false } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function createProviderFromPreset(presetId = 'deepseek'): AIProviderConfi
|
|||||||
{
|
{
|
||||||
name: preset.model || '默认模型',
|
name: preset.model || '默认模型',
|
||||||
id: preset.model,
|
id: preset.model,
|
||||||
capabilities: { chat: true, vision: false, longContext: false }
|
capabilities: { chat: true, vision: false, ocr: false, longContext: false }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
defaultModel: preset.model,
|
defaultModel: preset.model,
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
SummaryDateRange,
|
SummaryDateRange,
|
||||||
SummaryMessageType
|
SummaryMessageType
|
||||||
} from '../utils/group-report'
|
} from '../utils/group-report'
|
||||||
|
import { ReportTemplateId } from '../components/reports/ReportTemplateSelector'
|
||||||
|
|
||||||
const REPORT_STEP_TIMEOUT_MS = 90_000
|
const REPORT_STEP_TIMEOUT_MS = 90_000
|
||||||
|
const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
|
||||||
|
|
||||||
export type ReportGenerationPhase =
|
export type ReportGenerationPhase =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
@@ -29,8 +31,11 @@ export interface AiModelConfig {
|
|||||||
modelName: string
|
modelName: string
|
||||||
configured: boolean
|
configured: boolean
|
||||||
status: 'untested' | 'connected' | 'error'
|
status: 'untested' | 'connected' | 'error'
|
||||||
|
timeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReportMemberNamePreference = 'groupNickname' | 'wechatNickname' | 'remark'
|
||||||
|
|
||||||
export interface ReportPaths {
|
export interface ReportPaths {
|
||||||
htmlPath: string
|
htmlPath: string
|
||||||
pngPath: string
|
pngPath: string
|
||||||
@@ -84,10 +89,14 @@ export interface RangeMessageState {
|
|||||||
error: string
|
error: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTimeout = async <T>(promise: Promise<T>, label: string): Promise<T> => {
|
const withTimeout = async <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
label: string,
|
||||||
|
timeoutMs = REPORT_STEP_TIMEOUT_MS
|
||||||
|
): Promise<T> => {
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
const timeout = new Promise<never>((_, reject) => {
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), REPORT_STEP_TIMEOUT_MS)
|
timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), timeoutMs)
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
return await Promise.race([promise, timeout])
|
return await Promise.race([promise, timeout])
|
||||||
@@ -127,14 +136,33 @@ const estimateTokenUsage = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyGroupMemberNames = async (contact: Contact, messages: Message[]): Promise<Message[]> => {
|
const applyGroupMemberNames = async (
|
||||||
let memberMap = new Map<string, { nickname: string; avatar: string }>()
|
contact: Contact,
|
||||||
|
messages: Message[],
|
||||||
|
preference: ReportMemberNamePreference
|
||||||
|
): Promise<Message[]> => {
|
||||||
|
let memberMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
>()
|
||||||
try {
|
try {
|
||||||
const snapshot = await withTimeout(window.api.getGroupSnapshot(contact.md5), '读取群成员')
|
const snapshot = await withTimeout(window.api.getGroupSnapshot(contact.md5), '读取群成员')
|
||||||
memberMap = new Map(
|
memberMap = new Map(
|
||||||
(snapshot?.members || []).map((member) => [
|
(snapshot?.members || []).map((member) => [
|
||||||
member.wxid,
|
member.wxid,
|
||||||
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
|
{
|
||||||
|
nickname: member.nickname || member.wxid,
|
||||||
|
groupNickname: member.groupNickname || '',
|
||||||
|
wechatNickname: member.wechatNickname || '',
|
||||||
|
remark: member.remark || '',
|
||||||
|
avatar: member.avatar || ''
|
||||||
|
}
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -143,11 +171,17 @@ const applyGroupMemberNames = async (contact: Contact, messages: Message[]): Pro
|
|||||||
|
|
||||||
if (!memberMap.size) return messages
|
if (!memberMap.size) return messages
|
||||||
return messages.map((message) => {
|
return messages.map((message) => {
|
||||||
if (!isInternalName(message.name)) return message
|
|
||||||
const senderId = String(message.senderId || message.name || '')
|
const senderId = String(message.senderId || message.name || '')
|
||||||
const member = memberMap.get(senderId)
|
const member = memberMap.get(senderId)
|
||||||
if (!member?.nickname || isInternalName(member.nickname)) return message
|
if (!member) return message
|
||||||
return { ...message, name: member.nickname, img: message.img || member.avatar }
|
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
|
||||||
|
groupNickname: [member.groupNickname, member.wechatNickname, member.remark, member.nickname],
|
||||||
|
wechatNickname: [member.wechatNickname, member.groupNickname, member.remark, member.nickname],
|
||||||
|
remark: [member.remark, member.groupNickname, member.wechatNickname, member.nickname]
|
||||||
|
}
|
||||||
|
const name = preferredNames[preference].find((value) => value && !isInternalName(value))
|
||||||
|
if (!name) return message
|
||||||
|
return { ...message, name, img: message.img || member.avatar }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +208,12 @@ export function useGroupReportGeneration({
|
|||||||
closeResult: () => void
|
closeResult: () => void
|
||||||
copyImage: () => Promise<{ success: boolean; error?: string }>
|
copyImage: () => Promise<{ success: boolean; error?: string }>
|
||||||
revealReport: () => Promise<{ success: boolean; error?: string }>
|
revealReport: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
templateId: ReportTemplateId
|
||||||
|
setTemplateId: (value: ReportTemplateId) => void
|
||||||
|
memberNamePreference: ReportMemberNamePreference
|
||||||
|
setMemberNamePreference: (value: ReportMemberNamePreference) => void
|
||||||
|
reportTimeoutSeconds: number
|
||||||
|
setReportTimeoutSeconds: (value: number) => void
|
||||||
} {
|
} {
|
||||||
const [phase, setPhase] = useState<ReportGenerationPhase>('idle')
|
const [phase, setPhase] = useState<ReportGenerationPhase>('idle')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -181,11 +221,31 @@ export function useGroupReportGeneration({
|
|||||||
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
|
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
|
||||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||||
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
||||||
|
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
||||||
|
const [memberNamePreference, setMemberNamePreferenceState] =
|
||||||
|
useState<ReportMemberNamePreference>(() => {
|
||||||
|
const saved = localStorage.getItem('group_report_member_name_preference')
|
||||||
|
return saved === 'wechatNickname' || saved === 'remark' ? saved : 'groupNickname'
|
||||||
|
})
|
||||||
|
const [reportTimeoutSeconds, setReportTimeoutSecondsState] = useState<number>(() => {
|
||||||
|
const saved = Number(localStorage.getItem('group_report_timeout_seconds'))
|
||||||
|
return Number.isFinite(saved) && saved >= 30 ? saved : 300
|
||||||
|
})
|
||||||
const [generationMetadata, setGenerationMetadata] = useState<ReportGenerationMetadata>({
|
const [generationMetadata, setGenerationMetadata] = useState<ReportGenerationMetadata>({
|
||||||
generationLogs: []
|
generationLogs: []
|
||||||
})
|
})
|
||||||
const rangeRequestIdRef = useRef(0)
|
const rangeRequestIdRef = useRef(0)
|
||||||
|
|
||||||
|
const setMemberNamePreference = useCallback((value: ReportMemberNamePreference): void => {
|
||||||
|
localStorage.setItem('group_report_member_name_preference', value)
|
||||||
|
setMemberNamePreferenceState(value)
|
||||||
|
}, [])
|
||||||
|
const setReportTimeoutSeconds = useCallback((value: number): void => {
|
||||||
|
const normalized = Math.max(30, Math.min(1800, Math.round(Number(value) || 300)))
|
||||||
|
localStorage.setItem('group_report_timeout_seconds', String(normalized))
|
||||||
|
setReportTimeoutSecondsState(normalized)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const isGenerating =
|
const isGenerating =
|
||||||
phase === 'loadingMessages' ||
|
phase === 'loadingMessages' ||
|
||||||
phase === 'preparingInput' ||
|
phase === 'preparingInput' ||
|
||||||
@@ -332,8 +392,12 @@ export function useGroupReportGeneration({
|
|||||||
|
|
||||||
setPhase('preparingInput')
|
setPhase('preparingInput')
|
||||||
const input = await trackStep('整理输入', async () => {
|
const input = await trackStep('整理输入', async () => {
|
||||||
const namedReportMessages = await applyGroupMemberNames(sourceContact, filteredMessages)
|
const namedReportMessages = await applyGroupMemberNames(
|
||||||
return buildGroupReportInput(namedReportMessages, sourceContact, true)
|
sourceContact,
|
||||||
|
filteredMessages,
|
||||||
|
memberNamePreference
|
||||||
|
)
|
||||||
|
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full')
|
||||||
})
|
})
|
||||||
|
|
||||||
setPhase('requestingModel')
|
setPhase('requestingModel')
|
||||||
@@ -345,9 +409,11 @@ export function useGroupReportGeneration({
|
|||||||
withTimeout(
|
withTimeout(
|
||||||
window.api.aiChat(aiMessages, {
|
window.api.aiChat(aiMessages, {
|
||||||
providerId: modelConfig.providerId,
|
providerId: modelConfig.providerId,
|
||||||
modelId: modelConfig.model
|
modelId: modelConfig.model,
|
||||||
|
timeoutMs: reportTimeoutSeconds * 1000
|
||||||
}),
|
}),
|
||||||
'AI 生成日报'
|
'AI 生成日报',
|
||||||
|
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||||
@@ -357,11 +423,18 @@ export function useGroupReportGeneration({
|
|||||||
? result.usage
|
? result.usage
|
||||||
: estimateTokenUsage(aiMessages, result.data)
|
: estimateTokenUsage(aiMessages, result.data)
|
||||||
|
|
||||||
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
|
const report = parseGroupDailyReport(
|
||||||
|
result.data,
|
||||||
|
input.topSpeakers,
|
||||||
|
input.activeTimeline,
|
||||||
|
input.voiceLeaderboard || [],
|
||||||
|
input.metadata,
|
||||||
|
input.media
|
||||||
|
)
|
||||||
|
|
||||||
setPhase('exportingReport')
|
setPhase('exportingReport')
|
||||||
const exported = await withTimeout(
|
const exported = await withTimeout(
|
||||||
window.api.exportGroupReport({ report, metadata: input.metadata }),
|
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
|
||||||
'日报图片导出'
|
'日报图片导出'
|
||||||
)
|
)
|
||||||
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||||
@@ -399,7 +472,16 @@ export function useGroupReportGeneration({
|
|||||||
setError(errorMessage(generateError))
|
setError(errorMessage(generateError))
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
}
|
}
|
||||||
}, [isGenerating, loadRangeMessages, modelConfig, sourceContact, summaryMessageTypes])
|
}, [
|
||||||
|
isGenerating,
|
||||||
|
loadRangeMessages,
|
||||||
|
memberNamePreference,
|
||||||
|
modelConfig,
|
||||||
|
reportTimeoutSeconds,
|
||||||
|
sourceContact,
|
||||||
|
summaryMessageTypes,
|
||||||
|
templateId
|
||||||
|
])
|
||||||
|
|
||||||
const clearError = useCallback((): void => {
|
const clearError = useCallback((): void => {
|
||||||
setError('')
|
setError('')
|
||||||
@@ -437,6 +519,12 @@ export function useGroupReportGeneration({
|
|||||||
clearError,
|
clearError,
|
||||||
closeResult,
|
closeResult,
|
||||||
copyImage,
|
copyImage,
|
||||||
revealReport
|
revealReport,
|
||||||
|
templateId,
|
||||||
|
setTemplateId,
|
||||||
|
memberNamePreference,
|
||||||
|
setMemberNamePreference,
|
||||||
|
reportTimeoutSeconds,
|
||||||
|
setReportTimeoutSeconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
import { Contact, Message } from '../../../shared/types'
|
||||||
|
import {
|
||||||
|
GroupDailyReport,
|
||||||
|
GroupReportMetadata,
|
||||||
|
ReportFunBadge,
|
||||||
|
ReportMediaGalleryItem,
|
||||||
|
ReportMode,
|
||||||
|
ReportSpeakerRank,
|
||||||
|
ReportVisionGalleryItem,
|
||||||
|
ReportVoiceHighlight,
|
||||||
|
ReportVoiceLeaderboardItem
|
||||||
|
} from '../../../shared/group-report'
|
||||||
|
|
||||||
|
export interface GroupReportTranscriptRow {
|
||||||
|
id: string
|
||||||
|
datetime: string
|
||||||
|
timestamp: number
|
||||||
|
sender: string
|
||||||
|
content: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupReportFactsSnapshot {
|
||||||
|
metadata: GroupReportMetadata
|
||||||
|
transcriptRows: GroupReportTranscriptRow[]
|
||||||
|
topSpeakers: ReportSpeakerRank[]
|
||||||
|
activeTimeline: string
|
||||||
|
media: GroupDailyReport['media']
|
||||||
|
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||||
|
factsPrompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isInternalIdentifier = (value: string): boolean =>
|
||||||
|
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
|
||||||
|
|
||||||
|
export const summarySender = (
|
||||||
|
message: Message,
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean
|
||||||
|
): string => {
|
||||||
|
if (message.from === 'assistant') {
|
||||||
|
const ownGroupNickname = message.name?.trim()
|
||||||
|
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
|
||||||
|
return ownGroupNickname
|
||||||
|
}
|
||||||
|
return '我'
|
||||||
|
}
|
||||||
|
const candidate = isGroup ? message.name : contact?.m_nsNickName
|
||||||
|
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
export const summaryContent = (message: Message): string => {
|
||||||
|
const data = message.contentData
|
||||||
|
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'image':
|
||||||
|
return '[图片]'
|
||||||
|
case 'sticker':
|
||||||
|
return '[表情]'
|
||||||
|
case 'voice':
|
||||||
|
return `[语音${data.duration ? ` ${data.duration}秒` : ''}]`
|
||||||
|
case 'share':
|
||||||
|
return `[分享] ${data.title}${data.des ? `:${data.des}` : ''}`
|
||||||
|
case 'quote': {
|
||||||
|
const reply = data.title || data.content || message.content || '[回复]'
|
||||||
|
const quotedSender =
|
||||||
|
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
|
||||||
|
return `${reply}(引用 ${quotedSender}:${data.quotedContent || `[引用${data.quotedType || '消息'}]`})`
|
||||||
|
}
|
||||||
|
case 'location':
|
||||||
|
return `[位置] ${data.poiname || data.label || '位置消息'}`
|
||||||
|
case 'card':
|
||||||
|
return `[名片] ${data.nickname || '微信名片'}`
|
||||||
|
case 'voip':
|
||||||
|
return `[通话] ${data.status}${data.duration ? `,${data.duration}秒` : ''}`
|
||||||
|
case 'system':
|
||||||
|
case 'text':
|
||||||
|
return data.content
|
||||||
|
case 'unknown':
|
||||||
|
return `[${message.type || '未知消息'}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${message.type || '消息'}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseTimestamp = (message: Message): number => {
|
||||||
|
const value = new Date(message.datetime).getTime()
|
||||||
|
return Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localDate = (timestamp: number): string => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localTime = (timestamp: number): string =>
|
||||||
|
new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveVoiceDuration = (message: Message): number => {
|
||||||
|
const fromData = message.contentData?.type === 'voice' ? message.contentData.duration : undefined
|
||||||
|
return Math.max(0, Number(fromData ?? message.voiceDuration ?? 0) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncate = (value: string, max = 48): string =>
|
||||||
|
value.length > max ? `${value.slice(0, max - 1)}…` : value
|
||||||
|
|
||||||
|
const buildImageContext = (
|
||||||
|
messages: Message[],
|
||||||
|
index: number,
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean
|
||||||
|
): {
|
||||||
|
note: string
|
||||||
|
stats: string
|
||||||
|
responseCount: number
|
||||||
|
participantCount: number
|
||||||
|
snippets: string[]
|
||||||
|
} => {
|
||||||
|
const baseTime = parseTimestamp(messages[index])
|
||||||
|
const participants = new Set<string>()
|
||||||
|
const snippets: string[] = []
|
||||||
|
let responseCount = 0
|
||||||
|
|
||||||
|
for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) {
|
||||||
|
const candidate = messages[offset]
|
||||||
|
const candidateTime = parseTimestamp(candidate)
|
||||||
|
if (baseTime && candidateTime && candidateTime - baseTime > 20 * 60 * 1000) break
|
||||||
|
if (candidate.type === '系统消息' || candidate.from === 'system') continue
|
||||||
|
|
||||||
|
const sender = summarySender(candidate, contact, isGroup)
|
||||||
|
const sameSender = sender === summarySender(messages[index], contact, isGroup)
|
||||||
|
const content = summaryContent(candidate)
|
||||||
|
if (!sameSender) {
|
||||||
|
responseCount += 1
|
||||||
|
participants.add(sender)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
snippets.length < 3 &&
|
||||||
|
!content.startsWith('[图片]') &&
|
||||||
|
!content.startsWith('[表情]') &&
|
||||||
|
!content.startsWith('[语音')
|
||||||
|
) {
|
||||||
|
snippets.push(truncate(content, 28))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = snippets.length
|
||||||
|
? `图片发出后,群里接着聊到:${snippets.join(' / ')}`
|
||||||
|
: responseCount > 0
|
||||||
|
? '图片发出后引发了一波接续讨论。'
|
||||||
|
: '这张图片更多像是一次轻量分享,没有形成长链路讨论。'
|
||||||
|
|
||||||
|
const statsParts: string[] = []
|
||||||
|
if (responseCount > 0) statsParts.push(`${responseCount} 条后续消息`)
|
||||||
|
if (participants.size > 0) statsParts.push(`${participants.size} 人接话`)
|
||||||
|
if (!statsParts.length) statsParts.push('讨论热度较低')
|
||||||
|
|
||||||
|
return {
|
||||||
|
note,
|
||||||
|
stats: statsParts.join(' · '),
|
||||||
|
responseCount,
|
||||||
|
participantCount: participants.size,
|
||||||
|
snippets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildMediaSection = async (
|
||||||
|
messages: Message[],
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean,
|
||||||
|
topSpeakersMap: Map<string, number>
|
||||||
|
): Promise<{
|
||||||
|
media: GroupDailyReport['media']
|
||||||
|
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||||
|
warnings: string[]
|
||||||
|
}> => {
|
||||||
|
const warnings: string[] = []
|
||||||
|
const rawImageCandidates = messages
|
||||||
|
.map((message, index) => {
|
||||||
|
if (message.contentData?.type !== 'image') return null
|
||||||
|
const sender = summarySender(message, contact, isGroup)
|
||||||
|
const context = buildImageContext(messages, index, contact, isGroup)
|
||||||
|
return {
|
||||||
|
sourceMessageIds: [message.id],
|
||||||
|
md5: message.contentData.md5,
|
||||||
|
datName: message.contentData.datName,
|
||||||
|
sessionId: message.sessionId,
|
||||||
|
sender,
|
||||||
|
time: localTime(parseTimestamp(message)),
|
||||||
|
note: context.note,
|
||||||
|
stats: context.stats,
|
||||||
|
replyCount: context.responseCount,
|
||||||
|
participantCount: context.participantCount,
|
||||||
|
score: context.responseCount * 3 + context.participantCount * 2 + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||||
|
.sort((left, right) => right.score - left.score)
|
||||||
|
.slice(0, 6)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AI 图片理解(ImageInsightService 接入)
|
||||||
|
// 通过 main 进程拿 Top 3 热点图 + 已缓存的 Insight;未缓存的并发调 AI
|
||||||
|
// 失败不阻塞:任何错误只记日志,降级到原 gallery
|
||||||
|
// ============================================================
|
||||||
|
let visionGallery: ReportVisionGalleryItem[] = []
|
||||||
|
try {
|
||||||
|
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
|
||||||
|
const startTime = messages.length ? parseTimestamp(messages[0]) : 0
|
||||||
|
const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0
|
||||||
|
|
||||||
|
// 从 renderer 已加载的消息中提取图片候选(复用 buildImageContext 已算的 replyCount)
|
||||||
|
const imageInputs = rawImageCandidates.map((c) => {
|
||||||
|
const srcMsg = messages.find((m) => m.id === c.sourceMessageIds[0]) || messages[0]
|
||||||
|
return {
|
||||||
|
messageId: c.sourceMessageIds[0] || '',
|
||||||
|
md5: c.md5,
|
||||||
|
datName: c.datName,
|
||||||
|
sessionId: c.sessionId || sessionId,
|
||||||
|
sender: c.sender,
|
||||||
|
sentAt: parseTimestamp(srcMsg),
|
||||||
|
responseCount: c.replyCount || 0,
|
||||||
|
interactionCount: c.participantCount || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const candidatesResp = await window.api.imageListCandidates({
|
||||||
|
sessionId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
limit: 3,
|
||||||
|
inputs: imageInputs
|
||||||
|
})
|
||||||
|
const candidates = candidatesResp.success ? candidatesResp.candidates : []
|
||||||
|
console.log('[buildMediaSection] imageListCandidates returned', candidates.length, 'candidates')
|
||||||
|
|
||||||
|
// 对每个候选:缓存命中直接用,未命中并发调 imageAnalyze
|
||||||
|
const analyzed = await Promise.all(
|
||||||
|
candidates.map(async (candidate) => {
|
||||||
|
if (candidate.insight) return candidate.insight
|
||||||
|
// 未命中:解密图片拿 base64 → 调 AI
|
||||||
|
try {
|
||||||
|
const img = await window.api.getImage(
|
||||||
|
candidate.md5,
|
||||||
|
candidate.datName,
|
||||||
|
candidate.sessionId
|
||||||
|
)
|
||||||
|
if (!img.success || !img.data) {
|
||||||
|
warnings.push(
|
||||||
|
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片读取失败:${img.error || '未知错误'}`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const analyzeResp = await window.api.imageAnalyze({
|
||||||
|
imageHash: candidate.imageHash,
|
||||||
|
imageDataUrl: img.data,
|
||||||
|
messageId: candidate.messageId,
|
||||||
|
sender: candidate.sender,
|
||||||
|
sentAt: candidate.sentAt,
|
||||||
|
sessionId: candidate.sessionId,
|
||||||
|
force: false
|
||||||
|
})
|
||||||
|
if (!analyzeResp.success || !analyzeResp.insight) {
|
||||||
|
warnings.push(
|
||||||
|
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别失败:${analyzeResp.error || '模型未返回识别结果'}`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return analyzeResp.insight
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[buildMediaSection] image analyze failed:', error)
|
||||||
|
warnings.push(
|
||||||
|
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别异常:${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
visionGallery = analyzed
|
||||||
|
.filter((it): it is NonNullable<typeof it> => Boolean(it))
|
||||||
|
.map((it) => ({
|
||||||
|
messageId: it.messageId,
|
||||||
|
imageHash: it.imageHash,
|
||||||
|
sender: it.sender,
|
||||||
|
time: localTime(it.sentAt),
|
||||||
|
description: it.description,
|
||||||
|
ocrText: it.ocrText,
|
||||||
|
tags: it.tags,
|
||||||
|
category: it.category,
|
||||||
|
importance: it.importance,
|
||||||
|
sourceMessageIds: [it.messageId]
|
||||||
|
}))
|
||||||
|
// 为 visionGallery 加载原图 dataUrl(给 main 渲染用,不暴露给 LLM)
|
||||||
|
if (visionGallery.length) {
|
||||||
|
visionGallery = await Promise.all(
|
||||||
|
visionGallery.map(async (item) => {
|
||||||
|
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
|
||||||
|
if (!orig) return item
|
||||||
|
try {
|
||||||
|
const img = await window.api.getImage(orig.md5, orig.datName, orig.sessionId)
|
||||||
|
if (img.success && img.data?.startsWith('data:image/')) {
|
||||||
|
return { ...item, imageUrl: img.data }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[buildMediaSection] preload image failed for', item.messageId, error)
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[buildMediaSection] vision flow failed, fallback to empty:', error)
|
||||||
|
warnings.push(`图片识别流程失败:${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
visionGallery = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageCandidates = await Promise.all(
|
||||||
|
rawImageCandidates.map(async (item) => {
|
||||||
|
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
|
||||||
|
if (!result.success || !result.data?.startsWith('data:image/')) return null
|
||||||
|
return {
|
||||||
|
sender: item.sender,
|
||||||
|
time: item.time,
|
||||||
|
imageUrl: result.data,
|
||||||
|
note: item.note,
|
||||||
|
stats: item.stats,
|
||||||
|
inferenceLabel: '基于图片后的聊天上下文推断',
|
||||||
|
sourceMessageIds: item.sourceMessageIds,
|
||||||
|
replyCount: item.replyCount,
|
||||||
|
score: item.score
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const gallery: ReportMediaGalleryItem[] = imageCandidates
|
||||||
|
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||||
|
.sort((left, right) => right.score - left.score)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((item) => ({
|
||||||
|
sender: item.sender,
|
||||||
|
time: item.time,
|
||||||
|
imageUrl: item.imageUrl,
|
||||||
|
note: item.note,
|
||||||
|
stats: item.stats,
|
||||||
|
inferenceLabel: item.inferenceLabel,
|
||||||
|
sourceMessageIds: item.sourceMessageIds,
|
||||||
|
replyCount: item.replyCount
|
||||||
|
}))
|
||||||
|
|
||||||
|
const voiceMessages = messages
|
||||||
|
.filter((message) => message.contentData?.type === 'voice')
|
||||||
|
.map((message) => ({
|
||||||
|
sender: summarySender(message, contact, isGroup),
|
||||||
|
duration: resolveVoiceDuration(message),
|
||||||
|
time: localTime(parseTimestamp(message))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const voiceTotals = new Map<string, { count: number; duration: number }>()
|
||||||
|
for (const item of voiceMessages) {
|
||||||
|
const current = voiceTotals.get(item.sender) || { count: 0, duration: 0 }
|
||||||
|
current.count += 1
|
||||||
|
current.duration += item.duration
|
||||||
|
voiceTotals.set(item.sender, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceLeaderboard: ReportVoiceLeaderboardItem[] = Array.from(voiceTotals.entries())
|
||||||
|
.map(([sender, value]) => ({
|
||||||
|
sender,
|
||||||
|
count: value.count,
|
||||||
|
durationSec: value.duration
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.durationSec - left.durationSec || right.count - left.count)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
let bestStreak: { sender: string; count: number; duration: number; time: string } | null = null
|
||||||
|
let currentStreak: { sender: string; count: number; duration: number; time: string } | null = null
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.contentData?.type !== 'voice') {
|
||||||
|
currentStreak = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const sender = summarySender(message, contact, isGroup)
|
||||||
|
const duration = resolveVoiceDuration(message)
|
||||||
|
const time = localTime(parseTimestamp(message))
|
||||||
|
if (currentStreak && currentStreak.sender === sender) {
|
||||||
|
currentStreak.count += 1
|
||||||
|
currentStreak.duration += duration
|
||||||
|
} else {
|
||||||
|
currentStreak = { sender, count: 1, duration, time }
|
||||||
|
}
|
||||||
|
if (!bestStreak || currentStreak.count > bestStreak.count) {
|
||||||
|
bestStreak = { ...currentStreak }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceHighlights: ReportVoiceHighlight[] = []
|
||||||
|
if (voiceLeaderboard[0]) {
|
||||||
|
voiceHighlights.push({
|
||||||
|
title: '语音输出王',
|
||||||
|
sender: voiceLeaderboard[0].sender,
|
||||||
|
note: `共发送 ${voiceLeaderboard[0].count} 条语音,累计 ${voiceLeaderboard[0].durationSec} 秒。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (bestStreak && bestStreak.count >= 2) {
|
||||||
|
voiceHighlights.push({
|
||||||
|
title: '连续发言时刻',
|
||||||
|
sender: bestStreak.sender,
|
||||||
|
note: `${bestStreak.time} 连发 ${bestStreak.count} 条语音,共 ${bestStreak.duration} 秒。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const funBadges: ReportFunBadge[] = []
|
||||||
|
const topSpeaker = Array.from(topSpeakersMap.entries()).sort(
|
||||||
|
(left, right) => right[1] - left[1]
|
||||||
|
)[0]
|
||||||
|
if (topSpeaker) {
|
||||||
|
funBadges.push({
|
||||||
|
title: '高能输出王',
|
||||||
|
owner: topSpeaker[0],
|
||||||
|
note: `今天一共发了 ${topSpeaker[1]} 条消息。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (gallery[0]) {
|
||||||
|
funBadges.push({
|
||||||
|
title: '图片话题王',
|
||||||
|
owner: gallery[0].sender,
|
||||||
|
note: `${gallery[0].time} 的图片带动了最明显的一轮讨论。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (voiceLeaderboard[0]) {
|
||||||
|
funBadges.push({
|
||||||
|
title: '语音麦霸',
|
||||||
|
owner: voiceLeaderboard[0].sender,
|
||||||
|
note: `语音总时长暂居第一,适合放进“今日声音档案”。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
media: {
|
||||||
|
gallery,
|
||||||
|
visionGallery,
|
||||||
|
voiceHighlights: voiceHighlights.slice(0, 2),
|
||||||
|
funBadges: funBadges.slice(0, 3)
|
||||||
|
},
|
||||||
|
voiceLeaderboard,
|
||||||
|
warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectQuestionCandidates = (
|
||||||
|
messages: Message[],
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean
|
||||||
|
): string[] =>
|
||||||
|
messages
|
||||||
|
.map((message) => ({
|
||||||
|
id: message.id,
|
||||||
|
sender: summarySender(message, contact, isGroup),
|
||||||
|
content: summaryContent(message)
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
/[??]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么')
|
||||||
|
)
|
||||||
|
.slice(-6)
|
||||||
|
.map((item) => `${item.sender}(${item.id}):${truncate(item.content, 32)}`)
|
||||||
|
|
||||||
|
const collectReplyFacts = (
|
||||||
|
messages: Message[],
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean
|
||||||
|
): string[] =>
|
||||||
|
messages
|
||||||
|
.filter((message) => message.contentData?.type === 'quote' && message.contentData.quotedSender)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((message) => {
|
||||||
|
const sender = summarySender(message, contact, isGroup)
|
||||||
|
const quotedSender =
|
||||||
|
message.contentData?.type === 'quote' && message.contentData.quotedSender
|
||||||
|
? message.contentData.quotedSender
|
||||||
|
: '群成员'
|
||||||
|
return `${sender} 回复了 ${quotedSender}`
|
||||||
|
})
|
||||||
|
|
||||||
|
export const buildGroupReportFacts = async (
|
||||||
|
messages: Message[],
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean,
|
||||||
|
reportMode: ReportMode
|
||||||
|
): Promise<GroupReportFactsSnapshot> => {
|
||||||
|
const transcriptRows = messages.map((message) => ({
|
||||||
|
id: message.id,
|
||||||
|
datetime: message.datetime,
|
||||||
|
timestamp: parseTimestamp(message),
|
||||||
|
sender: summarySender(message, contact, isGroup),
|
||||||
|
content: summaryContent(message),
|
||||||
|
avatar: message.img
|
||||||
|
}))
|
||||||
|
|
||||||
|
let firstTimestamp = Number.POSITIVE_INFINITY
|
||||||
|
let lastTimestamp = Number.NEGATIVE_INFINITY
|
||||||
|
for (const row of transcriptRows) {
|
||||||
|
if (!Number.isFinite(row.timestamp)) continue
|
||||||
|
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
|
||||||
|
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
|
||||||
|
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
|
||||||
|
|
||||||
|
const speakerCounts = new Map<string, number>()
|
||||||
|
const hourCounts = new Map<number, number>()
|
||||||
|
const avatars: Record<string, string | undefined> = {}
|
||||||
|
let imageCount = 0
|
||||||
|
let stickerCount = 0
|
||||||
|
let voiceCount = 0
|
||||||
|
let voiceDurationSec = 0
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const sender = summarySender(message, contact, isGroup)
|
||||||
|
const timestamp = parseTimestamp(message)
|
||||||
|
speakerCounts.set(sender, (speakerCounts.get(sender) || 0) + 1)
|
||||||
|
if (Number.isFinite(timestamp)) {
|
||||||
|
const hour = new Date(timestamp).getHours()
|
||||||
|
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
|
||||||
|
}
|
||||||
|
if (message.img && !avatars[sender]) avatars[sender] = message.img
|
||||||
|
if (message.contentData?.type === 'image') imageCount += 1
|
||||||
|
if (message.contentData?.type === 'sticker') stickerCount += 1
|
||||||
|
if (message.contentData?.type === 'voice') {
|
||||||
|
voiceCount += 1
|
||||||
|
voiceDurationSec += resolveVoiceDuration(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
|
||||||
|
.sort((left, right) => right.count - left.count)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
|
||||||
|
.sort((left, right) => right.count - left.count)
|
||||||
|
.slice(0, 4)
|
||||||
|
.sort((left, right) => left.hour - right.hour)
|
||||||
|
.map(
|
||||||
|
({ hour, count }) =>
|
||||||
|
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59(${count}条)`
|
||||||
|
)
|
||||||
|
.join('、')
|
||||||
|
|
||||||
|
const startDate = localDate(firstTimestamp)
|
||||||
|
const endDate = localDate(lastTimestamp)
|
||||||
|
const sameDay = startDate === endDate
|
||||||
|
const dateRange = sameDay
|
||||||
|
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
|
||||||
|
: `${startDate} ${localTime(firstTimestamp)} 至 ${endDate} ${localTime(lastTimestamp)}`
|
||||||
|
const durationMs = Math.max(0, lastTimestamp - firstTimestamp)
|
||||||
|
const durationHours = durationMs / 3600000
|
||||||
|
const timeSpan = (() => {
|
||||||
|
if (sameDay) {
|
||||||
|
if (durationHours < 1) {
|
||||||
|
const minutes = Math.max(1, Math.round(durationMs / 60000))
|
||||||
|
return `${minutes} min`
|
||||||
|
}
|
||||||
|
const hours = Math.max(1, Math.ceil(durationHours))
|
||||||
|
return `${hours} h`
|
||||||
|
}
|
||||||
|
const days = Math.max(1, Math.ceil(durationMs / 86400000))
|
||||||
|
return `${days} d`
|
||||||
|
})()
|
||||||
|
|
||||||
|
const contactName = contact?.m_nsNickName || ''
|
||||||
|
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
|
||||||
|
const metadata: GroupReportMetadata = {
|
||||||
|
groupName,
|
||||||
|
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
|
||||||
|
dateRange,
|
||||||
|
messageCount: transcriptRows.length,
|
||||||
|
activeUsers: speakerCounts.size,
|
||||||
|
imageCount,
|
||||||
|
voiceCount,
|
||||||
|
stickerCount,
|
||||||
|
mediaMessageCount: imageCount + voiceCount + stickerCount,
|
||||||
|
timeSpan,
|
||||||
|
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||||
|
recordNote: `基于 WechatExplorer 已加载的 ${transcriptRows.length} 条记录`,
|
||||||
|
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。',
|
||||||
|
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
|
||||||
|
avatars,
|
||||||
|
reportMode
|
||||||
|
}
|
||||||
|
|
||||||
|
const { media, voiceLeaderboard, warnings } = await buildMediaSection(
|
||||||
|
messages,
|
||||||
|
contact,
|
||||||
|
isGroup,
|
||||||
|
speakerCounts
|
||||||
|
)
|
||||||
|
if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings]
|
||||||
|
if (imageCount > 0 && !media.visionGallery?.length) {
|
||||||
|
metadata.footerNote = `图片识别未成功:${warnings[0] || '当前模型未返回图片理解结果'}。其余内容基于已读取聊天记录生成。`
|
||||||
|
} else if (media.visionGallery?.length) {
|
||||||
|
metadata.footerNote = `基于已读取聊天记录生成;其中 ${media.visionGallery.length} 张图片已由当前视觉模型识别。`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
transcriptRows.length > 0 &&
|
||||||
|
transcriptRows.every((row) => row.content === '[图片]') &&
|
||||||
|
!media.visionGallery?.length
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
warnings[0] || '所选记录只有图片,但当前图片均未能识别,请检查图片解密密钥和模型视觉能力'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const factsPrompt = [
|
||||||
|
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
|
||||||
|
`消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`,
|
||||||
|
activeTimeline ? `活跃时段:${activeTimeline}` : '',
|
||||||
|
media.gallery.length
|
||||||
|
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats})`).join(';')}`
|
||||||
|
: '',
|
||||||
|
// AI 图片理解结果(由 ImageInsightService 提供,缓存命中或已调用 Vision)
|
||||||
|
(media.visionGallery?.length ?? 0) > 0
|
||||||
|
? `AI 图片识别摘要:${(media.visionGallery || [])
|
||||||
|
.map(
|
||||||
|
(it) =>
|
||||||
|
`[${it.time} ${it.sender}] ${it.description}${it.ocrText ? `(OCR: ${it.ocrText})` : ''}${it.tags.length ? ` [${it.tags.join('/')}]` : ''}`
|
||||||
|
)
|
||||||
|
.join(';')}`
|
||||||
|
: '',
|
||||||
|
voiceLeaderboard.length
|
||||||
|
? `语音榜:${voiceLeaderboard
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => `${item.sender} ${item.count} 条 / ${item.durationSec} 秒`)
|
||||||
|
.join(';')}`
|
||||||
|
: '',
|
||||||
|
collectQuestionCandidates(messages, contact, isGroup).length
|
||||||
|
? `疑似待跟进问题:${collectQuestionCandidates(messages, contact, isGroup).join(';')}`
|
||||||
|
: '',
|
||||||
|
collectReplyFacts(messages, contact, isGroup).length
|
||||||
|
? `回复关系样本:${collectReplyFacts(messages, contact, isGroup).join(';')}`
|
||||||
|
: ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
transcriptRows,
|
||||||
|
topSpeakers,
|
||||||
|
activeTimeline,
|
||||||
|
media,
|
||||||
|
voiceLeaderboard,
|
||||||
|
factsPrompt
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,11 @@ export interface AIProviderAuth {
|
|||||||
export interface AIModelCapabilities {
|
export interface AIModelCapabilities {
|
||||||
chat: boolean
|
chat: boolean
|
||||||
vision: boolean
|
vision: boolean
|
||||||
|
/**
|
||||||
|
* OCR 是 vision 能力的派生(几乎所有 vision 模型都能 OCR)。
|
||||||
|
* 不作为用户独立配置项,UI 上显示为"图片文字识别"标签。
|
||||||
|
*/
|
||||||
|
ocr: boolean
|
||||||
longContext: boolean
|
longContext: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +71,7 @@ export interface AIRuntimeModelConfig {
|
|||||||
modelName: string
|
modelName: string
|
||||||
configured: boolean
|
configured: boolean
|
||||||
status: AIProviderSummary['status']
|
status: AIProviderSummary['status']
|
||||||
|
timeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LegacyAIConfig {
|
export interface LegacyAIConfig {
|
||||||
@@ -77,6 +83,7 @@ export interface LegacyAIConfig {
|
|||||||
export interface AIChatRequestOptions {
|
export interface AIChatRequestOptions {
|
||||||
providerId?: string
|
providerId?: string
|
||||||
modelId?: string
|
modelId?: string
|
||||||
|
timeoutMs?: number
|
||||||
// Legacy compatibility only. New callers must use providerId/modelId.
|
// Legacy compatibility only. New callers must use providerId/modelId.
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
baseURL?: string
|
baseURL?: string
|
||||||
|
|||||||
+197
-4
@@ -1,4 +1,36 @@
|
|||||||
export type ReportHeat = '高' | '中' | '低'
|
export type ReportHeat = '高' | '中' | '低'
|
||||||
|
export type ReportMode = 'compact' | 'full'
|
||||||
|
export type ReportSectionKey =
|
||||||
|
| 'hero'
|
||||||
|
| 'topics'
|
||||||
|
| 'importantMessages'
|
||||||
|
| 'actions'
|
||||||
|
| 'moments'
|
||||||
|
| 'analytics'
|
||||||
|
| 'keywords'
|
||||||
|
| 'resources'
|
||||||
|
| 'qa'
|
||||||
|
| 'storylines'
|
||||||
|
| 'reversals'
|
||||||
|
| 'gallery'
|
||||||
|
| 'vision'
|
||||||
|
| 'voices'
|
||||||
|
| 'badges'
|
||||||
|
| 'chains'
|
||||||
|
|
||||||
|
export interface ReportSectionMeta {
|
||||||
|
enabled: boolean
|
||||||
|
importance: number
|
||||||
|
confidence: number
|
||||||
|
totalCount: number
|
||||||
|
displayedCount: number
|
||||||
|
hiddenCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTopicConclusion {
|
||||||
|
text: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportTopic {
|
export interface ReportTopic {
|
||||||
title: string
|
title: string
|
||||||
@@ -7,13 +39,25 @@ export interface ReportTopic {
|
|||||||
participants: string[]
|
participants: string[]
|
||||||
summary: string
|
summary: string
|
||||||
conclusion?: string
|
conclusion?: string
|
||||||
|
conclusions?: ReportTopicConclusion[]
|
||||||
keywords: string[]
|
keywords: string[]
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
image?: {
|
||||||
|
imageUrl?: string
|
||||||
|
note: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
/** AI 图片识别缓存 key(由 visionGallery 提供,main 渲染时按此取 base64) */
|
||||||
|
imageHash?: string
|
||||||
|
/** AI 真实识别的描述(来自 ImageInsight.description) */
|
||||||
|
aiDescription?: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportResource {
|
export interface ReportResource {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
sender?: string
|
sender?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportImportantMessage {
|
export interface ReportImportantMessage {
|
||||||
@@ -21,22 +65,30 @@ export interface ReportImportantMessage {
|
|||||||
time: string
|
time: string
|
||||||
content: string
|
content: string
|
||||||
note: string
|
note: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
importance?: number
|
||||||
|
confidence?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportQuoteMessage {
|
export interface ReportQuoteMessage {
|
||||||
sender: string
|
sender: string
|
||||||
content: string
|
content: string
|
||||||
|
sourceMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportQuote {
|
export interface ReportQuote {
|
||||||
messages: ReportQuoteMessage[]
|
messages: ReportQuoteMessage[]
|
||||||
note: string
|
note: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
importance?: number
|
||||||
|
confidence?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportQuestionAnswer {
|
export interface ReportQuestionAnswer {
|
||||||
question: string
|
question: string
|
||||||
answer: string
|
answer: string
|
||||||
answerer?: string
|
answerer?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportTopicHeat {
|
export interface ReportTopicHeat {
|
||||||
@@ -49,19 +101,157 @@ export interface ReportSpeakerRank {
|
|||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReportHero {
|
||||||
|
headline: string
|
||||||
|
summary: string
|
||||||
|
keyTakeaway?: string
|
||||||
|
pendingNote?: string
|
||||||
|
statusLine?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportMediaGalleryItem {
|
||||||
|
sender: string
|
||||||
|
time: string
|
||||||
|
imageUrl: string
|
||||||
|
note: string
|
||||||
|
stats?: string
|
||||||
|
inferenceLabel?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
replyCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片 AI 理解结果(由 ImageInsightService 提供)。
|
||||||
|
* 与 ReportMediaGalleryItem 不同:本类型不含 imageUrl(由 main 渲染时按需注入),
|
||||||
|
* description 是 AI 真实识别的结果(非基于上下文的推断)。
|
||||||
|
*/
|
||||||
|
export interface ReportVisionGalleryItem {
|
||||||
|
messageId: string
|
||||||
|
imageHash: string
|
||||||
|
sender: string
|
||||||
|
time: string
|
||||||
|
description: string
|
||||||
|
ocrText?: string
|
||||||
|
tags: string[]
|
||||||
|
category: 'screenshot' | 'photo' | 'meme' | 'document' | 'chart' | 'other'
|
||||||
|
importance: 'low' | 'medium' | 'high'
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
/** 预加载好的 dataURL,render 时直接嵌进 HTML */
|
||||||
|
imageUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportVoiceHighlight {
|
||||||
|
title: string
|
||||||
|
sender: string
|
||||||
|
note: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportVoiceLeaderboardItem {
|
||||||
|
sender: string
|
||||||
|
count: number
|
||||||
|
durationSec: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportFunBadge {
|
||||||
|
title: string
|
||||||
|
owner: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTodoItem {
|
||||||
|
task: string
|
||||||
|
owner?: string | null
|
||||||
|
deadline?: string | null
|
||||||
|
topic?: string | null
|
||||||
|
note?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
importance?: number
|
||||||
|
confidence?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportUnresolvedItem {
|
||||||
|
question: string
|
||||||
|
owner?: string
|
||||||
|
status: '待跟进' | '暂未回答' | '进行中'
|
||||||
|
note: string
|
||||||
|
lastDiscussedAt?: string | null
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
importance?: number
|
||||||
|
confidence?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportStorylineStage {
|
||||||
|
time: string
|
||||||
|
event: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportStoryline {
|
||||||
|
title: string
|
||||||
|
stages: ReportStorylineStage[]
|
||||||
|
result?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportReversal {
|
||||||
|
topic: string
|
||||||
|
initialView: string
|
||||||
|
finalView: string
|
||||||
|
note?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportParticipantChain {
|
||||||
|
topic: string
|
||||||
|
chain: string[]
|
||||||
|
note?: string
|
||||||
|
sourceMessageIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSummaryStats {
|
||||||
|
messageCount: number
|
||||||
|
activeUsers: number
|
||||||
|
topicCount: number
|
||||||
|
mediaCount: number
|
||||||
|
imageCount: number
|
||||||
|
voiceCount: number
|
||||||
|
stickerCount: number
|
||||||
|
conclusionCount: number
|
||||||
|
todoCount: number
|
||||||
|
unresolvedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupDailyReport {
|
export interface GroupDailyReport {
|
||||||
overview: string
|
overview: string
|
||||||
|
mode?: ReportMode
|
||||||
|
hero?: ReportHero
|
||||||
topics: ReportTopic[]
|
topics: ReportTopic[]
|
||||||
resources: ReportResource[]
|
resources: ReportResource[]
|
||||||
importantMessages: ReportImportantMessage[]
|
importantMessages: ReportImportantMessage[]
|
||||||
quotes: ReportQuote[]
|
quotes: ReportQuote[]
|
||||||
qa: ReportQuestionAnswer[]
|
qa: ReportQuestionAnswer[]
|
||||||
|
todos: ReportTodoItem[]
|
||||||
|
unresolved: ReportUnresolvedItem[]
|
||||||
|
storylines: ReportStoryline[]
|
||||||
|
reversals: ReportReversal[]
|
||||||
|
participantChains: ReportParticipantChain[]
|
||||||
analytics: {
|
analytics: {
|
||||||
topicHeat: ReportTopicHeat[]
|
topicHeat: ReportTopicHeat[]
|
||||||
activeTimeline: string
|
activeTimeline: string
|
||||||
topSpeakers: ReportSpeakerRank[]
|
topSpeakers: ReportSpeakerRank[]
|
||||||
|
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||||
}
|
}
|
||||||
keywords: string[]
|
keywords: string[]
|
||||||
|
media: {
|
||||||
|
gallery: ReportMediaGalleryItem[]
|
||||||
|
/** AI 识别的图片(由 ImageInsightService 提供),渲染时由 main 按 imageHash 取原图 */
|
||||||
|
visionGallery?: ReportVisionGalleryItem[]
|
||||||
|
voiceHighlights: ReportVoiceHighlight[]
|
||||||
|
funBadges: ReportFunBadge[]
|
||||||
|
}
|
||||||
|
summaryStats?: ReportSummaryStats
|
||||||
|
sectionMeta?: Partial<Record<ReportSectionKey, ReportSectionMeta>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupReportMetadata {
|
export interface GroupReportMetadata {
|
||||||
@@ -70,24 +260,27 @@ export interface GroupReportMetadata {
|
|||||||
dateRange: string
|
dateRange: string
|
||||||
messageCount: number
|
messageCount: number
|
||||||
activeUsers: number
|
activeUsers: number
|
||||||
|
imageCount?: number
|
||||||
|
voiceCount?: number
|
||||||
|
stickerCount?: number
|
||||||
|
mediaMessageCount?: number
|
||||||
timeSpan: string
|
timeSpan: string
|
||||||
generatedAt: string
|
generatedAt: string
|
||||||
recordNote: string
|
recordNote: string
|
||||||
footerNote: string
|
footerNote: string
|
||||||
heroParticipants: string[]
|
heroParticipants: string[]
|
||||||
avatars: Record<string, string | undefined>
|
avatars: Record<string, string | undefined>
|
||||||
// === 新增(可选,向后兼容) ===
|
reportMode?: ReportMode
|
||||||
/** 群昵称 / wxid / md5,服务端用来反推真头像(从 getGroupSnapshot) */
|
|
||||||
talker?: string
|
talker?: string
|
||||||
/** 预留,与 /api/v1/chatlog 的 time 参数同格式 */
|
|
||||||
timeRange?: string
|
timeRange?: string
|
||||||
/** 服务端写回,告知 client enrich 失败/部分缺失 */
|
|
||||||
warnings?: string[]
|
warnings?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupReportExportRequest {
|
export interface GroupReportExportRequest {
|
||||||
report: GroupDailyReport
|
report: GroupDailyReport
|
||||||
metadata: GroupReportMetadata
|
metadata: GroupReportMetadata
|
||||||
|
/** 模板 ID:'v1' 经典 / 'v2' 当前。缺省或未知值用默认(v2) */
|
||||||
|
templateId?: 'v1' | 'v2'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupReportExportResult {
|
export interface GroupReportExportResult {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// src/shared/image-insight.ts
|
||||||
|
// ImageInsight:微信图片的 AI 理解结果持久化数据结构
|
||||||
|
// 与 WechatExplorer 整体 AI 知识平台定位一致 — 图片理解结果可索引、可缓存、可复用。
|
||||||
|
|
||||||
|
export type ImageCategory =
|
||||||
|
| 'screenshot' // 截图
|
||||||
|
| 'photo' // 实拍照片
|
||||||
|
| 'meme' // 表情包
|
||||||
|
| 'document' // 文档/合同/票据
|
||||||
|
| 'chart' // 图表/数据
|
||||||
|
| 'other'
|
||||||
|
|
||||||
|
export type ImageImportance = 'low' | 'medium' | 'high'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单张微信图片的 AI 理解结果(持久化到 image-insights.json)
|
||||||
|
*
|
||||||
|
* imageHash 缓存 key 策略:
|
||||||
|
* 优先用微信原始 md5(已存在,不强制计算)
|
||||||
|
* 无 md5 才用 sha256(rawBytes).slice(0, 32)
|
||||||
|
*/
|
||||||
|
export interface ImageInsight {
|
||||||
|
id: string // UUID
|
||||||
|
messageId: string // 微信消息 ID(用于追溯)
|
||||||
|
imageHash: string // 缓存 key:微信 md5 优先,无 md5 才用 sha256
|
||||||
|
md5?: string // 微信图片 md5
|
||||||
|
datName?: string // .dat 文件名
|
||||||
|
|
||||||
|
/** AI 输出 */
|
||||||
|
description: string // 1-2 句中文描述
|
||||||
|
ocrText?: string // OCR 提取的文字(vision 模型一并返回)
|
||||||
|
tags: string[] // 关键词标签
|
||||||
|
category: ImageCategory
|
||||||
|
importance: ImageImportance
|
||||||
|
|
||||||
|
/** 元数据 */
|
||||||
|
provider: string // AI provider ID
|
||||||
|
model: string // AI model ID
|
||||||
|
createdAt: number // 首次分析时间戳
|
||||||
|
updatedAt: number // 最近更新时间
|
||||||
|
|
||||||
|
/** 关联消息信息 */
|
||||||
|
sender: string
|
||||||
|
sentAt: number
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片理解请求(main 进程内部使用)
|
||||||
|
*/
|
||||||
|
export interface ImageAnalysisRequest {
|
||||||
|
/** 必传,缓存 key */
|
||||||
|
imageHash: string
|
||||||
|
/** base64 dataURL,仅 main 内部使用 */
|
||||||
|
imageDataUrl: string
|
||||||
|
messageId: string
|
||||||
|
sender: string
|
||||||
|
sentAt: number
|
||||||
|
sessionId: string
|
||||||
|
/** 强制重新分析(忽略缓存) */
|
||||||
|
force?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片理解响应
|
||||||
|
*/
|
||||||
|
export interface ImageAnalysisResponse {
|
||||||
|
success: boolean
|
||||||
|
insight?: ImageInsight
|
||||||
|
/** 是否来自缓存 */
|
||||||
|
fromCache?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片候选(日报使用)
|
||||||
|
* 主进程根据消息列表计算热度,返回 Top N + 已缓存的 Insight
|
||||||
|
*/
|
||||||
|
export interface ImageCandidate {
|
||||||
|
messageId: string
|
||||||
|
imageHash: string
|
||||||
|
md5?: string
|
||||||
|
datName?: string
|
||||||
|
sessionId: string
|
||||||
|
sender: string
|
||||||
|
sentAt: number
|
||||||
|
/** 热度分数 */
|
||||||
|
heatScore: number
|
||||||
|
/** 命中缓存时附带 */
|
||||||
|
insight?: ImageInsight
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageCandidateQuery {
|
||||||
|
sessionId: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
/** 取 Top N,默认 3 */
|
||||||
|
limit?: number
|
||||||
|
/** 由 renderer 从已加载消息中提取的图片候选(包含热度信息) */
|
||||||
|
inputs?: Array<{
|
||||||
|
messageId: string
|
||||||
|
md5?: string
|
||||||
|
datName?: string
|
||||||
|
sessionId: string
|
||||||
|
sender: string
|
||||||
|
sentAt: number
|
||||||
|
responseCount: number
|
||||||
|
interactionCount: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user