mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
feat: 修改群聊日报生成与群成员信息展示
- 支持结构化 AI 群聊日报及移动端长图导出 - 支持日报时间范围和消息类型筛选 - 支持群成员及当前用户群昵称解析 - 移除消息加载更多并优化图片懒加载 - 增强头像处理与敏感密钥日志保护
This commit is contained in:
@@ -34,7 +34,6 @@
|
|||||||
"better-sqlite3-multiple-ciphers": "^12.5.0",
|
"better-sqlite3-multiple-ciphers": "^12.5.0",
|
||||||
"fs-extra": "^11.3.2",
|
"fs-extra": "^11.3.2",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
"html-to-image": "^1.11.13",
|
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"openai": "^6.10.0",
|
"openai": "^6.10.0",
|
||||||
"silk-wasm": "^3.7.1"
|
"silk-wasm": "^3.7.1"
|
||||||
|
|||||||
Generated
-6
@@ -26,7 +26,6 @@ specifiers:
|
|||||||
eslint-plugin-react-refresh: ^0.4.24
|
eslint-plugin-react-refresh: ^0.4.24
|
||||||
fs-extra: ^11.3.2
|
fs-extra: ^11.3.2
|
||||||
fzstd: ^0.1.1
|
fzstd: ^0.1.1
|
||||||
html-to-image: ^1.11.13
|
|
||||||
koffi: ^2.9.0
|
koffi: ^2.9.0
|
||||||
openai: ^6.10.0
|
openai: ^6.10.0
|
||||||
prettier: ^3.7.4
|
prettier: ^3.7.4
|
||||||
@@ -42,7 +41,6 @@ dependencies:
|
|||||||
better-sqlite3-multiple-ciphers: 12.5.0
|
better-sqlite3-multiple-ciphers: 12.5.0
|
||||||
fs-extra: 11.3.2
|
fs-extra: 11.3.2
|
||||||
fzstd: 0.1.1
|
fzstd: 0.1.1
|
||||||
html-to-image: 1.11.13
|
|
||||||
koffi: 2.16.2
|
koffi: 2.16.2
|
||||||
openai: 6.10.0
|
openai: 6.10.0
|
||||||
silk-wasm: 3.7.1
|
silk-wasm: 3.7.1
|
||||||
@@ -2946,10 +2944,6 @@ packages:
|
|||||||
lru-cache: 6.0.0
|
lru-cache: 6.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/html-to-image/1.11.13:
|
|
||||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/http-cache-semantics/4.2.0:
|
/http-cache-semantics/4.2.0:
|
||||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>{{REPORT_TITLE}}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
width: 430px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 430px;
|
||||||
|
background: #f3f5f7;
|
||||||
|
color: #1f2933;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
.report {
|
||||||
|
width: 430px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 22px 14px 34px;
|
||||||
|
}
|
||||||
|
.hero,
|
||||||
|
.card,
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.hero-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 23px;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.avatar-grid {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.avatar-grid img,
|
||||||
|
.avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat b {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #07a352;
|
||||||
|
}
|
||||||
|
.stat span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 5px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #07c160;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid #edf0f2;
|
||||||
|
}
|
||||||
|
.topic-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.topic-title-row h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
.heat,
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #eef8f2;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hot {
|
||||||
|
background: #fff4e5;
|
||||||
|
color: #d46b08;
|
||||||
|
}
|
||||||
|
.blue {
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
.red {
|
||||||
|
background: #fff1f0;
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
.topic-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.card p {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
.participants {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.person-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px 3px 3px;
|
||||||
|
}
|
||||||
|
.person-chip img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.person-chip b {
|
||||||
|
max-width: 58px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.keywords {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.keywords span {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.resource {
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.resource b {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
.important-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.important-card > .avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.important-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
.important-meta b {
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
.important-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.important-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-left: 3px solid #07c160;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #07a352;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.chat-block {
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.chat-msg {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.chat-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.chat-name {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667085;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.chat-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px 12px 12px 12px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.quote-note {
|
||||||
|
background: #fff8e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #8a5a00;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.qa-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.qa-card b {
|
||||||
|
display: block;
|
||||||
|
color: #1f2933;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.qa-card div {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #485465;
|
||||||
|
}
|
||||||
|
.bar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 82px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
height: 10px;
|
||||||
|
background: #edf1f5;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: #07c160;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.rank {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px solid #eef0f2;
|
||||||
|
}
|
||||||
|
.rank img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.rank b {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.rank span {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.cloud-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cloud-tags span {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #485465;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.cloud-tags .xl {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #07a352;
|
||||||
|
background: #e9f8ef;
|
||||||
|
}
|
||||||
|
.cloud-tags .lg {
|
||||||
|
font-size: 17px;
|
||||||
|
color: #1677ff;
|
||||||
|
background: #eef5ff;
|
||||||
|
}
|
||||||
|
.cloud-tags .md {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #d46b08;
|
||||||
|
background: #fff4e5;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 16px 4px 0;
|
||||||
|
color: #98a2b3;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #8a94a6;
|
||||||
|
}
|
||||||
|
.empty-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.report {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.stat b {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="report">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1>{{GROUP_NAME}}日报</h1>
|
||||||
|
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||||
|
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||||
|
<div class="stat"><b>{{TIME_SPAN}}</b><span>时间跨度</span></div>
|
||||||
|
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section topics">
|
||||||
|
<div class="section-title">今日讨论热点</div>
|
||||||
|
{{TOPIC_CARDS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">实用信息与资源</div>
|
||||||
|
{{RESOURCE_ITEMS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">重要消息汇总</div>
|
||||||
|
{{IMPORTANT_MESSAGES}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">有趣对话或金句</div>
|
||||||
|
{{QUOTE_BLOCKS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section qa {{QA_EMPTY_CLASS}}">
|
||||||
|
<div class="section-title">问题与解答</div>
|
||||||
|
{{QA_CARDS}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section analytics">
|
||||||
|
<div class="section-title">群内数据可视化</div>
|
||||||
|
{{HEAT_BARS}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
|
||||||
|
话唠榜 TOP5(基于已读取记录估算)
|
||||||
|
</div>
|
||||||
|
{{RANK_ITEMS}}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section cloud">
|
||||||
|
<div class="section-title">词云/关键词</div>
|
||||||
|
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
数据来源:微信群聊记录<br />
|
||||||
|
生成时间:{{GENERATED_AT}}<br />
|
||||||
|
{{FOOTER_NOTE}}
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import os from 'os'
|
||||||
|
import path from 'path'
|
||||||
|
import {
|
||||||
|
GroupReportExportRequest,
|
||||||
|
GroupReportExportResult,
|
||||||
|
ReportHeat
|
||||||
|
} from '../shared/group-report'
|
||||||
|
|
||||||
|
const TEMPLATE_NAME = 'mobile_daily_report.html'
|
||||||
|
|
||||||
|
const escapeHtml = (value: unknown): string =>
|
||||||
|
String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
const sanitizeFileName = (value: string): string =>
|
||||||
|
value
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim() || '未命名群聊'
|
||||||
|
|
||||||
|
const hashName = (name: string): number => {
|
||||||
|
let hash = 0
|
||||||
|
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackAvatar = (name: string): string => {
|
||||||
|
const hue = hashName(name) % 360
|
||||||
|
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
||||||
|
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageMimeType = (contentType: string | null, source: string): string => {
|
||||||
|
if (contentType?.startsWith('image/')) return contentType.split(';')[0]
|
||||||
|
const extension = path.extname(source).toLowerCase()
|
||||||
|
if (extension === '.png') return 'image/png'
|
||||||
|
if (extension === '.webp') return 'image/webp'
|
||||||
|
if (extension === '.gif') return 'image/gif'
|
||||||
|
return 'image/jpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
|
||||||
|
if (!source) return fallbackAvatar(name)
|
||||||
|
if (/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (/^https?:\/\//i.test(source)) {
|
||||||
|
const response = await fetch(source, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 WechatExplorer',
|
||||||
|
Referer: 'https://weixin.qq.com/'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(8000)
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||||
|
const mime = imageMimeType(response.headers.get('content-type'), source)
|
||||||
|
return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPath = source.startsWith('file://') ? new URL(source) : source
|
||||||
|
const buffer = await fs.readFile(localPath)
|
||||||
|
return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
|
||||||
|
return fallbackAvatar(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const templatePath = (): string => {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
|
||||||
|
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
|
||||||
|
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
|
||||||
|
]
|
||||||
|
const found = candidates.find((candidate) => fs.existsSync(candidate))
|
||||||
|
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
const heatClass = (heat: ReportHeat): string => {
|
||||||
|
if (heat === '高') return 'hot'
|
||||||
|
if (heat === '低') return 'blue'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||||
|
html.replaceAll(`{{${key}}}`, value)
|
||||||
|
|
||||||
|
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||||
|
const { report, metadata } = request
|
||||||
|
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||||
|
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
|
||||||
|
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
|
||||||
|
report.quotes.forEach((quote) =>
|
||||||
|
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||||
|
)
|
||||||
|
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
||||||
|
|
||||||
|
const avatars = new Map<string, string>()
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(avatarNames).map(async (name) => {
|
||||||
|
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
|
||||||
|
|
||||||
|
const heroNames = metadata.heroParticipants.slice(0, 4)
|
||||||
|
while (heroNames.length < 4) heroNames.push(metadata.groupName)
|
||||||
|
const heroAvatars = heroNames
|
||||||
|
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const topicCards = report.topics
|
||||||
|
.map(
|
||||||
|
(topic) => `<div class="card topic-card">
|
||||||
|
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||||
|
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||||
|
<p>${escapeHtml(topic.summary)}</p>
|
||||||
|
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
|
||||||
|
<div class="participants">${topic.participants
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(
|
||||||
|
(name) =>
|
||||||
|
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
|
||||||
|
)
|
||||||
|
.join('')}</div>
|
||||||
|
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const resourceItems = report.resources
|
||||||
|
.map(
|
||||||
|
(resource) =>
|
||||||
|
`<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const importantMessages = report.importantMessages
|
||||||
|
.map(
|
||||||
|
(message) => `<div class="important-card">
|
||||||
|
<img class="avatar" src="${avatar(message.sender)}" alt="">
|
||||||
|
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div>
|
||||||
|
<div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const quoteBlocks = report.quotes
|
||||||
|
.map(
|
||||||
|
(quote) =>
|
||||||
|
`<div class="chat-block">${quote.messages
|
||||||
|
.map(
|
||||||
|
(
|
||||||
|
message
|
||||||
|
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><div>
|
||||||
|
<div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div>
|
||||||
|
</div></div>`
|
||||||
|
)
|
||||||
|
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const qaCards = report.qa
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
|
||||||
|
const heatBars = report.analytics.topicHeat
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const rankItems = report.analytics.topSpeakers
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(
|
||||||
|
(speaker, index) =>
|
||||||
|
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const cloudTags = report.keywords
|
||||||
|
.slice(0, 15)
|
||||||
|
.map(
|
||||||
|
(word, index) =>
|
||||||
|
`<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
let html = await fs.readFile(templatePath(), 'utf8')
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||||
|
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||||
|
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||||
|
RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()),
|
||||||
|
HERO_AVATARS: heroAvatars,
|
||||||
|
MESSAGE_COUNT: String(metadata.messageCount),
|
||||||
|
ACTIVE_USERS: String(metadata.activeUsers),
|
||||||
|
TIME_SPAN: escapeHtml(metadata.timeSpan),
|
||||||
|
TOPIC_COUNT: String(report.topics.length),
|
||||||
|
TOPIC_CARDS: topicCards,
|
||||||
|
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
|
||||||
|
RESOURCE_ITEMS: resourceItems,
|
||||||
|
MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
|
||||||
|
IMPORTANT_MESSAGES: importantMessages,
|
||||||
|
QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
|
||||||
|
QUOTE_BLOCKS: quoteBlocks,
|
||||||
|
QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
|
||||||
|
QA_CARDS: qaCards,
|
||||||
|
HEAT_BARS: heatBars,
|
||||||
|
RANK_ITEMS: rankItems,
|
||||||
|
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
||||||
|
CLOUD_TAGS: cloudTags,
|
||||||
|
GENERATED_AT: escapeHtml(metadata.generatedAt),
|
||||||
|
FOOTER_NOTE: escapeHtml(metadata.footerNote)
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
|
||||||
|
const reportWindow = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
width: 430,
|
||||||
|
height: 800,
|
||||||
|
frame: false,
|
||||||
|
backgroundColor: '#f3f5f7',
|
||||||
|
webPreferences: { sandbox: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reportWindow.loadFile(htmlPath)
|
||||||
|
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
||||||
|
document.fonts.ready,
|
||||||
|
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
|
||||||
|
img.addEventListener('load', resolve, { once: true });
|
||||||
|
img.addEventListener('error', resolve, { once: true });
|
||||||
|
}))
|
||||||
|
])`)
|
||||||
|
reportWindow.webContents.debugger.attach('1.3')
|
||||||
|
const metrics = (await reportWindow.webContents.debugger.sendCommand(
|
||||||
|
'Page.getLayoutMetrics'
|
||||||
|
)) as { cssContentSize: { width: number; height: number } }
|
||||||
|
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
|
||||||
|
const height = Math.ceil(metrics.cssContentSize.height)
|
||||||
|
const screenshot = (await reportWindow.webContents.debugger.sendCommand(
|
||||||
|
'Page.captureScreenshot',
|
||||||
|
{
|
||||||
|
format: 'png',
|
||||||
|
captureBeyondViewport: true,
|
||||||
|
fromSurface: true,
|
||||||
|
clip: { x: 0, y: 0, width, height, scale: 1 }
|
||||||
|
}
|
||||||
|
)) as { data: string }
|
||||||
|
const png = Buffer.from(screenshot.data, 'base64')
|
||||||
|
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
||||||
|
await fs.writeFile(pngPath, png)
|
||||||
|
return `data:image/png;base64,${screenshot.data}`
|
||||||
|
} finally {
|
||||||
|
if (reportWindow.webContents.debugger.isAttached()) {
|
||||||
|
reportWindow.webContents.debugger.detach()
|
||||||
|
}
|
||||||
|
reportWindow.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportGroupReport = async (
|
||||||
|
request: GroupReportExportRequest
|
||||||
|
): Promise<GroupReportExportResult> => {
|
||||||
|
try {
|
||||||
|
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||||
|
await fs.ensureDir(outputDir)
|
||||||
|
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
|
||||||
|
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||||
|
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||||
|
const html = await renderReportHtml(request)
|
||||||
|
await fs.writeFile(htmlPath, html, 'utf8')
|
||||||
|
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
|
||||||
|
return { success: true, htmlPath, pngPath, imageDataUrl }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GroupReport] export failed:', error)
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,8 +236,6 @@ export class ImageDecryptService {
|
|||||||
version,
|
version,
|
||||||
'file:',
|
'file:',
|
||||||
datPath,
|
datPath,
|
||||||
'xorKey:',
|
|
||||||
this.xorKey,
|
|
||||||
'aesKey present:',
|
'aesKey present:',
|
||||||
!!this.aesKey
|
!!this.aesKey
|
||||||
)
|
)
|
||||||
@@ -258,7 +256,6 @@ export class ImageDecryptService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||||
console.log('[ImageDecrypt] AES key bytes:', key.toString('hex'), 'length:', key.length)
|
|
||||||
decrypted = this.decryptDatV4(datPath, key)
|
decrypted = this.decryptDatV4(datPath, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-12
@@ -11,6 +11,8 @@ import {
|
|||||||
parseStickerMessageFromRow
|
parseStickerMessageFromRow
|
||||||
} from './message-parser'
|
} from './message-parser'
|
||||||
import { ImageDecryptService } from './image-decrypt-service'
|
import { ImageDecryptService } from './image-decrypt-service'
|
||||||
|
import { exportGroupReport } from './group-report-service'
|
||||||
|
import { GroupReportExportRequest } from '../shared/group-report'
|
||||||
|
|
||||||
let wechatDb: WechatDb | null = null
|
let wechatDb: WechatDb | null = null
|
||||||
let voiceService: VoiceService | null = null
|
let voiceService: VoiceService | null = null
|
||||||
@@ -53,8 +55,8 @@ function normalizeMsgType(value: string | number | undefined): number {
|
|||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// 创建浏览器窗口
|
// 创建浏览器窗口
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1400,
|
||||||
height: 670,
|
height: 800,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
...(process.platform === 'linux' ? { icon } : {}),
|
...(process.platform === 'linux' ? { icon } : {}),
|
||||||
@@ -102,9 +104,7 @@ app.whenReady().then(() => {
|
|||||||
ipcMain.handle('db:init', (_, key: string) => {
|
ipcMain.handle('db:init', (_, key: string) => {
|
||||||
try {
|
try {
|
||||||
const trimmedKey = String(key || '').trim()
|
const trimmedKey = String(key || '').trim()
|
||||||
console.log(
|
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||||
`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length} keyPreview=${trimmedKey.slice(0, 6)}...${trimmedKey.slice(-6)}`
|
|
||||||
)
|
|
||||||
wechatDb = new WechatDb(key)
|
wechatDb = new WechatDb(key)
|
||||||
const wcdb4Client = wechatDb.getWcdb4Client()
|
const wcdb4Client = wechatDb.getWcdb4Client()
|
||||||
if (wcdb4Client) {
|
if (wcdb4Client) {
|
||||||
@@ -175,6 +175,10 @@ app.whenReady().then(() => {
|
|||||||
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
|
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
|
||||||
const groupMembers = wechatDb.getGroupMembersForChat(userMd5)
|
const groupMembers = wechatDb.getGroupMembersForChat(userMd5)
|
||||||
const myAvatar = wechatDb.getMyAvatarUrl()
|
const myAvatar = wechatDb.getMyAvatarUrl()
|
||||||
|
const myGroupNickname =
|
||||||
|
username?.endsWith('@chatroom') && wcdb4Client
|
||||||
|
? wcdb4Client.getMyGroupNickname(username)
|
||||||
|
: undefined
|
||||||
|
|
||||||
return rawMessages.map((msg: WechatMessage) => {
|
return rawMessages.map((msg: WechatMessage) => {
|
||||||
const rawMsgType = parseInt(msg.messageType)
|
const rawMsgType = parseInt(msg.messageType)
|
||||||
@@ -187,13 +191,12 @@ app.whenReady().then(() => {
|
|||||||
let content = msg.msgContent
|
let content = msg.msgContent
|
||||||
let img = ''
|
let img = ''
|
||||||
let name = ''
|
let name = ''
|
||||||
if (isMine && myAvatar) {
|
if (isMine) {
|
||||||
img = myAvatar
|
if (myAvatar) img = myAvatar
|
||||||
} else if (typeof msg.senderAvatar === 'string') {
|
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
|
||||||
img = msg.senderAvatar
|
} else {
|
||||||
}
|
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
|
||||||
if (typeof msg.senderNickname === 'string') {
|
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
|
||||||
name = msg.senderNickname
|
|
||||||
}
|
}
|
||||||
// 检查内容是否以 wxid 开头并包含冒号
|
// 检查内容是否以 wxid 开头并包含冒号
|
||||||
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
|
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
|
||||||
@@ -343,6 +346,19 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
|
||||||
|
return exportGroupReport(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||||
|
try {
|
||||||
|
shell.showItemInFolder(filePath)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getVoiceData',
|
'db:getVoiceData',
|
||||||
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
|
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
|
||||||
|
|||||||
+77
-10
@@ -65,6 +65,7 @@ export class Wcdb4Client {
|
|||||||
private initialized = false
|
private initialized = false
|
||||||
private displayNameCache = new Map<string, string>()
|
private displayNameCache = new Map<string, string>()
|
||||||
private avatarCache = new Map<string, string>()
|
private avatarCache = new Map<string, string>()
|
||||||
|
private groupNicknameCache = new Map<string, Map<string, string>>()
|
||||||
private cachedSessions: Wcdb4Session[] | null = null
|
private cachedSessions: Wcdb4Session[] | null = null
|
||||||
|
|
||||||
private wcdbInit: (() => number) | null = null
|
private wcdbInit: (() => number) | null = null
|
||||||
@@ -96,6 +97,9 @@ export class Wcdb4Client {
|
|||||||
private wcdbGetGroupMembers:
|
private wcdbGetGroupMembers:
|
||||||
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
|
private wcdbGetGroupNicknames:
|
||||||
|
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
||||||
|
| null = null
|
||||||
private wcdbOpenMessageCursor:
|
private wcdbOpenMessageCursor:
|
||||||
| ((
|
| ((
|
||||||
handle: number,
|
handle: number,
|
||||||
@@ -234,6 +238,7 @@ export class Wcdb4Client {
|
|||||||
this.cachedSessions = null
|
this.cachedSessions = null
|
||||||
this.displayNameCache.clear()
|
this.displayNameCache.clear()
|
||||||
this.avatarCache.clear()
|
this.avatarCache.clear()
|
||||||
|
this.groupNicknameCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
getSessions(): Wcdb4Session[] {
|
getSessions(): Wcdb4Session[] {
|
||||||
@@ -291,12 +296,7 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMyAvatarUrl(): string | undefined {
|
getMyAvatarUrl(): string | undefined {
|
||||||
const rawAccountName = path.basename(this.accountRoot)
|
const candidates = this.getMyUsernameCandidates()
|
||||||
const candidates = this.uniq([
|
|
||||||
this.wxid,
|
|
||||||
rawAccountName,
|
|
||||||
Wcdb4Client.cleanAccountDirName(rawAccountName)
|
|
||||||
])
|
|
||||||
this.hydrateAvatarUrls(candidates)
|
this.hydrateAvatarUrls(candidates)
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@@ -307,6 +307,15 @@ export class Wcdb4Client {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMyGroupNickname(chatroomId: string): string | undefined {
|
||||||
|
const groupNicknames = this.getGroupNicknames(chatroomId)
|
||||||
|
for (const candidate of this.getMyUsernameCandidates()) {
|
||||||
|
const nickname = groupNicknames.get(candidate)
|
||||||
|
if (nickname) return nickname
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
private getMessagesByCursor(
|
private getMessagesByCursor(
|
||||||
username: string,
|
username: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
@@ -413,11 +422,12 @@ export class Wcdb4Client {
|
|||||||
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const groupNicknames = this.getGroupNicknames(chatroomId)
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
|
this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
|
||||||
)
|
)
|
||||||
|
|
||||||
return (Array.isArray(rows) ? rows : []).map((row) => {
|
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
||||||
const username = this.pickString(row, [
|
const username = this.pickString(row, [
|
||||||
'username',
|
'username',
|
||||||
'userName',
|
'userName',
|
||||||
@@ -425,10 +435,15 @@ export class Wcdb4Client {
|
|||||||
'member_username',
|
'member_username',
|
||||||
'm_nsUsrName'
|
'm_nsUsrName'
|
||||||
])
|
])
|
||||||
const nickname = this.pickString(row, [
|
const memberNickname = this.pickString(row, [
|
||||||
'nickname',
|
'nickname',
|
||||||
|
'nickName',
|
||||||
'displayName',
|
'displayName',
|
||||||
'display_name',
|
'display_name',
|
||||||
|
'groupNickname',
|
||||||
|
'group_nickname',
|
||||||
|
'roomNickname',
|
||||||
|
'room_nickname',
|
||||||
'remark',
|
'remark',
|
||||||
'm_nsNickName'
|
'm_nsNickName'
|
||||||
])
|
])
|
||||||
@@ -440,21 +455,60 @@ export class Wcdb4Client {
|
|||||||
])
|
])
|
||||||
|
|
||||||
if (username) {
|
if (username) {
|
||||||
if (nickname) this.displayNameCache.set(username, nickname)
|
|
||||||
if (avatar) this.avatarCache.set(username, avatar)
|
if (avatar) this.avatarCache.set(username, avatar)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
m_nsUsrName: username,
|
m_nsUsrName: username,
|
||||||
nickname: nickname || username,
|
nickname: groupNicknames.get(username) || memberNickname,
|
||||||
m_nsHeadImgUrl: avatar
|
m_nsHeadImgUrl: avatar
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const missingDisplayNames = members
|
||||||
|
.filter((member) => !member.nickname)
|
||||||
|
.map((member) => member.m_nsUsrName)
|
||||||
|
.filter(Boolean)
|
||||||
|
this.hydrateDisplayNames(missingDisplayNames)
|
||||||
|
return members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
nickname:
|
||||||
|
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||||
|
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||||
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getGroupNicknames(chatroomId: string): Map<string, string> {
|
||||||
|
const cached = this.groupNicknameCache.get(chatroomId)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const nicknames = new Map<string, string>()
|
||||||
|
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = this.callJson<Record<string, string> | Record<string, unknown>[]>(
|
||||||
|
(handle, outJson) => this.wcdbGetGroupNicknames!(handle, chatroomId, outJson)
|
||||||
|
)
|
||||||
|
this.readStringMap(rows, [
|
||||||
|
'nickname',
|
||||||
|
'nickName',
|
||||||
|
'displayName',
|
||||||
|
'display_name',
|
||||||
|
'groupNickname',
|
||||||
|
'group_nickname',
|
||||||
|
'name'
|
||||||
|
]).forEach((nickname, username) => nicknames.set(username, nickname))
|
||||||
|
this.groupNicknameCache.set(chatroomId, nicknames)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[WCDB4] failed to get group nicknames for ${chatroomId}:`, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nicknames
|
||||||
|
}
|
||||||
|
|
||||||
async getVoiceData(
|
async getVoiceData(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
createTime: number,
|
createTime: number,
|
||||||
@@ -650,6 +704,14 @@ export class Wcdb4Client {
|
|||||||
this.wcdbGetGroupMembers = null
|
this.wcdbGetGroupMembers = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.wcdbGetGroupNicknames = lib.func(
|
||||||
|
'int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)'
|
||||||
|
) as (handle: number, chatroomId: string, outJson: WcdbVoidOut) => number
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetGroupNicknames = null
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.wcdbOpenMessageCursor = lib.func(
|
this.wcdbOpenMessageCursor = lib.func(
|
||||||
'int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)'
|
'int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)'
|
||||||
@@ -1168,6 +1230,11 @@ export class Wcdb4Client {
|
|||||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getMyUsernameCandidates(): string[] {
|
||||||
|
const rawAccountName = path.basename(this.accountRoot)
|
||||||
|
return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)])
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeTimestamp(input: number): number {
|
private normalizeTimestamp(input: number): number {
|
||||||
if (!input || input <= 0) return 0
|
if (!input || input <= 0) return 0
|
||||||
const normalized = input > 1e12 ? Math.floor(input / 1000) : Math.floor(input)
|
const normalized = input > 1e12 ? Math.floor(input / 1000) : Math.floor(input)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class WechatDb {
|
|||||||
|
|
||||||
constructor(rawKey: string) {
|
constructor(rawKey: string) {
|
||||||
this.rawKey = rawKey
|
this.rawKey = rawKey
|
||||||
console.log(`Initializing WechatDb with key: ${rawKey}`)
|
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||||
|
|
||||||
if (this.tryOpenWechat4()) {
|
if (this.tryOpenWechat4()) {
|
||||||
this.chatDb = this.getChatDbNumber()
|
this.chatDb = this.getChatDbNumber()
|
||||||
|
|||||||
Vendored
+3
@@ -1,5 +1,6 @@
|
|||||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||||
import { Contact, Message } from '../shared/types'
|
import { Contact, Message } from '../shared/types'
|
||||||
|
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
@@ -58,6 +59,8 @@ declare global {
|
|||||||
cdnUrl?: string,
|
cdnUrl?: string,
|
||||||
md5?: string
|
md5?: string
|
||||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
|
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||||
|
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import { electronAPI } from '@electron-toolkit/preload'
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
|
import { GroupReportExportRequest } from '../shared/group-report'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
@@ -19,7 +20,10 @@ const api = {
|
|||||||
ipcRenderer.invoke('db:parseMessage', content, messageType),
|
ipcRenderer.invoke('db:parseMessage', content, messageType),
|
||||||
getImage: (imageMd5?: string, imageDatNameOrThumb?: string | boolean, sessionId?: string) =>
|
getImage: (imageMd5?: string, imageDatNameOrThumb?: string | boolean, sessionId?: string) =>
|
||||||
ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId),
|
ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId),
|
||||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5)
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
|
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||||
|
ipcRenderer.invoke('report:export', request),
|
||||||
|
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
|
|||||||
@@ -703,6 +703,79 @@ body {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-settings-modal {
|
||||||
|
width: min(460px, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-settings-modal h3 {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-filter-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-filter-label {
|
||||||
|
margin-bottom: 7px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-date-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-date-options label {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
border: 1px solid #d8dcdf;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #4a5257;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-date-options label.selected {
|
||||||
|
border-color: #07c160;
|
||||||
|
background: #eefaf3;
|
||||||
|
color: #078f49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-date-options input,
|
||||||
|
.ai-type-options input {
|
||||||
|
margin: 0;
|
||||||
|
accent-color: #07c160;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-type-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px 10px;
|
||||||
|
padding: 11px;
|
||||||
|
border: 1px solid #e1e4e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-type-options label {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #3f474c;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.image-preview-modal {
|
.image-preview-modal {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { toPng } from 'html-to-image'
|
|
||||||
import { Message, Contact } from '../../../shared/types'
|
import { Message, Contact } from '../../../shared/types'
|
||||||
import { VoicePlayer } from './VoicePlayer'
|
import { VoicePlayer } from './VoicePlayer'
|
||||||
import { RichMessageBubble } from './RichMessageBubble'
|
import { RichMessageBubble } from './RichMessageBubble'
|
||||||
import { ImageBubble } from './ImageBubble'
|
import { ImageBubble } from './ImageBubble'
|
||||||
|
import {
|
||||||
|
buildGroupReportInput,
|
||||||
|
GROUP_REPORT_SYSTEM_PROMPT,
|
||||||
|
parseGroupDailyReport
|
||||||
|
} from '../utils/group-report'
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
contact: Contact | null
|
contact: Contact | null
|
||||||
@@ -13,27 +17,41 @@ interface ChatWindowProps {
|
|||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt = `你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
|
type SummaryDateRange = 'today' | 'yesterday' | '7days'
|
||||||
请注意 不要回复总结除外的内容, 并且不要输出 群友的wxid 微信id 只需要显示群名称
|
type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system'
|
||||||
请帮我将给出的群聊内容总结成一个群聊报告,需要你生成7个最重要 最火爆的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容:
|
|
||||||
- 整体评价
|
|
||||||
- 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示)
|
|
||||||
- 参与者(不超过5个人,将重复的人名去重)
|
|
||||||
- 注意按时间排序,时间段(从日期几点到几点)
|
|
||||||
- 过程(50到200字左右)
|
|
||||||
- 评价(50字以下)
|
|
||||||
- 生成这7天内热度最高的话题,27日到2日一共7天
|
|
||||||
需要生成27, 28, 29, 30, 31, 1, 2日的话题总结
|
|
||||||
- 分割线: ------------
|
|
||||||
|
|
||||||
另外有以下要求:
|
const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
|
||||||
1. 每个话题结束使用------------分割
|
{ value: 'today', label: '今天' },
|
||||||
2. 使用中文冒号
|
{ value: 'yesterday', label: '昨日' },
|
||||||
3. 无需大标题
|
{ value: '7days', label: '最近 7 天' }
|
||||||
4. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类
|
]
|
||||||
5. 每个话题详细写出参与者
|
|
||||||
|
|
||||||
最后总结下今日最活跃的前五个发言者`
|
const SUMMARY_TYPE_OPTIONS: {
|
||||||
|
value: SummaryMessageType
|
||||||
|
label: string
|
||||||
|
messageTypes: string[]
|
||||||
|
}[] = [
|
||||||
|
{ value: 'text', label: '文本', messageTypes: ['普通文本'] },
|
||||||
|
{ value: 'image', label: '图片', messageTypes: ['图片'] },
|
||||||
|
{ value: 'sticker', label: '表情包', messageTypes: ['表情包'] },
|
||||||
|
{ value: 'video', label: '视频', messageTypes: ['视频'] },
|
||||||
|
{ value: 'voice', label: '语音', messageTypes: ['语音'] },
|
||||||
|
{ value: 'share', label: '分享/引用', messageTypes: ['分享消息', '名片', '位置', '通话'] },
|
||||||
|
{ value: 'system', label: '系统消息', messageTypes: ['系统消息'] }
|
||||||
|
]
|
||||||
|
|
||||||
|
const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endTime: number } => {
|
||||||
|
const now = new Date()
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||||
|
const endTime = Math.floor(Date.now() / 1000)
|
||||||
|
if (range === 'yesterday') {
|
||||||
|
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
|
||||||
|
}
|
||||||
|
if (range === '7days') {
|
||||||
|
return { startTime: startOfToday - 6 * 86400, endTime }
|
||||||
|
}
|
||||||
|
return { startTime: startOfToday, endTime }
|
||||||
|
}
|
||||||
|
|
||||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||||
contact,
|
contact,
|
||||||
@@ -42,10 +60,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData
|
onRefreshData
|
||||||
}) => {
|
}) => {
|
||||||
const isGroupChat = contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
const isGroupChat = Boolean(
|
||||||
|
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||||
|
)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [generatedImage, setGeneratedImage] = useState<string | null>(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)
|
||||||
@@ -62,8 +82,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
|
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
|
||||||
)
|
)
|
||||||
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat')
|
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat')
|
||||||
|
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
||||||
|
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
||||||
|
|
||||||
const handleSaveSettings = (): void => {
|
const handleSaveSettings = (): void => {
|
||||||
|
if (!summaryMessageTypes.length) {
|
||||||
|
alert('请至少选择一种消息类型')
|
||||||
|
return
|
||||||
|
}
|
||||||
localStorage.setItem('ai_api_key', apiKey)
|
localStorage.setItem('ai_api_key', apiKey)
|
||||||
localStorage.setItem('ai_base_url', baseURL)
|
localStorage.setItem('ai_base_url', baseURL)
|
||||||
localStorage.setItem('ai_model', model)
|
localStorage.setItem('ai_model', model)
|
||||||
@@ -71,6 +97,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
AIChat()
|
AIChat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleSummaryMessageType = (type: SummaryMessageType): void => {
|
||||||
|
setSummaryMessageTypes((current) =>
|
||||||
|
current.includes(type) ? current.filter((item) => item !== type) : [...current, type]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const scrollToBottom = (): void => {
|
const scrollToBottom = (): void => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||||
}
|
}
|
||||||
@@ -188,86 +220,52 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [summaryContent, setSummaryContent] = useState<string>('')
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const AIChat = async (): Promise<void> => {
|
const AIChat = async (): Promise<void> => {
|
||||||
if (!messages || messages.length === 0) {
|
if (!contact) return
|
||||||
alert('当前没有消息可供总结')
|
if (!summaryMessageTypes.length) {
|
||||||
|
alert('请至少选择一种消息类型')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const filteredMessages = messages
|
|
||||||
.filter((msg) => !'分享消息,图片,表情包,视频'.split(',').includes(msg.type))
|
|
||||||
.map((msg) => {
|
|
||||||
return {
|
|
||||||
from: msg.from,
|
|
||||||
type: msg.type,
|
|
||||||
datetime: msg.datetime,
|
|
||||||
content: msg.content,
|
|
||||||
name: msg.name
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const recentMessages = filteredMessages
|
|
||||||
.map((msg) => {
|
|
||||||
return `${msg.datetime} ${msg.from}: ${msg.content}`
|
|
||||||
})
|
|
||||||
.join('\n')
|
|
||||||
|
|
||||||
const prompt = `请总结以下微信聊天记录的核心内容:\n\n${recentMessages}`
|
|
||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
console.log('正在请求AI...')
|
const { startTime, endTime } = getSummaryDateRange(summaryDateRange)
|
||||||
|
const rangeMessages = await window.api.getMessages(contact.md5, startTime, endTime)
|
||||||
|
const allowedTypes = new Set(
|
||||||
|
SUMMARY_TYPE_OPTIONS.filter((option) => summaryMessageTypes.includes(option.value)).flatMap(
|
||||||
|
(option) => option.messageTypes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
|
||||||
|
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
|
||||||
|
|
||||||
|
const input = buildGroupReportInput(reportMessages, contact, isGroupChat)
|
||||||
|
console.log('🚀 ~ AIChat ~ input:', input)
|
||||||
|
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
|
||||||
const result = await window.api.aiChat(
|
const result = await window.api.aiChat(
|
||||||
[
|
[
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||||
{ role: 'user', content: prompt }
|
{ role: 'user', content: input.prompt }
|
||||||
],
|
],
|
||||||
{ apiKey, model, baseURL }
|
{ apiKey, model, baseURL }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||||
console.log('AI Summary:', result.data)
|
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
|
||||||
setSummaryContent(result.data)
|
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
|
||||||
|
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||||
// 等待状态更新和渲染
|
throw new Error(exported.error || '日报文件生成失败')
|
||||||
setTimeout(() => {
|
|
||||||
textToImage()
|
|
||||||
setIsLoading(false) // 图片生成开始后停止加载
|
|
||||||
}, 500)
|
|
||||||
} else {
|
|
||||||
console.error('AI Error:', result.error)
|
|
||||||
alert(`AI 请求失败: ${result.error}`)
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
}
|
||||||
|
setGeneratedImage(exported.imageDataUrl)
|
||||||
|
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI Call Failed:', error)
|
console.error('AI Call Failed:', error)
|
||||||
alert('AI 请求发生错误')
|
alert(`AI 日报生成失败:${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const textToImage = async (): Promise<void> => {
|
|
||||||
if (imageContainerRef.current) {
|
|
||||||
try {
|
|
||||||
const dataUrl = await toPng(imageContainerRef.current, {
|
|
||||||
cacheBust: true,
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
style: {
|
|
||||||
transform: 'scale(1)'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (dataUrl && dataUrl.length > 100) {
|
|
||||||
setGeneratedImage(dataUrl)
|
|
||||||
} else {
|
|
||||||
alert('生成图片为空')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to generate image', err)
|
|
||||||
alert('生成图片失败: ' + (err instanceof Error ? err.message : String(err)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const handleCopyImage = async (): Promise<void> => {
|
const handleCopyImage = async (): Promise<void> => {
|
||||||
if (!generatedImage) return
|
if (!generatedImage) return
|
||||||
const result = await window.api.copyImage(generatedImage)
|
const result = await window.api.copyImage(generatedImage)
|
||||||
@@ -276,8 +274,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [displayLimit, setDisplayLimit] = useState(100)
|
|
||||||
|
|
||||||
const filteredMessages = React.useMemo(() => {
|
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 || '')
|
||||||
@@ -290,8 +286,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
})
|
})
|
||||||
}, [messages, contentFilter])
|
}, [messages, contentFilter])
|
||||||
|
|
||||||
const visibleMessages = filteredMessages.slice(0, displayLimit)
|
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
return (
|
return (
|
||||||
<div className="chat-window">
|
<div className="chat-window">
|
||||||
@@ -308,7 +302,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="message-list wechat-message-list">
|
<div className="message-list wechat-message-list">
|
||||||
{visibleMessages.map((msg) => {
|
{filteredMessages.map((msg) => {
|
||||||
const isMine = msg.from === 'assistant'
|
const isMine = msg.from === 'assistant'
|
||||||
const displayName = isMine
|
const displayName = isMine
|
||||||
? '我'
|
? '我'
|
||||||
@@ -368,22 +362,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{filteredMessages.length > displayLimit && (
|
|
||||||
<div style={{ textAlign: 'center', padding: '10px' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setDisplayLimit((prev) => prev + 100)}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
backgroundColor: '#f0f0f0',
|
|
||||||
border: '1px solid #ccc',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
加载更多 ({filteredMessages.length - displayLimit} 条剩余)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -425,42 +403,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
zIndex: -1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '820px',
|
|
||||||
padding: '20px',
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
fontSize: '22px',
|
|
||||||
color: '#000',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
fontFamily: 'sans-serif',
|
|
||||||
lineHeight: '1.5'
|
|
||||||
}}
|
|
||||||
ref={imageContainerRef}
|
|
||||||
>
|
|
||||||
{summaryContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 加载模态框 */}
|
{/* 加载模态框 */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
|
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
|
||||||
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
|
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
|
||||||
<div style={{ fontSize: '16px', color: '#333' }}>正在生成 AI 总结...</div>
|
<div style={{ fontSize: '16px', color: '#333' }}>正在生成群聊日报...</div>
|
||||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
|
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
|
||||||
请稍候,生成后将自动转换为图片
|
正在分析记录、处理头像并生成 HTML 和长图
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -496,6 +446,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
>
|
>
|
||||||
📋 复制图片
|
📋 复制图片
|
||||||
</button>
|
</button>
|
||||||
|
{reportPaths && (
|
||||||
|
<button
|
||||||
|
onClick={() => window.api.revealGroupReport(reportPaths.pngPath)}
|
||||||
|
style={{ padding: '5px 10px', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
在文件夹中显示
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setGeneratedImage(null)}
|
onClick={() => setGeneratedImage(null)}
|
||||||
style={{ padding: '5px 10px', cursor: 'pointer' }}
|
style={{ padding: '5px 10px', cursor: 'pointer' }}
|
||||||
@@ -559,8 +517,43 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
{/* AI Settings Modal */}
|
{/* AI Settings Modal */}
|
||||||
{showSettingsModal && (
|
{showSettingsModal && (
|
||||||
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
|
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content ai-settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>AI 设置</h3>
|
<h3>AI 设置</h3>
|
||||||
|
<div className="ai-filter-section">
|
||||||
|
<div className="ai-filter-label">时间范围</div>
|
||||||
|
<div className="ai-date-options">
|
||||||
|
{SUMMARY_DATE_OPTIONS.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={summaryDateRange === option.value ? 'selected' : ''}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="summary-date-range"
|
||||||
|
value={option.value}
|
||||||
|
checked={summaryDateRange === option.value}
|
||||||
|
onChange={() => setSummaryDateRange(option.value)}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ai-filter-section">
|
||||||
|
<div className="ai-filter-label">消息类型</div>
|
||||||
|
<div className="ai-type-options">
|
||||||
|
{SUMMARY_TYPE_OPTIONS.map((option) => (
|
||||||
|
<label key={option.value}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={summaryMessageTypes.includes(option.value)}
|
||||||
|
onChange={() => toggleSummaryMessageType(option.value)}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '5px' }}>模型服务:</label>
|
<label style={{ display: 'block', marginBottom: '5px' }}>模型服务:</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import type { JSX, MouseEvent } from 'react'
|
import type { JSX, MouseEvent } from 'react'
|
||||||
|
|
||||||
interface ImageBubbleProps {
|
interface ImageBubbleProps {
|
||||||
@@ -19,6 +19,7 @@ export function ImageBubble({
|
|||||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const loadImage = useCallback(async () => {
|
const loadImage = useCallback(async () => {
|
||||||
if (imageUrl || loading) return
|
if (imageUrl || loading) return
|
||||||
@@ -50,10 +51,22 @@ export function ImageBubble({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageUrl || loading || error) return
|
if (imageUrl || loading || error) return
|
||||||
const timer = window.setTimeout(() => {
|
const element = containerRef.current
|
||||||
void loadImage()
|
if (!element || typeof IntersectionObserver === 'undefined') {
|
||||||
}, 0)
|
const timer = window.setTimeout(() => void loadImage(), 0)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) return
|
||||||
|
observer.disconnect()
|
||||||
|
void loadImage()
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px 0px' }
|
||||||
|
)
|
||||||
|
observer.observe(element)
|
||||||
|
return () => observer.disconnect()
|
||||||
}, [error, imageUrl, loadImage, loading])
|
}, [error, imageUrl, loadImage, loading])
|
||||||
|
|
||||||
const handleCopy = async (event: MouseEvent): Promise<void> => {
|
const handleCopy = async (event: MouseEvent): Promise<void> => {
|
||||||
@@ -88,7 +101,7 @@ export function ImageBubble({
|
|||||||
|
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="image-bubble image-placeholder">
|
<div ref={containerRef} className="image-bubble image-placeholder">
|
||||||
<div className="image-placeholder-icon">🖼</div>
|
<div className="image-placeholder-icon">🖼</div>
|
||||||
<div className="image-placeholder-text">加载图片中</div>
|
<div className="image-placeholder-text">加载图片中</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { Contact, Message } from '../../../shared/types'
|
||||||
|
import {
|
||||||
|
GroupDailyReport,
|
||||||
|
GroupReportMetadata,
|
||||||
|
ReportHeat,
|
||||||
|
ReportImportantMessage,
|
||||||
|
ReportQuestionAnswer,
|
||||||
|
ReportQuote,
|
||||||
|
ReportResource,
|
||||||
|
ReportSpeakerRank,
|
||||||
|
ReportTopic
|
||||||
|
} from '../../../shared/group-report'
|
||||||
|
|
||||||
|
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
|
||||||
|
|
||||||
|
原则:
|
||||||
|
1. 不得编造聊天中没有的事实、结论、参与者或链接内容。
|
||||||
|
2. 仅使用输入中的昵称,不输出 wxid、微信 ID、会话 ID 等内部标识。
|
||||||
|
3. 图片、表情、语音、视频或链接内容不可见时,仅标注消息类型,不要猜测。
|
||||||
|
4. 摘要说明发生了什么、大家如何回应、最后形成什么结论或氛围。
|
||||||
|
5. 语气准确、轻巧、有信息密度,避免侮辱性和歧视性评价。
|
||||||
|
6. 没有实际内容的可选栏目输出空数组,不要凑数。
|
||||||
|
7. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。
|
||||||
|
|
||||||
|
JSON 结构必须为:
|
||||||
|
{
|
||||||
|
"overview": "1至2句整体讨论风格与氛围",
|
||||||
|
"topics": [{
|
||||||
|
"title": "话题标题",
|
||||||
|
"timeRange": "HH:mm-HH:mm",
|
||||||
|
"heat": "高|中|低",
|
||||||
|
"participants": ["昵称"],
|
||||||
|
"summary": "话题摘要",
|
||||||
|
"conclusion": "结论或氛围",
|
||||||
|
"keywords": ["关键词"]
|
||||||
|
}],
|
||||||
|
"resources": [{"title":"资源名","description":"用途或内容","sender":"昵称"}],
|
||||||
|
"importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要"}],
|
||||||
|
"quotes": [{"messages":[{"sender":"昵称","content":"简短原话"}],"note":"点评"}],
|
||||||
|
"qa": [{"question":"问题","answer":"答案与结论","answerer":"昵称"}],
|
||||||
|
"keywords": ["关键词"]
|
||||||
|
}
|
||||||
|
|
||||||
|
topics 提取 3 至 7 个,参与者最多 5 人,quotes 最多 3 组,keywords 输出 8 至 15 个。`
|
||||||
|
|
||||||
|
const isInternalIdentifier = (value: string): boolean =>
|
||||||
|
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
|
||||||
|
|
||||||
|
const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
|
||||||
|
if (message.from === 'assistant') {
|
||||||
|
const ownGroupNickname = message.name?.trim()
|
||||||
|
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
|
||||||
|
return ownGroupNickname
|
||||||
|
}
|
||||||
|
return '我'
|
||||||
|
}
|
||||||
|
const candidate = isGroup ? message.name : contact?.m_nsNickName
|
||||||
|
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryContent = (message: Message): string => {
|
||||||
|
const data = message.contentData
|
||||||
|
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'image':
|
||||||
|
return '[图片]'
|
||||||
|
case 'sticker':
|
||||||
|
return '[表情]'
|
||||||
|
case 'voice':
|
||||||
|
return `[语音${data.duration ? ` ${data.duration}秒` : ''}]`
|
||||||
|
case 'share':
|
||||||
|
return `[分享] ${data.title}${data.des ? `:${data.des}` : ''}`
|
||||||
|
case 'quote': {
|
||||||
|
const reply = data.title || data.content || message.content || '[回复]'
|
||||||
|
const quotedSender =
|
||||||
|
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
|
||||||
|
return `${reply}(引用 ${quotedSender}:${data.quotedContent || `[引用${data.quotedType || '消息'}]`})`
|
||||||
|
}
|
||||||
|
case 'location':
|
||||||
|
return `[位置] ${data.poiname || data.label || '位置消息'}`
|
||||||
|
case 'card':
|
||||||
|
return `[名片] ${data.nickname || '微信名片'}`
|
||||||
|
case 'voip':
|
||||||
|
return `[通话] ${data.status}${data.duration ? `,${data.duration}秒` : ''}`
|
||||||
|
case 'system':
|
||||||
|
case 'text':
|
||||||
|
return data.content
|
||||||
|
case 'unknown':
|
||||||
|
return `[${message.type || '未知消息'}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[${message.type || '消息'}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
const localDate = (timestamp: number): string => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const localTime = (timestamp: number): string =>
|
||||||
|
new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface GroupReportInput {
|
||||||
|
prompt: string
|
||||||
|
metadata: GroupReportMetadata
|
||||||
|
topSpeakers: ReportSpeakerRank[]
|
||||||
|
activeTimeline: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildGroupReportInput = (
|
||||||
|
messages: Message[],
|
||||||
|
contact: Contact | null,
|
||||||
|
isGroup: boolean
|
||||||
|
): GroupReportInput => {
|
||||||
|
const rows = messages.map((message) => ({
|
||||||
|
datetime: message.datetime,
|
||||||
|
timestamp: new Date(message.datetime).getTime(),
|
||||||
|
sender: summarySender(message, contact, isGroup),
|
||||||
|
content: summaryContent(message),
|
||||||
|
avatar: message.img
|
||||||
|
}))
|
||||||
|
|
||||||
|
let firstTimestamp = Number.POSITIVE_INFINITY
|
||||||
|
let lastTimestamp = Number.NEGATIVE_INFINITY
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!Number.isFinite(row.timestamp)) continue
|
||||||
|
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
|
||||||
|
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
|
||||||
|
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
|
||||||
|
const speakerCounts = new Map<string, number>()
|
||||||
|
const hourCounts = new Map<number, number>()
|
||||||
|
const avatars: Record<string, string | undefined> = {}
|
||||||
|
for (const row of rows) {
|
||||||
|
speakerCounts.set(row.sender, (speakerCounts.get(row.sender) || 0) + 1)
|
||||||
|
if (Number.isFinite(row.timestamp)) {
|
||||||
|
const hour = new Date(row.timestamp).getHours()
|
||||||
|
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
|
||||||
|
}
|
||||||
|
if (row.avatar && !avatars[row.sender]) avatars[row.sender] = row.avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
|
||||||
|
.sort((left, right) => right.count - left.count)
|
||||||
|
.slice(0, 5)
|
||||||
|
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
|
||||||
|
.sort((left, right) => right.count - left.count)
|
||||||
|
.slice(0, 4)
|
||||||
|
.sort((left, right) => left.hour - right.hour)
|
||||||
|
.map(
|
||||||
|
({ hour, count }) =>
|
||||||
|
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59(${count}条)`
|
||||||
|
)
|
||||||
|
.join('、')
|
||||||
|
|
||||||
|
const startDate = localDate(firstTimestamp)
|
||||||
|
const endDate = localDate(lastTimestamp)
|
||||||
|
const sameDay = startDate === endDate
|
||||||
|
const dateRange = sameDay
|
||||||
|
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
|
||||||
|
: `${startDate} ${localTime(firstTimestamp)} 至 ${endDate} ${localTime(lastTimestamp)}`
|
||||||
|
const timeSpan = sameDay
|
||||||
|
? `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 3600000))}小时`
|
||||||
|
: `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 86400000))}天`
|
||||||
|
const contactName = contact?.m_nsNickName || ''
|
||||||
|
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
|
||||||
|
const metadata: GroupReportMetadata = {
|
||||||
|
groupName,
|
||||||
|
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
|
||||||
|
dateRange,
|
||||||
|
messageCount: rows.length,
|
||||||
|
activeUsers: speakerCounts.size,
|
||||||
|
timeSpan,
|
||||||
|
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||||
|
recordNote: `基于当前已加载的 ${rows.length} 条记录`,
|
||||||
|
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容仅按类型统计。',
|
||||||
|
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
|
||||||
|
avatars
|
||||||
|
}
|
||||||
|
const transcript = rows.map((row) => `${row.datetime} ${row.sender}:${row.content}`).join('\n')
|
||||||
|
const prompt = `请为以下微信${isGroup ? '群聊' : '对话'}记录生成日报 JSON。
|
||||||
|
|
||||||
|
会话名:${groupName}
|
||||||
|
时间范围:${dateRange}
|
||||||
|
消息数:${rows.length}
|
||||||
|
活跃人数:${speakerCounts.size}
|
||||||
|
完整性:仅基于当前应用已加载的记录。
|
||||||
|
|
||||||
|
聊天记录:
|
||||||
|
${transcript}`
|
||||||
|
return { prompt, metadata, topSpeakers, activeTimeline }
|
||||||
|
}
|
||||||
|
|
||||||
|
const asObject = (value: unknown): Record<string, unknown> =>
|
||||||
|
value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const asString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
|
||||||
|
const asName = (value: unknown): string => {
|
||||||
|
const name = asString(value)
|
||||||
|
return name && !isInternalIdentifier(name) ? name : '未命名群成员'
|
||||||
|
}
|
||||||
|
const asNumber = (value: unknown): number => {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
|
||||||
|
const asStrings = (value: unknown, limit = 20): string[] =>
|
||||||
|
asArray(value).map(asString).filter(Boolean).slice(0, limit)
|
||||||
|
|
||||||
|
const normalizeHeat = (value: unknown): ReportHeat => {
|
||||||
|
const heat = asString(value)
|
||||||
|
if (heat.includes('高')) return '高'
|
||||||
|
if (heat.includes('低')) return '低'
|
||||||
|
return '中'
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractJson = (raw: string): unknown => {
|
||||||
|
const cleaned = raw
|
||||||
|
.trim()
|
||||||
|
.replace(/^```(?:json)?\s*/i, '')
|
||||||
|
.replace(/\s*```$/i, '')
|
||||||
|
const start = cleaned.indexOf('{')
|
||||||
|
const end = cleaned.lastIndexOf('}')
|
||||||
|
if (start < 0 || end <= start) throw new Error('AI 未返回可解析的日报 JSON')
|
||||||
|
return JSON.parse(cleaned.slice(start, end + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseGroupDailyReport = (
|
||||||
|
raw: string,
|
||||||
|
topSpeakers: ReportSpeakerRank[],
|
||||||
|
activeTimeline: string
|
||||||
|
): GroupDailyReport => {
|
||||||
|
const root = asObject(extractJson(raw))
|
||||||
|
const topics: ReportTopic[] = asArray(root.topics)
|
||||||
|
.map((value) => {
|
||||||
|
const item = asObject(value)
|
||||||
|
return {
|
||||||
|
title: asString(item.title),
|
||||||
|
timeRange: asString(item.timeRange),
|
||||||
|
heat: normalizeHeat(item.heat),
|
||||||
|
participants: asArray(item.participants).map(asName).filter(Boolean).slice(0, 5),
|
||||||
|
summary: asString(item.summary),
|
||||||
|
conclusion: asString(item.conclusion),
|
||||||
|
keywords: asStrings(item.keywords, 8)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((topic) => topic.title && topic.summary)
|
||||||
|
.slice(0, 7)
|
||||||
|
if (!topics.length) throw new Error('AI 日报中没有有效话题')
|
||||||
|
|
||||||
|
const resources: ReportResource[] = asArray(root.resources)
|
||||||
|
.map((value) => {
|
||||||
|
const item = asObject(value)
|
||||||
|
return {
|
||||||
|
title: asString(item.title),
|
||||||
|
description: asString(item.description),
|
||||||
|
sender: item.sender ? asName(item.sender) : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => item.title && item.description)
|
||||||
|
const importantMessages: ReportImportantMessage[] = asArray(root.importantMessages)
|
||||||
|
.map((value) => {
|
||||||
|
const item = asObject(value)
|
||||||
|
return {
|
||||||
|
sender: asName(item.sender),
|
||||||
|
time: asString(item.time),
|
||||||
|
content: asString(item.content),
|
||||||
|
note: asString(item.note)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => item.sender && item.content)
|
||||||
|
const quotes: ReportQuote[] = asArray(root.quotes)
|
||||||
|
.map((value) => {
|
||||||
|
const item = asObject(value)
|
||||||
|
return {
|
||||||
|
messages: asArray(item.messages)
|
||||||
|
.map((messageValue) => {
|
||||||
|
const message = asObject(messageValue)
|
||||||
|
return { sender: asName(message.sender), content: asString(message.content) }
|
||||||
|
})
|
||||||
|
.filter((message) => message.sender && message.content),
|
||||||
|
note: asString(item.note)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((quote) => quote.messages.length)
|
||||||
|
.slice(0, 3)
|
||||||
|
const qa: ReportQuestionAnswer[] = asArray(root.qa)
|
||||||
|
.map((value) => {
|
||||||
|
const item = asObject(value)
|
||||||
|
return {
|
||||||
|
question: asString(item.question),
|
||||||
|
answer: asString(item.answer),
|
||||||
|
answerer: item.answerer ? asName(item.answerer) : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => item.question && item.answer)
|
||||||
|
|
||||||
|
return {
|
||||||
|
overview: asString(root.overview) || '基于已读取记录生成的群聊日报。',
|
||||||
|
topics,
|
||||||
|
resources,
|
||||||
|
importantMessages,
|
||||||
|
quotes,
|
||||||
|
qa,
|
||||||
|
analytics: {
|
||||||
|
topicHeat: topics.map((topic) => ({
|
||||||
|
topic: topic.title,
|
||||||
|
score: topic.heat === '高' ? 100 : topic.heat === '中' ? 65 : 35
|
||||||
|
})),
|
||||||
|
activeTimeline: activeTimeline || '暂无可用时间统计',
|
||||||
|
topSpeakers: topSpeakers.map((speaker) => ({
|
||||||
|
name: speaker.name,
|
||||||
|
count: Math.max(0, asNumber(speaker.count))
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
keywords: asStrings(root.keywords, 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
export type ReportHeat = '高' | '中' | '低'
|
||||||
|
|
||||||
|
export interface ReportTopic {
|
||||||
|
title: string
|
||||||
|
timeRange: string
|
||||||
|
heat: ReportHeat
|
||||||
|
participants: string[]
|
||||||
|
summary: string
|
||||||
|
conclusion?: string
|
||||||
|
keywords: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportResource {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
sender?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportImportantMessage {
|
||||||
|
sender: string
|
||||||
|
time: string
|
||||||
|
content: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportQuoteMessage {
|
||||||
|
sender: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportQuote {
|
||||||
|
messages: ReportQuoteMessage[]
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportQuestionAnswer {
|
||||||
|
question: string
|
||||||
|
answer: string
|
||||||
|
answerer?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTopicHeat {
|
||||||
|
topic: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSpeakerRank {
|
||||||
|
name: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupDailyReport {
|
||||||
|
overview: string
|
||||||
|
topics: ReportTopic[]
|
||||||
|
resources: ReportResource[]
|
||||||
|
importantMessages: ReportImportantMessage[]
|
||||||
|
quotes: ReportQuote[]
|
||||||
|
qa: ReportQuestionAnswer[]
|
||||||
|
analytics: {
|
||||||
|
topicHeat: ReportTopicHeat[]
|
||||||
|
activeTimeline: string
|
||||||
|
topSpeakers: ReportSpeakerRank[]
|
||||||
|
}
|
||||||
|
keywords: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupReportMetadata {
|
||||||
|
groupName: string
|
||||||
|
reportDate: string
|
||||||
|
dateRange: string
|
||||||
|
messageCount: number
|
||||||
|
activeUsers: number
|
||||||
|
timeSpan: string
|
||||||
|
generatedAt: string
|
||||||
|
recordNote: string
|
||||||
|
footerNote: string
|
||||||
|
heroParticipants: string[]
|
||||||
|
avatars: Record<string, string | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupReportExportRequest {
|
||||||
|
report: GroupDailyReport
|
||||||
|
metadata: GroupReportMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupReportExportResult {
|
||||||
|
success: boolean
|
||||||
|
htmlPath?: string
|
||||||
|
pngPath?: string
|
||||||
|
imageDataUrl?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user