feat: 完善群聊日报模板与图片理解

This commit is contained in:
Wxw-Gu
2026-07-15 16:29:25 +08:00
parent e150605c91
commit 515348b6d8
29 changed files with 3702 additions and 560 deletions
+1
View File
@@ -1,4 +1,5 @@
node_modules node_modules
*.tsbuildinfo
dist dist
out out
.env .env
+59
View File
@@ -495,6 +495,60 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; 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 { .badge-card {
background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%); background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
border: 1px solid #edf0f2; border: 1px solid #edf0f2;
@@ -742,6 +796,11 @@
{{REVERSALS_MORE_NOTE}} {{REVERSALS_MORE_NOTE}}
</section> </section>
<section class="section {{VISION_EMPTY_CLASS}}">
<div class="section-title">{{VISION_TITLE}}</div>
{{VISION_CARDS}}
</section>
<section class="section {{GALLERY_EMPTY_CLASS}}"> <section class="section {{GALLERY_EMPTY_CLASS}}">
<div class="section-title">今日群相册</div> <div class="section-title">今日群相册</div>
{{GALLERY_CARDS}} {{GALLERY_CARDS}}
+592
View File
@@ -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>
+829
View File
@@ -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>
+101
View File
@@ -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()
+91 -23
View File
@@ -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,13 +145,10 @@ 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 modeLabel = (mode: GroupReportMetadata['reportMode']): string =>
mode === 'full' ? '完整版' : '精简版'
const sectionMeta = ( const sectionMeta = (
request: GroupReportExportRequest, request: GroupReportExportRequest,
key: keyof NonNullable<typeof request.report.sectionMeta> key: keyof NonNullable<typeof request.report.sectionMeta>
) => request.report.sectionMeta?.[key] ): ReportSectionMeta | undefined => request.report.sectionMeta?.[key]
const sectionClass = ( const sectionClass = (
request: GroupReportExportRequest, request: GroupReportExportRequest,
@@ -205,8 +209,25 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
: '' : ''
} }
${ ${
topic.image?.imageUrl topic.image
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>` ? (() => {
// 优先用已有 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
@@ -325,6 +346,24 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
) )
.join('') .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 || []) const voiceCards = (report.media?.voiceHighlights || [])
.map( .map(
(item) => `<div class="qa-card"> (item) => `<div class="qa-card">
@@ -362,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(
@@ -390,14 +443,16 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
unresolvedCount: report.unresolved.length unresolvedCount: report.unresolved.length
} }
let html = await fs.readFile(templatePath(), 'utf8') 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', 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(metadata.recordNote), RECORD_NOTE: escapeHtml(metadata.recordNote),
REPORT_MODE_LABEL: escapeHtml(modeLabel(metadata.reportMode)), // v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
// v2 模板使用的 hero-*
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'), HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview), HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
HERO_TAKEAWAY: escapeHtml(report.hero?.keyTakeaway || ''), HERO_TAKEAWAY: escapeHtml(report.hero?.keyTakeaway || ''),
@@ -442,6 +497,14 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
CHAINS_EMPTY_CLASS: sectionClass(request, 'chains', report.participantChains?.length > 0), CHAINS_EMPTY_CLASS: sectionClass(request, 'chains', report.participantChains?.length > 0),
CHAIN_CARDS: chainCards, CHAIN_CARDS: chainCards,
CHAINS_MORE_NOTE: overflowNote(request, 'chains'), 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_EMPTY_CLASS: sectionClass(request, 'gallery', report.media?.gallery?.length > 0),
GALLERY_CARDS: galleryCards, GALLERY_CARDS: galleryCards,
GALLERY_MORE_NOTE: overflowNote(request, 'gallery'), GALLERY_MORE_NOTE: overflowNote(request, 'gallery'),
@@ -463,9 +526,13 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
KEYWORDS_MORE_NOTE: overflowNote(request, 'keywords'), KEYWORDS_MORE_NOTE: overflowNote(request, 'keywords'),
ANALYTICS_EMPTY_CLASS: sectionClass(request, 'analytics', true), 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
} }
@@ -520,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}_${request.metadata.reportMode === 'full' ? '完整版' : '精简版'}` 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()
+36 -1
View File
@@ -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
View File
@@ -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())
+94 -8
View File
@@ -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
model.capabilities.vision = true // 老配置可能没有 ocr 字段,补默认 false
this.writeMetadata(data) if (typeof model.capabilities.ocr !== 'boolean') model.capabilities.ocr = false
let changed = false
if (caps.vision === true && !model.capabilities.vision) {
model.capabilities.vision = true
// vision 开启默认带 ocr(派生能力)
if (!model.capabilities.ocr) {
model.capabilities.ocr = true
}
changed = true
}
if (caps.ocr === true && !model.capabilities.ocr) {
model.capabilities.ocr = true
changed = true
}
if (changed) this.writeMetadata(data)
} }
private ensureEnvironmentMigration(): void { 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)
+11 -1
View File
@@ -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 || ''
})) }))
+94
View File
@@ -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 }
}
+279
View File
@@ -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()
+24 -8
View File
@@ -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 {
+29 -1
View File
@@ -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
View File
@@ -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) {
+14 -1
View File
@@ -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}
+219
View File
@@ -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;
}
+131 -446
View File
@@ -1,124 +1,111 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Message, Contact } from '../../../shared/types' import { Message, Contact } from '../../../shared/types'
import { VoicePlayer } from './VoicePlayer' import { ChatHeader } from './chat/ChatHeader'
import { RichMessageBubble } from './RichMessageBubble' import { ChatStatusBar } from './chat/ChatStatusBar'
import { ImageBubble } from './ImageBubble' import { DataTrustBar } from './chat/DataTrustBar'
import { import { EmptyConversationState } from './chat/EmptyConversationState'
buildGroupReportInput, import { ExportRange } from './chat/ExportMenu'
GROUP_REPORT_SYSTEM_PROMPT, import { MessageList } from './chat/MessageList'
parseGroupDailyReport
} from '../utils/group-report'
import type { ReportMode } from '../../../shared/group-report'
interface ChatWindowProps { interface ChatWindowProps {
contact: Contact | null contact: Contact | null
messages: Message[] messages: Message[]
isLoadingMessages?: boolean
contentFilter?: string contentFilter?: string
dateRange?: string
onContentFilterChange?: (keyword: string) => void
onRefresh?: () => void onRefresh?: () => void
onRefreshData?: () => void onRefreshData?: () => void
onCreateGroupReport?: () => void
isAiLoading?: boolean
} }
type SummaryDateRange = 'today' | 'yesterday' | '7days' const MAX_RENDERED_MESSAGES = 600
type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system' const DATE_RANGE_LABELS: Record<string, string> = {
today: '今天',
yesterday: '昨日',
'7': '7 天',
'30': '30 天',
all: '全部'
}
const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [ const formatClock = (date: Date): string =>
{ value: 'today', label: '今天' }, `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
{ value: 'yesterday', label: '昨日' },
{ value: '7days', label: '最近 7 天' }
]
const SUMMARY_TYPE_OPTIONS: { const formatRangeDate = (date: Date, now: Date): string => {
value: SummaryMessageType const clock = formatClock(date)
label: string if (date.getFullYear() === now.getFullYear()) {
messageTypes: string[] return `${date.getMonth() + 1}${date.getDate()}${clock}`
}[] = [ }
{ value: 'text', label: '文本', messageTypes: ['普通文本'] }, return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${clock}`
{ value: 'image', label: '图片', messageTypes: ['图片'] }, }
{ value: 'sticker', label: '表情包', messageTypes: ['表情包'] },
{ value: 'video', label: '视频', messageTypes: ['视频'] },
{ value: 'voice', label: '语音', messageTypes: ['语音'] },
{ value: 'share', label: '分享/引用', messageTypes: ['分享消息', '名片', '位置', '通话'] },
{ value: 'system', label: '系统消息', messageTypes: ['系统消息'] }
]
const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endTime: number } => { const getChatHeaderRangeLabel = (range: string): string => {
const now = new Date() const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000 const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const endTime = Math.floor(Date.now() / 1000) const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
if (range === 'yesterday') {
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 } if (range === 'today') return `今天 00:00—现在`
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
if (range === '7') {
const start = new Date(Date.now() - 7 * 86400000)
return `${formatRangeDate(start, now)}—现在`
} }
if (range === '7days') { if (range === '30') {
return { startTime: startOfToday - 6 * 86400, endTime } const start = new Date(Date.now() - 30 * 86400000)
return `${formatRangeDate(start, now)}—现在`
} }
return { startTime: startOfToday, endTime } if (range === 'all') return '全部记录'
return DATE_RANGE_LABELS[range] || '当前范围'
} }
const ChatWindow: React.FC<ChatWindowProps> = ({ const ChatWindow: React.FC<ChatWindowProps> = ({
contact, contact,
messages, messages,
isLoadingMessages,
contentFilter, contentFilter,
dateRange = 'today',
onContentFilterChange,
onRefresh, onRefresh,
onRefreshData onRefreshData,
onCreateGroupReport,
isAiLoading = false
}) => { }) => {
const isGroupChat = Boolean( const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom') contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
) )
const messageListRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null)
const [previewImage, setPreviewImage] = useState<string | null>(null) const [previewImage, setPreviewImage] = useState<string | null>(null)
const [imageScale, setImageScale] = useState(0.75) const [imageScale, setImageScale] = useState(0.75)
const [imageRotation, setImageRotation] = useState(0) const [imageRotation, setImageRotation] = useState(0)
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 }) const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
const imageViewerStageRef = useRef<HTMLDivElement>(null)
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>( const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null null
) )
const [showAvatar, setShowAvatar] = useState(true) const [showAvatar, setShowAvatar] = useState(true)
const [isAtLatest, setIsAtLatest] = useState(true)
// AI Settings const scrollToBottom = useCallback((): void => {
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [apiKey, setApiKey] = useState(() => localStorage.getItem('ai_api_key') || '')
const [baseURL, setBaseURL] = useState(
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
)
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-v4-flash')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const [reportMode, setReportMode] = useState<ReportMode>(
() => (localStorage.getItem('group_report_mode') as ReportMode) || 'compact'
)
const handleSaveSettings = (): void => {
if (!summaryMessageTypes.length) {
alert('请至少选择一种消息类型')
return
}
localStorage.setItem('ai_api_key', apiKey)
localStorage.setItem('ai_base_url', baseURL)
localStorage.setItem('ai_model', model)
localStorage.setItem('group_report_mode', reportMode)
setShowSettingsModal(false)
AIChat()
}
const toggleSummaryMessageType = (type: SummaryMessageType): void => {
setSummaryMessageTypes((current) =>
current.includes(type) ? current.filter((item) => item !== type) : [...current, type]
)
}
const scrollToBottom = (): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }) messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
} setIsAtLatest(true)
}, [])
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
const target = event.currentTarget
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
setIsAtLatest(distanceToBottom <= 24)
}, [])
useEffect(() => { useEffect(() => {
scrollToBottom() const frame = window.requestAnimationFrame(() => scrollToBottom())
}, [messages]) return () => window.cancelAnimationFrame(frame)
}, [messages, scrollToBottom])
const openImagePreview = (imageUrl: string): void => { const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl) setPreviewImage(imageUrl)
setImageScale(0.75) setImageScale(1)
setImageRotation(0) setImageRotation(0)
setImageOffset({ x: 0, y: 0 }) setImageOffset({ x: 0, y: 0 })
} }
@@ -129,17 +116,18 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
} }
const zoomImage = (delta: number): void => { const zoomImage = (delta: number): void => {
setImageScale((prev) => Math.min(3, Math.max(0.25, Number((prev + delta).toFixed(2))))) setImageScale((prev) => Math.min(8, Math.max(0.1, Number((prev + delta).toFixed(2)))))
} }
const resetImageTransform = (): void => { const resetImageTransform = (): void => {
setImageScale(0.75) setImageScale(1)
setImageRotation(0) setImageRotation(0)
setImageOffset({ x: 0, y: 0 }) setImageOffset({ x: 0, y: 0 })
} }
const handleViewerWheel = (event: React.WheelEvent): void => { const handleViewerWheel = (event: React.WheelEvent): void => {
event.preventDefault() event.preventDefault()
event.stopPropagation()
zoomImage(event.deltaY > 0 ? -0.1 : 0.1) zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
} }
@@ -166,7 +154,25 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
imageDragRef.current = null imageDragRef.current = null
} }
const handleExport = (days: number | 'all'): void => { useEffect(() => {
if (!previewImage) return
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const stage = imageViewerStageRef.current
const preventBackgroundWheel = (event: WheelEvent): void => {
event.preventDefault()
}
stage?.addEventListener('wheel', preventBackgroundWheel, { passive: false })
return () => {
document.body.style.overflow = previousOverflow
stage?.removeEventListener('wheel', preventBackgroundWheel)
}
}, [previewImage])
const handleExport = (days: ExportRange): void => {
if (!messages.length) return if (!messages.length) return
let filtered = messages let filtered = messages
@@ -225,67 +231,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
document.body.removeChild(link) document.body.removeChild(link)
} }
const [isLoading, setIsLoading] = useState(false)
const AIChat = async (): Promise<void> => {
if (!contact) return
if (!summaryMessageTypes.length) {
alert('请至少选择一种消息类型')
return
}
setIsLoading(true)
try {
const { startTime, endTime } = getSummaryDateRange(summaryDateRange)
const rangeMessages = await window.api.getMessages(contact.md5, startTime, endTime)
const allowedTypes = new Set(
SUMMARY_TYPE_OPTIONS.filter((option) => summaryMessageTypes.includes(option.value)).flatMap(
(option) => option.messageTypes
)
)
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
const input = await buildGroupReportInput(reportMessages, contact, isGroupChat, reportMode)
console.log('🚀 ~ AIChat ~ input:', input)
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
const result = await window.api.aiChat(
[
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
],
{ apiKey, model, baseURL }
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
const report = parseGroupDailyReport(
result.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard,
input.metadata,
input.media
)
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
throw new Error(exported.error || '日报文件生成失败')
}
setGeneratedImage(exported.imageDataUrl)
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
} catch (error) {
console.error('AI Call Failed:', error)
alert(`AI 日报生成失败:${error instanceof Error ? error.message : String(error)}`)
} finally {
setIsLoading(false)
}
}
const handleCopyImage = async (): Promise<void> => {
if (!generatedImage) return
const result = await window.api.copyImage(generatedImage)
if (result.success) {
alert('复制成功')
}
}
const filteredMessages = React.useMemo(() => { const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => { return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '') const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
@@ -297,194 +242,53 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
return typeMatch && contentMatch return typeMatch && contentMatch
}) })
}, [messages, contentFilter]) }, [messages, contentFilter])
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
const renderedMessages = React.useMemo(
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
[filteredMessages]
)
if (!contact) { if (!contact) return <EmptyConversationState />
return (
<div className="chat-window"> const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
<div className="empty-state"></div>
</div>
)
}
return ( return (
<div className="chat-window"> <div className="chat-window">
<div className="chat-header"> <ChatHeader
<h2>{contact.m_nsNickName}</h2> contact={contact}
<div className="window-controls"></div> isGroupChat={isGroupChat}
</div> dateRangeLabel={dateRangeLabel}
loadedCount={messages.length}
<div className="message-list wechat-message-list"> filteredCount={filteredMessages.length}
{filteredMessages.map((msg) => { contentFilter={contentFilter || ''}
const isMine = msg.from === 'assistant' isAiLoading={isAiLoading}
const isSystem = msg.from === 'system' || msg.type === '系统消息' canExport={messages.length > 0}
const displayName = isMine onContentFilterChange={onContentFilterChange || (() => undefined)}
? '我' onRefresh={onRefresh}
: isGroupChat onRefreshData={onRefreshData}
? msg.name || msg.from onExport={handleExport}
: contact.m_nsNickName onOpenAiSettings={onCreateGroupReport || (() => undefined)}
const avatarSrc = isMine ? msg.img : msg.img || contact.avatar />
const isVoice = msg.type === '语音' <DataTrustBar messageCount={messages.length} />
const isImage = msg.type === '图片' <MessageList
const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'].includes( contact={contact}
msg.type messages={renderedMessages}
) hiddenMessageCount={hiddenMessageCount}
isLoadingMessages={isLoadingMessages}
if (isSystem) { isGroupChat={isGroupChat}
return ( showAvatar={showAvatar}
<div key={msg.id} className="wechat-system-message-row"> listRef={messageListRef}
<div className="wechat-system-message">{msg.content}</div> bottomRef={messagesEndRef}
<div className="wechat-system-message-meta">{msg.datetime}</div> onScroll={handleMessageListScroll}
</div> onImageClick={openImagePreview}
) />
} <ChatStatusBar
count={renderedMessages.length}
return ( showAvatar={showAvatar}
<div key={msg.id} className={`wechat-message-row ${isMine ? 'mine' : 'other'}`}> isAtLatest={isAtLatest}
{!isMine && showAvatar && ( onShowAvatarChange={setShowAvatar}
<div className="message-avatar"> onJumpToLatest={scrollToBottom}
{avatarSrc ? ( />
<img src={avatarSrc} alt={displayName} referrerPolicy="no-referrer" />
) : (
(displayName || '?').charAt(0)
)}
</div>
)}
<div className="message-stack">
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
<div
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${isImage ? 'image-message-bubble' : ''}`}
>
{isVoice && msg.sessionId ? (
<VoicePlayer
sessionId={msg.sessionId}
localId={msg.localId || 0}
createTime={msg.createTime || 0}
/>
) : isImage && msg.contentData && msg.contentData.type === 'image' ? (
<ImageBubble
imageMd5={msg.contentData.md5}
imageDatName={msg.contentData.datName}
sessionId={msg.sessionId}
onImageClick={openImagePreview}
/>
) : isRichMedia && msg.contentData ? (
<RichMessageBubble contentData={msg.contentData} />
) : (
<div className="message-text">{msg.content}</div>
)}
</div>
<div className="message-meta">
<span>{msg.datetime}</span>
<span>{msg.type}</span>
</div>
</div>
{isMine && showAvatar && (
<div className="message-avatar mine-avatar">
{avatarSrc ? <img src={avatarSrc} alt="我" referrerPolicy="no-referrer" /> : '我'}
</div>
)}
</div>
)
})}
<div ref={messagesEndRef} />
</div>
<div className="chat-toolbar">
<label
style={{ marginRight: '10px', display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<input
type="checkbox"
checked={showAvatar}
onChange={(e) => setShowAvatar(e.target.checked)}
style={{ marginRight: '5px' }}
/>
</label>
<button className="toolbar-btn" onClick={onRefresh}>
🔄
</button>
<button className="toolbar-btn" onClick={onRefreshData}>
🔄
</button>
<button className="toolbar-btn" onClick={() => handleExport('all')}>
📤
</button>
<button className="toolbar-btn" onClick={() => handleExport(0)}>
🕒
</button>
<button className="toolbar-btn" onClick={() => handleExport(1)}>
📅
</button>
<button className="toolbar-btn" onClick={() => handleExport(7)}>
📅 7
</button>
<button className="toolbar-btn" onClick={() => handleExport(30)}>
📅 30
</button>
<button className="toolbar-btn" onClick={() => setShowSettingsModal(true)}>
🤖 AI总结群聊
</button>
</div>
{/* 加载模态框 */}
{isLoading && (
<div className="modal-overlay">
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
<div style={{ fontSize: '16px', color: '#333' }}>...</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
HTML
</div>
</div>
</div>
)}
{/* 图片预览模态框 */}
{generatedImage && (
<div className="modal-overlay" onClick={() => setGeneratedImage(null)}>
<div className="modal-content image-preview-modal" onClick={(e) => e.stopPropagation()}>
<div className="report-preview-frame">
<div className="report-preview-scroller">
<img
src={generatedImage}
alt="Generated Summary"
className="report-preview-image"
/>
</div>
</div>
<div className="report-preview-actions">
<button
onClick={handleCopyImage}
style={{
padding: '8px 15px',
cursor: 'pointer',
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '4px'
}}
>
📋
</button>
{reportPaths && (
<button
onClick={() => window.api.revealGroupReport(reportPaths.pngPath)}
style={{ padding: '5px 10px', cursor: 'pointer' }}
>
</button>
)}
<button
onClick={() => setGeneratedImage(null)}
style={{ padding: '5px 10px', cursor: 'pointer' }}
>
</button>
</div>
</div>
</div>
)}
{previewImage && ( {previewImage && (
<div className="image-viewer-overlay" onClick={closeImagePreview}> <div className="image-viewer-overlay" onClick={closeImagePreview}>
@@ -515,6 +319,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</button> </button>
</div> </div>
<div <div
ref={imageViewerStageRef}
className="image-viewer-stage" className="image-viewer-stage"
onWheel={handleViewerWheel} onWheel={handleViewerWheel}
onMouseDown={handleViewerMouseDown} onMouseDown={handleViewerMouseDown}
@@ -534,126 +339,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</div> </div>
</div> </div>
)} )}
{/* AI Settings Modal */}
{showSettingsModal && (
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
<div className="modal-content ai-settings-modal" onClick={(e) => e.stopPropagation()}>
<h3>AI </h3>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-date-options">
{SUMMARY_DATE_OPTIONS.map((option) => (
<label
key={option.value}
className={summaryDateRange === option.value ? 'selected' : ''}
>
<input
type="radio"
name="summary-date-range"
value={option.value}
checked={summaryDateRange === option.value}
onChange={() => setSummaryDateRange(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-date-options">
<label className={reportMode === 'compact' ? 'selected' : ''}>
<input
type="radio"
name="report-mode"
value="compact"
checked={reportMode === 'compact'}
onChange={() => setReportMode('compact')}
/>
</label>
<label className={reportMode === 'full' ? 'selected' : ''}>
<input
type="radio"
name="report-mode"
value="full"
checked={reportMode === 'full'}
onChange={() => setReportMode('full')}
/>
</label>
</div>
</div>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-type-options">
{SUMMARY_TYPE_OPTIONS.map((option) => (
<label key={option.value}>
<input
type="checkbox"
checked={summaryMessageTypes.includes(option.value)}
onChange={() => toggleSummaryMessageType(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>:</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
style={{ width: '100%', padding: '8px' }}
>
<option value="deepseek-v4-pro">DeepSeek V4 Pro</option>
<option value="deepseek-v4-flash">DeepSeek V4 Flash</option>
<option value="gpt-4o">GPT-4o</option>
<option value="gpt-4o-mini">GPT-4o Mini</option>
<option value="gpt-4-turbo">GPT-4 Turbo</option>
<option value="claude-3-5-sonnet-20240620">Claude 3.5 Sonnet</option>
<option value="moonshot-v1-8k">Moonshot V1</option>
</select>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>Base URL:</label>
<input
type="text"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
placeholder="https://api.deepseek.com"
style={{ width: '95%', padding: '8px' }}
/>
</div>
<div className="form-group" style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>API Key:</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter your API Key"
style={{ width: '95%', padding: '8px' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button onClick={() => setShowSettingsModal(false)}></button>
<button
onClick={handleSaveSettings}
style={{
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
padding: '8px 15px',
borderRadius: '4px'
}}
>
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }
@@ -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
} }
} }
+182 -8
View File
@@ -6,6 +6,7 @@ import {
ReportMediaGalleryItem, ReportMediaGalleryItem,
ReportMode, ReportMode,
ReportSpeakerRank, ReportSpeakerRank,
ReportVisionGalleryItem,
ReportVoiceHighlight, ReportVoiceHighlight,
ReportVoiceLeaderboardItem ReportVoiceLeaderboardItem
} from '../../../shared/group-report' } from '../../../shared/group-report'
@@ -32,7 +33,11 @@ export interface GroupReportFactsSnapshot {
export const isInternalIdentifier = (value: string): boolean => export const isInternalIdentifier = (value: string): boolean =>
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value) /@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 => { export const summarySender = (
message: Message,
contact: Contact | null,
isGroup: boolean
): string => {
if (message.from === 'assistant') { if (message.from === 'assistant') {
const ownGroupNickname = message.name?.trim() const ownGroupNickname = message.name?.trim()
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) { if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
@@ -176,7 +181,9 @@ const buildMediaSection = async (
): Promise<{ ): Promise<{
media: GroupDailyReport['media'] media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[] voiceLeaderboard: ReportVoiceLeaderboardItem[]
warnings: string[]
}> => { }> => {
const warnings: string[] = []
const rawImageCandidates = messages const rawImageCandidates = messages
.map((message, index) => { .map((message, index) => {
if (message.contentData?.type !== 'image') return null if (message.contentData?.type !== 'image') return null
@@ -192,6 +199,7 @@ const buildMediaSection = async (
note: context.note, note: context.note,
stats: context.stats, stats: context.stats,
replyCount: context.responseCount, replyCount: context.responseCount,
participantCount: context.participantCount,
score: context.responseCount * 3 + context.participantCount * 2 + 1 score: context.responseCount * 3 + context.participantCount * 2 + 1
} }
}) })
@@ -199,6 +207,122 @@ const buildMediaSection = async (
.sort((left, right) => right.score - left.score) .sort((left, right) => right.score - left.score)
.slice(0, 6) .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( const imageCandidates = await Promise.all(
rawImageCandidates.map(async (item) => { rawImageCandidates.map(async (item) => {
const result = await window.api.getImage(item.md5, item.datName, item.sessionId) const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
@@ -221,7 +345,16 @@ const buildMediaSection = async (
.filter((item): item is NonNullable<typeof item> => Boolean(item)) .filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((left, right) => right.score - left.score) .sort((left, right) => right.score - left.score)
.slice(0, 4) .slice(0, 4)
.map(({ score: _score, ...item }) => item) .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 const voiceMessages = messages
.filter((message) => message.contentData?.type === 'voice') .filter((message) => message.contentData?.type === 'voice')
@@ -286,7 +419,9 @@ const buildMediaSection = async (
} }
const funBadges: ReportFunBadge[] = [] const funBadges: ReportFunBadge[] = []
const topSpeaker = Array.from(topSpeakersMap.entries()).sort((left, right) => right[1] - left[1])[0] const topSpeaker = Array.from(topSpeakersMap.entries()).sort(
(left, right) => right[1] - left[1]
)[0]
if (topSpeaker) { if (topSpeaker) {
funBadges.push({ funBadges.push({
title: '高能输出王', title: '高能输出王',
@@ -312,10 +447,12 @@ const buildMediaSection = async (
return { return {
media: { media: {
gallery, gallery,
visionGallery,
voiceHighlights: voiceHighlights.slice(0, 2), voiceHighlights: voiceHighlights.slice(0, 2),
funBadges: funBadges.slice(0, 3) funBadges: funBadges.slice(0, 3)
}, },
voiceLeaderboard voiceLeaderboard,
warnings
} }
} }
@@ -330,11 +467,18 @@ const collectQuestionCandidates = (
sender: summarySender(message, contact, isGroup), sender: summarySender(message, contact, isGroup),
content: summaryContent(message) content: summaryContent(message)
})) }))
.filter((item) => /[?]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么')) .filter(
(item) =>
/[?]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么')
)
.slice(-6) .slice(-6)
.map((item) => `${item.sender}${item.id}):${truncate(item.content, 32)}`) .map((item) => `${item.sender}${item.id}):${truncate(item.content, 32)}`)
const collectReplyFacts = (messages: Message[], contact: Contact | null, isGroup: boolean): string[] => const collectReplyFacts = (
messages: Message[],
contact: Contact | null,
isGroup: boolean
): string[] =>
messages messages
.filter((message) => message.contentData?.type === 'quote' && message.contentData.quotedSender) .filter((message) => message.contentData?.type === 'quote' && message.contentData.quotedSender)
.slice(0, 10) .slice(0, 10)
@@ -446,14 +590,35 @@ export const buildGroupReportFacts = async (
mediaMessageCount: imageCount + voiceCount + stickerCount, mediaMessageCount: imageCount + voiceCount + stickerCount,
timeSpan, timeSpan,
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }), generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
recordNote: `基于当前已加载的 ${transcriptRows.length} 条记录`, recordNote: `基于 WechatExplorer 已加载的 ${transcriptRows.length} 条记录`,
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。', footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。',
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name), heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
avatars, avatars,
reportMode reportMode
} }
const { media, voiceLeaderboard } = await buildMediaSection(messages, contact, isGroup, speakerCounts) 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 = [ const factsPrompt = [
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`, `报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
@@ -462,6 +627,15 @@ export const buildGroupReportFacts = async (
media.gallery.length media.gallery.length
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats}`).join('')}` ? `图片观察:${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.length
? `语音榜:${voiceLeaderboard ? `语音榜:${voiceLeaderboard
.slice(0, 3) .slice(0, 3)
+274 -36
View File
@@ -127,7 +127,15 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
maxQa: 0, maxQa: 0,
topicSummaryLength: 100, topicSummaryLength: 100,
noteLength: 72, noteLength: 72,
enabledSections: ['hero', 'topics', 'importantMessages', 'actions', 'moments', 'analytics', 'keywords'] enabledSections: [
'hero',
'topics',
'importantMessages',
'actions',
'moments',
'analytics',
'keywords'
]
}, },
full: { full: {
maxTopics: 6, maxTopics: 6,
@@ -159,6 +167,7 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
'qa', 'qa',
'storylines', 'storylines',
'reversals', 'reversals',
'vision',
'gallery', 'gallery',
'voices', 'voices',
'badges', 'badges',
@@ -174,6 +183,14 @@ export const buildGroupReportInput = async (
reportMode: ReportMode reportMode: ReportMode
): Promise<GroupReportInput> => { ): Promise<GroupReportInput> => {
const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode) const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode)
const desiredTopicCount =
facts.metadata.messageCount >= 1000
? '建议提炼 6-8 个互不重复的主要话题'
: facts.metadata.messageCount >= 500
? '建议提炼 5-6 个互不重复的主要话题'
: facts.metadata.messageCount >= 200
? '建议提炼 4-5 个互不重复的主要话题'
: '按实际内容提炼 1-4 个主要话题,不要为了凑数编造'
const transcript = facts.transcriptRows const transcript = facts.transcriptRows
.map((row) => `[${row.id}] ${row.datetime} ${row.sender}${row.content}`) .map((row) => `[${row.id}] ${row.datetime} ${row.sender}${row.content}`)
.join('\n') .join('\n')
@@ -185,6 +202,7 @@ export const buildGroupReportInput = async (
${facts.metadata.messageCount} ${facts.metadata.messageCount}
${facts.metadata.activeUsers} ${facts.metadata.activeUsers}
${REPORT_MODE_LABEL[reportMode]} ${REPORT_MODE_LABEL[reportMode]}
${desiredTopicCount}
@@ -372,7 +390,8 @@ const parseUnresolved = (root: Record<string, unknown>): ReportUnresolvedItem[]
owner: item.owner ? normalizeName(item.owner) : undefined, owner: item.owner ? normalizeName(item.owner) : undefined,
status: normalizedStatus, status: normalizedStatus,
note: asString(item.note), note: asString(item.note),
lastDiscussedAt: item.lastDiscussedAt === null ? null : asNullableString(item.lastDiscussedAt), lastDiscussedAt:
item.lastDiscussedAt === null ? null : asNullableString(item.lastDiscussedAt),
sourceMessageIds: asStrings(item.sourceMessageIds, 8), sourceMessageIds: asStrings(item.sourceMessageIds, 8),
importance: asNumber(item.importance), importance: asNumber(item.importance),
confidence: asNumber(item.confidence) confidence: asNumber(item.confidence)
@@ -429,7 +448,8 @@ const parseParticipantChains = (root: Record<string, unknown>): ReportParticipan
}) })
.filter((item) => item.topic && item.chain.length) .filter((item) => item.topic && item.chain.length)
const scoreByHeat = (heat: ReportHeat): number => (heat === '高' ? 0.95 : heat === '中' ? 0.75 : 0.55) const scoreByHeat = (heat: ReportHeat): number =>
heat === '高' ? 0.95 : heat === '中' ? 0.75 : 0.55
const scoreByCount = (count: number, max = 5): number => Math.min(1, Math.max(0.3, count / max)) const scoreByCount = (count: number, max = 5): number => Math.min(1, Math.max(0.3, count / max))
@@ -483,24 +503,60 @@ const clampTopics = (topics: ReportTopic[], config: ReportModeConfig): ReportTop
keywords: topic.keywords.slice(0, 3) keywords: topic.keywords.slice(0, 3)
})) }))
const topicLimitForMessageVolume = (
config: ReportModeConfig,
messageCount: number
): ReportModeConfig => {
if (messageCount >= 1000) return { ...config, maxTopics: Math.max(config.maxTopics, 8) }
if (messageCount >= 500) return { ...config, maxTopics: Math.max(config.maxTopics, 6) }
if (messageCount >= 200) return { ...config, maxTopics: Math.max(config.maxTopics, 5) }
if (messageCount >= 80) return { ...config, maxTopics: Math.max(config.maxTopics, 4) }
return config
}
const attachHighImpactImage = ( const attachHighImpactImage = (
topics: ReportTopic[], topics: ReportTopic[],
gallery: GroupDailyReport['media']['gallery'] gallery: GroupDailyReport['media']['gallery'],
visionGallery?: GroupDailyReport['media']['visionGallery']
): ReportTopic[] => { ): ReportTopic[] => {
if (!gallery.length) return topics if (!gallery.length && !visionGallery?.length) return topics
// 优先用 visionGallery(AI 真实识别的 description)
const firstVision = visionGallery?.find((it) => it.importance !== 'low')
const [firstImage, ...rest] = gallery const [firstImage, ...rest] = gallery
const nextTopics = topics.map((topic, index) => const nextTopics = topics.map((topic, index) => {
index === 0 && firstImage.replyCount && firstImage.replyCount >= 3 if (index !== 0) return topic
? { if (firstVision) {
...topic, // AI 真实识别路径:note 直接用 description,不带"根据推断"前缀
image: { const noteParts = [firstVision.description]
imageUrl: firstImage.imageUrl, if (firstVision.ocrText) noteParts.push(`文字:${firstVision.ocrText}`)
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`, if (firstVision.tags.length) noteParts.push(`标签:${firstVision.tags.join('/')}`)
sourceMessageIds: firstImage.sourceMessageIds return {
} ...topic,
image: {
note: noteParts.join(' · '),
sourceMessageIds: firstVision.sourceMessageIds
// 注意:不填 imageUrl,因为 unknown imageHash 等问题可能导致 main 取不到原图,
// 让 renderer 在 buildGroupReportFacts 阶段就把 dataUrl 预先加载好塞到 gallery 里,
// 这里走 gallery 路径自然带 imageUrl
} }
: topic }
) }
if (firstImage?.replyCount && firstImage.replyCount >= 3) {
return {
...topic,
image: {
imageUrl: firstImage.imageUrl,
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`,
sourceMessageIds: firstImage.sourceMessageIds
}
}
}
return topic
})
if (firstVision) {
// visionGallery 用过的不再展示
return nextTopics
}
gallery.splice(0, rest.length >= 0 ? 1 : 0) gallery.splice(0, rest.length >= 0 ? 1 : 0)
return nextTopics return nextTopics
} }
@@ -510,7 +566,7 @@ const postProcessReport = (
mode: ReportMode, mode: ReportMode,
metadata: GroupReportMetadata metadata: GroupReportMetadata
): GroupDailyReport => { ): GroupDailyReport => {
const config = REPORT_MODE_CONFIG[mode] const config = topicLimitForMessageVolume(REPORT_MODE_CONFIG[mode], metadata.messageCount)
const topicsScored = sortByScore(report.topics, (item) => scoreByHeat(item.heat)) const topicsScored = sortByScore(report.topics, (item) => scoreByHeat(item.heat))
const topicsDeduped = dedupeItems( const topicsDeduped = dedupeItems(
@@ -518,8 +574,12 @@ const postProcessReport = (
(item) => item.sourceMessageIds || [], (item) => item.sourceMessageIds || [],
(item) => createSignature(item.title, item.summary) (item) => createSignature(item.title, item.summary)
) )
let gallery = [...report.media.gallery] const gallery = [...report.media.gallery]
const topics = attachHighImpactImage(clampTopics(topicsDeduped, config), gallery) const topics = attachHighImpactImage(
clampTopics(topicsDeduped, config),
gallery,
report.media.visionGallery
)
const importantMessagesRaw = sortByScore(report.importantMessages, (item) => const importantMessagesRaw = sortByScore(report.importantMessages, (item) =>
Math.max(item.importance || 0, item.confidence || 0.6) Math.max(item.importance || 0, item.confidence || 0.6)
@@ -567,11 +627,16 @@ const postProcessReport = (
})) }))
const quotesRaw = sortByScore(report.quotes, (item) => const quotesRaw = sortByScore(report.quotes, (item) =>
Math.max(item.importance || 0.55, item.confidence || 0.55, scoreByCount(item.messages.length, 4)) Math.max(
item.importance || 0.55,
item.confidence || 0.55,
scoreByCount(item.messages.length, 4)
)
) )
const quotes = dedupeItems( const quotes = dedupeItems(
quotesRaw, quotesRaw,
(item) => item.sourceMessageIds || item.messages.map((message) => message.sourceMessageId || ''), (item) =>
item.sourceMessageIds || item.messages.map((message) => message.sourceMessageId || ''),
(item) => createSignature(item.note, item.messages.map((message) => message.content).join('|')) (item) => createSignature(item.note, item.messages.map((message) => message.content).join('|'))
) )
.slice(0, config.maxQuotes) .slice(0, config.maxQuotes)
@@ -611,13 +676,19 @@ const postProcessReport = (
const hero = { const hero = {
headline: headline:
report.hero?.headline || topics[0]?.title || `${metadata.groupName}${mode === 'compact' ? '速览' : '日报'}`, report.hero?.headline ||
topics[0]?.title ||
`${metadata.groupName}${mode === 'compact' ? '速览' : '日报'}`,
summary: truncate( summary: truncate(
report.hero?.summary || report.overview || '今天群里有新的讨论进展。', report.hero?.summary || report.overview || '今天群里有新的讨论进展。',
mode === 'compact' ? 84 : 120 mode === 'compact' ? 84 : 120
), ),
keyTakeaway: report.hero?.keyTakeaway ? truncate(report.hero.keyTakeaway, config.noteLength) : undefined, keyTakeaway: report.hero?.keyTakeaway
pendingNote: report.hero?.pendingNote ? truncate(report.hero.pendingNote, config.noteLength) : undefined, ? truncate(report.hero.keyTakeaway, config.noteLength)
: undefined,
pendingNote: report.hero?.pendingNote
? truncate(report.hero.pendingNote, config.noteLength)
: undefined,
statusLine: statusLine:
report.hero?.statusLine || report.hero?.statusLine ||
`今日形成 ${summaryStats.conclusionCount} 个结论 · ${summaryStats.todoCount} 个待办 · ${summaryStats.unresolvedCount} 个问题尚未解决` `今日形成 ${summaryStats.conclusionCount} 个结论 · ${summaryStats.todoCount} 个待办 · ${summaryStats.unresolvedCount} 个问题尚未解决`
@@ -625,7 +696,13 @@ const postProcessReport = (
const sectionMeta: Partial<Record<ReportSectionKey, ReportSectionMeta>> = { const sectionMeta: Partial<Record<ReportSectionKey, ReportSectionMeta>> = {
hero: buildSectionMeta(true, 1, 1, 1, 0.95), hero: buildSectionMeta(true, 1, 1, 1, 0.95),
topics: buildSectionMeta(config.enabledSections.includes('topics'), topics.length, report.topics.length, 0.98, 0.85), topics: buildSectionMeta(
config.enabledSections.includes('topics'),
topics.length,
report.topics.length,
0.98,
0.85
),
importantMessages: buildSectionMeta( importantMessages: buildSectionMeta(
config.enabledSections.includes('importantMessages'), config.enabledSections.includes('importantMessages'),
importantMessages.length, importantMessages.length,
@@ -640,17 +717,84 @@ const postProcessReport = (
0.97, 0.97,
0.8 0.8
), ),
moments: buildSectionMeta(config.enabledSections.includes('moments'), quotes.length, report.quotes.length, 0.75, 0.72), moments: buildSectionMeta(
config.enabledSections.includes('moments'),
quotes.length,
report.quotes.length,
0.75,
0.72
),
analytics: buildSectionMeta(config.enabledSections.includes('analytics'), 1, 1, 0.8, 0.95), analytics: buildSectionMeta(config.enabledSections.includes('analytics'), 1, 1, 0.8, 0.95),
keywords: buildSectionMeta(config.enabledSections.includes('keywords'), keywords.length, report.keywords.length, 0.68, 0.9), keywords: buildSectionMeta(
resources: buildSectionMeta(config.enabledSections.includes('resources'), resources.length, report.resources.length, 0.55, 0.75), config.enabledSections.includes('keywords'),
qa: buildSectionMeta(config.enabledSections.includes('qa'), qa.length, report.qa.length, 0.62, 0.78), keywords.length,
storylines: buildSectionMeta(config.enabledSections.includes('storylines'), storylines.length, report.storylines.length, 0.63, 0.74), report.keywords.length,
reversals: buildSectionMeta(config.enabledSections.includes('reversals'), reversals.length, report.reversals.length, 0.54, 0.72), 0.68,
gallery: buildSectionMeta(config.enabledSections.includes('gallery'), gallery.length, report.media.gallery.length, 0.6, 0.8), 0.9
voices: buildSectionMeta(config.enabledSections.includes('voices'), voiceHighlights.length, report.media.voiceHighlights.length, 0.56, 0.84), ),
badges: buildSectionMeta(config.enabledSections.includes('badges'), funBadges.length, report.media.funBadges.length, 0.45, 0.65), resources: buildSectionMeta(
chains: buildSectionMeta(config.enabledSections.includes('chains'), participantChains.length, report.participantChains.length, 0.58, 0.71) config.enabledSections.includes('resources'),
resources.length,
report.resources.length,
0.55,
0.75
),
qa: buildSectionMeta(
config.enabledSections.includes('qa'),
qa.length,
report.qa.length,
0.62,
0.78
),
storylines: buildSectionMeta(
config.enabledSections.includes('storylines'),
storylines.length,
report.storylines.length,
0.63,
0.74
),
reversals: buildSectionMeta(
config.enabledSections.includes('reversals'),
reversals.length,
report.reversals.length,
0.54,
0.72
),
vision: buildSectionMeta(
config.enabledSections.includes('vision'),
report.media.visionGallery?.length || 0,
report.media.visionGallery?.length || 0,
0.72,
0.9
),
gallery: buildSectionMeta(
config.enabledSections.includes('gallery'),
gallery.length,
report.media.gallery.length,
0.6,
0.8
),
voices: buildSectionMeta(
config.enabledSections.includes('voices'),
voiceHighlights.length,
report.media.voiceHighlights.length,
0.56,
0.84
),
badges: buildSectionMeta(
config.enabledSections.includes('badges'),
funBadges.length,
report.media.funBadges.length,
0.45,
0.65
),
chains: buildSectionMeta(
config.enabledSections.includes('chains'),
participantChains.length,
report.participantChains.length,
0.58,
0.71
)
} }
return { return {
@@ -670,6 +814,7 @@ const postProcessReport = (
keywords, keywords,
media: { media: {
gallery, gallery,
visionGallery: report.media.visionGallery,
voiceHighlights, voiceHighlights,
funBadges funBadges
}, },
@@ -698,7 +843,8 @@ export const parseGroupDailyReport = (
heroRoot && Object.keys(heroRoot).length heroRoot && Object.keys(heroRoot).length
? { ? {
headline: asString(heroRoot.headline) || topics[0]?.title || '今日群聊速览', headline: asString(heroRoot.headline) || topics[0]?.title || '今日群聊速览',
summary: asString(heroRoot.summary) || asString(root.overview) || '今天群里有新的讨论进展。', summary:
asString(heroRoot.summary) || asString(root.overview) || '今天群里有新的讨论进展。',
keyTakeaway: asString(heroRoot.keyTakeaway), keyTakeaway: asString(heroRoot.keyTakeaway),
pendingNote: asString(heroRoot.pendingNote), pendingNote: asString(heroRoot.pendingNote),
statusLine: asString(heroRoot.statusLine) statusLine: asString(heroRoot.statusLine)
@@ -732,3 +878,95 @@ export const parseGroupDailyReport = (
return postProcessReport(report, metadata.reportMode || 'compact', metadata) return postProcessReport(report, metadata.reportMode || 'compact', metadata)
} }
// ============================================================
// main 分支兼容导出(SummaryDateRange / getSummaryDateRange 等)
// 用于让 App.tsx / useGroupReportGeneration.ts 等 main 分支文件能继续编译
// ============================================================
export type SummaryDateRange = 'today' | 'yesterday' | '7days'
export type SummaryMessageType =
| 'text'
| 'image'
| 'sticker'
| 'video'
| 'voice'
| 'share'
| 'system'
export const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
{ value: 'today', label: '今天' },
{ value: 'yesterday', label: '昨日' },
{ value: '7days', label: '近 7 天' }
]
export const SUMMARY_TYPE_OPTIONS: {
value: SummaryMessageType
label: string
messageTypes: string[]
description: string
}[] = [
{
value: 'text',
label: '文本',
messageTypes: ['普通文本'],
description: '使用文本内容、发送者和时间。'
},
{
value: 'image',
label: '图片',
messageTypes: ['图片'],
description: '图片 AI 识别结果将作为 ImageInsight 注入日报(由 ImageInsightService 提供)。'
},
{
value: 'sticker',
label: '表情包',
messageTypes: ['表情包'],
description: '不理解表情内容,仅按类型参与统计。'
},
{
value: 'video',
label: '视频',
messageTypes: ['视频'],
description: '不理解视频画面,仅按类型参与统计。'
},
{
value: 'voice',
label: '语音',
messageTypes: ['语音'],
description: '当前不转写语音,仅参与数量和活跃度统计。'
},
{
value: 'share',
label: '分享/引用',
messageTypes: ['分享消息', '名片', '位置', '通话'],
description: '使用解析到的标题、引用文本或类型信息。'
},
{
value: 'system',
label: '系统消息',
messageTypes: ['系统消息'],
description: '使用系统消息文本或类型信息。'
}
]
export const getSummaryDateRange = (
range: SummaryDateRange
): { startTime: number; endTime: number } => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
const endTime = Math.floor(Date.now() / 1000)
if (range === 'yesterday') {
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
}
if (range === '7days') {
return { startTime: startOfToday - 6 * 86400, endTime }
}
return { startTime: startOfToday, endTime }
}
export const isInternalName = (value?: string): boolean => {
const text = String(value || '').trim()
return (
!text || /^wxid_/i.test(text) || /@chatroom$/i.test(text) || /^[a-z0-9_-]{18,}$/i.test(text)
)
}
+7
View File
@@ -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
+29
View File
@@ -13,6 +13,7 @@ export type ReportSectionKey =
| 'storylines' | 'storylines'
| 'reversals' | 'reversals'
| 'gallery' | 'gallery'
| 'vision'
| 'voices' | 'voices'
| 'badges' | 'badges'
| 'chains' | 'chains'
@@ -45,6 +46,10 @@ export interface ReportTopic {
imageUrl?: string imageUrl?: string
note: string note: string
sourceMessageIds?: string[] sourceMessageIds?: string[]
/** AI 图片识别缓存 key(由 visionGallery 提供,main 渲染时按此取 base64) */
imageHash?: string
/** AI 真实识别的描述(来自 ImageInsight.description) */
aiDescription?: string
} | null } | null
} }
@@ -115,6 +120,26 @@ export interface ReportMediaGalleryItem {
replyCount?: number 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 { export interface ReportVoiceHighlight {
title: string title: string
sender: string sender: string
@@ -220,6 +245,8 @@ export interface GroupDailyReport {
keywords: string[] keywords: string[]
media: { media: {
gallery: ReportMediaGalleryItem[] gallery: ReportMediaGalleryItem[]
/** AI 识别的图片(由 ImageInsightService 提供),渲染时由 main 按 imageHash 取原图 */
visionGallery?: ReportVisionGalleryItem[]
voiceHighlights: ReportVoiceHighlight[] voiceHighlights: ReportVoiceHighlight[]
funBadges: ReportFunBadge[] funBadges: ReportFunBadge[]
} }
@@ -252,6 +279,8 @@ export interface GroupReportMetadata {
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 {
+110
View File
@@ -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
}>
}