mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-08 03:18:31 +09:00
Initial open source release
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# Optional. Defaults to ~/.wechat-radar
|
||||||
|
WECHAT_RADAR_DATA_DIR=
|
||||||
|
|
||||||
|
# Comma-separated names used to detect messages that mention you.
|
||||||
|
# Example: WECHAT_RADAR_MY_NAMES=张三,San Zhang,zhangsan
|
||||||
|
WECHAT_RADAR_MY_NAMES=
|
||||||
|
|
||||||
|
# Use demo data when wx-cli is unavailable. Set to 1 to enable.
|
||||||
|
WECHAT_RADAR_DEMO=0
|
||||||
|
|
||||||
|
# Optional model name used by Codex CLI based topic/link summarization.
|
||||||
|
WECHAT_RADAR_CODEX_MODEL=
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# next
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# local env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# local runtime data
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.tsbuildinfo
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
.cache/
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# agent/private workflow metadata
|
||||||
|
.beads/
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 WeChat Radar contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Privacy
|
||||||
|
|
||||||
|
WeChat Radar is designed as a local-first tool.
|
||||||
|
|
||||||
|
- Chat data is stored in a local SQLite database under `~/.wechat-radar` by default.
|
||||||
|
- The app does not upload chat records to a hosted service.
|
||||||
|
- The app reads data through your local `wx` CLI installation.
|
||||||
|
- Do not commit `*.db`, `.env.local`, logs, or generated runtime data.
|
||||||
|
- If you enable optional LLM/Codex workflows, review what data those tools receive before using them.
|
||||||
|
|
||||||
|
You are responsible for complying with local law, platform terms, and group member expectations before reading, storing, or processing chat data.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 微信雷达(WeChat Radar)
|
||||||
|
|
||||||
|
本地优先的微信群聊情报看板。它从本机 `wx-cli` 或 demo 数据中提取趋势、关键链接、行动机会、人物雷达和内容选题,帮助你从大量微信群消息里发现真正有用的信号。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 首页情报工作台:今日值得出手、趋势升温、异常信号、链接精选、人物雷达、内容选题。
|
||||||
|
- 链接情报:聚合最近一天出现的文章、工具和资源,按重复度和跨群扩散排序。
|
||||||
|
- 话题雷达:按日期构建跨群话题,并查看相关原始消息。
|
||||||
|
- 群列表与群详情:查看群活跃度、每日趋势、Top 发言人和完整消息。
|
||||||
|
- 本地 SQLite:默认数据目录 `~/.wechat-radar`。
|
||||||
|
- 首次启动向导:配置你的微信名、检查 `wx-cli`、确认隐私、可一键使用示例数据。
|
||||||
|
|
||||||
|
## 运行要求
|
||||||
|
|
||||||
|
- macOS
|
||||||
|
- Node.js 20+
|
||||||
|
- pnpm
|
||||||
|
- 可选:[`wx-cli`](https://github.com/jackwener/wx-cli),用于读取本机微信数据
|
||||||
|
|
||||||
|
没有 `wx-cli` 也可以使用 demo 模式预览界面。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm rebuild better-sqlite3
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 [http://localhost:3000](http://localhost:3000)。首次访问会进入 `/setup`。
|
||||||
|
|
||||||
|
## Demo 模式
|
||||||
|
|
||||||
|
如果你还没有配置 `wx-cli`,可以在 `/setup` 勾选“使用示例数据体验”。也可以命令行生成示例数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm demo:seed
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
示例数据会写入 `~/.wechat-radar/radar.db`。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
可以通过 `.env.local` 配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
WECHAT_RADAR_DATA_DIR=~/.wechat-radar
|
||||||
|
WECHAT_RADAR_MY_NAMES=你的微信名,你的群昵称
|
||||||
|
WECHAT_RADAR_DEMO=0
|
||||||
|
WECHAT_RADAR_CODEX_MODEL=
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以在首次启动向导中配置,配置会保存在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.wechat-radar/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
关键配置:
|
||||||
|
|
||||||
|
| 配置 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `WECHAT_RADAR_DATA_DIR` | 本地数据目录,默认 `~/.wechat-radar` |
|
||||||
|
| `WECHAT_RADAR_MY_NAMES` | 用于识别 @我的多个昵称,逗号分隔 |
|
||||||
|
| `WECHAT_RADAR_DEMO` | 设置为 `1` 时启用 demo 模式 |
|
||||||
|
| `WECHAT_RADAR_CODEX_MODEL` | 可选,Codex CLI 话题/链接整理使用 |
|
||||||
|
|
||||||
|
## wx-cli 接入
|
||||||
|
|
||||||
|
确认 `wx` 命令可用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wx --version
|
||||||
|
wx daemon status
|
||||||
|
wx sessions -n 10 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 daemon 没运行,请按你的 `wx-cli` 文档启动或修复。
|
||||||
|
|
||||||
|
## 数据存储
|
||||||
|
|
||||||
|
默认数据目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.wechat-radar/
|
||||||
|
├── radar.db
|
||||||
|
└── config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库包含:
|
||||||
|
|
||||||
|
- `messages`:同步后的本地消息。
|
||||||
|
- `daily_stats`:每日聚合统计。
|
||||||
|
- `mentions`:@我的索引。
|
||||||
|
- `groups` / `group_tags`:本地分组。
|
||||||
|
- `topics` / `topic_messages`:话题雷达结果。
|
||||||
|
- `link_intelligence_cache`:链接情报缓存。
|
||||||
|
|
||||||
|
不要把 `radar.db`、`.env.local` 或日志提交到 Git。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # 本地开发
|
||||||
|
pnpm build # 生产构建
|
||||||
|
pnpm lint # 代码检查
|
||||||
|
pnpm demo:seed # 写入示例数据
|
||||||
|
```
|
||||||
|
|
||||||
|
## 隐私说明
|
||||||
|
|
||||||
|
微信雷达默认只读本机数据,并把处理结果写入本地 SQLite。项目本身不提供云端服务,也不会自动上传聊天记录。
|
||||||
|
|
||||||
|
你需要自行确认读取、保存、处理聊天数据符合当地法律、平台规则和群成员预期。更多见 [PRIVACY.md](./PRIVACY.md)。
|
||||||
|
|
||||||
|
## 开源协议
|
||||||
|
|
||||||
|
MIT,见 [LICENSE](./LICENSE)。
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
Please open a GitHub security advisory or a private issue if you find a vulnerability.
|
||||||
|
|
||||||
|
## Local data
|
||||||
|
|
||||||
|
The most sensitive asset is your local SQLite database. Keep it outside synced folders and do not publish it. The default path is `~/.wechat-radar/radar.db`.
|
||||||
|
|
||||||
|
## Command execution
|
||||||
|
|
||||||
|
The app invokes `wx` via `child_process.execFile` with argument arrays. Avoid changing this to shell string execution.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { wxSessions } from '@/lib/wx';
|
||||||
|
import { listGroups, listAllTags, tagGroup } from '@/lib/groups';
|
||||||
|
import { classifyGroupHeuristic } from '@/lib/group-classifier';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
chatroom_id: string;
|
||||||
|
name: string;
|
||||||
|
summary: string;
|
||||||
|
current_group_ids: number[];
|
||||||
|
suggested_group_id: number | null;
|
||||||
|
suggested_group_name: string | null;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApplySchema = z.object({
|
||||||
|
picks: z.array(
|
||||||
|
z.object({
|
||||||
|
chatroom_id: z.string().min(1),
|
||||||
|
group_id: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const sessions = await wxSessions(500);
|
||||||
|
const groupSessions = sessions.filter((s) => s.is_group);
|
||||||
|
const groups = listGroups();
|
||||||
|
const tags = listAllTags();
|
||||||
|
const tagged = new Map<string, number[]>();
|
||||||
|
for (const t of tags) {
|
||||||
|
const arr = tagged.get(t.chatroom_id) ?? [];
|
||||||
|
arr.push(t.group_id);
|
||||||
|
tagged.set(t.chatroom_id, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions: Suggestion[] = groupSessions
|
||||||
|
.filter((g) => !tagged.has(g.username))
|
||||||
|
.slice(0, 200)
|
||||||
|
.map((g) => {
|
||||||
|
const guess = classifyGroupHeuristic(g.chat, g.summary, groups);
|
||||||
|
return {
|
||||||
|
chatroom_id: g.username,
|
||||||
|
name: g.chat,
|
||||||
|
summary: g.summary,
|
||||||
|
current_group_ids: tagged.get(g.username) ?? [],
|
||||||
|
suggested_group_id: guess?.group_id ?? null,
|
||||||
|
suggested_group_name: guess?.group_name ?? null,
|
||||||
|
reason: guess?.reason ?? '未匹配到关键词',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, groups, suggestions });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const parsed = ApplySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ ok: false, error: parsed.error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
for (const p of parsed.data.picks) {
|
||||||
|
tagGroup(p.chatroom_id, p.group_id);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true, applied: parsed.data.picks.length });
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { wxDaemonStatus } from '@/lib/wx';
|
||||||
|
import { cache, CK } from '@/lib/cache';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
let s = cache.get(CK.daemon()) as Awaited<ReturnType<typeof wxDaemonStatus>> | undefined;
|
||||||
|
if (!s) {
|
||||||
|
s = await wxDaemonStatus();
|
||||||
|
cache.set(CK.daemon(), s, 30);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true, ...s });
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { existsSync, statSync } from 'node:fs';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const dataDir = process.env.WECHAT_RADAR_DATA_DIR ?? join(homedir(), '.wechat-radar');
|
||||||
|
const dbPath = join(dataDir, 'radar.db');
|
||||||
|
const dbSize = existsSync(dbPath) ? statSync(dbPath).size : 0;
|
||||||
|
const counts = {
|
||||||
|
groups: (db().prepare('SELECT COUNT(*) AS n FROM groups').get() as { n: number }).n,
|
||||||
|
messages: (db().prepare('SELECT COUNT(*) AS n FROM messages').get() as { n: number }).n,
|
||||||
|
daily_stats: (db().prepare('SELECT COUNT(*) AS n FROM daily_stats').get() as { n: number }).n,
|
||||||
|
sync_state: (db().prepare('SELECT COUNT(*) AS n FROM sync_state').get() as { n: number }).n,
|
||||||
|
};
|
||||||
|
const topGroups = db().prepare(`
|
||||||
|
SELECT chatroom_id, COUNT(*) AS n FROM messages GROUP BY chatroom_id ORDER BY n DESC LIMIT 5
|
||||||
|
`).all();
|
||||||
|
return NextResponse.json({ dataDir, dbPath, dbSize, counts, topGroups });
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { setFavorite, tagGroup, untagGroup, tagsForChatroom } from '@/lib/groups';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const TagSchema = z.object({
|
||||||
|
chatroom_id: z.string().min(1),
|
||||||
|
group_id: z.number().int().positive(),
|
||||||
|
action: z.enum(['add', 'remove']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FavSchema = z.object({
|
||||||
|
chatroom_id: z.string().min(1),
|
||||||
|
fav: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const id = url.searchParams.get('chatroom_id');
|
||||||
|
if (!id) return NextResponse.json({ ok: false, error: 'chatroom_id required' }, { status: 400 });
|
||||||
|
return NextResponse.json({ ok: true, group_ids: tagsForChatroom(id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const tag = TagSchema.safeParse(body);
|
||||||
|
if (tag.success) {
|
||||||
|
if (tag.data.action === 'add') tagGroup(tag.data.chatroom_id, tag.data.group_id);
|
||||||
|
else untagGroup(tag.data.chatroom_id, tag.data.group_id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
const fav = FavSchema.safeParse(body);
|
||||||
|
if (fav.success) {
|
||||||
|
setFavorite(fav.data.chatroom_id, fav.data.fav);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: false, error: 'invalid payload' }, { status: 400 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { todayStr } from '@/lib/range';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { listMessagesForDate, getSyncState, listAllSyncedDates } from '@/lib/messages-store';
|
||||||
|
import { wxSessions } from '@/lib/wx';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface DailyHistoryRow {
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
ctx: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const chatroomId = decodeURIComponent(id);
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const date = url.searchParams.get('date') ?? todayStr();
|
||||||
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 1000), 5000);
|
||||||
|
|
||||||
|
// 拉群名(从 wx sessions)
|
||||||
|
let chatName = chatroomId;
|
||||||
|
try {
|
||||||
|
const sessions = await wxSessions(500);
|
||||||
|
const found = sessions.find((s) => s.username === chatroomId);
|
||||||
|
if (found) chatName = found.chat;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 当日消息
|
||||||
|
const messages = listMessagesForDate(chatroomId, date, limit);
|
||||||
|
|
||||||
|
// 当日聚合统计
|
||||||
|
const total = messages.length;
|
||||||
|
const senderMap = new Map<string, number>();
|
||||||
|
const typeMap = new Map<string, number>();
|
||||||
|
const hours = new Array(24).fill(0) as number[];
|
||||||
|
for (const m of messages) {
|
||||||
|
senderMap.set(m.sender, (senderMap.get(m.sender) ?? 0) + 1);
|
||||||
|
typeMap.set(m.type, (typeMap.get(m.type) ?? 0) + 1);
|
||||||
|
if (m.timestamp) {
|
||||||
|
const h = new Date(m.timestamp * 1000).getHours();
|
||||||
|
if (h >= 0 && h < 24) hours[h]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stats = {
|
||||||
|
chat: chatName,
|
||||||
|
total,
|
||||||
|
by_hour: hours.map((count, hour) => ({ hour, count })),
|
||||||
|
by_type: Array.from(typeMap.entries())
|
||||||
|
.map(([type, count]) => ({ type, count }))
|
||||||
|
.sort((a, b) => b.count - a.count),
|
||||||
|
top_senders: Array.from(senderMap.entries())
|
||||||
|
.map(([sender, count]) => ({ sender, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 20),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 历史日柱图
|
||||||
|
const dailyHistory = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT date, total FROM daily_stats WHERE chatroom_id = ? ORDER BY date ASC',
|
||||||
|
)
|
||||||
|
.all(chatroomId) as DailyHistoryRow[];
|
||||||
|
|
||||||
|
// 同步状态
|
||||||
|
const syncState = getSyncState(chatroomId);
|
||||||
|
const syncedDates = listAllSyncedDates(chatroomId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
chatroom_id: chatroomId,
|
||||||
|
date,
|
||||||
|
stats,
|
||||||
|
recent: messages,
|
||||||
|
daily_history: dailyHistory,
|
||||||
|
sync_state: syncState ?? null,
|
||||||
|
synced_dates: syncedDates,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createGroup, deleteGroup, listGroups } from '@/lib/groups';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const CreateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(40),
|
||||||
|
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||||
|
emoji: z.string().max(8).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ ok: true, groups: listGroups() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const parsed = CreateSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ ok: false, error: parsed.error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const id = createGroup(parsed.data);
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: e instanceof Error ? e.message : 'unknown' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const id = Number(url.searchParams.get('id'));
|
||||||
|
if (!id) return NextResponse.json({ ok: false, error: 'id required' }, { status: 400 });
|
||||||
|
deleteGroup(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { countMentions, listMentions, markMentionsSeen } from '@/lib/mentions';
|
||||||
|
import { wxSessions } from '@/lib/wx';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const limit = Math.min(Math.max(Number(url.searchParams.get('limit') ?? 1000), 1), 5000);
|
||||||
|
|
||||||
|
const sessions = await wxSessions(500);
|
||||||
|
const nameByChatroom = new Map<string, string>();
|
||||||
|
for (const s of sessions) nameByChatroom.set(s.username, s.chat);
|
||||||
|
|
||||||
|
const items = listMentions(limit).map((m) => ({
|
||||||
|
...m,
|
||||||
|
chat_name: nameByChatroom.get(m.chatroom_id) ?? m.chatroom_id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, total: countMentions(), items });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as { chatroom_id?: string };
|
||||||
|
markMentionsSeen(body.chatroom_id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { wxNewMessages, wxSessions } from '@/lib/wx';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 600;
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const interval = Math.max(Number(url.searchParams.get('interval') ?? 5000), 2000);
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const send = (obj: unknown) =>
|
||||||
|
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (stopped) return;
|
||||||
|
try {
|
||||||
|
const [msgs, sessions] = await Promise.all([
|
||||||
|
wxNewMessages(50).catch(() => []),
|
||||||
|
wxSessions(500).catch(() => []),
|
||||||
|
]);
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
for (const s of sessions) names.set(s.username, s.chat);
|
||||||
|
const enriched = msgs.map((m) => ({
|
||||||
|
...m,
|
||||||
|
chat_name: names.get(m.username) ?? m.username,
|
||||||
|
}));
|
||||||
|
send({ type: 'tick', count: msgs.length, items: enriched, ts: Date.now() });
|
||||||
|
} catch (e) {
|
||||||
|
send({ type: 'error', error: e instanceof Error ? e.message : 'unknown' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send initial heartbeat so the client knows the stream is open
|
||||||
|
send({ type: 'open', interval });
|
||||||
|
await tick();
|
||||||
|
timer = setInterval(tick, interval);
|
||||||
|
|
||||||
|
req.signal.addEventListener('abort', () => {
|
||||||
|
stopped = true;
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { existsSync, statSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const dataDir = process.env.WECHAT_RADAR_DATA_DIR ?? join(homedir(), '.wechat-radar');
|
||||||
|
const dest = join(dataDir, 'radar-recovered.db');
|
||||||
|
try {
|
||||||
|
db().pragma('wal_checkpoint(TRUNCATE)');
|
||||||
|
db().exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`);
|
||||||
|
const size = existsSync(dest) ? statSync(dest).size : 0;
|
||||||
|
return NextResponse.json({ ok: true, dest, size });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: e instanceof Error ? e.message : 'unknown' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { wxSessions } from '@/lib/wx';
|
||||||
|
import { syncFullHistory } from '@/lib/stats-aggregator';
|
||||||
|
import { normalizeDate, normalizeRangeKey, rangeToWindow, type RangeKey } from '@/lib/range';
|
||||||
|
import { readConfig } from '@/lib/config';
|
||||||
|
import { cache, CK } from '@/lib/cache';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 1800; // 30 min
|
||||||
|
|
||||||
|
interface RescanBody {
|
||||||
|
range?: RangeKey;
|
||||||
|
anchorDate?: string;
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
full?: boolean; // 一键全量:1 年
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as RescanBody;
|
||||||
|
|
||||||
|
let since: string;
|
||||||
|
let until: string;
|
||||||
|
let scope: string;
|
||||||
|
|
||||||
|
if (body.full) {
|
||||||
|
const w = rangeToWindow('year');
|
||||||
|
since = w.since;
|
||||||
|
until = w.until;
|
||||||
|
scope = 'full(365d)';
|
||||||
|
} else if (body.since && body.until) {
|
||||||
|
since = body.since;
|
||||||
|
until = body.until;
|
||||||
|
scope = `custom(${since}~${until})`;
|
||||||
|
} else {
|
||||||
|
const range = normalizeRangeKey(body.range, 'month');
|
||||||
|
const w = rangeToWindow(range, normalizeDate(body.anchorDate));
|
||||||
|
since = w.since;
|
||||||
|
until = w.until;
|
||||||
|
scope = range;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await wxSessions(500);
|
||||||
|
const targets = sessions
|
||||||
|
.filter((s) => s.is_group)
|
||||||
|
.map((s) => ({ chatroomId: s.username, display: s.chat }));
|
||||||
|
|
||||||
|
const cfg = readConfig();
|
||||||
|
const concurrency = cfg.rescanConcurrency ?? 6;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const send = (obj: unknown) =>
|
||||||
|
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
||||||
|
|
||||||
|
send({ type: 'start', scope, since, until, groups: targets.length });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncFullHistory({
|
||||||
|
targets,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
concurrency,
|
||||||
|
onProgress: (p) => send(p),
|
||||||
|
});
|
||||||
|
cache.del(CK.sessions());
|
||||||
|
send({
|
||||||
|
type: 'finished',
|
||||||
|
ok: result.ok,
|
||||||
|
failed: result.failed,
|
||||||
|
messages: result.messages,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
send({ type: 'error', error: e instanceof Error ? e.message : 'unknown' });
|
||||||
|
} finally {
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { wxSessions } from '@/lib/wx';
|
||||||
|
import type { WxSession } from '@/lib/wx-types';
|
||||||
|
import { cache, CK } from '@/lib/cache';
|
||||||
|
import { listGroups, listAllTags, listFavorites } from '@/lib/groups';
|
||||||
|
import { effectiveGroupIds } from '@/lib/group-classifier';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { readConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const sessions = await loadSessionsSafe(500);
|
||||||
|
|
||||||
|
const groups = listGroups();
|
||||||
|
const tags = listAllTags();
|
||||||
|
const favorites = new Set(listFavorites());
|
||||||
|
|
||||||
|
const tagsByChatroom = new Map<string, number[]>();
|
||||||
|
for (const t of tags) {
|
||||||
|
const arr = tagsByChatroom.get(t.chatroom_id) ?? [];
|
||||||
|
arr.push(t.group_id);
|
||||||
|
tagsByChatroom.set(t.chatroom_id, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsList = sessions.filter((s) => s.is_group);
|
||||||
|
|
||||||
|
const enriched = groupsList.map((s) => {
|
||||||
|
const groupIds = effectiveGroupIds(
|
||||||
|
s.chat,
|
||||||
|
s.summary,
|
||||||
|
tagsByChatroom.get(s.username) ?? [],
|
||||||
|
groups,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
chatroom_id: s.username,
|
||||||
|
name: s.chat,
|
||||||
|
last_msg_type: s.last_msg_type,
|
||||||
|
last_sender: s.last_sender,
|
||||||
|
summary: s.summary,
|
||||||
|
time: s.time,
|
||||||
|
timestamp: s.timestamp,
|
||||||
|
unread: s.unread,
|
||||||
|
is_favorite: favorites.has(s.username),
|
||||||
|
group_ids: groupIds,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberCounts = new Map<number, number>();
|
||||||
|
for (const g of enriched) {
|
||||||
|
for (const groupId of g.group_ids) {
|
||||||
|
memberCounts.set(groupId, (memberCounts.get(groupId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const categories = groups.map((g) => ({
|
||||||
|
...g,
|
||||||
|
member_count: memberCounts.get(g.id) ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
total: groupsList.length,
|
||||||
|
groups: enriched,
|
||||||
|
categories,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : 'unknown error';
|
||||||
|
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loadSessionsSafe(limit: number): Promise<WxSession[]> {
|
||||||
|
if (readConfig().demoMode) return listLocalSessionsFallback(limit);
|
||||||
|
const cached = cache.get(CK.sessions()) as WxSession[] | undefined;
|
||||||
|
try {
|
||||||
|
const sessions = await wxSessions(limit);
|
||||||
|
cache.set(CK.sessions(), sessions, 60);
|
||||||
|
return sessions;
|
||||||
|
} catch (e) {
|
||||||
|
if (cached?.length) return cached;
|
||||||
|
console.warn('wx sessions failed, falling back to local radar.db', e);
|
||||||
|
return listLocalSessionsFallback(limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLocalSessionsFallback(limit: number): WxSession[] {
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT m.chatroom_id, m.sender, m.content, m.time, m.timestamp, m.type
|
||||||
|
FROM messages m
|
||||||
|
JOIN (
|
||||||
|
SELECT chatroom_id, MAX(timestamp) AS timestamp
|
||||||
|
FROM messages
|
||||||
|
GROUP BY chatroom_id
|
||||||
|
) latest
|
||||||
|
ON latest.chatroom_id = m.chatroom_id
|
||||||
|
AND latest.timestamp = m.timestamp
|
||||||
|
GROUP BY m.chatroom_id
|
||||||
|
ORDER BY m.timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(limit) as Array<{
|
||||||
|
chatroom_id: string;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
chat: r.chatroom_id,
|
||||||
|
chat_type: 'group',
|
||||||
|
is_group: true,
|
||||||
|
last_msg_type: r.type,
|
||||||
|
last_sender: r.sender,
|
||||||
|
summary: r.content,
|
||||||
|
time: r.time,
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
unread: 0,
|
||||||
|
username: r.chatroom_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { DATA_DIR, configStatus, writeConfig } from '@/lib/config';
|
||||||
|
import { seedDemoData } from '@/lib/demo-data';
|
||||||
|
import { wxAvailable, wxDaemonStatus } from '@/lib/wx';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const SetupSchema = z.object({
|
||||||
|
myNicknames: z.array(z.string()).default([]),
|
||||||
|
privacyConfirmed: z.boolean(),
|
||||||
|
demoMode: z.boolean().default(false),
|
||||||
|
defaultSyncDays: z.number().int().min(1).max(365).default(7),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const [wxInstalled, daemon] = await Promise.all([wxAvailable(), wxDaemonStatus()]);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
...configStatus(),
|
||||||
|
dataDir: DATA_DIR,
|
||||||
|
checks: {
|
||||||
|
wxInstalled,
|
||||||
|
wxDaemonRunning: daemon.running,
|
||||||
|
wxDaemonPid: daemon.pid ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const parsed = SetupSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ ok: false, error: parsed.error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
const names = parsed.data.myNicknames.map((name) => name.trim()).filter(Boolean);
|
||||||
|
if (!parsed.data.demoMode && names.length === 0) {
|
||||||
|
return NextResponse.json({ ok: false, error: '请至少填写一个自己的微信名或群昵称' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const config = writeConfig({
|
||||||
|
myNicknames: names,
|
||||||
|
privacyConfirmed: parsed.data.privacyConfirmed,
|
||||||
|
demoMode: parsed.data.demoMode,
|
||||||
|
defaultSyncDays: parsed.data.defaultSyncDays,
|
||||||
|
setupCompleted: true,
|
||||||
|
});
|
||||||
|
const demo = parsed.data.demoMode ? seedDemoData() : null;
|
||||||
|
return NextResponse.json({ ok: true, configured: true, config, demo });
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { wxSessions } from '@/lib/wx';
|
||||||
|
import type { WxSession } from '@/lib/wx-types';
|
||||||
|
import { listCachedStatsRange } from '@/lib/stats-aggregator';
|
||||||
|
import { listAllTags, listGroups, listFavorites } from '@/lib/groups';
|
||||||
|
import { effectiveGroupIds } from '@/lib/group-classifier';
|
||||||
|
import { rangeToWindow, dateList, normalizeDate, normalizeRangeKey } from '@/lib/range';
|
||||||
|
import { countMentionsBetween } from '@/lib/mentions';
|
||||||
|
import { buildDashboardIntelligence } from '@/lib/dashboard-intelligence';
|
||||||
|
import { cache, CK } from '@/lib/cache';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { readConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const range = normalizeRangeKey(url.searchParams.get('range'), 'week');
|
||||||
|
const anchorDate = normalizeDate(url.searchParams.get('date'));
|
||||||
|
const w = rangeToWindow(range, anchorDate);
|
||||||
|
|
||||||
|
const sessions = await loadSessionsSafe(500);
|
||||||
|
const groups = sessions.filter((s) => s.is_group);
|
||||||
|
const groupNames = new Map(groups.map((g) => [g.username, g.chat]));
|
||||||
|
const allCount = groups.length;
|
||||||
|
|
||||||
|
const cached = listCachedStatsRange(w.since, w.until);
|
||||||
|
const totalMessages = cached.reduce((sum, r) => sum + r.total, 0);
|
||||||
|
|
||||||
|
const dates = dateList(w.since, w.until);
|
||||||
|
const trendByDate = new Map<string, number>(dates.map((d) => [d, 0]));
|
||||||
|
for (const r of cached) {
|
||||||
|
if (trendByDate.has(r.date)) {
|
||||||
|
trendByDate.set(r.date, (trendByDate.get(r.date) ?? 0) + r.total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const trend = dates.map((d) => ({ date: d, count: trendByDate.get(d) ?? 0 }));
|
||||||
|
|
||||||
|
const peak = trend.reduce((max, t) => (t.count > max.count ? t : max), { date: '', count: 0 });
|
||||||
|
const sumTrend = trend.reduce((s, t) => s + t.count, 0);
|
||||||
|
const avg = trend.length > 0 ? sumTrend / trend.length : 0;
|
||||||
|
|
||||||
|
const totalsByGroup = new Map<string, number>();
|
||||||
|
const sendersByGroup = new Map<string, Map<string, number>>();
|
||||||
|
for (const r of cached) {
|
||||||
|
totalsByGroup.set(r.chatroom_id, (totalsByGroup.get(r.chatroom_id) ?? 0) + r.total);
|
||||||
|
const senderMap = sendersByGroup.get(r.chatroom_id) ?? new Map<string, number>();
|
||||||
|
for (const s of r.top_senders) {
|
||||||
|
senderMap.set(s.sender, (senderMap.get(s.sender) ?? 0) + s.count);
|
||||||
|
}
|
||||||
|
sendersByGroup.set(r.chatroom_id, senderMap);
|
||||||
|
}
|
||||||
|
const active = groups.filter((g) => (totalsByGroup.get(g.username) ?? 0) > 0).length;
|
||||||
|
const silent = allCount - active;
|
||||||
|
|
||||||
|
const topActiveGroups = groups
|
||||||
|
.map((g) => ({
|
||||||
|
chatroom_id: g.username,
|
||||||
|
name: g.chat,
|
||||||
|
summary: g.summary,
|
||||||
|
total: totalsByGroup.get(g.username) ?? 0,
|
||||||
|
top_senders: Array.from(sendersByGroup.get(g.username)?.entries() ?? [])
|
||||||
|
.map(([sender, count]) => ({ sender, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 3),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.total > 0)
|
||||||
|
.sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
|
const tags = listAllTags();
|
||||||
|
const cats = listGroups();
|
||||||
|
const tagsByChatroom = new Map<string, number[]>();
|
||||||
|
for (const t of tags) {
|
||||||
|
const arr = tagsByChatroom.get(t.chatroom_id) ?? [];
|
||||||
|
arr.push(t.group_id);
|
||||||
|
tagsByChatroom.set(t.chatroom_id, arr);
|
||||||
|
}
|
||||||
|
const effectiveTagsByChatroom = new Map<string, number[]>();
|
||||||
|
for (const g of groups) {
|
||||||
|
effectiveTagsByChatroom.set(
|
||||||
|
g.username,
|
||||||
|
effectiveGroupIds(g.chat, g.summary, tagsByChatroom.get(g.username) ?? [], cats),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const taggedChatroomIds = new Set(
|
||||||
|
Array.from(effectiveTagsByChatroom.entries())
|
||||||
|
.filter(([, ids]) => ids.length > 0)
|
||||||
|
.map(([chatroomId]) => chatroomId),
|
||||||
|
);
|
||||||
|
const unsortedCount = groups.filter((g) => (effectiveTagsByChatroom.get(g.username) ?? []).length === 0).length;
|
||||||
|
|
||||||
|
const categoryStats = cats.map((c) => {
|
||||||
|
const memberIds = Array.from(effectiveTagsByChatroom.entries())
|
||||||
|
.filter(([, ids]) => ids.includes(c.id))
|
||||||
|
.map(([chatroomId]) => chatroomId);
|
||||||
|
const memberSet = new Set(memberIds);
|
||||||
|
let groupMessageCount = 0;
|
||||||
|
for (const r of cached) {
|
||||||
|
if (memberSet.has(r.chatroom_id)) groupMessageCount += r.total;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
color: c.color,
|
||||||
|
emoji: c.emoji,
|
||||||
|
group_count: memberIds.length,
|
||||||
|
message_count: groupMessageCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const unsortedMessageCount = cached
|
||||||
|
.filter((r) => !taggedChatroomIds.has(r.chatroom_id))
|
||||||
|
.reduce((s, r) => s + r.total, 0);
|
||||||
|
if (unsortedCount > 0) {
|
||||||
|
categoryStats.push({
|
||||||
|
id: -1,
|
||||||
|
name: '未分类',
|
||||||
|
color: '#94a3b8',
|
||||||
|
emoji: '❓',
|
||||||
|
group_count: unsortedCount,
|
||||||
|
message_count: unsortedMessageCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorites = listFavorites();
|
||||||
|
const mentionCount = countMentionsBetween(unixStartOfDay(w.since), unixEndOfDay(w.until));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
range,
|
||||||
|
window: w,
|
||||||
|
cards: {
|
||||||
|
active_groups: active,
|
||||||
|
total_groups: allCount,
|
||||||
|
total_messages: totalMessages,
|
||||||
|
mentions: mentionCount,
|
||||||
|
silent_groups: silent,
|
||||||
|
avg_per_group: allCount ? Math.round(totalMessages / allCount) : 0,
|
||||||
|
},
|
||||||
|
trend: {
|
||||||
|
data: trend,
|
||||||
|
peak,
|
||||||
|
avg,
|
||||||
|
total: sumTrend,
|
||||||
|
},
|
||||||
|
active_groups: topActiveGroups,
|
||||||
|
categories: categoryStats,
|
||||||
|
intelligence: buildDashboardIntelligence(w.until, groupNames),
|
||||||
|
sidebar_counts: {
|
||||||
|
all: allCount,
|
||||||
|
favorites: favorites.length,
|
||||||
|
unsorted: unsortedCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : 'unknown error';
|
||||||
|
console.error('/api/stats failed', e);
|
||||||
|
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionsSafe(limit: number): Promise<WxSession[]> {
|
||||||
|
if (readConfig().demoMode) return listLocalSessionsFallback(limit);
|
||||||
|
const cached = cache.get(CK.sessions()) as WxSession[] | undefined;
|
||||||
|
try {
|
||||||
|
const sessions = await wxSessions(limit);
|
||||||
|
cache.set(CK.sessions(), sessions, 60);
|
||||||
|
return sessions;
|
||||||
|
} catch (e) {
|
||||||
|
if (cached?.length) return cached;
|
||||||
|
console.warn('wx sessions failed, falling back to local radar.db', e);
|
||||||
|
return listLocalSessionsFallback(limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLocalSessionsFallback(limit: number): WxSession[] {
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT m.chatroom_id, m.sender, m.content, m.time, m.timestamp, m.type
|
||||||
|
FROM messages m
|
||||||
|
JOIN (
|
||||||
|
SELECT chatroom_id, MAX(timestamp) AS timestamp
|
||||||
|
FROM messages
|
||||||
|
GROUP BY chatroom_id
|
||||||
|
) latest
|
||||||
|
ON latest.chatroom_id = m.chatroom_id
|
||||||
|
AND latest.timestamp = m.timestamp
|
||||||
|
GROUP BY m.chatroom_id
|
||||||
|
ORDER BY m.timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(limit) as Array<{
|
||||||
|
chatroom_id: string;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
chat: r.chatroom_id,
|
||||||
|
chat_type: 'group',
|
||||||
|
is_group: true,
|
||||||
|
last_msg_type: r.type,
|
||||||
|
last_sender: r.sender,
|
||||||
|
summary: r.content,
|
||||||
|
time: r.time,
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
unread: 0,
|
||||||
|
username: r.chatroom_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unixStartOfDay(date: string) {
|
||||||
|
const [year, month, day] = date.split('-').map(Number);
|
||||||
|
return Math.floor(new Date(year, month - 1, day, 0, 0, 0, 0).getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unixEndOfDay(date: string) {
|
||||||
|
const [year, month, day] = date.split('-').map(Number);
|
||||||
|
return Math.floor(new Date(year, month - 1, day, 23, 59, 59, 999).getTime() / 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getTopicDetail } from '@/lib/topics';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
ctx: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const numId = Number(id);
|
||||||
|
if (!Number.isInteger(numId) || numId <= 0) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'invalid id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const detail = await getTopicDetail(numId);
|
||||||
|
if (!detail) return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
|
||||||
|
return NextResponse.json({ ok: true, ...detail });
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDailyLinkIntelligence } from '@/lib/link-intelligence';
|
||||||
|
import { todayStr } from '@/lib/range';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const date = url.searchParams.get('date') ?? todayStr();
|
||||||
|
if (!DATE_RE.test(date)) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'invalid date' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const refresh = url.searchParams.get('refresh') === '1' || url.searchParams.get('refresh') === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getDailyLinkIntelligence(date, { refresh });
|
||||||
|
return NextResponse.json({ ok: true, ...result });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: e instanceof Error ? e.message : 'unknown' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { listTopics } from '@/lib/topics';
|
||||||
|
import { todayStr } from '@/lib/range';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const date = url.searchParams.get('date') ?? todayStr();
|
||||||
|
const topics = listTopics(date);
|
||||||
|
return NextResponse.json({ ok: true, date, topics });
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { mimeFor, resolveWxImage } from '@/lib/wx-image';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const localIdStr = url.searchParams.get('local_id');
|
||||||
|
const chatroomId = url.searchParams.get('chatroom') ?? undefined;
|
||||||
|
const hintMonth = url.searchParams.get('month') ?? undefined;
|
||||||
|
|
||||||
|
const localId = Number(localIdStr);
|
||||||
|
if (!localIdStr || !Number.isInteger(localId) || localId <= 0) {
|
||||||
|
return new Response('invalid local_id', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动推断 month:从本地 messages 表查这条消息的日期
|
||||||
|
let resolvedMonth = hintMonth;
|
||||||
|
if (!resolvedMonth && chatroomId) {
|
||||||
|
const row = db()
|
||||||
|
.prepare('SELECT date FROM messages WHERE chatroom_id = ? AND local_id = ?')
|
||||||
|
.get(chatroomId, localId) as { date: string } | undefined;
|
||||||
|
if (row?.date) resolvedMonth = row.date.slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await resolveWxImage(localId, resolvedMonth);
|
||||||
|
if (!found) {
|
||||||
|
return new Response('image not found in wx cache', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = await readFile(/*turbopackIgnore: true*/ found.path);
|
||||||
|
return new Response(new Uint8Array(buf), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeFor(found.format),
|
||||||
|
'Cache-Control': 'public, max-age=86400, immutable',
|
||||||
|
'X-Image-Type': found.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import { ArrowLeft, Sparkles, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
type Group = { id: number; name: string; color: string; emoji: string | null };
|
||||||
|
type Suggestion = {
|
||||||
|
chatroom_id: string;
|
||||||
|
name: string;
|
||||||
|
summary: string;
|
||||||
|
suggested_group_id: number | null;
|
||||||
|
suggested_group_name: string | null;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ClassifyPage() {
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||||
|
const [picks, setPicks] = useState<Record<string, number | null>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const r = await fetch('/api/ai-classify');
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) {
|
||||||
|
setGroups(j.groups);
|
||||||
|
setSuggestions(j.suggestions);
|
||||||
|
const initial: Record<string, number | null> = {};
|
||||||
|
for (const s of j.suggestions as Suggestion[]) {
|
||||||
|
initial[s.chatroom_id] = s.suggested_group_id;
|
||||||
|
}
|
||||||
|
setPicks(initial);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => void load());
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(null);
|
||||||
|
const list = Object.entries(picks)
|
||||||
|
.filter(([, v]) => v !== null)
|
||||||
|
.map(([chatroom_id, group_id]) => ({ chatroom_id, group_id: group_id as number }));
|
||||||
|
if (list.length === 0) {
|
||||||
|
setMsg('没有可应用的分类');
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await fetch('/api/ai-classify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ picks: list }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
setBusy(false);
|
||||||
|
if (j.ok) {
|
||||||
|
setMsg(`已应用 ${j.applied} 条`);
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
setMsg('应用失败:' + (j.error ?? '未知'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const matched = suggestions.filter((s) => picks[s.chatroom_id] !== null).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/" className="text-[var(--text-3)] hover:text-[var(--text)]">
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="report-kicker">AI Classification</div>
|
||||||
|
<div className="flex items-center gap-2 text-[15px] font-semibold">
|
||||||
|
<Sparkles size={16} className="text-[var(--accent)]" />
|
||||||
|
AI 智能分类
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
|
||||||
|
{suggestions.length} 个未分组群 · 已建议 {matched} 条
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{msg && <span className="text-[12px] text-[var(--text-2)]">{msg}</span>}
|
||||||
|
<button className="btn btn-primary" onClick={apply} disabled={busy || matched === 0}>
|
||||||
|
<Check size={13} />
|
||||||
|
<span>{busy ? '应用中…' : `应用 ${matched} 条`}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{suggestions.length === 0 ? (
|
||||||
|
<div className="py-20 text-center text-[12px] text-[var(--text-3)]">
|
||||||
|
所有群都已分类
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead className="border-b border-[var(--border-soft)] text-[11px] uppercase tracking-wider text-[var(--text-3)]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-normal">群名</th>
|
||||||
|
<th className="px-4 py-2 text-left font-normal">最近消息</th>
|
||||||
|
<th className="px-4 py-2 text-left font-normal">建议分组</th>
|
||||||
|
<th className="px-4 py-2 text-left font-normal">理由</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<tr
|
||||||
|
key={s.chatroom_id}
|
||||||
|
className="border-b border-[var(--border-soft)] last:border-b-0 hover:bg-[var(--surface-2)]"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 max-w-[200px]">
|
||||||
|
<div className="truncate text-[var(--text)]">{s.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 max-w-[260px]">
|
||||||
|
<div className="truncate text-[11px] text-[var(--text-3)]">{s.summary}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<select
|
||||||
|
value={picks[s.chatroom_id] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPicks((p) => ({
|
||||||
|
...p,
|
||||||
|
[s.chatroom_id]: e.target.value ? Number(e.target.value) : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="control-surface rounded px-2 py-1 text-[12px] text-[var(--text)] outline-none"
|
||||||
|
>
|
||||||
|
<option value="">— 跳过 —</option>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.emoji ?? ''} {g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-[11px] text-[var(--text-3)]">{s.reason}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+146
@@ -0,0 +1,146 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0d0b;
|
||||||
|
--bg-2: #10110e;
|
||||||
|
--surface: #141713;
|
||||||
|
--surface-2: #1a1e19;
|
||||||
|
--surface-3: #24281f;
|
||||||
|
--border: #33382f;
|
||||||
|
--border-soft: rgba(184, 176, 145, 0.16);
|
||||||
|
--text: #edf1e8;
|
||||||
|
--text-2: #b0b3a8;
|
||||||
|
--text-3: #7d8177;
|
||||||
|
--accent: #7dd3a8;
|
||||||
|
--accent-2: #46b978;
|
||||||
|
--accent-soft: rgba(125, 211, 168, 0.13);
|
||||||
|
--warn: #d5a253;
|
||||||
|
--warn-soft: rgba(213, 162, 83, 0.14);
|
||||||
|
--danger: #df6b6b;
|
||||||
|
--danger-soft: rgba(223, 107, 107, 0.14);
|
||||||
|
--shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-bg: var(--bg);
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
--color-surface-2: var(--surface-2);
|
||||||
|
--color-surface-3: var(--surface-3);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-text: var(--text);
|
||||||
|
--color-text-2: var(--text-2);
|
||||||
|
--color-text-3: var(--text-3);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-2: var(--accent-2);
|
||||||
|
--color-warn: var(--warn);
|
||||||
|
--color-danger: var(--danger);
|
||||||
|
--font-sans: ui-sans-serif, system-ui, -apple-system, "PingFang SC",
|
||||||
|
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-feature-settings: "tnum" 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
linear-gradient(120deg, rgba(125, 211, 168, 0.055), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgba(213, 162, 83, 0.075), transparent 30%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #26362d;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #355044;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(237, 241, 232, 0.035), transparent 48%),
|
||||||
|
var(--surface);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: rgba(16, 24, 18, 0.86);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s, color 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
border-color: rgba(125, 211, 168, 0.38);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
.btn-active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: rgba(125, 211, 168, 0.36);
|
||||||
|
}
|
||||||
|
.btn-warn {
|
||||||
|
background: var(--warn-soft);
|
||||||
|
color: var(--warn);
|
||||||
|
border-color: rgba(213, 162, 83, 0.38);
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(180deg, var(--accent), var(--accent-2));
|
||||||
|
color: #07120c;
|
||||||
|
border-color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(180deg, #98e2bb, var(--accent-2));
|
||||||
|
color: #07120c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-surface {
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: rgba(16, 24, 18, 0.82);
|
||||||
|
box-shadow: 0 1px 0 rgba(237, 241, 232, 0.03) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-kicker {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-chip {
|
||||||
|
border: 1px solid rgba(125, 211, 168, 0.2);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, use } from 'react';
|
||||||
|
import dynamicImport from 'next/dynamic';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import MessageContent from '@/components/MessageContent';
|
||||||
|
import { ArrowLeft, BarChart3, Calendar, History, ListFilter, MessageSquare, Star, Trophy } from 'lucide-react';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
|
||||||
|
const ReactECharts = dynamicImport(() => import('echarts-for-react'), { ssr: false });
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
type DailyHistory = { date: string; total: number };
|
||||||
|
|
||||||
|
type Detail = {
|
||||||
|
ok: boolean;
|
||||||
|
chatroom_id: string;
|
||||||
|
date: string;
|
||||||
|
stats: {
|
||||||
|
chat: string;
|
||||||
|
total: number;
|
||||||
|
by_hour: Array<{ hour: number; count: number }>;
|
||||||
|
by_type: Array<{ type: string; count: number }>;
|
||||||
|
top_senders: Array<{ sender: string; count: number }>;
|
||||||
|
} | null;
|
||||||
|
recent: Array<{
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
daily_history: DailyHistory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GroupDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const chatroomId = decodeURIComponent(id);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const requestedDate = searchParams.get('date');
|
||||||
|
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [date, setDate] = useState(requestedDate ?? today);
|
||||||
|
const [data, setData] = useState<Detail | null>(null);
|
||||||
|
const [fav, setFav] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = async (d: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/group/${encodeURIComponent(chatroomId)}?date=${d}&limit=500`);
|
||||||
|
const j = (await r.json()) as Detail;
|
||||||
|
if (!j.ok) {
|
||||||
|
setErr('详情加载失败');
|
||||||
|
} else {
|
||||||
|
setData(j);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : '未知错误');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => void load(date));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chatroomId, date]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestedDate || !data || date !== today || (data.stats?.total ?? 0) > 0) return;
|
||||||
|
const latest = data.daily_history
|
||||||
|
.filter((d) => d.total > 0)
|
||||||
|
.sort((a, b) => b.date.localeCompare(a.date))[0];
|
||||||
|
if (latest && latest.date !== date) queueMicrotask(() => setDate(latest.date));
|
||||||
|
}, [data, date, requestedDate, today]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/group-tags?chatroom_id=${encodeURIComponent(chatroomId)}`);
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok && Array.isArray(j.group_ids)) setFav(false); // tags only, fav read separately if needed
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
}, [chatroomId]);
|
||||||
|
|
||||||
|
const toggleFav = async () => {
|
||||||
|
const next = !fav;
|
||||||
|
setFav(next);
|
||||||
|
await fetch('/api/group-tags', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ chatroom_id: chatroomId, fav: next }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hourOption: EChartsOption | null = data?.stats
|
||||||
|
? {
|
||||||
|
grid: { top: 20, right: 16, bottom: 28, left: 36 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: '#101812',
|
||||||
|
borderColor: '#27342c',
|
||||||
|
textStyle: { color: '#edf1e8' },
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.stats.by_hour.map((h) => `${h.hour}:00`),
|
||||||
|
axisLine: { lineStyle: { color: '#27342c' } },
|
||||||
|
axisLabel: { color: '#737f75', fontSize: 10 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(154,174,158,0.12)' } },
|
||||||
|
axisLabel: { color: '#737f75', fontSize: 10 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: data.stats.by_hour.map((h) => h.count),
|
||||||
|
itemStyle: { color: '#7dd3a8' },
|
||||||
|
barWidth: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const dailyOption: EChartsOption | null =
|
||||||
|
data?.daily_history && data.daily_history.length > 0
|
||||||
|
? {
|
||||||
|
grid: { top: 20, right: 16, bottom: 30, left: 36 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: '#101812',
|
||||||
|
borderColor: '#27342c',
|
||||||
|
textStyle: { color: '#edf1e8' },
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.daily_history.map((d) => d.date.slice(5)),
|
||||||
|
axisLine: { lineStyle: { color: '#27342c' } },
|
||||||
|
axisLabel: { color: '#737f75', fontSize: 10 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(154,174,158,0.12)' } },
|
||||||
|
axisLabel: { color: '#737f75', fontSize: 10 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: data.daily_history.map((d) => ({
|
||||||
|
value: d.total,
|
||||||
|
itemStyle: { color: d.date === date ? '#7dd3a8' : '#28372f' },
|
||||||
|
})),
|
||||||
|
barWidth: 14,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const dateOptions = useMemo(() => {
|
||||||
|
if (!data?.daily_history) return [];
|
||||||
|
return data.daily_history
|
||||||
|
.filter((d) => d.total > 0 || d.date === date)
|
||||||
|
.map((d) => d.date)
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
}, [data, date]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Link href="/" className="shrink-0 text-[var(--text-3)] hover:text-[var(--text)]">
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="report-kicker">Group Brief</div>
|
||||||
|
<div className="truncate text-[15px] font-semibold">
|
||||||
|
{data?.stats?.chat ?? (loading ? '加载中…' : chatroomId)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
|
||||||
|
{date} · 当日 {data?.stats?.total ?? 0} 条 · 历史 {data?.daily_history?.length ?? 0} 天
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="control-surface flex items-center gap-1.5 rounded-md px-2.5 py-1.5">
|
||||||
|
<Calendar size={13} className="text-[var(--text-3)]" />
|
||||||
|
{dateOptions.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="bg-transparent text-[12px] outline-none"
|
||||||
|
>
|
||||||
|
{!dateOptions.includes(date) && <option value={date}>{date}(未扫描)</option>}
|
||||||
|
{dateOptions.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{d}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className={`btn ${fav ? 'btn-warn' : ''}`} onClick={toggleFav}>
|
||||||
|
<Star size={13} />
|
||||||
|
{fav ? '已收藏' : '收藏'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
{err && <div className="card p-4 text-[12px] text-[var(--danger)]">{err}</div>}
|
||||||
|
|
||||||
|
{/* 历史日活跃柱图 */}
|
||||||
|
{dailyOption && (
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||||
|
<History size={14} className="text-[var(--accent)]" />
|
||||||
|
历史每日消息量
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-3)]">
|
||||||
|
共 {data!.daily_history.length} 天 · 点选日期查看
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ReactECharts
|
||||||
|
option={dailyOption}
|
||||||
|
style={{ height: 160 }}
|
||||||
|
onEvents={{
|
||||||
|
click: (e: { name: string }) => {
|
||||||
|
const matched = data?.daily_history.find((d) => d.date.slice(5) === e.name);
|
||||||
|
if (matched) setDate(matched.date);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 当日 24 小时分布 */}
|
||||||
|
{hourOption && (data?.stats?.total ?? 0) > 0 && (
|
||||||
|
<div className="card mt-4 p-5">
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-[14px] font-semibold">
|
||||||
|
<BarChart3 size={14} className="text-[var(--accent)]" />
|
||||||
|
{date} 24 小时分布
|
||||||
|
</div>
|
||||||
|
<ReactECharts option={hourOption} style={{ height: 200 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top 发言人 + 消息类型 */}
|
||||||
|
{data?.stats && data.stats.total > 0 && (
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="mb-3 flex items-center gap-1.5 text-[14px] font-semibold">
|
||||||
|
<Trophy size={14} className="text-[var(--warn)]" />
|
||||||
|
Top 发言人
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{data.stats.top_senders.slice(0, 12).map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`${s.sender}-${i}`}
|
||||||
|
className="flex items-center justify-between text-[13px]"
|
||||||
|
>
|
||||||
|
<span className="truncate text-[var(--text-2)]">
|
||||||
|
{i + 1}. {s.sender}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums text-[var(--text)]">{s.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="mb-3 flex items-center gap-1.5 text-[14px] font-semibold">
|
||||||
|
<ListFilter size={14} className="text-[var(--accent)]" />
|
||||||
|
消息类型
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{data.stats.by_type.map((t, i) => (
|
||||||
|
<div
|
||||||
|
key={`${t.type}-${i}`}
|
||||||
|
className="flex items-center justify-between text-[13px]"
|
||||||
|
>
|
||||||
|
<span className="text-[var(--text-2)]">{t.type}</span>
|
||||||
|
<span className="tabular-nums text-[var(--text)]">{t.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 当日完整消息列表 */}
|
||||||
|
<div className="card mt-4 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-soft)] px-5 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||||
|
<MessageSquare size={14} className="text-[var(--accent)]" />
|
||||||
|
{date} 完整消息
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-3)]">
|
||||||
|
{loading ? '加载中…' : `共 ${data?.recent.length ?? 0} 条`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!loading && data?.recent && data.recent.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-[12px] text-[var(--text-3)]">
|
||||||
|
当日无消息
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-[var(--border-soft)]">
|
||||||
|
{(data?.recent ?? []).map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.local_id}
|
||||||
|
className="grid grid-cols-[120px_1fr_60px_70px] gap-3 px-5 py-2 text-[12px] hover:bg-[var(--surface-2)]"
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium text-[var(--text)]">{m.sender}</span>
|
||||||
|
<div className="text-[var(--text-2)]">
|
||||||
|
<MessageContent content={m.content} chatroomId={chatroomId} />
|
||||||
|
</div>
|
||||||
|
<span className="text-right text-[10px] text-[var(--text-3)]">{m.type}</span>
|
||||||
|
<span className="text-right text-[var(--text-3)] tabular-nums">
|
||||||
|
{m.time.slice(11)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import { Star, ChevronRight, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
chatroom_id: string;
|
||||||
|
name: string;
|
||||||
|
summary: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
unread: number;
|
||||||
|
is_favorite: boolean;
|
||||||
|
group_ids: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionsResp = {
|
||||||
|
ok: boolean;
|
||||||
|
total: number;
|
||||||
|
groups: Group[];
|
||||||
|
categories: Array<{ id: number; name: string; color: string; emoji: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GroupsListPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<GroupsListFallback />}>
|
||||||
|
<GroupsListContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupsListContent() {
|
||||||
|
const params = useSearchParams();
|
||||||
|
const filter = params.get('filter') ?? 'all';
|
||||||
|
const groupId = params.get('group_id');
|
||||||
|
|
||||||
|
const [data, setData] = useState<SessionsResp | null>(null);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [bumping, setBumping] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const r = await fetch('/api/sessions');
|
||||||
|
setData(await r.json());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const r = await fetch('/api/sessions');
|
||||||
|
const json = (await r.json()) as SessionsResp;
|
||||||
|
if (!cancelled) setData(json);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
let list = data.groups;
|
||||||
|
if (filter === 'favorites') list = list.filter((g) => g.is_favorite);
|
||||||
|
if (filter === 'unsorted') list = list.filter((g) => g.group_ids.length === 0);
|
||||||
|
if (filter === 'group' && groupId)
|
||||||
|
list = list.filter((g) => g.group_ids.includes(Number(groupId)));
|
||||||
|
if (q.trim()) {
|
||||||
|
const k = q.trim().toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
(g) => g.name.toLowerCase().includes(k) || g.summary.toLowerCase().includes(k),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...list].sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
}, [data, filter, groupId, q]);
|
||||||
|
|
||||||
|
const title = useMemo(() => {
|
||||||
|
if (filter === 'favorites') return '收藏的群';
|
||||||
|
if (filter === 'unsorted') return '未分组的群';
|
||||||
|
if (filter === 'group' && groupId && data) {
|
||||||
|
const c = data.categories.find((c) => c.id === Number(groupId));
|
||||||
|
return c ? `分组:${c.emoji ?? ''} ${c.name}` : '分组';
|
||||||
|
}
|
||||||
|
return '所有群';
|
||||||
|
}, [filter, groupId, data]);
|
||||||
|
|
||||||
|
const toggleFav = async (chatroomId: string, current: boolean) => {
|
||||||
|
setBumping(chatroomId);
|
||||||
|
await fetch('/api/group-tags', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ chatroom_id: chatroomId, fav: !current }),
|
||||||
|
});
|
||||||
|
setBumping(null);
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<div className="report-kicker">Group Directory</div>
|
||||||
|
<div className="text-[15px] font-semibold">{title}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
|
||||||
|
{filtered.length} / {data?.total ?? 0} 个群
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="control-surface flex items-center gap-2 rounded-md px-2.5 py-1.5">
|
||||||
|
<Search size={13} className="text-[var(--text-3)]" />
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="搜索群名或最近消息…"
|
||||||
|
className="w-60 bg-transparent text-[12px] outline-none placeholder:text-[var(--text-3)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{!data ? (
|
||||||
|
<div className="py-20 text-center text-[12px] text-[var(--text-3)]">加载中…</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="py-20 text-center text-[12px] text-[var(--text-3)]">没有匹配的群</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filtered.map((g) => (
|
||||||
|
<div
|
||||||
|
key={g.chatroom_id}
|
||||||
|
className="group grid grid-cols-[1fr_140px_60px_24px] items-center gap-3 rounded-md border border-transparent px-3 py-2.5 text-[13px] hover:border-[var(--border-soft)] hover:bg-[var(--surface-2)]"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/groups/${encodeURIComponent(g.chatroom_id)}`}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="truncate font-medium text-[var(--text)]">{g.name}</div>
|
||||||
|
{g.unread > 0 && (
|
||||||
|
<span className="shrink-0 rounded bg-[var(--danger)] px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||||
|
{g.unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[11px] text-[var(--text-3)]">{g.summary}</div>
|
||||||
|
</Link>
|
||||||
|
<div className="text-right text-[11px] text-[var(--text-3)]">{g.time}</div>
|
||||||
|
<button
|
||||||
|
className={bumping === g.chatroom_id ? 'opacity-50' : ''}
|
||||||
|
onClick={() => toggleFav(g.chatroom_id, g.is_favorite)}
|
||||||
|
title={g.is_favorite ? '取消收藏' : '加入收藏'}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={14}
|
||||||
|
className={
|
||||||
|
g.is_favorite ? 'fill-[var(--warn)] text-[var(--warn)]' : 'text-[var(--text-3)] hover:text-[var(--text)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={`/groups/${encodeURIComponent(g.chatroom_id)}`}
|
||||||
|
className="text-[var(--text-3)] hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupsListFallback() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<div className="report-kicker">Group Directory</div>
|
||||||
|
<div className="text-[15px] font-semibold">所有群</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">加载中…</div>
|
||||||
|
</div>
|
||||||
|
<div className="control-surface flex items-center gap-2 rounded-md px-2.5 py-1.5">
|
||||||
|
<Search size={13} className="text-[var(--text-3)]" />
|
||||||
|
<div className="h-4 w-60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
<div className="py-20 text-center text-[12px] text-[var(--text-3)]">加载中…</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "微信雷达",
|
||||||
|
description: "本地优先的微信群聊情报看板",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" className="h-full">
|
||||||
|
<body className="min-h-full">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import { Calendar, ExternalLink, Newspaper, RefreshCw, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
|
type LinkInsight = {
|
||||||
|
kind: 'article' | 'tool';
|
||||||
|
url: string;
|
||||||
|
canonical_url: string;
|
||||||
|
title: string;
|
||||||
|
domain: string;
|
||||||
|
count: number;
|
||||||
|
group_count: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
sources: Array<{
|
||||||
|
chatroom_id: string;
|
||||||
|
chat_name: string;
|
||||||
|
sender: string;
|
||||||
|
time: string;
|
||||||
|
local_id: number;
|
||||||
|
snippet: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinkInsightResp = {
|
||||||
|
ok: boolean;
|
||||||
|
date: string;
|
||||||
|
articles: LinkInsight[];
|
||||||
|
tools: LinkInsight[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function localToday(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LinksPage() {
|
||||||
|
const [date, setDate] = useState(() => localToday());
|
||||||
|
const [links, setLinks] = useState<LinkInsightResp | null>(null);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/topics/links?date=${date}`);
|
||||||
|
const j = (await r.json()) as LinkInsightResp;
|
||||||
|
if (!cancelled && j.ok) setLinks(j);
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
|
const loading = links?.date !== date;
|
||||||
|
|
||||||
|
async function refreshLinks() {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/topics/links?date=${date}&refresh=1`, { cache: 'no-store' });
|
||||||
|
const j = (await r.json()) as LinkInsightResp;
|
||||||
|
if (j.ok) setLinks(j);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<div className="report-kicker">Link Intelligence</div>
|
||||||
|
<div className="flex items-center gap-2 text-[15px] font-semibold">
|
||||||
|
<Newspaper size={16} className="text-[var(--accent)]" />
|
||||||
|
链接情报 · 文章与工具
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
|
||||||
|
{loading
|
||||||
|
? `${date} · 加载中…`
|
||||||
|
: `${date} · ${links.articles.length} 篇文章 · ${links.tools.length} 个工具/资源`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="control-surface flex items-center gap-1.5 rounded-md px-2.5 py-1.5">
|
||||||
|
<Calendar size={13} className="text-[var(--text-3)]" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refreshLinks}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="btn"
|
||||||
|
title="重新整理当天链接标题和去重结果"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} />
|
||||||
|
{refreshing ? '整理中' : '重新整理'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid flex-1 grid-cols-1 gap-5 overflow-hidden p-5 xl:grid-cols-2">
|
||||||
|
<LinkInsightPanel
|
||||||
|
title="文章链接"
|
||||||
|
icon={<Newspaper size={14} className="text-[var(--accent)]" />}
|
||||||
|
items={loading ? [] : links.articles}
|
||||||
|
date={date}
|
||||||
|
loading={loading}
|
||||||
|
empty="当天还没有文章链接"
|
||||||
|
/>
|
||||||
|
<LinkInsightPanel
|
||||||
|
title="工具与资源"
|
||||||
|
icon={<Wrench size={14} className="text-[var(--warn)]" />}
|
||||||
|
items={loading ? [] : links.tools}
|
||||||
|
date={date}
|
||||||
|
loading={loading}
|
||||||
|
empty="当天还没有工具链接"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkInsightPanel({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
items,
|
||||||
|
date,
|
||||||
|
loading,
|
||||||
|
empty,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
items: LinkInsight[];
|
||||||
|
date: string;
|
||||||
|
loading: boolean;
|
||||||
|
empty: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="card flex min-h-0 min-w-0 flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-soft)] px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-[12px] font-semibold">
|
||||||
|
{icon}
|
||||||
|
<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-[var(--text-3)]">{loading ? '加载中' : `${items.length} 条`}</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-16 text-center text-[11px] text-[var(--text-3)]">加载中…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-[11px] text-[var(--text-3)]">{empty}</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<LinkInsightRow key={item.canonical_url} item={item} date={date} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkInsightRow({ item, date }: { item: LinkInsight; date: string }) {
|
||||||
|
const first = item.sources[0];
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-transparent px-2 py-2 hover:border-[var(--border-soft)] hover:bg-[var(--surface-2)]">
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="group flex min-w-0 items-start justify-between gap-2"
|
||||||
|
title={item.title}
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)] group-hover:text-[var(--accent)]">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block truncate text-[10px] text-[var(--text-3)]">{item.domain}</span>
|
||||||
|
</span>
|
||||||
|
<ExternalLink size={12} className="mt-0.5 shrink-0 text-[var(--text-3)] group-hover:text-[var(--accent)]" />
|
||||||
|
</a>
|
||||||
|
<div className="mt-1 flex items-center justify-between gap-2 text-[10px] text-[var(--text-3)]">
|
||||||
|
<Link
|
||||||
|
href={`/groups/${encodeURIComponent(first.chatroom_id)}?date=${date}`}
|
||||||
|
className="min-w-0 truncate text-[var(--text-2)] hover:text-[var(--accent)]"
|
||||||
|
title={`${first.chat_name} · ${first.sender}`}
|
||||||
|
>
|
||||||
|
{first.chat_name} · {first.sender}
|
||||||
|
</Link>
|
||||||
|
<span className="shrink-0 tabular-nums">
|
||||||
|
{item.count > 1 ? `${item.count} 次 · ` : ''}
|
||||||
|
{item.last_seen?.slice(11) ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{first.snippet && (
|
||||||
|
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{first.snippet}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import MessageContent from '@/components/MessageContent';
|
||||||
|
import { AtSign, ChevronRight, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
type MentionItem = {
|
||||||
|
chatroom_id: string;
|
||||||
|
chat_name: string;
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
seen: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MentionsResp = {
|
||||||
|
ok: boolean;
|
||||||
|
total: number;
|
||||||
|
items: MentionItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function dateOf(time: string) {
|
||||||
|
return time?.slice(0, 10) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentionsPage() {
|
||||||
|
const [data, setData] = useState<MentionsResp | null>(null);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/mentions?limit=5000');
|
||||||
|
const j = (await r.json()) as MentionsResp;
|
||||||
|
if (!cancelled && j.ok) setData(j);
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
const keyword = q.trim().toLowerCase();
|
||||||
|
if (!keyword) return items;
|
||||||
|
return items.filter((item) => {
|
||||||
|
const haystack = `${item.chat_name} ${item.sender} ${item.content}`.toLowerCase();
|
||||||
|
return haystack.includes(keyword);
|
||||||
|
});
|
||||||
|
}, [data, q]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border)] px-6 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-[15px] font-semibold">
|
||||||
|
<AtSign size={16} className="text-[var(--warn)]" />
|
||||||
|
@ 我的消息
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
|
||||||
|
{data ? `${filtered.length} / ${data.total} 条` : '加载中…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2.5 py-1.5">
|
||||||
|
<Search size={13} className="text-[var(--text-3)]" />
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="搜索群名、发送人或内容…"
|
||||||
|
className="w-72 bg-transparent text-[12px] outline-none placeholder:text-[var(--text-3)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{!data ? (
|
||||||
|
<div className="py-20 text-center text-[12px] text-[var(--text-3)]">加载中…</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="py-20 text-center text-[12px] text-[var(--text-3)]">没有匹配的 @ 消息</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map((item) => {
|
||||||
|
const date = dateOf(item.time);
|
||||||
|
return (
|
||||||
|
<div key={`${item.chatroom_id}-${item.local_id}`} className="card p-3 text-[12px]">
|
||||||
|
<div className="flex items-start justify-between gap-3 text-[11px] text-[var(--text-3)]">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/groups/${encodeURIComponent(item.chatroom_id)}${date ? `?date=${date}` : ''}`}
|
||||||
|
className="text-[var(--accent)] hover:underline"
|
||||||
|
>
|
||||||
|
{item.chat_name}
|
||||||
|
</Link>
|
||||||
|
<span>{' · '}</span>
|
||||||
|
<span className="font-medium text-[var(--text-2)]">{item.sender || '未知发送人'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right tabular-nums">
|
||||||
|
<div>{item.time || '未知时间'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 leading-relaxed text-[var(--text)]">
|
||||||
|
<MessageContent content={item.content} chatroomId={item.chatroom_id} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<Link
|
||||||
|
href={`/groups/${encodeURIComponent(item.chatroom_id)}${date ? `?date=${date}` : ''}`}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-[var(--text-3)] hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
<span>查看群记录</span>
|
||||||
|
<ChevronRight size={13} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import TopBar, { type RangeKey, type RefreshMode } from '@/components/TopBar';
|
||||||
|
import StatGrid, { type CardsData } from '@/components/StatGrid';
|
||||||
|
import TrendChart, { type TrendPoint } from '@/components/TrendChart';
|
||||||
|
import ActiveGroupsList, { type ActiveGroup } from '@/components/ActiveGroupsList';
|
||||||
|
import CategoryChart, { type CategoryStat } from '@/components/CategoryChart';
|
||||||
|
import IntelligenceBrief, { type DashboardIntelligence } from '@/components/IntelligenceBrief';
|
||||||
|
|
||||||
|
type StatsResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
range: RangeKey;
|
||||||
|
window: { since: string; until: string; days: number };
|
||||||
|
cards: CardsData;
|
||||||
|
trend: { data: TrendPoint[]; peak: TrendPoint; avg: number; total: number };
|
||||||
|
active_groups: ActiveGroup[];
|
||||||
|
categories: CategoryStat[];
|
||||||
|
intelligence: DashboardIntelligence;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [range, setRange] = useState<RangeKey>('month');
|
||||||
|
const [date, setDate] = useState(() => localToday());
|
||||||
|
const [mode, setMode] = useState<RefreshMode>('auto');
|
||||||
|
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||||
|
const [rescanning, setRescanning] = useState(false);
|
||||||
|
const [rescanInfo, setRescanInfo] = useState<string | undefined>(undefined);
|
||||||
|
const [setupChecked, setSetupChecked] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/setup', { cache: 'no-store' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (!cancelled && j.ok && !j.configured) {
|
||||||
|
window.location.href = '/setup';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (!cancelled) setSetupChecked(true);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setStats(await fetchStats(range, date));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}, [range, date]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setupChecked) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const j = await fetchStats(range, date);
|
||||||
|
if (!cancelled) setStats(j);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [range, date, setupChecked]);
|
||||||
|
|
||||||
|
const runRescan = useCallback(
|
||||||
|
async (full: boolean) => {
|
||||||
|
setRescanning(true);
|
||||||
|
setRescanInfo(full ? '全量同步启动…(365 天,预计 8-15 分钟)' : '启动重扫…');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/rescan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(full ? { full: true } : { range, anchorDate: date }),
|
||||||
|
});
|
||||||
|
if (!r.ok || !r.body) {
|
||||||
|
setRescanInfo('重扫失败');
|
||||||
|
setRescanning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = r.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += dec.decode(value, { stream: true });
|
||||||
|
let nl;
|
||||||
|
while ((nl = buf.indexOf('\n\n')) !== -1) {
|
||||||
|
const chunk = buf.slice(0, nl).trim();
|
||||||
|
buf = buf.slice(nl + 2);
|
||||||
|
if (!chunk.startsWith('data:')) continue;
|
||||||
|
try {
|
||||||
|
const evt = JSON.parse(chunk.slice(5).trim());
|
||||||
|
if (evt.type === 'start') {
|
||||||
|
setRescanInfo(`同步 ${evt.groups} 群 · ${evt.since} ~ ${evt.until}`);
|
||||||
|
} else if (evt.type === 'progress') {
|
||||||
|
const pct = Math.floor((evt.done / evt.total) * 100);
|
||||||
|
setRescanInfo(
|
||||||
|
`同步中 ${evt.done}/${evt.total} (${pct}%) · 已存 ${evt.inserted_messages ?? 0} 条 · ${evt.current ?? ''}`,
|
||||||
|
);
|
||||||
|
} else if (evt.type === 'done' || evt.type === 'finished') {
|
||||||
|
setRescanInfo(
|
||||||
|
`完成 · ${evt.messages ?? evt.inserted_messages ?? 0} 条消息已入库`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setRescanInfo('重扫失败:' + (e instanceof Error ? e.message : 'unknown'));
|
||||||
|
} finally {
|
||||||
|
setRescanning(false);
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[range, date, reload],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!setupChecked) {
|
||||||
|
return <div className="flex h-screen items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--text-3)]">加载配置…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-[var(--bg)]">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<TopBar
|
||||||
|
range={range}
|
||||||
|
date={date}
|
||||||
|
onRangeChange={setRange}
|
||||||
|
onDateChange={setDate}
|
||||||
|
mode={mode}
|
||||||
|
onModeChange={setMode}
|
||||||
|
rescanning={rescanning}
|
||||||
|
onRescan={() => runRescan(false)}
|
||||||
|
onFullSync={() => runRescan(true)}
|
||||||
|
rescanInfo={rescanInfo ?? infoLine(stats)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<StatGrid cards={stats?.cards} days={stats?.window.days ?? 7} />
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<IntelligenceBrief intelligence={stats?.intelligence} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<TrendChart
|
||||||
|
data={stats?.trend.data ?? []}
|
||||||
|
peak={stats?.trend.peak ?? { date: '', count: 0 }}
|
||||||
|
avg={stats?.trend.avg ?? 0}
|
||||||
|
total={stats?.trend.total ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 2xl:grid-cols-[1.4fr_1fr]">
|
||||||
|
<ActiveGroupsList groups={stats?.active_groups ?? []} />
|
||||||
|
<CategoryChart categories={stats?.categories ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localToday(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function infoLine(stats: StatsResponse | null) {
|
||||||
|
if (!stats) return undefined;
|
||||||
|
return `${stats.window.since} ~ ${stats.window.until} · 共 ${stats.cards.total_groups} 个群`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats(range: RangeKey, date: string): Promise<StatsResponse> {
|
||||||
|
const r = await fetch(`/api/stats?range=${range}&date=${date}`, { cache: 'no-store' });
|
||||||
|
const text = await r.text();
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new Error(`/api/stats returned an empty response (${r.status})`);
|
||||||
|
}
|
||||||
|
const j = JSON.parse(text) as StatsResponse;
|
||||||
|
if (!r.ok || !j.ok) {
|
||||||
|
throw new Error(j.error ?? `/api/stats failed (${r.status})`);
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CheckCircle2, Database, ShieldCheck, UserRound, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
|
type SetupStatus = {
|
||||||
|
ok: boolean;
|
||||||
|
dataDir: string;
|
||||||
|
configured: boolean;
|
||||||
|
config: { myNicknames: string[]; demoMode: boolean; privacyConfirmed: boolean; defaultSyncDays: number };
|
||||||
|
checks: { wxInstalled: boolean; wxDaemonRunning: boolean; wxDaemonPid: number | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SetupPage() {
|
||||||
|
const [status, setStatus] = useState<SetupStatus | null>(null);
|
||||||
|
const [names, setNames] = useState('');
|
||||||
|
const [demoMode, setDemoMode] = useState(false);
|
||||||
|
const [privacyConfirmed, setPrivacyConfirmed] = useState(false);
|
||||||
|
const [defaultSyncDays, setDefaultSyncDays] = useState(7);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const res = await fetch('/api/setup', { cache: 'no-store' });
|
||||||
|
const json = (await res.json()) as SetupStatus;
|
||||||
|
setStatus(json);
|
||||||
|
setNames(json.config.myNicknames.join(', '));
|
||||||
|
setDemoMode(json.config.demoMode);
|
||||||
|
setPrivacyConfirmed(json.config.privacyConfirmed);
|
||||||
|
setDefaultSyncDays(json.config.defaultSyncDays ?? 7);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
myNicknames: names.split(',').map((name) => name.trim()).filter(Boolean),
|
||||||
|
demoMode,
|
||||||
|
privacyConfirmed,
|
||||||
|
defaultSyncDays,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || !json.ok) throw new Error(json.error ?? '保存失败');
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[var(--bg)] px-6 py-8 text-[var(--text)]">
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<div className="report-kicker">WeChat Radar Setup</div>
|
||||||
|
<h1 className="mt-2 text-[28px] font-semibold">配置微信雷达</h1>
|
||||||
|
<p className="mt-2 text-[13px] leading-relaxed text-[var(--text-2)]">
|
||||||
|
首次运行需要确认本地环境、填写你的微信名,并选择是否使用示例数据。所有数据默认保存在本机。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<section className="card p-5">
|
||||||
|
<SectionTitle icon={<Wrench size={15} />} title="环境检查" />
|
||||||
|
<CheckRow label="wx-cli" ok={status?.checks.wxInstalled ?? false} detail={status?.checks.wxInstalled ? '已安装' : '未检测到 wx 命令'} />
|
||||||
|
<CheckRow label="wx-daemon" ok={status?.checks.wxDaemonRunning ?? false} detail={status?.checks.wxDaemonRunning ? `运行中 PID ${status?.checks.wxDaemonPid ?? ''}` : '未运行,可先使用 demo 模式'} />
|
||||||
|
<CheckRow label="数据目录" ok detail={status?.dataDir ?? '加载中'} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card p-5">
|
||||||
|
<SectionTitle icon={<UserRound size={15} />} title="你的微信名" />
|
||||||
|
<label className="mt-3 block text-[12px] text-[var(--text-3)]">多个名称用英文逗号分隔</label>
|
||||||
|
<input
|
||||||
|
value={names}
|
||||||
|
onChange={(e) => setNames(e.target.value)}
|
||||||
|
placeholder="张三, San Zhang, zhangsan"
|
||||||
|
className="control-surface mt-2 w-full rounded-md px-3 py-2 text-[13px] outline-none"
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-[11px] text-[var(--text-3)]">用于识别 @我的、自己相关讨论和提醒。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card p-5">
|
||||||
|
<SectionTitle icon={<Database size={15} />} title="数据模式" />
|
||||||
|
<label className="mt-4 flex items-center gap-2 text-[13px]">
|
||||||
|
<input type="checkbox" checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)} />
|
||||||
|
使用示例数据体验
|
||||||
|
</label>
|
||||||
|
<label className="mt-4 block text-[12px] text-[var(--text-3)]">首次同步天数</label>
|
||||||
|
<select
|
||||||
|
value={defaultSyncDays}
|
||||||
|
onChange={(e) => setDefaultSyncDays(Number(e.target.value))}
|
||||||
|
className="control-surface mt-2 rounded-md px-3 py-2 text-[13px] outline-none"
|
||||||
|
>
|
||||||
|
<option value={1}>最近 1 天</option>
|
||||||
|
<option value={7}>最近 7 天</option>
|
||||||
|
<option value={30}>最近 30 天</option>
|
||||||
|
<option value={365}>最近 365 天</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card p-5">
|
||||||
|
<SectionTitle icon={<ShieldCheck size={15} />} title="隐私确认" />
|
||||||
|
<label className="mt-4 flex items-start gap-2 text-[13px] leading-relaxed">
|
||||||
|
<input className="mt-1" type="checkbox" checked={privacyConfirmed} onChange={(e) => setPrivacyConfirmed(e.target.checked)} />
|
||||||
|
<span>我理解聊天数据会存储在本地 SQLite 中,不会自动上传;我会自行确认数据读取和处理符合相关规则。</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mt-4 text-[13px] text-[var(--danger)]">{error}</div>}
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<button className="btn" onClick={() => window.location.href = '/'}>稍后再说</button>
|
||||||
|
<button className="btn btn-primary" disabled={busy || !privacyConfirmed} onClick={submit}>
|
||||||
|
{busy ? '保存中…' : '完成配置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ icon, title }: { icon: React.ReactNode; title: string }) {
|
||||||
|
return <div className="flex items-center gap-1.5 text-[14px] font-semibold text-[var(--text)]">{icon}{title}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckRow({ label, ok, detail }: { label: string; ok: boolean; detail: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 text-[13px]">
|
||||||
|
<span className="text-[var(--text-2)]">{label}</span>
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5 text-right text-[12px] text-[var(--text-3)]">
|
||||||
|
<CheckCircle2 size={13} className={ok ? 'text-[var(--accent)]' : 'text-[var(--text-3)]'} />
|
||||||
|
<span className="truncate">{detail}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import { Activity, Pause, Play } from 'lucide-react';
|
||||||
|
|
||||||
|
type StreamMessage = {
|
||||||
|
local_id: number;
|
||||||
|
username: string;
|
||||||
|
chat_name: string;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StreamEvent =
|
||||||
|
| { type: 'open'; interval: number }
|
||||||
|
| { type: 'tick'; count: number; items: StreamMessage[]; ts: number }
|
||||||
|
| { type: 'error'; error: string };
|
||||||
|
|
||||||
|
export default function SignalsPage() {
|
||||||
|
const [items, setItems] = useState<StreamMessage[]>([]);
|
||||||
|
const [running, setRunning] = useState(true);
|
||||||
|
const [lastTick, setLastTick] = useState<number | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const ctlRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) {
|
||||||
|
ctlRef.current?.abort();
|
||||||
|
ctlRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctl = new AbortController();
|
||||||
|
ctlRef.current = ctl;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/new-messages', { signal: ctl.signal });
|
||||||
|
if (!r.body) return;
|
||||||
|
const reader = r.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += dec.decode(value, { stream: true });
|
||||||
|
let nl;
|
||||||
|
while ((nl = buf.indexOf('\n\n')) !== -1) {
|
||||||
|
const chunk = buf.slice(0, nl).trim();
|
||||||
|
buf = buf.slice(nl + 2);
|
||||||
|
if (!chunk.startsWith('data:')) continue;
|
||||||
|
try {
|
||||||
|
const evt = JSON.parse(chunk.slice(5).trim()) as StreamEvent;
|
||||||
|
if (evt.type === 'tick') {
|
||||||
|
setLastTick(evt.ts);
|
||||||
|
setErr(null);
|
||||||
|
if (evt.items.length) {
|
||||||
|
setItems((prev) => [...evt.items, ...prev].slice(0, 200));
|
||||||
|
}
|
||||||
|
} else if (evt.type === 'error') {
|
||||||
|
setErr(evt.error);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name !== 'AbortError') setErr((e as Error).message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => ctl.abort();
|
||||||
|
}, [running]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<div className="report-kicker">Live Signals</div>
|
||||||
|
<div className="flex items-center gap-2 text-[15px] font-semibold">
|
||||||
|
<Activity size={16} className="text-[var(--accent)]" />
|
||||||
|
信号流 · 实时
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
|
||||||
|
{err
|
||||||
|
? `错误:${err}`
|
||||||
|
: lastTick
|
||||||
|
? `上次刷新:${new Date(lastTick).toLocaleTimeString()} · ${items.length} 条已收`
|
||||||
|
: '等待第一条消息…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`btn ${running ? 'btn-warn' : 'btn-primary'}`}
|
||||||
|
onClick={() => setRunning((v) => !v)}
|
||||||
|
>
|
||||||
|
{running ? <Pause size={13} /> : <Play size={13} />}
|
||||||
|
<span>{running ? '暂停' : '继续'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="py-20 text-center text-[12px] text-[var(--text-3)]">
|
||||||
|
等待新消息(每 5 秒拉取一次)…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((m, i) => (
|
||||||
|
<Row key={`${m.username}-${m.local_id}-${i}`} m={m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ m }: { m: StreamMessage }) {
|
||||||
|
return (
|
||||||
|
<div className="card grid grid-cols-[140px_1fr_120px] gap-3 px-4 py-3 text-[13px]">
|
||||||
|
<div className="truncate text-[var(--text-2)]">
|
||||||
|
<div className="truncate font-medium text-[var(--text)]">{m.chat_name}</div>
|
||||||
|
<div className="truncate text-[11px] text-[var(--text-3)]">{m.sender}</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[var(--text)]">{m.content}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">类型:{m.type}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-[11px] text-[var(--text-3)] tabular-nums">{m.time}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import MessageContent from '@/components/MessageContent';
|
||||||
|
import { Sparkles, RefreshCw, Calendar } from 'lucide-react';
|
||||||
|
|
||||||
|
type Topic = {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
message_count: number;
|
||||||
|
group_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TopicMessage = {
|
||||||
|
chatroom_id: string;
|
||||||
|
chat_name: string;
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: string;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function localToday(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopicsPage() {
|
||||||
|
const [date, setDate] = useState(() => localToday());
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
|
const [detail, setDetail] = useState<{ topic: Topic; messages: TopicMessage[] } | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [info, setInfo] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/topics?date=${date}`);
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) setTopics(j.topics);
|
||||||
|
} catch {}
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/topics?date=${date}`);
|
||||||
|
const j = await r.json();
|
||||||
|
if (!cancelled && j.ok) setTopics(j.topics);
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const r = await fetch(`/api/topics/${selected}`);
|
||||||
|
const j = await r.json();
|
||||||
|
if (!cancelled && j.ok) {
|
||||||
|
setDetail({
|
||||||
|
topic: {
|
||||||
|
id: j.id,
|
||||||
|
date: j.date,
|
||||||
|
title: j.title,
|
||||||
|
summary: j.summary,
|
||||||
|
message_count: j.message_count,
|
||||||
|
group_count: j.group_count,
|
||||||
|
},
|
||||||
|
messages: j.messages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const selectedDetail = selected ? detail : null;
|
||||||
|
|
||||||
|
const build = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setInfo('启动 Codex CLI 话题聚合…');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/topics/build', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ date }),
|
||||||
|
});
|
||||||
|
if (!r.ok || !r.body) {
|
||||||
|
setInfo('构建失败');
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = r.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += dec.decode(value, { stream: true });
|
||||||
|
let nl;
|
||||||
|
while ((nl = buf.indexOf('\n\n')) !== -1) {
|
||||||
|
const chunk = buf.slice(0, nl).trim();
|
||||||
|
buf = buf.slice(nl + 2);
|
||||||
|
if (!chunk.startsWith('data:')) continue;
|
||||||
|
try {
|
||||||
|
const evt = JSON.parse(chunk.slice(5).trim());
|
||||||
|
if (evt.type === 'start') {
|
||||||
|
setInfo(`${date} · 开始构建话题…`);
|
||||||
|
} else if (evt.type === 'load') {
|
||||||
|
setInfo(evt.message ?? '加载当日消息…');
|
||||||
|
} else if (evt.type === 'llm' && evt.done !== undefined) {
|
||||||
|
setInfo(evt.message ?? `Codex 聚合 ${evt.done}/${evt.total}`);
|
||||||
|
} else if (evt.type === 'save' && evt.done !== undefined) {
|
||||||
|
setInfo(`保存话题 ${evt.done}/${evt.total} · ${evt.message ?? ''}`);
|
||||||
|
} else if (evt.type === 'finished' || evt.type === 'done') {
|
||||||
|
setInfo(`完成 · ${evt.topics ?? evt.count ?? 0} 个话题`);
|
||||||
|
} else if (evt.type === 'error') {
|
||||||
|
setInfo('错误:' + evt.error);
|
||||||
|
} else if (evt.message) {
|
||||||
|
setInfo(evt.message);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setInfo('错误:' + (e instanceof Error ? e.message : 'unknown'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
}, [date, reload]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<div className="report-kicker">Cross-Group Topics</div>
|
||||||
|
<div className="flex items-center gap-2 text-[15px] font-semibold">
|
||||||
|
<Sparkles size={16} className="text-[var(--accent)]" />
|
||||||
|
话题雷达 · 跨群聚合
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
|
||||||
|
{info ?? `${date} · ${topics.length} 个话题`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="control-surface flex items-center gap-1.5 rounded-md px-2.5 py-1.5">
|
||||||
|
<Calendar size={13} className="text-[var(--text-3)]" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className={`btn ${busy ? 'btn-warn' : 'btn-primary'}`} onClick={build} disabled={busy}>
|
||||||
|
<RefreshCw size={13} className={busy ? 'animate-spin' : ''} />
|
||||||
|
<span>{busy ? '构建中…' : '构建话题'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid flex-1 grid-cols-[420px_1fr] overflow-hidden">
|
||||||
|
<div className="overflow-y-auto border-r border-[var(--border-soft)] p-4">
|
||||||
|
{topics.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-[12px] text-[var(--text-3)]">
|
||||||
|
当日还没构建话题 · 点击「构建话题」
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topics.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`card w-full p-4 text-left transition-colors ${
|
||||||
|
selected === t.id ? 'border-[rgba(125,211,168,0.48)] bg-[var(--surface-2)]' : 'hover:bg-[var(--surface-2)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setDetail(null);
|
||||||
|
setSelected(t.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[14px] font-semibold text-[var(--text)]">{t.title}</div>
|
||||||
|
{t.summary && (
|
||||||
|
<div className="mt-1 line-clamp-2 text-[11px] text-[var(--text-3)]">
|
||||||
|
{t.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-[10px] text-[var(--text-3)] shrink-0">
|
||||||
|
<div className="font-semibold text-[var(--accent)]">{t.message_count}</div>
|
||||||
|
<div>{t.group_count} 群</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto p-5">
|
||||||
|
{!selectedDetail ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-[12px] text-[var(--text-3)]">
|
||||||
|
左侧选一个话题查看跨群讨论
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[18px] font-semibold">{selectedDetail.topic.title}</div>
|
||||||
|
{selectedDetail.topic.summary && (
|
||||||
|
<div className="mb-4 text-[13px] leading-relaxed text-[var(--text-2)]">
|
||||||
|
{selectedDetail.topic.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-4 flex gap-4 text-[11px] text-[var(--text-3)]">
|
||||||
|
<span>消息:{selectedDetail.topic.message_count}</span>
|
||||||
|
<span>跨群:{selectedDetail.topic.group_count}</span>
|
||||||
|
<span>日期:{selectedDetail.topic.date}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedDetail.messages.map((m) => (
|
||||||
|
<div
|
||||||
|
key={`${m.chatroom_id}-${m.local_id}`}
|
||||||
|
className="card p-3 text-[12px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-[var(--text-3)]">
|
||||||
|
<span>
|
||||||
|
<Link
|
||||||
|
href={`/groups/${encodeURIComponent(m.chatroom_id)}?date=${selectedDetail.topic.date}`}
|
||||||
|
className="text-[var(--accent)] hover:underline"
|
||||||
|
>
|
||||||
|
{m.chat_name}
|
||||||
|
</Link>
|
||||||
|
{' · '}
|
||||||
|
<span className="font-medium text-[var(--text-2)]">{m.sender}</span>
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">{m.time?.slice(11) ?? ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 text-[var(--text)]">
|
||||||
|
<MessageContent content={m.content} chatroomId={m.chatroom_id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Flame } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface ActiveGroup {
|
||||||
|
chatroom_id: string;
|
||||||
|
name: string;
|
||||||
|
summary: string;
|
||||||
|
total: number;
|
||||||
|
top_senders: Array<{ sender: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActiveGroupsList({ groups }: { groups: ActiveGroup[] }) {
|
||||||
|
const max = groups[0]?.total ?? 1;
|
||||||
|
return (
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||||
|
<Flame size={14} className="text-[var(--warn)]" />
|
||||||
|
智能活跃群
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-3)]">
|
||||||
|
去噪后 {groups.length} 个 · 综合排序
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-[12px] text-[var(--text-3)]">
|
||||||
|
暂无数据 · 点击「重扫」加载
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{groups.slice(0, 12).map((g, i) => (
|
||||||
|
<Row key={g.chatroom_id} group={g} rank={i + 1} max={max} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ group, rank, max }: { group: ActiveGroup; rank: number; max: number }) {
|
||||||
|
const initial = group.name.slice(0, 2);
|
||||||
|
const senders = group.top_senders
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((s) => s.sender)
|
||||||
|
.join(' · ');
|
||||||
|
const pct = (group.total / max) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/groups/${encodeURIComponent(group.chatroom_id)}`}
|
||||||
|
className="grid grid-cols-[24px_36px_1fr_70px] items-center gap-3 rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]"
|
||||||
|
>
|
||||||
|
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--border-soft)] bg-[var(--surface-2)] text-[11px] text-[var(--text-2)]">
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13px] text-[var(--text)]">{group.name}</div>
|
||||||
|
{senders && (
|
||||||
|
<div className="truncate text-[11px] text-[var(--text-3)]">{senders}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[14px] font-semibold tabular-nums text-[var(--text)]">
|
||||||
|
{group.total.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-1 overflow-hidden rounded-full bg-[var(--surface-2)]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[var(--accent)]"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { PieChart } from 'lucide-react';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
|
||||||
|
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
|
||||||
|
|
||||||
|
export interface CategoryStat {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
emoji: string | null;
|
||||||
|
group_count: number;
|
||||||
|
message_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mode = 'donut' | 'ring' | 'bar' | 'radar';
|
||||||
|
const MODES: { key: Mode; label: string }[] = [
|
||||||
|
{ key: 'donut', label: '同心环' },
|
||||||
|
{ key: 'ring', label: '圆环' },
|
||||||
|
{ key: 'bar', label: '柱状' },
|
||||||
|
{ key: 'radar', label: '雷达' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CategoryChart({ categories }: { categories: CategoryStat[] }) {
|
||||||
|
const [mode, setMode] = useState<Mode>('bar');
|
||||||
|
|
||||||
|
const totalGroups = categories.reduce((s, c) => s + c.group_count, 0);
|
||||||
|
|
||||||
|
const option = useMemo<EChartsOption>(() => {
|
||||||
|
const data = categories
|
||||||
|
.filter((c) => c.group_count > 0)
|
||||||
|
.map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
value: c.group_count,
|
||||||
|
itemStyle: { color: c.color },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseTooltip = {
|
||||||
|
backgroundColor: '#101812',
|
||||||
|
borderColor: '#27342c',
|
||||||
|
textStyle: { color: '#edf1e8' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (mode === 'bar') {
|
||||||
|
return {
|
||||||
|
grid: { top: 8, right: 24, bottom: 8, left: 80 },
|
||||||
|
tooltip: { trigger: 'item', ...baseTooltip },
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(154,174,158,0.12)' } },
|
||||||
|
axisLabel: { color: '#737f75', fontSize: 10 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.map((d) => d.name),
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { color: '#aab4aa', fontSize: 11 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data,
|
||||||
|
barWidth: 12,
|
||||||
|
label: { show: true, position: 'right', color: '#aab4aa', fontSize: 10 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'donut' || mode === 'ring') {
|
||||||
|
const radius = mode === 'donut' ? ['38%', '70%'] : ['55%', '70%'];
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'item', ...baseTooltip },
|
||||||
|
legend: {
|
||||||
|
orient: 'vertical',
|
||||||
|
right: 8,
|
||||||
|
top: 'middle',
|
||||||
|
textStyle: { color: '#aab4aa', fontSize: 10 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
radius,
|
||||||
|
center: ['38%', '50%'],
|
||||||
|
data,
|
||||||
|
label: { show: false },
|
||||||
|
labelLine: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: baseTooltip,
|
||||||
|
radar: {
|
||||||
|
center: ['50%', '54%'],
|
||||||
|
radius: 88,
|
||||||
|
indicator: data.map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
max: Math.max(...data.map((x) => x.value), 1),
|
||||||
|
})),
|
||||||
|
axisName: { color: '#aab4aa', fontSize: 10 },
|
||||||
|
splitLine: { lineStyle: { color: '#27342c' } },
|
||||||
|
splitArea: { areaStyle: { color: ['rgba(16,24,18,0.42)'] } },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'radar',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
value: data.map((d) => d.value),
|
||||||
|
areaStyle: { color: 'rgba(125,211,168,0.2)' },
|
||||||
|
lineStyle: { color: '#7dd3a8' },
|
||||||
|
itemStyle: { color: '#7dd3a8' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [categories, mode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||||
|
<PieChart size={14} className="text-[var(--accent)]" />
|
||||||
|
分类构成
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-3)]">
|
||||||
|
{categories.length} 类 · {totalGroups} 群
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex gap-1 text-[11px]">
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
onClick={() => setMode(m.key)}
|
||||||
|
className={`rounded px-2 py-0.5 transition-colors ${
|
||||||
|
mode === m.key
|
||||||
|
? 'bg-[var(--accent-soft)] text-[var(--accent)]'
|
||||||
|
: 'text-[var(--text-3)] hover:text-[var(--text-2)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categories.length > 0 ? (
|
||||||
|
<ReactECharts option={option} style={{ height: 280 }} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[280px] items-center justify-center text-[12px] text-[var(--text-3)]">
|
||||||
|
暂无分类数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
Clipboard,
|
||||||
|
ExternalLink,
|
||||||
|
Flame,
|
||||||
|
Lightbulb,
|
||||||
|
Link2,
|
||||||
|
Radar,
|
||||||
|
Target,
|
||||||
|
UserRoundCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface DashboardSignalItem {
|
||||||
|
chatroom_id: string;
|
||||||
|
chat_name: string;
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
time: string;
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
score: number;
|
||||||
|
reasons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardOpportunityItem extends DashboardSignalItem {
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardActionItem extends DashboardOpportunityItem {
|
||||||
|
why: string;
|
||||||
|
urgency: 'high' | 'medium' | 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSignalSource {
|
||||||
|
sender: string;
|
||||||
|
signal_count: number;
|
||||||
|
group_count: number;
|
||||||
|
top_group: string;
|
||||||
|
last_seen: string;
|
||||||
|
strengths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTopicLifecycle {
|
||||||
|
title: string;
|
||||||
|
status: 'rising' | 'spreading' | 'hot' | 'cooling';
|
||||||
|
today_count: number;
|
||||||
|
previous_avg: number;
|
||||||
|
group_count: number;
|
||||||
|
reason: string;
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardLinkHighlight {
|
||||||
|
kind: 'article' | 'tool';
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
domain: string;
|
||||||
|
score: number;
|
||||||
|
verdict: string;
|
||||||
|
count: number;
|
||||||
|
group_count: number;
|
||||||
|
last_seen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardPeopleRadar {
|
||||||
|
sender: string;
|
||||||
|
role: '分享者' | '需求提出者' | '连接者' | '观点源';
|
||||||
|
score: number;
|
||||||
|
group_count: number;
|
||||||
|
signal_count: number;
|
||||||
|
top_group: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardContentIdea {
|
||||||
|
title: string;
|
||||||
|
angle: string;
|
||||||
|
suggested_channel: '公众号' | 'X' | '小红书' | '博客';
|
||||||
|
evidence: string;
|
||||||
|
source_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardAnomalySignal {
|
||||||
|
kind: 'spike' | 'cross_group' | 'dense_links' | 'quiet_day';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
severity: 'high' | 'medium' | 'low';
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardIntelligence {
|
||||||
|
date: string;
|
||||||
|
must_read: DashboardSignalItem[];
|
||||||
|
opportunities: DashboardOpportunityItem[];
|
||||||
|
signal_sources: DashboardSignalSource[];
|
||||||
|
action_items: DashboardActionItem[];
|
||||||
|
topic_lifecycle: DashboardTopicLifecycle[];
|
||||||
|
link_highlights: DashboardLinkHighlight[];
|
||||||
|
people_radar: DashboardPeopleRadar[];
|
||||||
|
content_ideas: DashboardContentIdea[];
|
||||||
|
anomalies: DashboardAnomalySignal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IntelligenceBrief({ intelligence }: { intelligence?: DashboardIntelligence }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const data =
|
||||||
|
intelligence ??
|
||||||
|
({
|
||||||
|
date: '',
|
||||||
|
must_read: [],
|
||||||
|
opportunities: [],
|
||||||
|
signal_sources: [],
|
||||||
|
action_items: [],
|
||||||
|
topic_lifecycle: [],
|
||||||
|
link_highlights: [],
|
||||||
|
people_radar: [],
|
||||||
|
content_ideas: [],
|
||||||
|
anomalies: [],
|
||||||
|
} satisfies DashboardIntelligence);
|
||||||
|
const summary = useMemo(() => buildDailySummary(data), [data]);
|
||||||
|
|
||||||
|
async function copySummary() {
|
||||||
|
await navigator.clipboard.writeText(summary);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="card flex items-center justify-between gap-4 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="report-kicker">Briefing Note</div>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-[14px] font-semibold">
|
||||||
|
<Radar size={14} className="text-[var(--accent)]" />
|
||||||
|
今日情报简报
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-[12px] text-[var(--text-2)]">
|
||||||
|
{summary.split('\n').slice(1, 4).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copySummary}
|
||||||
|
className="btn shrink-0"
|
||||||
|
disabled={!data.date}
|
||||||
|
title="复制今日情报摘要"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={13} /> : <Clipboard size={13} />}
|
||||||
|
<span>{copied ? '已复制' : '复制摘要'}</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.1fr_1fr_1fr]">
|
||||||
|
<ActionPanel date={data.date} items={data.action_items} fallback={data.opportunities} />
|
||||||
|
<TopicLifecyclePanel topics={data.topic_lifecycle} />
|
||||||
|
<AnomalyPanel anomalies={data.anomalies} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.1fr_1fr_1fr]">
|
||||||
|
<LinkHighlightPanel items={data.link_highlights} />
|
||||||
|
<PeopleRadarPanel people={data.people_radar} fallback={data.signal_sources} />
|
||||||
|
<ContentIdeaPanel ideas={data.content_ideas} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MustReadPanel date={data.date} items={data.must_read} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionPanel({
|
||||||
|
date,
|
||||||
|
items,
|
||||||
|
fallback,
|
||||||
|
}: {
|
||||||
|
date: string;
|
||||||
|
items: DashboardActionItem[];
|
||||||
|
fallback: DashboardOpportunityItem[];
|
||||||
|
}) {
|
||||||
|
const displayItems =
|
||||||
|
items.length > 0
|
||||||
|
? items
|
||||||
|
: fallback.map((item) => ({
|
||||||
|
...item,
|
||||||
|
why: '包含明确需求或行动线索,适合进入上下文判断',
|
||||||
|
urgency: 'medium' as const,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<section className="card min-h-[300px] p-4">
|
||||||
|
<PanelTitle
|
||||||
|
icon={<Target size={14} className="text-[var(--warn)]" />}
|
||||||
|
title="今日值得出手"
|
||||||
|
meta={`${displayItems.length} 条`}
|
||||||
|
/>
|
||||||
|
{displayItems.length === 0 ? (
|
||||||
|
<EmptyState text="暂无明确行动项" />
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{displayItems.slice(0, 6).map((item) => (
|
||||||
|
<Link
|
||||||
|
key={`${item.chatroom_id}:${item.local_id}`}
|
||||||
|
href={`/groups/${encodeURIComponent(item.chatroom_id)}?date=${date}`}
|
||||||
|
className="block rounded-md border border-transparent px-2 py-2 transition-colors hover:border-[rgba(213,162,83,0.34)] hover:bg-[var(--surface-2)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="rounded border border-[rgba(213,162,83,0.22)] bg-[var(--warn-soft)] px-1.5 py-0.5 text-[10px] text-[var(--warn)]">
|
||||||
|
{item.action}
|
||||||
|
</span>
|
||||||
|
<span className={urgencyClass(item.urgency)}>{urgencyText(item.urgency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-[12px] leading-snug text-[var(--text)]">{item.title}</div>
|
||||||
|
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{item.why}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopicLifecyclePanel({ topics }: { topics: DashboardTopicLifecycle[] }) {
|
||||||
|
return (
|
||||||
|
<section className="card min-h-[300px] p-4">
|
||||||
|
<PanelTitle
|
||||||
|
icon={<Flame size={14} className="text-[var(--accent)]" />}
|
||||||
|
title="趋势升温"
|
||||||
|
meta={`${topics.length} 个话题`}
|
||||||
|
/>
|
||||||
|
{topics.length === 0 ? (
|
||||||
|
<EmptyState text="暂无可识别趋势" />
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{topics.slice(0, 6).map((topic) => (
|
||||||
|
<div key={topic.title} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{topic.title}</div>
|
||||||
|
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{topic.reason}</div>
|
||||||
|
</div>
|
||||||
|
<span className={topicStatusClass(topic.status)}>{topicStatusText(topic.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-2 text-[10px] text-[var(--text-3)]">
|
||||||
|
<span>{topic.today_count} 条</span>
|
||||||
|
<span>{topic.group_count} 群</span>
|
||||||
|
<span>均值 {topic.previous_avg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnomalyPanel({ anomalies }: { anomalies: DashboardAnomalySignal[] }) {
|
||||||
|
return (
|
||||||
|
<section className="card min-h-[300px] p-4">
|
||||||
|
<PanelTitle
|
||||||
|
icon={<AlertTriangle size={14} className="text-[var(--warn)]" />}
|
||||||
|
title="异常信号"
|
||||||
|
meta={`${anomalies.length} 条`}
|
||||||
|
/>
|
||||||
|
{anomalies.length === 0 ? (
|
||||||
|
<EmptyState text="暂无异常波动" />
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{anomalies.slice(0, 6).map((item) => {
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{item.title}</div>
|
||||||
|
<span className={severityClass(item.severity)}>{severityText(item.severity)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-[10px] leading-snug text-[var(--text-3)]">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return item.href ? (
|
||||||
|
<a
|
||||||
|
key={`${item.kind}:${item.title}`}
|
||||||
|
href={item.href}
|
||||||
|
target={item.href.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={item.href.startsWith('http') ? 'noreferrer' : undefined}
|
||||||
|
className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div key={`${item.kind}:${item.title}`} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkHighlightPanel({ items }: { items: DashboardLinkHighlight[] }) {
|
||||||
|
return (
|
||||||
|
<section className="card min-h-[300px] p-4">
|
||||||
|
<PanelTitle
|
||||||
|
icon={<Link2 size={14} className="text-[var(--accent)]" />}
|
||||||
|
title="链接精选"
|
||||||
|
meta={`${items.length} 条`}
|
||||||
|
/>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState text="暂无高价值链接" />
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{items.slice(0, 6).map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.url}
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{item.title}</div>
|
||||||
|
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{item.verdict}</div>
|
||||||
|
</div>
|
||||||
|
<span className="signal-chip rounded px-1.5 py-0.5 text-[10px]">
|
||||||
|
{item.kind === 'tool' ? '工具' : '文章'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-2 text-[10px] text-[var(--text-3)]">
|
||||||
|
<span className="truncate">{item.domain}</span>
|
||||||
|
<span>{item.group_count} 群</span>
|
||||||
|
<span>{item.count} 次</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PeopleRadarPanel({
|
||||||
|
people,
|
||||||
|
fallback,
|
||||||
|
}: {
|
||||||
|
people: DashboardPeopleRadar[];
|
||||||
|
fallback: DashboardSignalSource[];
|
||||||
|
}) {
|
||||||
|
const displayPeople =
|
||||||
|
people.length > 0
|
||||||
|
? people
|
||||||
|
: fallback.map((source) => ({
|
||||||
|
sender: source.sender,
|
||||||
|
role: '分享者' as const,
|
||||||
|
score: source.signal_count,
|
||||||
|
group_count: source.group_count,
|
||||||
|
signal_count: source.signal_count,
|
||||||
|
top_group: source.top_group,
|
||||||
|
reason: `${source.signal_count} 条高信号`,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<section className="card min-h-[300px] p-4">
|
||||||
|
<PanelTitle
|
||||||
|
icon={<UserRoundCheck size={14} className="text-[var(--accent)]" />}
|
||||||
|
title="人物雷达"
|
||||||
|
meta={`${displayPeople.length} 人`}
|
||||||
|
/>
|
||||||
|
{displayPeople.length === 0 ? (
|
||||||
|
<EmptyState text="暂无稳定情报源" />
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
{displayPeople.slice(0, 7).map((person, index) => (
|
||||||
|
<div key={`${person.sender}:${person.top_group}`} className="grid grid-cols-[22px_1fr_42px] items-center gap-2 rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||||
|
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[12px] text-[var(--text)]">{person.sender}</div>
|
||||||
|
<div className="truncate text-[10px] text-[var(--text-3)]">
|
||||||
|
{person.role} · {person.reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[13px] font-semibold tabular-nums text-[var(--accent)]">{person.score}</div>
|
||||||
|
<div className="text-[10px] text-[var(--text-3)]">{person.group_count} 群</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentIdeaPanel({ ideas }: { ideas: DashboardContentIdea[] }) {
|
||||||
|
return (
|
||||||
|
<section className="card min-h-[300px] p-4">
|
||||||
|
<PanelTitle
|
||||||
|
icon={<Lightbulb size={14} className="text-[var(--warn)]" />}
|
||||||
|
title="内容选题"
|
||||||
|
meta={`${ideas.length} 个`}
|
||||||
|
/>
|
||||||
|
{ideas.length === 0 ? (
|
||||||
|
<EmptyState text="暂无可转化选题" />
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{ideas.slice(0, 6).map((idea) => (
|
||||||
|
<div key={`${idea.suggested_channel}:${idea.title}`} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)]">{idea.title}</div>
|
||||||
|
<span className="rounded bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] text-[var(--text-2)]">
|
||||||
|
{idea.suggested_channel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{idea.angle}</div>
|
||||||
|
<div className="mt-1 text-[10px] text-[var(--text-3)]">证据:{idea.evidence}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MustReadPanel({ date, items }: { date: string; items: DashboardSignalItem[] }) {
|
||||||
|
return (
|
||||||
|
<section className="card min-h-[300px] p-4">
|
||||||
|
<PanelTitle
|
||||||
|
icon={<Radar size={14} className="text-[var(--accent)]" />}
|
||||||
|
title="最值得关注"
|
||||||
|
meta={`${items.length} 条高信号`}
|
||||||
|
/>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState text="暂无高信号消息" />
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
{items.slice(0, 5).map((item, index) => (
|
||||||
|
<SignalRow key={`${item.chatroom_id}:${item.local_id}`} item={item} date={date} rank={index + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignalRow({
|
||||||
|
item,
|
||||||
|
date,
|
||||||
|
rank,
|
||||||
|
}: {
|
||||||
|
item: DashboardSignalItem;
|
||||||
|
date: string;
|
||||||
|
rank: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/groups/${encodeURIComponent(item.chatroom_id)}?date=${date}`}
|
||||||
|
className="grid grid-cols-[22px_1fr_16px] items-start gap-2 rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]"
|
||||||
|
>
|
||||||
|
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="flex min-w-0 items-start justify-between gap-2">
|
||||||
|
<span className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{item.title}</span>
|
||||||
|
<span className="shrink-0 rounded bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] tabular-nums text-[var(--text-3)]">
|
||||||
|
{item.score}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] text-[var(--text-3)]">
|
||||||
|
<span className="truncate">
|
||||||
|
{item.chat_name} · {item.sender}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums">{item.time.slice(11)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{item.reasons.slice(0, 3).map((reason) => (
|
||||||
|
<span
|
||||||
|
key={reason}
|
||||||
|
className="signal-chip rounded px-1.5 py-0.5 text-[10px]"
|
||||||
|
title={reasonExplanation(reason)}
|
||||||
|
>
|
||||||
|
{reason}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ExternalLink size={12} className="mt-0.5 text-[var(--text-3)]" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelTitle({ icon, title, meta }: { icon: ReactNode; title: string; meta: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-3)]">{meta}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[220px] items-center justify-center text-[12px] text-[var(--text-3)]">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDailySummary(data: DashboardIntelligence): string {
|
||||||
|
const lines = [`${data.date || '今日'} 情报摘要`];
|
||||||
|
lines.push(`出手:${data.action_items.slice(0, 3).map((item) => `${item.action}|${item.title}`).join(';') || '暂无'}`);
|
||||||
|
lines.push(`趋势:${data.topic_lifecycle.slice(0, 3).map((topic) => `${topic.title}(${topicStatusText(topic.status)})`).join(';') || '暂无'}`);
|
||||||
|
lines.push(
|
||||||
|
`链接:${data.link_highlights.slice(0, 3).map((item) => item.title).join(';') || '暂无'}`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
`异常:${data.anomalies.slice(0, 2).map((item) => item.title).join(';') || '暂无'}`,
|
||||||
|
);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgencyText(urgency: DashboardActionItem['urgency']): string {
|
||||||
|
if (urgency === 'high') return '高';
|
||||||
|
if (urgency === 'medium') return '中';
|
||||||
|
return '低';
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgencyClass(urgency: DashboardActionItem['urgency']): string {
|
||||||
|
const base = 'shrink-0 rounded px-1.5 py-0.5 text-[10px]';
|
||||||
|
if (urgency === 'high') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
|
||||||
|
if (urgency === 'medium') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
|
||||||
|
return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicStatusText(status: DashboardTopicLifecycle['status']): string {
|
||||||
|
if (status === 'spreading') return '扩散';
|
||||||
|
if (status === 'rising') return '升温';
|
||||||
|
if (status === 'cooling') return '退潮';
|
||||||
|
return '高热';
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicStatusClass(status: DashboardTopicLifecycle['status']): string {
|
||||||
|
const base = 'shrink-0 rounded px-1.5 py-0.5 text-[10px]';
|
||||||
|
if (status === 'spreading') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
|
||||||
|
if (status === 'rising') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
|
||||||
|
if (status === 'cooling') return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
|
||||||
|
return `${base} bg-[var(--surface-2)] text-[var(--text-2)]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityText(severity: DashboardAnomalySignal['severity']): string {
|
||||||
|
if (severity === 'high') return '高';
|
||||||
|
if (severity === 'medium') return '中';
|
||||||
|
return '低';
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityClass(severity: DashboardAnomalySignal['severity']): string {
|
||||||
|
const base = 'shrink-0 rounded px-1.5 py-0.5 text-[10px]';
|
||||||
|
if (severity === 'high') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
|
||||||
|
if (severity === 'medium') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
|
||||||
|
return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonExplanation(reason: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'机会/需求': '包含合作、采购、报名、求推荐或找资源等可行动信号',
|
||||||
|
'工具/产品': '提到了工具、产品、模型、插件、项目或技术栈',
|
||||||
|
链接信号: '包含可跳转链接,适合进入原文或资源查看',
|
||||||
|
可跟进: '含有联系、报名、评估、试用、帮忙等行动线索',
|
||||||
|
长观点: '消息长度较高,可能包含完整观点或经验复盘',
|
||||||
|
问题: '包含明确问题,可能适合回复或继续追踪',
|
||||||
|
};
|
||||||
|
return map[reason] ?? reason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
const IMG_RE = /\[图片\]\s*local_id=(\d+)/g;
|
||||||
|
|
||||||
|
export default function MessageContent({
|
||||||
|
content,
|
||||||
|
chatroomId,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
chatroomId: string;
|
||||||
|
}) {
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
// 没有图片占位符直接返回文本
|
||||||
|
if (!content.includes('[图片]')) {
|
||||||
|
return <span className="whitespace-pre-wrap break-words">{content}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切片:文本 + 图片 + 文本 + ...
|
||||||
|
const parts: Array<{ type: 'text'; v: string } | { type: 'img'; localId: number }> = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const m of content.matchAll(IMG_RE)) {
|
||||||
|
if (m.index === undefined) continue;
|
||||||
|
if (m.index > last) {
|
||||||
|
parts.push({ type: 'text', v: content.slice(last, m.index) });
|
||||||
|
}
|
||||||
|
parts.push({ type: 'img', localId: Number(m[1]) });
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < content.length) {
|
||||||
|
parts.push({ type: 'text', v: content.slice(last) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="whitespace-pre-wrap break-words">
|
||||||
|
{parts.map((p, i) => {
|
||||||
|
if (p.type === 'text') return <span key={i}>{p.v}</span>;
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={`/api/wx-image?chatroom=${encodeURIComponent(chatroomId)}&local_id=${p.localId}`}
|
||||||
|
alt={`图片 ${p.localId}`}
|
||||||
|
loading="lazy"
|
||||||
|
className="my-1 inline-block max-h-[280px] max-w-full rounded border border-[var(--border)] align-middle"
|
||||||
|
onError={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.replaceWith(
|
||||||
|
Object.assign(document.createElement('span'), {
|
||||||
|
className: 'text-[var(--text-3)]',
|
||||||
|
textContent: `[图片缺失 local_id=${p.localId}]`,
|
||||||
|
}) as HTMLElement,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#ef4444',
|
||||||
|
'#f97316',
|
||||||
|
'#f59e0b',
|
||||||
|
'#eab308',
|
||||||
|
'#7dd3a8',
|
||||||
|
'#10b981',
|
||||||
|
'#06b6d4',
|
||||||
|
'#0ea5e9',
|
||||||
|
'#6366f1',
|
||||||
|
'#8b5cf6',
|
||||||
|
'#a855f7',
|
||||||
|
'#ec4899',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NewGroupModal({
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [emoji, setEmoji] = useState('');
|
||||||
|
const [color, setColor] = useState(COLORS[0]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setErr('分组名不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/groups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name.trim(), color, emoji: emoji.trim() || undefined }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok) {
|
||||||
|
setErr(j.error ?? '创建失败');
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCreated();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : '未知错误');
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="card w-[400px] p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[15px] font-semibold">新建分组</div>
|
||||||
|
<button onClick={onClose} className="text-[var(--text-3)] hover:text-[var(--text)]">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] text-[var(--text-3)]">分组名</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="如:AI · 编程"
|
||||||
|
className="control-surface mt-1 w-full rounded-md px-3 py-2 text-[13px] text-[var(--text)] outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] text-[var(--text-3)]">Emoji(可选)</label>
|
||||||
|
<input
|
||||||
|
value={emoji}
|
||||||
|
onChange={(e) => setEmoji(e.target.value)}
|
||||||
|
placeholder="🤖"
|
||||||
|
maxLength={4}
|
||||||
|
className="control-surface mt-1 w-full rounded-md px-3 py-2 text-[13px] text-[var(--text)] outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] text-[var(--text-3)]">颜色</label>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
className={`size-6 rounded-full ring-2 transition-all ${
|
||||||
|
color === c ? 'ring-white' : 'ring-transparent'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{err && <div className="text-[12px] text-[var(--danger)]">{err}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button className="btn" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={submit} disabled={busy}>
|
||||||
|
{busy ? '创建中…' : '创建'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
Star,
|
||||||
|
Folder,
|
||||||
|
Inbox,
|
||||||
|
LayoutDashboard,
|
||||||
|
Activity,
|
||||||
|
Sparkles,
|
||||||
|
Link2,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
type Category = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
emoji: string | null;
|
||||||
|
member_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SidebarData = {
|
||||||
|
ok: boolean;
|
||||||
|
total: number;
|
||||||
|
categories: Category[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DaemonStatus = {
|
||||||
|
ok: boolean;
|
||||||
|
running: boolean;
|
||||||
|
pid?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [data, setData] = useState<SidebarData | null>(null);
|
||||||
|
const [daemon, setDaemon] = useState<DaemonStatus | null>(null);
|
||||||
|
const [unsorted, setUnsorted] = useState(0);
|
||||||
|
const [favorites, setFavorites] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sessions');
|
||||||
|
const j = await r.json();
|
||||||
|
setData(j);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/stats?range=week');
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok && j.sidebar_counts) {
|
||||||
|
setUnsorted(j.sidebar_counts.unsorted);
|
||||||
|
setFavorites(j.sidebar_counts.favorites);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/daemon');
|
||||||
|
const j = await r.json();
|
||||||
|
setDaemon(j);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, 30_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-screen w-[236px] shrink-0 flex-col border-r border-[var(--border-soft)] bg-[rgba(10,16,12,0.86)] backdrop-blur">
|
||||||
|
<div className="border-b border-[var(--border-soft)] px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link href="/" className="min-w-0">
|
||||||
|
<div className="report-kicker">WeChat Radar</div>
|
||||||
|
<div className="mt-1 text-[15px] font-semibold tracking-wide text-[var(--text)]">
|
||||||
|
微信雷达
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 text-[var(--text-3)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--text)]"
|
||||||
|
aria-label="设置"
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-[var(--text-3)]">私有看板 · 高信号优先</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="px-2 pb-2 pt-3">
|
||||||
|
<NavItem
|
||||||
|
href="/"
|
||||||
|
icon={<LayoutDashboard size={15} />}
|
||||||
|
label="看板"
|
||||||
|
badge="Brief"
|
||||||
|
active={pathname === '/'}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/signals"
|
||||||
|
icon={<Activity size={15} />}
|
||||||
|
label="信号流"
|
||||||
|
badge="Live"
|
||||||
|
active={pathname === '/signals'}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/topics"
|
||||||
|
icon={<Sparkles size={15} />}
|
||||||
|
label="话题雷达"
|
||||||
|
badge="Cross"
|
||||||
|
active={pathname === '/topics'}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/links"
|
||||||
|
icon={<Link2 size={15} />}
|
||||||
|
label="链接情报"
|
||||||
|
badge="Link"
|
||||||
|
active={pathname === '/links'}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="px-4 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-3)]">
|
||||||
|
Groups
|
||||||
|
</div>
|
||||||
|
<nav className="px-2">
|
||||||
|
<NavItem href="/groups" icon={<Inbox size={15} />} label="所有群" count={data?.total} />
|
||||||
|
<NavItem href="/groups?filter=favorites" icon={<Star size={15} />} label="收藏" count={favorites} />
|
||||||
|
<NavItem
|
||||||
|
href="/groups?filter=unsorted"
|
||||||
|
icon={<Folder size={15} />}
|
||||||
|
label="未分组"
|
||||||
|
count={unsorted}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="px-4 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-3)]">
|
||||||
|
Collections
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||||
|
{(data?.categories ?? []).map((c) => (
|
||||||
|
<CategoryItem key={c.id} category={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--border-soft)] px-4 py-2 text-[11px] text-[var(--text-3)]">
|
||||||
|
{daemon?.running ? (
|
||||||
|
<span>
|
||||||
|
<span className="inline-block size-2 rounded-full bg-[var(--accent)] mr-1.5 align-middle" />
|
||||||
|
wx-daemon 运行中{daemon.pid ? ` (PID ${daemon.pid})` : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<span className="inline-block size-2 rounded-full bg-[var(--danger)] mr-1.5 align-middle" />
|
||||||
|
wx-daemon 未运行
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
badge,
|
||||||
|
count,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
badge?: string;
|
||||||
|
count?: number;
|
||||||
|
active?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={`group relative flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-[13px] transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-[var(--accent-soft)] text-[var(--text)]'
|
||||||
|
: 'text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active && <span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-[var(--accent)]" />}
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{badge && (
|
||||||
|
<span className="signal-chip rounded px-1.5 py-0.5 text-[10px] font-medium">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{count !== undefined && (
|
||||||
|
<span className="text-[11px] text-[var(--text-3)] tabular-nums">{count}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryItem({ category }: { category: Category }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/groups?filter=group&group_id=${category.id}`}
|
||||||
|
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-[13px] text-[var(--text-2)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
<span
|
||||||
|
className="inline-block size-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: category.color }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{category.emoji ? `${category.emoji} ` : ''}
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[var(--text-3)] tabular-nums">
|
||||||
|
{category.member_count ?? 0}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Activity, MessageCircle, AtSign, MoonStar } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface CardsData {
|
||||||
|
active_groups: number;
|
||||||
|
total_groups: number;
|
||||||
|
total_messages: number;
|
||||||
|
mentions: number;
|
||||||
|
silent_groups: number;
|
||||||
|
avg_per_group: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatGrid({ cards, days }: { cards?: CardsData; days: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Card
|
||||||
|
icon={<Activity size={14} className="text-[var(--accent)]" />}
|
||||||
|
label="活跃群"
|
||||||
|
value={cards?.active_groups ?? '—'}
|
||||||
|
sub={cards ? `共扫 ${cards.total_groups} 个群` : '等待扫描'}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
icon={<MessageCircle size={14} className="text-[var(--accent)]" />}
|
||||||
|
label="总消息"
|
||||||
|
value={cards?.total_messages?.toLocaleString() ?? '—'}
|
||||||
|
sub={
|
||||||
|
cards
|
||||||
|
? `过去 ${days * 24}h · 平均每群 ${cards.avg_per_group} 条`
|
||||||
|
: '等待扫描'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
icon={<AtSign size={14} className="text-[var(--warn)]" />}
|
||||||
|
label="@ 我的"
|
||||||
|
value={cards?.mentions ?? 0}
|
||||||
|
sub={cards ? '需要回复' : '等待扫描'}
|
||||||
|
accent="warn"
|
||||||
|
href="/mentions"
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
icon={<MoonStar size={14} className="text-[var(--text-3)]" />}
|
||||||
|
label="静默群"
|
||||||
|
value={cards?.silent_groups ?? '—'}
|
||||||
|
sub={cards ? `过去 ${days * 24}h 无活动` : '等待扫描'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
accent,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
sub: string;
|
||||||
|
accent?: 'warn';
|
||||||
|
href?: string;
|
||||||
|
}) {
|
||||||
|
const className = `card group relative overflow-hidden px-5 py-4 ${
|
||||||
|
href
|
||||||
|
? 'block transition-colors hover:border-[rgba(213,162,83,0.5)] hover:bg-[var(--surface-2)] focus:outline-none focus:ring-1 focus:ring-[var(--warn)]'
|
||||||
|
: ''
|
||||||
|
}`;
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className={`absolute inset-x-0 top-0 h-px ${accent === 'warn' ? 'bg-[var(--warn)]' : 'bg-[var(--accent)]'} opacity-60`} />
|
||||||
|
<div className="flex items-center justify-between gap-2 text-[12px] text-[var(--text-2)]">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.14em] text-[var(--text-3)]">Metric</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`mt-3 text-[34px] font-semibold leading-none tabular-nums ${
|
||||||
|
accent === 'warn' ? 'text-[var(--warn)]' : 'text-[var(--text)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-[var(--text-3)]">{sub}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={className} aria-label={`查看${label}消息`}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Calendar, RefreshCw, Database } from 'lucide-react';
|
||||||
|
|
||||||
|
export type RangeKey = 'day' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
||||||
|
export type RefreshMode = 'auto' | 'hour' | 'day' | 'week';
|
||||||
|
|
||||||
|
const RANGES: { key: RangeKey; label: string }[] = [
|
||||||
|
{ key: 'day', label: '日' },
|
||||||
|
{ key: 'week', label: '周' },
|
||||||
|
{ key: 'month', label: '月' },
|
||||||
|
{ key: 'quarter', label: '季' },
|
||||||
|
{ key: 'year', label: '年' },
|
||||||
|
{ key: 'custom', label: '自定义' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODES: { key: RefreshMode; label: string }[] = [
|
||||||
|
{ key: 'auto', label: '自动' },
|
||||||
|
{ key: 'hour', label: '时' },
|
||||||
|
{ key: 'day', label: '日' },
|
||||||
|
{ key: 'week', label: '周' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TopBar({
|
||||||
|
range,
|
||||||
|
date,
|
||||||
|
onRangeChange,
|
||||||
|
onDateChange,
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
rescanning,
|
||||||
|
onRescan,
|
||||||
|
onFullSync,
|
||||||
|
rescanInfo,
|
||||||
|
}: {
|
||||||
|
range: RangeKey;
|
||||||
|
date: string;
|
||||||
|
onRangeChange: (r: RangeKey) => void;
|
||||||
|
onDateChange: (date: string) => void;
|
||||||
|
mode: RefreshMode;
|
||||||
|
onModeChange: (m: RefreshMode) => void;
|
||||||
|
rescanning: boolean;
|
||||||
|
onRescan: () => void;
|
||||||
|
onFullSync?: () => void;
|
||||||
|
rescanInfo?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<div className="report-kicker">Daily Intelligence</div>
|
||||||
|
<div className="mt-1 text-[16px] font-semibold tracking-wide">微信雷达 · 情报看板</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
|
||||||
|
{rescanInfo ?? '尚未扫描,点击「重扫」加载数据'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="control-surface flex items-center gap-1.5 rounded-md px-2.5 py-1.5">
|
||||||
|
<Calendar size={13} className="text-[var(--text-3)]" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => onDateChange(e.target.value)}
|
||||||
|
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
|
||||||
|
title="按日期查看微信雷达"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SegGroup>
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<SegBtn key={r.key} active={range === r.key} onClick={() => onRangeChange(r.key)}>
|
||||||
|
{r.label}
|
||||||
|
</SegBtn>
|
||||||
|
))}
|
||||||
|
</SegGroup>
|
||||||
|
|
||||||
|
<SegGroup>
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<SegBtn key={m.key} active={mode === m.key} onClick={() => onModeChange(m.key)}>
|
||||||
|
{m.label}
|
||||||
|
</SegBtn>
|
||||||
|
))}
|
||||||
|
</SegGroup>
|
||||||
|
|
||||||
|
{onFullSync && (
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={onFullSync}
|
||||||
|
disabled={rescanning}
|
||||||
|
title="一次性同步过去 365 天的所有消息到本地数据库"
|
||||||
|
>
|
||||||
|
<Database size={13} />
|
||||||
|
<span>全量同步</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn ${rescanning ? 'btn-warn' : 'btn-primary'}`}
|
||||||
|
onClick={onRescan}
|
||||||
|
disabled={rescanning}
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={rescanning ? 'animate-spin' : ''} />
|
||||||
|
<span>{rescanning ? '同步中…' : '重扫'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegGroup({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="control-surface flex overflow-hidden rounded-md">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegBtn({
|
||||||
|
children,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
active?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`px-2.5 py-1 text-[12px] transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-[var(--accent-soft)] text-[var(--accent)]'
|
||||||
|
: 'text-[var(--text-2)] hover:text-[var(--text)]'
|
||||||
|
}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { TrendingUp } from 'lucide-react';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
|
||||||
|
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
|
||||||
|
|
||||||
|
export interface TrendPoint {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrendChart({
|
||||||
|
data,
|
||||||
|
peak,
|
||||||
|
avg,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
data: TrendPoint[];
|
||||||
|
peak: TrendPoint;
|
||||||
|
avg: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const option = useMemo<EChartsOption>(
|
||||||
|
() => ({
|
||||||
|
grid: { top: 30, right: 24, bottom: 30, left: 50 },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: '#101812',
|
||||||
|
borderColor: '#27342c',
|
||||||
|
textStyle: { color: '#edf1e8' },
|
||||||
|
formatter: (params: unknown) => {
|
||||||
|
const arr = params as Array<{ name: string; value: number }>;
|
||||||
|
const p = arr[0];
|
||||||
|
return `<div style="font-size:12px"><div style="color:#aab4aa">${p.name}</div><div style="color:#7dd3a8;font-weight:600;margin-top:2px">消息数:${p.value} 条</div></div>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.map((d) => d.date.slice(5)),
|
||||||
|
axisLine: { lineStyle: { color: '#27342c' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { color: '#737f75', fontSize: 11 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(154,174,158,0.12)' } },
|
||||||
|
axisLabel: { color: '#737f75', fontSize: 11 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
data: data.map((d) => d.count),
|
||||||
|
lineStyle: { color: '#7dd3a8', width: 2 },
|
||||||
|
itemStyle: { color: '#7dd3a8' },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(125,211,168,0.34)' },
|
||||||
|
{ offset: 1, color: 'rgba(125,211,168,0.02)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||||
|
<TrendingUp size={14} className="text-[var(--accent)]" />
|
||||||
|
消息走势
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-3)]">
|
||||||
|
过去 {data.length} 天 · {total.toLocaleString()} 条
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex gap-6 text-[11px] text-[var(--text-3)]">
|
||||||
|
<span>
|
||||||
|
峰值 <span className="text-[var(--text)]">{peak.count} 条/天</span>
|
||||||
|
{peak.date && <span className="ml-1 text-[var(--text-3)]">· {peak.date}</span>}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
均值 <span className="text-[var(--text)]">{avg.toFixed(1)}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
总计 <span className="text-[var(--text)]">{total.toLocaleString()} 条</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
{data.length > 0 ? (
|
||||||
|
<ReactECharts option={option} style={{ height: 280 }} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[280px] items-center justify-center text-[12px] text-[var(--text-3)]">
|
||||||
|
暂无数据 · 点击右上「重扫」加载
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
"scripts/*.cjs",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import NodeCache from 'node-cache';
|
||||||
|
|
||||||
|
export const cache = new NodeCache({
|
||||||
|
stdTTL: 30,
|
||||||
|
checkperiod: 60,
|
||||||
|
useClones: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CK = {
|
||||||
|
sessions: () => 'sessions:all',
|
||||||
|
daemon: () => 'daemon:status',
|
||||||
|
stats: (chatroomId: string, since: string, until: string) =>
|
||||||
|
`stats:${chatroomId}:${since}:${until}`,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
export const DATA_DIR =
|
||||||
|
process.env.WECHAT_RADAR_DATA_DIR ||
|
||||||
|
join(homedir(), '.wechat-radar');
|
||||||
|
|
||||||
|
const CONFIG_PATH = join(DATA_DIR, 'config.json');
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
myNicknames: string[];
|
||||||
|
defaultRange: 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||||
|
rescanConcurrency: number;
|
||||||
|
privacyConfirmed: boolean;
|
||||||
|
setupCompleted: boolean;
|
||||||
|
demoMode: boolean;
|
||||||
|
defaultSyncDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envNames(): string[] {
|
||||||
|
return (process.env.WECHAT_RADAR_MY_NAMES || '')
|
||||||
|
.split(',')
|
||||||
|
.map((name) => name.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: Config = {
|
||||||
|
myNicknames: envNames(),
|
||||||
|
defaultRange: 'week',
|
||||||
|
rescanConcurrency: 5,
|
||||||
|
privacyConfirmed: false,
|
||||||
|
setupCompleted: false,
|
||||||
|
demoMode: process.env.WECHAT_RADAR_DEMO === '1',
|
||||||
|
defaultSyncDays: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readConfig(): Config {
|
||||||
|
if (!existsSync(CONFIG_PATH)) {
|
||||||
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
||||||
|
writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2), 'utf-8');
|
||||||
|
return DEFAULTS;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Config>;
|
||||||
|
const merged = { ...DEFAULTS, ...parsed };
|
||||||
|
if (envNames().length > 0) merged.myNicknames = envNames();
|
||||||
|
if (process.env.WECHAT_RADAR_DEMO === '1') merged.demoMode = true;
|
||||||
|
return merged;
|
||||||
|
} catch {
|
||||||
|
return DEFAULTS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeConfig(patch: Partial<Config>): Config {
|
||||||
|
const cur = readConfig();
|
||||||
|
const merged = { ...cur, ...patch };
|
||||||
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
||||||
|
writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), 'utf-8');
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configStatus() {
|
||||||
|
const cfg = readConfig();
|
||||||
|
return {
|
||||||
|
dataDir: DATA_DIR,
|
||||||
|
configPath: CONFIG_PATH,
|
||||||
|
configured: cfg.setupCompleted && cfg.privacyConfirmed && (cfg.demoMode || cfg.myNicknames.length > 0),
|
||||||
|
config: cfg,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,836 @@
|
|||||||
|
import { db } from './db';
|
||||||
|
import { cache } from './cache';
|
||||||
|
import { todayStr } from './range';
|
||||||
|
|
||||||
|
const MAX_ROWS = 1600;
|
||||||
|
const MAX_MUST_READ = 8;
|
||||||
|
const MAX_OPPORTUNITIES = 5;
|
||||||
|
const MAX_SIGNAL_SOURCES = 8;
|
||||||
|
const MAX_ACTION_ITEMS = 8;
|
||||||
|
const MAX_TOPIC_LIFECYCLE = 6;
|
||||||
|
const MAX_LINK_HIGHLIGHTS = 8;
|
||||||
|
const MAX_PEOPLE_RADAR = 8;
|
||||||
|
const MAX_CONTENT_IDEAS = 6;
|
||||||
|
const MAX_ANOMALIES = 6;
|
||||||
|
const CACHE_TTL_SECONDS = 90;
|
||||||
|
|
||||||
|
const URL_RE = /https?:\/\/[^\s<>"']+/i;
|
||||||
|
const URL_GLOBAL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||||
|
const TOOL_RE =
|
||||||
|
/工具|产品|项目|插件|模型|智能体|Agent|Claude|Gemini|Codex|API|CLI|MCP|开源|GitHub|Chrome|飞书|Notion|Obsidian|workflow|workspace/i;
|
||||||
|
const OPPORTUNITY_RE =
|
||||||
|
/求推荐|求一个|谁有|谁能.*(推荐|帮|做|开发|联系)|有没有.*(工具|方案|资源|推荐)|想找|找人|招募|报名|内测|名额|一起做|采购|团购|项目合作|合作.*(项目|机会|对接|商演|商务)|需要.*(推荐|合作|对接|开发|方案)/i;
|
||||||
|
const ACTION_RE = /帮忙|看看|回复|跟进|对接|联系|报名|填写|试试|评估|整理|发我|私信/i;
|
||||||
|
const QUESTION_RE = /[??]|怎么|如何|为啥|为什么|能不能|可不可以|有没有/i;
|
||||||
|
const NOISE_RE = /撤回了一条消息|邀请.*加入了群聊|移出了群聊|以下为新消息/i;
|
||||||
|
const DIGEST_RE = /日报|每日情报|群日报|资源分享|今日小结|知识库更新/i;
|
||||||
|
|
||||||
|
interface MessageSignalRow {
|
||||||
|
chatroom_id: string;
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicDefinition {
|
||||||
|
title: string;
|
||||||
|
keywords: string[];
|
||||||
|
re: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOPIC_DEFINITIONS: TopicDefinition[] = [
|
||||||
|
{ title: 'Codex / Claude Code 工作流', keywords: ['Codex', 'Claude Code', 'CLI'], re: /codex|claude code|claude.?skills|clawdbot|cli|vibe.?coding/i },
|
||||||
|
{ title: 'AI Agent 与智能体', keywords: ['Agent', '智能体', '多智能体'], re: /agent|智能体|multi.?agent|工作流|workflow/i },
|
||||||
|
{ title: 'AI 工具与产品体验', keywords: ['工具', '产品', '内测'], re: /工具|产品|插件|内测|体验|注册|api|模型/i },
|
||||||
|
{ title: 'MCP / Skills / 开源项目', keywords: ['MCP', 'Skills', 'GitHub'], re: /mcp|skills?|github|开源|repo|仓库/i },
|
||||||
|
{ title: '内容创作与 AIGC', keywords: ['AIGC', '视频', '小红书'], re: /aigc|视频|音乐|图像|小红书|公众号|内容|创作|封面/i },
|
||||||
|
{ title: 'GEO / SEO / AI 营销', keywords: ['GEO', 'SEO', '营销'], re: /geo|seo|营销|搜索|获客|品牌|公关/i },
|
||||||
|
{ title: '知识库与飞书文档', keywords: ['飞书', '知识库', '文档'], re: /飞书|知识库|文档|notion|obsidian|wiki|表格/i },
|
||||||
|
{ title: '活动 / 报名 / 社群运营', keywords: ['活动', '报名', '直播'], re: /活动|报名|直播|训练营|课程|大会|线下|分享会|名额/i },
|
||||||
|
{ title: '团购 / 采购 / 商务机会', keywords: ['团购', '采购', '合作'], re: /团购|采购|报价|预算|合作|商务|对接/i },
|
||||||
|
{ title: '投资 / 财经 / 宏观讨论', keywords: ['投资', '财经', '股票'], re: /投资|财经|股票|基金|币圈|crypto|美股|港股/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface DashboardSignalItem {
|
||||||
|
chatroom_id: string;
|
||||||
|
chat_name: string;
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
time: string;
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
score: number;
|
||||||
|
reasons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardOpportunityItem extends DashboardSignalItem {
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSignalSource {
|
||||||
|
sender: string;
|
||||||
|
signal_count: number;
|
||||||
|
group_count: number;
|
||||||
|
top_group: string;
|
||||||
|
last_seen: string;
|
||||||
|
strengths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardActionItem extends DashboardOpportunityItem {
|
||||||
|
why: string;
|
||||||
|
urgency: 'high' | 'medium' | 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTopicLifecycle {
|
||||||
|
title: string;
|
||||||
|
status: 'rising' | 'spreading' | 'hot' | 'cooling';
|
||||||
|
today_count: number;
|
||||||
|
previous_avg: number;
|
||||||
|
group_count: number;
|
||||||
|
reason: string;
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardLinkHighlight {
|
||||||
|
kind: 'article' | 'tool';
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
domain: string;
|
||||||
|
score: number;
|
||||||
|
verdict: string;
|
||||||
|
count: number;
|
||||||
|
group_count: number;
|
||||||
|
last_seen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardPeopleRadar {
|
||||||
|
sender: string;
|
||||||
|
role: '分享者' | '需求提出者' | '连接者' | '观点源';
|
||||||
|
score: number;
|
||||||
|
group_count: number;
|
||||||
|
signal_count: number;
|
||||||
|
top_group: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardContentIdea {
|
||||||
|
title: string;
|
||||||
|
angle: string;
|
||||||
|
suggested_channel: '公众号' | 'X' | '小红书' | '博客';
|
||||||
|
evidence: string;
|
||||||
|
source_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardAnomalySignal {
|
||||||
|
kind: 'spike' | 'cross_group' | 'dense_links' | 'quiet_day';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
severity: 'high' | 'medium' | 'low';
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardIntelligence {
|
||||||
|
date: string;
|
||||||
|
must_read: DashboardSignalItem[];
|
||||||
|
opportunities: DashboardOpportunityItem[];
|
||||||
|
signal_sources: DashboardSignalSource[];
|
||||||
|
action_items: DashboardActionItem[];
|
||||||
|
topic_lifecycle: DashboardTopicLifecycle[];
|
||||||
|
link_highlights: DashboardLinkHighlight[];
|
||||||
|
people_radar: DashboardPeopleRadar[];
|
||||||
|
content_ideas: DashboardContentIdea[];
|
||||||
|
anomalies: DashboardAnomalySignal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDashboardIntelligence(
|
||||||
|
date = todayStr(),
|
||||||
|
groupNames = new Map<string, string>(),
|
||||||
|
): DashboardIntelligence {
|
||||||
|
date = resolveIntelligenceDate(date);
|
||||||
|
const key = `dashboard-intelligence:${date}:v11`;
|
||||||
|
const cached = cache.get(key) as DashboardIntelligence | undefined;
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT chatroom_id, local_id, sender, content, time, timestamp
|
||||||
|
FROM messages
|
||||||
|
WHERE date = ?
|
||||||
|
AND length(content) >= 8
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(date, MAX_ROWS) as MessageSignalRow[];
|
||||||
|
const historyRows = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT chatroom_id, local_id, sender, content, time, timestamp
|
||||||
|
FROM messages
|
||||||
|
WHERE date >= ?
|
||||||
|
AND date <= ?
|
||||||
|
AND length(content) >= 4
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(minusDays(date, 7), date, 9000) as MessageSignalRow[];
|
||||||
|
|
||||||
|
const candidates: DashboardSignalItem[] = [];
|
||||||
|
const opportunities: DashboardOpportunityItem[] = [];
|
||||||
|
const seenOpportunities = new Set<string>();
|
||||||
|
const sourceMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
sender: string;
|
||||||
|
signal_count: number;
|
||||||
|
groups: Set<string>;
|
||||||
|
topGroups: Map<string, number>;
|
||||||
|
last_seen: string;
|
||||||
|
link_count: number;
|
||||||
|
opportunity_count: number;
|
||||||
|
tool_count: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
const linkBuckets = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
kind: 'article' | 'tool';
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
domain: string;
|
||||||
|
count: number;
|
||||||
|
groups: Set<string>;
|
||||||
|
last_seen: string;
|
||||||
|
snippets: string[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
const seenSnippets = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const clean = cleanContent(row.content);
|
||||||
|
if (!clean || NOISE_RE.test(row.content)) continue;
|
||||||
|
|
||||||
|
const score = scoreContent(clean);
|
||||||
|
if (score < 4) continue;
|
||||||
|
|
||||||
|
const title = titleFromContent(clean);
|
||||||
|
const dedupeKey = normalizeDedupe(title || clean);
|
||||||
|
if (seenSnippets.has(dedupeKey)) continue;
|
||||||
|
seenSnippets.add(dedupeKey);
|
||||||
|
|
||||||
|
const reasons = reasonsFor(clean);
|
||||||
|
const item: DashboardSignalItem = {
|
||||||
|
chatroom_id: row.chatroom_id,
|
||||||
|
chat_name: groupNames.get(row.chatroom_id) ?? row.chatroom_id,
|
||||||
|
local_id: row.local_id,
|
||||||
|
sender: row.sender || '未知成员',
|
||||||
|
time: row.time,
|
||||||
|
title,
|
||||||
|
snippet: clean.slice(0, 150),
|
||||||
|
score,
|
||||||
|
reasons,
|
||||||
|
};
|
||||||
|
candidates.push(item);
|
||||||
|
|
||||||
|
if (isOpportunity(clean)) {
|
||||||
|
const opportunityKey = opportunityDedupeKey(clean, item.title);
|
||||||
|
if (!seenOpportunities.has(opportunityKey)) {
|
||||||
|
seenOpportunities.add(opportunityKey);
|
||||||
|
opportunities.push({ ...item, action: actionFor(clean) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceKey = item.sender.trim() || '未知成员';
|
||||||
|
const source = sourceMap.get(sourceKey) ?? {
|
||||||
|
sender: sourceKey,
|
||||||
|
signal_count: 0,
|
||||||
|
groups: new Set<string>(),
|
||||||
|
topGroups: new Map<string, number>(),
|
||||||
|
last_seen: item.time,
|
||||||
|
link_count: 0,
|
||||||
|
opportunity_count: 0,
|
||||||
|
tool_count: 0,
|
||||||
|
};
|
||||||
|
source.signal_count++;
|
||||||
|
source.groups.add(row.chatroom_id);
|
||||||
|
source.topGroups.set(item.chat_name, (source.topGroups.get(item.chat_name) ?? 0) + 1);
|
||||||
|
source.last_seen = source.last_seen > item.time ? source.last_seen : item.time;
|
||||||
|
if (URL_RE.test(clean) || clean.includes('链接')) source.link_count++;
|
||||||
|
if (isOpportunity(clean)) source.opportunity_count++;
|
||||||
|
if (TOOL_RE.test(clean)) source.tool_count++;
|
||||||
|
sourceMap.set(sourceKey, source);
|
||||||
|
|
||||||
|
for (const url of extractUrls(row.content)) {
|
||||||
|
const domain = domainOf(url);
|
||||||
|
if (!domain) continue;
|
||||||
|
const kind = isArticleUrl(url) ? 'article' : isToolUrl(url, clean) ? 'tool' : null;
|
||||||
|
if (!kind) continue;
|
||||||
|
const key = normalizeUrlKey(url);
|
||||||
|
const bucket = linkBuckets.get(key) ?? {
|
||||||
|
kind,
|
||||||
|
title: titleFromLinkContext(row.content, url),
|
||||||
|
url,
|
||||||
|
domain,
|
||||||
|
count: 0,
|
||||||
|
groups: new Set<string>(),
|
||||||
|
last_seen: row.time,
|
||||||
|
snippets: [],
|
||||||
|
};
|
||||||
|
bucket.count++;
|
||||||
|
bucket.groups.add(row.chatroom_id);
|
||||||
|
bucket.last_seen = bucket.last_seen > row.time ? bucket.last_seen : row.time;
|
||||||
|
if (clean) bucket.snippets.push(clean.slice(0, 80));
|
||||||
|
linkBuckets.set(key, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mustRead = candidates
|
||||||
|
.sort((a, b) => b.score - a.score || b.time.localeCompare(a.time))
|
||||||
|
.slice(0, MAX_MUST_READ);
|
||||||
|
const opportunityItems = opportunities
|
||||||
|
.sort((a, b) => b.score - a.score || b.time.localeCompare(a.time))
|
||||||
|
.slice(0, MAX_OPPORTUNITIES);
|
||||||
|
const signalSources = Array.from(sourceMap.values())
|
||||||
|
.map((s) => ({
|
||||||
|
sender: s.sender,
|
||||||
|
signal_count: s.signal_count,
|
||||||
|
group_count: s.groups.size,
|
||||||
|
top_group: topEntry(s.topGroups),
|
||||||
|
last_seen: s.last_seen,
|
||||||
|
strengths: strengthsFor(s),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.signal_count >= 2)
|
||||||
|
.sort((a, b) => b.signal_count - a.signal_count || b.group_count - a.group_count)
|
||||||
|
.slice(0, MAX_SIGNAL_SOURCES);
|
||||||
|
|
||||||
|
const actionItems = buildActionItems(opportunityItems, mustRead);
|
||||||
|
const topicLifecycle = buildTopicLifecycle(date, historyRows);
|
||||||
|
const linkHighlights = buildLinkHighlights(linkBuckets);
|
||||||
|
const peopleRadar = buildPeopleRadar(sourceMap);
|
||||||
|
const contentIdeas = buildContentIdeas(topicLifecycle, mustRead, linkHighlights);
|
||||||
|
const anomalies = buildAnomalies(date, groupNames, linkBuckets, rows.length);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
date,
|
||||||
|
must_read: mustRead,
|
||||||
|
opportunities: opportunityItems,
|
||||||
|
signal_sources: signalSources,
|
||||||
|
action_items: actionItems,
|
||||||
|
topic_lifecycle: topicLifecycle,
|
||||||
|
link_highlights: linkHighlights,
|
||||||
|
people_radar: peopleRadar,
|
||||||
|
content_ideas: contentIdeas,
|
||||||
|
anomalies,
|
||||||
|
};
|
||||||
|
|
||||||
|
cache.set(key, result, CACHE_TTL_SECONDS);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIntelligenceDate(date: string): string {
|
||||||
|
const row = db()
|
||||||
|
.prepare('SELECT date FROM messages WHERE date <= ? GROUP BY date ORDER BY date DESC LIMIT 1')
|
||||||
|
.get(date) as { date: string } | undefined;
|
||||||
|
return row?.date ?? date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActionItems(
|
||||||
|
opportunities: DashboardOpportunityItem[],
|
||||||
|
mustRead: DashboardSignalItem[],
|
||||||
|
): DashboardActionItem[] {
|
||||||
|
const fromOpportunity = opportunities.map((item) => ({
|
||||||
|
...item,
|
||||||
|
why: whyForAction(item.snippet),
|
||||||
|
urgency: urgencyFor(item.snippet, item.score),
|
||||||
|
}));
|
||||||
|
const fallback = mustRead
|
||||||
|
.filter((item) => item.reasons.includes('问题') || item.reasons.includes('工具/产品'))
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
action: item.reasons.includes('问题') ? '可回复观点' : '可试用/收藏',
|
||||||
|
why: item.reasons.includes('问题') ? '包含明确问题,适合补充观点或资源' : '包含工具/产品线索,适合试用或收入素材库',
|
||||||
|
urgency: 'medium' as const,
|
||||||
|
}));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return [...fromOpportunity, ...fallback]
|
||||||
|
.filter((item) => {
|
||||||
|
const key = normalizeDedupe(`${item.chatroom_id}:${item.title}`);
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.slice(0, MAX_ACTION_ITEMS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTopicLifecycle(date: string, rows: MessageSignalRow[]): DashboardTopicLifecycle[] {
|
||||||
|
const dayCounts = new Map<string, Map<string, { count: number; groups: Set<string> }>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const d = row.time.slice(0, 10);
|
||||||
|
if (!d) continue;
|
||||||
|
const clean = cleanContent(row.content);
|
||||||
|
for (const topic of TOPIC_DEFINITIONS) {
|
||||||
|
if (!topic.re.test(clean)) continue;
|
||||||
|
const perDay = dayCounts.get(topic.title) ?? new Map<string, { count: number; groups: Set<string> }>();
|
||||||
|
const bucket = perDay.get(d) ?? { count: 0, groups: new Set<string>() };
|
||||||
|
bucket.count++;
|
||||||
|
bucket.groups.add(row.chatroom_id);
|
||||||
|
perDay.set(d, bucket);
|
||||||
|
dayCounts.set(topic.title, perDay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TOPIC_DEFINITIONS.map((topic) => {
|
||||||
|
const perDay = dayCounts.get(topic.title) ?? new Map<string, { count: number; groups: Set<string> }>();
|
||||||
|
const today = perDay.get(date) ?? { count: 0, groups: new Set<string>() };
|
||||||
|
const previousValues = Array.from(perDay.entries())
|
||||||
|
.filter(([d]) => d < date)
|
||||||
|
.map(([, v]) => v.count);
|
||||||
|
const previousAvg =
|
||||||
|
previousValues.length > 0 ? previousValues.reduce((sum, n) => sum + n, 0) / previousValues.length : 0;
|
||||||
|
const ratio = today.count / Math.max(previousAvg, 1);
|
||||||
|
const status: DashboardTopicLifecycle['status'] =
|
||||||
|
today.groups.size >= 5
|
||||||
|
? 'spreading'
|
||||||
|
: ratio >= 1.8 && today.count >= 5
|
||||||
|
? 'rising'
|
||||||
|
: today.count >= 16
|
||||||
|
? 'hot'
|
||||||
|
: today.count < previousAvg * 0.45 && previousAvg >= 6
|
||||||
|
? 'cooling'
|
||||||
|
: 'hot';
|
||||||
|
return {
|
||||||
|
title: topic.title,
|
||||||
|
status,
|
||||||
|
today_count: today.count,
|
||||||
|
previous_avg: Number(previousAvg.toFixed(1)),
|
||||||
|
group_count: today.groups.size,
|
||||||
|
reason: topicReason(status, today.count, previousAvg, today.groups.size),
|
||||||
|
keywords: topic.keywords,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((topic) => topic.today_count > 0 || topic.status === 'cooling')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const priority = statusWeight(b.status) - statusWeight(a.status);
|
||||||
|
return priority || b.today_count - a.today_count || b.group_count - a.group_count;
|
||||||
|
})
|
||||||
|
.slice(0, MAX_TOPIC_LIFECYCLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinkHighlights(
|
||||||
|
linkBuckets: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
kind: 'article' | 'tool';
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
domain: string;
|
||||||
|
count: number;
|
||||||
|
groups: Set<string>;
|
||||||
|
last_seen: string;
|
||||||
|
snippets: string[];
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
): DashboardLinkHighlight[] {
|
||||||
|
return Array.from(linkBuckets.values())
|
||||||
|
.map((item) => {
|
||||||
|
const score = item.count * 2 + item.groups.size * 3 + (item.kind === 'tool' ? 2 : 0);
|
||||||
|
return {
|
||||||
|
kind: item.kind,
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
domain: item.domain,
|
||||||
|
score,
|
||||||
|
verdict: verdictForLink(item.kind, item.count, item.groups.size, item.snippets.join(' ')),
|
||||||
|
count: item.count,
|
||||||
|
group_count: item.groups.size,
|
||||||
|
last_seen: item.last_seen,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score || b.last_seen.localeCompare(a.last_seen))
|
||||||
|
.slice(0, MAX_LINK_HIGHLIGHTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPeopleRadar(
|
||||||
|
sourceMap: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
sender: string;
|
||||||
|
signal_count: number;
|
||||||
|
groups: Set<string>;
|
||||||
|
topGroups: Map<string, number>;
|
||||||
|
last_seen: string;
|
||||||
|
link_count: number;
|
||||||
|
opportunity_count: number;
|
||||||
|
tool_count: number;
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
): DashboardPeopleRadar[] {
|
||||||
|
return Array.from(sourceMap.values())
|
||||||
|
.map((s) => {
|
||||||
|
const score = s.signal_count * 2 + s.groups.size * 3 + s.link_count + s.opportunity_count * 2 + s.tool_count;
|
||||||
|
const role: DashboardPeopleRadar['role'] =
|
||||||
|
s.opportunity_count >= 2 ? '需求提出者' : s.groups.size >= 3 ? '连接者' : s.link_count >= s.tool_count ? '分享者' : '观点源';
|
||||||
|
return {
|
||||||
|
sender: s.sender,
|
||||||
|
role,
|
||||||
|
score,
|
||||||
|
group_count: s.groups.size,
|
||||||
|
signal_count: s.signal_count,
|
||||||
|
top_group: topEntry(s.topGroups),
|
||||||
|
reason: personReason(role, s.signal_count, s.groups.size),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((p) => p.signal_count >= 2)
|
||||||
|
.sort((a, b) => b.score - a.score || b.group_count - a.group_count)
|
||||||
|
.slice(0, MAX_PEOPLE_RADAR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContentIdeas(
|
||||||
|
topics: DashboardTopicLifecycle[],
|
||||||
|
mustRead: DashboardSignalItem[],
|
||||||
|
links: DashboardLinkHighlight[],
|
||||||
|
): DashboardContentIdea[] {
|
||||||
|
const ideas: DashboardContentIdea[] = [];
|
||||||
|
for (const topic of topics.slice(0, 4)) {
|
||||||
|
ideas.push({
|
||||||
|
title: `${topic.title}:今天微信群里真正升温的信号`,
|
||||||
|
angle: topic.status === 'spreading' ? '从跨群扩散解释为什么它值得关注' : '从真实讨论里提炼一个可执行判断',
|
||||||
|
suggested_channel: topic.title.includes('工作流') || topic.title.includes('开源') ? '博客' : '公众号',
|
||||||
|
evidence: topic.reason,
|
||||||
|
source_count: topic.today_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const link of links.slice(0, 2)) {
|
||||||
|
ideas.push({
|
||||||
|
title: `${link.kind === 'tool' ? '新工具观察' : '文章拆解'}:${link.title}`,
|
||||||
|
angle: link.verdict,
|
||||||
|
suggested_channel: link.kind === 'tool' ? 'X' : '公众号',
|
||||||
|
evidence: `${link.group_count} 个群提到,${link.count} 次出现`,
|
||||||
|
source_count: link.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ideas.length < MAX_CONTENT_IDEAS) {
|
||||||
|
for (const item of mustRead.slice(0, MAX_CONTENT_IDEAS - ideas.length)) {
|
||||||
|
ideas.push({
|
||||||
|
title: item.title,
|
||||||
|
angle: item.reasons.includes('问题') ? '从一个真实问题切入,给出判断和清单' : '把高信号讨论整理成一篇短观点',
|
||||||
|
suggested_channel: item.reasons.includes('工具/产品') ? 'X' : '公众号',
|
||||||
|
evidence: `${item.chat_name} · ${item.sender}`,
|
||||||
|
source_count: item.score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ideas.slice(0, MAX_CONTENT_IDEAS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnomalies(
|
||||||
|
date: string,
|
||||||
|
groupNames: Map<string, string>,
|
||||||
|
linkBuckets: Map<string, { count: number; groups: Set<string>; title: string; kind: 'article' | 'tool'; url: string }>,
|
||||||
|
todayRows: number,
|
||||||
|
): DashboardAnomalySignal[] {
|
||||||
|
const anomalies: DashboardAnomalySignal[] = [];
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT chatroom_id, date, total
|
||||||
|
FROM daily_stats
|
||||||
|
WHERE date >= ? AND date <= ? AND total > 0`,
|
||||||
|
)
|
||||||
|
.all(minusDays(date, 7), date) as Array<{ chatroom_id: string; date: string; total: number }>;
|
||||||
|
const byGroup = new Map<string, Array<{ date: string; total: number }>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const arr = byGroup.get(row.chatroom_id) ?? [];
|
||||||
|
arr.push({ date: row.date, total: row.total });
|
||||||
|
byGroup.set(row.chatroom_id, arr);
|
||||||
|
}
|
||||||
|
for (const [chatroomId, values] of byGroup) {
|
||||||
|
const today = values.find((v) => v.date === date)?.total ?? 0;
|
||||||
|
const prev = values.filter((v) => v.date < date).map((v) => v.total);
|
||||||
|
if (today < 20 || prev.length === 0) continue;
|
||||||
|
const avg = prev.reduce((sum, n) => sum + n, 0) / prev.length;
|
||||||
|
if (today >= Math.max(30, avg * 2.2)) {
|
||||||
|
anomalies.push({
|
||||||
|
kind: 'spike',
|
||||||
|
title: `${groupNames.get(chatroomId) ?? chatroomId} 突然升温`,
|
||||||
|
description: `今日 ${today} 条,约为近 7 日均值 ${avg.toFixed(1)} 的 ${Math.round(today / Math.max(avg, 1))} 倍`,
|
||||||
|
severity: today >= avg * 4 ? 'high' : 'medium',
|
||||||
|
href: `/groups/${encodeURIComponent(chatroomId)}?date=${date}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const link of Array.from(linkBuckets.values()).filter((l) => l.groups.size >= 3).slice(0, 3)) {
|
||||||
|
anomalies.push({
|
||||||
|
kind: 'cross_group',
|
||||||
|
title: `${link.kind === 'tool' ? '工具' : '文章'}跨群扩散`,
|
||||||
|
description: `${link.title} 被 ${link.groups.size} 个群同时提到,适合优先查看`,
|
||||||
|
severity: link.groups.size >= 5 ? 'high' : 'medium',
|
||||||
|
href: link.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todayRows === 0) {
|
||||||
|
anomalies.push({
|
||||||
|
kind: 'quiet_day',
|
||||||
|
title: '今日暂无本地消息',
|
||||||
|
description: '可能还未同步当天消息,建议重扫或检查 wx-daemon',
|
||||||
|
severity: 'low',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return anomalies
|
||||||
|
.sort((a, b) => severityWeight(b.severity) - severityWeight(a.severity))
|
||||||
|
.slice(0, MAX_ANOMALIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanContent(content: string): string {
|
||||||
|
const xmlText = xmlSummary(content);
|
||||||
|
return (xmlText || content)
|
||||||
|
.replace(/https?:\/\/\S+/g, ' 链接 ')
|
||||||
|
.replace(/\[引用\]/g, '')
|
||||||
|
.replace(/↳/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function minusDays(date: string, days: number): string {
|
||||||
|
const [year, month, day] = date.split('-').map(Number);
|
||||||
|
const d = new Date(year, month - 1, day);
|
||||||
|
d.setDate(d.getDate() - days);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUrls(content: string): string[] {
|
||||||
|
const decoded = decodeHtml(content);
|
||||||
|
return Array.from(decoded.matchAll(URL_GLOBAL_RE))
|
||||||
|
.map((m) => cleanUrl(m[0]))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanUrl(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/[),,。;;!?!?、\]}>]+$/g, '')
|
||||||
|
.replace(/\.{3,}$/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrlKey(raw: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(cleanUrl(raw));
|
||||||
|
u.hash = '';
|
||||||
|
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
|
||||||
|
u.searchParams.delete(key);
|
||||||
|
}
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainOf(raw: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(cleanUrl(raw)).hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArticleUrl(raw: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(cleanUrl(raw));
|
||||||
|
const host = u.hostname.replace(/^www\./, '');
|
||||||
|
if (host === 'mp.weixin.qq.com') return true;
|
||||||
|
if ((host === 'x.com' || host === 'twitter.com') && /\/status\/\d{12,}/.test(u.pathname)) return true;
|
||||||
|
if (host === 'youtube.com' || host === 'youtu.be') return true;
|
||||||
|
return /zhihu|toutiao|sohu|163\.com|qq\.com|medium\.com|substack\.com|juejin\.cn/i.test(host);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolUrl(raw: string, content: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(cleanUrl(raw));
|
||||||
|
const host = u.hostname.replace(/^www\./, '');
|
||||||
|
if (/qlogo|qpic|support\.weixin|res\.wx/i.test(host)) return false;
|
||||||
|
if (isArticleUrl(raw)) return false;
|
||||||
|
if (/github\.com|huggingface\.co|replicate\.com|vercel\.app|netlify\.app|feishu\.cn|notion\.so|notion\.site|docs\.google\.com/i.test(host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return TOOL_RE.test(content);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromLinkContext(content: string, url: string): string {
|
||||||
|
const xmlTitle = tagText(content, 'title');
|
||||||
|
if (xmlTitle) return xmlTitle.slice(0, 56);
|
||||||
|
const clean = decodeHtml(content)
|
||||||
|
.replace(url, '')
|
||||||
|
.replace(URL_GLOBAL_RE, '')
|
||||||
|
.replace(/\[引用\]/g, '')
|
||||||
|
.replace(/↳/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const first = clean.split(/[。!?!?]\s*/).find((part) => part.trim().length >= 6);
|
||||||
|
return (first ?? domainOf(url)).slice(0, 56);
|
||||||
|
}
|
||||||
|
|
||||||
|
function whyForAction(content: string): string {
|
||||||
|
if (/团购|采购|报价|预算/i.test(content)) return '包含采购/团购信号,可能直接转化为资源或商务机会';
|
||||||
|
if (/报名|名额|活动|会议|直播/i.test(content)) return '包含时间敏感入口,适合尽快确认是否参与';
|
||||||
|
if (/合作|对接|找人|招募|一起做/i.test(content)) return '包含合作或找人需求,适合主动连接';
|
||||||
|
if (/求推荐|有没有|谁有|需要/i.test(content)) return '有人提出明确需求,适合用你的资源网络回复';
|
||||||
|
return '具备明确上下文和行动动词,适合进入原群查看';
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgencyFor(content: string, score: number): DashboardActionItem['urgency'] {
|
||||||
|
if (/今天|今晚|明天|马上|名额|截止|限时|报名/i.test(content) || score >= 10) return 'high';
|
||||||
|
if (/合作|采购|团购|对接|求推荐/i.test(content) || score >= 7) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicReason(
|
||||||
|
status: DashboardTopicLifecycle['status'],
|
||||||
|
todayCount: number,
|
||||||
|
previousAvg: number,
|
||||||
|
groupCount: number,
|
||||||
|
): string {
|
||||||
|
if (status === 'spreading') return `跨 ${groupCount} 个群出现,已经不是单群噪音`;
|
||||||
|
if (status === 'rising') return `今日 ${todayCount} 条,高于近 7 日均值 ${previousAvg.toFixed(1)}`;
|
||||||
|
if (status === 'cooling') return `今日热度低于近 7 日均值,可能进入退潮期`;
|
||||||
|
return `今日 ${todayCount} 条讨论,保持高热度`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusWeight(status: DashboardTopicLifecycle['status']): number {
|
||||||
|
if (status === 'spreading') return 4;
|
||||||
|
if (status === 'rising') return 3;
|
||||||
|
if (status === 'hot') return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verdictForLink(kind: 'article' | 'tool', count: number, groupCount: number, snippets: string): string {
|
||||||
|
if (groupCount >= 3) return '跨群重复出现,优先查看';
|
||||||
|
if (kind === 'tool' && /实测|体验|教程|开源|github|保姆级/i.test(snippets)) return '有使用语境,值得试用';
|
||||||
|
if (kind === 'article' && /复盘|教程|深度|报告|访谈|经验/i.test(snippets)) return '具备可整理成内容的素材';
|
||||||
|
if (count >= 2) return '重复提到,适合收藏备查';
|
||||||
|
return kind === 'tool' ? '新工具线索,快速扫一眼' : '文章线索,按需阅读';
|
||||||
|
}
|
||||||
|
|
||||||
|
function personReason(role: DashboardPeopleRadar['role'], signalCount: number, groupCount: number): string {
|
||||||
|
if (role === '连接者') return `跨 ${groupCount} 个群出现,适合关注其连接的圈层`;
|
||||||
|
if (role === '需求提出者') return `提出多条可行动需求,适合跟进`;
|
||||||
|
if (role === '分享者') return `贡献 ${signalCount} 条链接/资料信号`;
|
||||||
|
return `贡献 ${signalCount} 条高信号观点`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityWeight(severity: DashboardAnomalySignal['severity']): number {
|
||||||
|
if (severity === 'high') return 3;
|
||||||
|
if (severity === 'medium') return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlSummary(content: string): string {
|
||||||
|
if (!content.includes('<msg>')) return '';
|
||||||
|
const title = tagText(content, 'title');
|
||||||
|
const des = tagText(content, 'des');
|
||||||
|
const url = tagText(content, 'url') || tagText(content, 'imgsourceurl');
|
||||||
|
return [title, des, url ? '链接' : ''].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagText(content: string, tag: string): string {
|
||||||
|
const text = content.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'))?.[1] ?? '';
|
||||||
|
return decodeHtml(text).replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreContent(content: string): number {
|
||||||
|
let score = 0;
|
||||||
|
if (URL_RE.test(content) || content.includes('链接')) score += 3;
|
||||||
|
if (TOOL_RE.test(content)) score += 3;
|
||||||
|
if (isOpportunity(content)) score += 4;
|
||||||
|
if (ACTION_RE.test(content)) score += 2;
|
||||||
|
if (QUESTION_RE.test(content)) score += 1;
|
||||||
|
if (content.length >= 80) score += 2;
|
||||||
|
if (content.length >= 180) score += 1;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonsFor(content: string): string[] {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (isOpportunity(content)) reasons.push('机会/需求');
|
||||||
|
if (TOOL_RE.test(content)) reasons.push('工具/产品');
|
||||||
|
if (URL_RE.test(content) || content.includes('链接')) reasons.push('链接信号');
|
||||||
|
if (ACTION_RE.test(content)) reasons.push('可跟进');
|
||||||
|
if (content.length >= 120) reasons.push('长观点');
|
||||||
|
if (QUESTION_RE.test(content)) reasons.push('问题');
|
||||||
|
return reasons.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionFor(content: string): string {
|
||||||
|
if (/团购|采购|报价|预算/i.test(content)) return '看采购/团购';
|
||||||
|
if (/报名|名额|活动|会议|直播/i.test(content)) return '看报名/活动';
|
||||||
|
if (/合作|对接|找人|招募|一起做/i.test(content)) return '看合作机会';
|
||||||
|
if (/求推荐|有没有|谁有|需要/i.test(content)) return '可回复推荐';
|
||||||
|
if (/帮忙|看看|评估|试试/i.test(content)) return '可协助跟进';
|
||||||
|
return '查看上下文';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpportunity(content: string): boolean {
|
||||||
|
return OPPORTUNITY_RE.test(content.slice(0, 140)) && !DIGEST_RE.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function opportunityDedupeKey(content: string, title: string): string {
|
||||||
|
if (content.includes('飞书录音豆')) return normalizeDedupe('飞书录音豆团购');
|
||||||
|
if (content.includes('团购')) {
|
||||||
|
const groupBuy = content.match(/([\p{L}\p{N}A-Za-z]{2,16}团购(?:表格|表)?)/u)?.[1];
|
||||||
|
if (groupBuy) return normalizeDedupe(groupBuy);
|
||||||
|
}
|
||||||
|
const phrase =
|
||||||
|
content.match(/[\p{L}\p{N}A-Za-z]{2,}.{0,18}(团购|报名|内测|合作|采购|对接|报价|预算)/u)?.[0] ??
|
||||||
|
title;
|
||||||
|
return normalizeDedupe(phrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromContent(content: string): string {
|
||||||
|
const withoutPrefix = content
|
||||||
|
.replace(/^[@#\s::-]+/, '')
|
||||||
|
.replace(/\[[^\]]{0,8}\]/g, '')
|
||||||
|
.replace(/[*_`#>]+/g, '')
|
||||||
|
.replace(/链接/g, '')
|
||||||
|
.trim();
|
||||||
|
const first = withoutPrefix.split(/[。!?!?]\s*/).find((p) => p.trim().length >= 6);
|
||||||
|
return (first ?? withoutPrefix).trim().slice(0, 46) || '值得查看的讨论';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDedupe(content: string): string {
|
||||||
|
return content.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '').slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
function topEntry(values: Map<string, number>): string {
|
||||||
|
return Array.from(values.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function strengthsFor(source: {
|
||||||
|
link_count: number;
|
||||||
|
opportunity_count: number;
|
||||||
|
tool_count: number;
|
||||||
|
}): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (source.tool_count > 0) out.push('工具');
|
||||||
|
if (source.link_count > 0) out.push('链接');
|
||||||
|
if (source.opportunity_count > 0) out.push('机会');
|
||||||
|
return out.length > 0 ? out.slice(0, 3) : ['观点'];
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { DATA_DIR } from './config';
|
||||||
|
|
||||||
|
const DB_PATH = join(DATA_DIR, 'radar.db');
|
||||||
|
|
||||||
|
let _db: Database.Database | null = null;
|
||||||
|
|
||||||
|
export function db(): Database.Database {
|
||||||
|
if (_db) return _db;
|
||||||
|
const dir = dirname(DB_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
_db = new Database(DB_PATH);
|
||||||
|
_db.pragma('journal_mode = WAL');
|
||||||
|
_db.pragma('foreign_keys = ON');
|
||||||
|
migrate(_db);
|
||||||
|
seed(_db);
|
||||||
|
return _db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate(d: Database.Database) {
|
||||||
|
d.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
emoji TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS group_tags (
|
||||||
|
chatroom_id TEXT NOT NULL,
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (chatroom_id, group_id),
|
||||||
|
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
chatroom_id TEXT PRIMARY KEY,
|
||||||
|
starred_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_stats (
|
||||||
|
chatroom_id TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
total INTEGER NOT NULL,
|
||||||
|
top_senders TEXT NOT NULL,
|
||||||
|
by_hour TEXT NOT NULL,
|
||||||
|
refreshed_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (chatroom_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON daily_stats(date);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mentions (
|
||||||
|
chatroom_id TEXT NOT NULL,
|
||||||
|
local_id INTEGER NOT NULL,
|
||||||
|
sender TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
time TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (chatroom_id, local_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mentions_time ON mentions(timestamp DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
chatroom_id TEXT NOT NULL,
|
||||||
|
local_id INTEGER NOT NULL,
|
||||||
|
sender TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
time TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (chatroom_id, local_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_chatroom_date ON messages(chatroom_id, date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
message_count INTEGER NOT NULL,
|
||||||
|
group_count INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topics_date ON topics(date DESC, message_count DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS topic_messages (
|
||||||
|
topic_id INTEGER NOT NULL,
|
||||||
|
chatroom_id TEXT NOT NULL,
|
||||||
|
local_id INTEGER NOT NULL,
|
||||||
|
score REAL NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (topic_id, chatroom_id, local_id),
|
||||||
|
FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_messages_topic ON topic_messages(topic_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS link_intelligence_cache (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
generated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (date, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_state (
|
||||||
|
chatroom_id TEXT PRIMARY KEY,
|
||||||
|
last_synced_at INTEGER NOT NULL,
|
||||||
|
first_message_date TEXT,
|
||||||
|
last_message_date TEXT,
|
||||||
|
total_messages INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
ensureColumn(d, 'sync_state', 'status', "TEXT NOT NULL DEFAULT 'unknown'");
|
||||||
|
ensureColumn(d, 'sync_state', 'last_error', 'TEXT');
|
||||||
|
ensureColumn(d, 'sync_state', 'failed_chunks', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn(d, 'sync_state', 'empty_chunks', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn(d, 'sync_state', 'total_chunks', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureColumn(d: Database.Database, table: string, name: string, definition: string) {
|
||||||
|
const rows = d.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||||
|
if (rows.some((r) => r.name === name)) return;
|
||||||
|
d.prepare(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEED_VERSION = 'wechat_radar_v1_2026_05_24';
|
||||||
|
|
||||||
|
const DEFAULT_GROUPS: Array<{ name: string; color: string; emoji: string }> = [
|
||||||
|
{ name: 'AI / Coding', color: '#7dd3a8', emoji: '💻' },
|
||||||
|
{ name: 'Tools', color: '#f59e0b', emoji: '🛠️' },
|
||||||
|
{ name: 'Articles', color: '#06b6d4', emoji: '📚' },
|
||||||
|
{ name: 'Business', color: '#10b981', emoji: '💼' },
|
||||||
|
{ name: 'Events', color: '#f97316', emoji: '📅' },
|
||||||
|
{ name: 'Research', color: '#a855f7', emoji: '🔬' },
|
||||||
|
{ name: 'Lifestyle', color: '#fb7185', emoji: '🏠' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function seed(d: Database.Database) {
|
||||||
|
const meta = d.prepare("SELECT value FROM meta WHERE key = 'seed_version'").get() as { value: string } | undefined;
|
||||||
|
if (meta?.value === SEED_VERSION) return;
|
||||||
|
|
||||||
|
const tagged = d.prepare('SELECT COUNT(*) AS n FROM group_tags').get() as { n: number };
|
||||||
|
if (tagged.n === 0) d.prepare('DELETE FROM groups').run();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const insertOrIgnore = d.prepare(
|
||||||
|
'INSERT OR IGNORE INTO groups (name, color, emoji, sort_order, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
);
|
||||||
|
d.transaction(() => {
|
||||||
|
DEFAULT_GROUPS.forEach((g, i) => insertOrIgnore.run(g.name, g.color, g.emoji, i, now));
|
||||||
|
})();
|
||||||
|
|
||||||
|
d.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('seed_version', ?)").run(SEED_VERSION);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { db } from './db';
|
||||||
|
import { writeConfig } from './config';
|
||||||
|
|
||||||
|
const GROUPS = [
|
||||||
|
{ id: 'demo-ai@chatroom', name: 'AI 产品讨论群' },
|
||||||
|
{ id: 'demo-coding@chatroom', name: 'Vibe Coding 交流群' },
|
||||||
|
{ id: 'demo-tools@chatroom', name: '效率工具分享群' },
|
||||||
|
{ id: 'demo-business@chatroom', name: 'AI 商业增长群' },
|
||||||
|
{ id: 'demo-life@chatroom', name: '生活与阅读群' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SENDERS = ['Alex', 'Ming', 'Luna', 'Kai', 'River', 'Yuki', 'Chen'];
|
||||||
|
const CONTENTS = [
|
||||||
|
'有没有适合团队知识库的 AI 工具?最好支持飞书和 Notion,同步成本低一点。',
|
||||||
|
'实测 Codex 处理中型前端改版很稳,关键是先给它足够清楚的验收标准。',
|
||||||
|
'分享一个开源项目 https://github.com/example/agent-workflow 可以把多 Agent 编排可视化。',
|
||||||
|
'这篇文章值得读:AI Agent 落地为什么卡在组织流程 https://mp.weixin.qq.com/s/demo-agent-org',
|
||||||
|
'下周有一个 AI 工具内测名额,想找 20 个真实团队试用,感兴趣可以报名。',
|
||||||
|
'GEO 和 SEO 的差别今天讨论很多,核心不是关键词,而是结构化证据和可信来源。',
|
||||||
|
'有没有人熟悉 Chrome Extension 上架流程?需要一个 checklist。',
|
||||||
|
'@你的微信名 这个话题你可能有经验:如何把群聊素材整理成公众号选题?',
|
||||||
|
'新的语音转文字工具体验不错 https://example.com/voice-note 支持批量导出 Markdown。',
|
||||||
|
'今天最值得关注的是 AI 工具开始从个人效率走向团队工作流。',
|
||||||
|
];
|
||||||
|
|
||||||
|
function ymd(d: Date) {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedDemoData() {
|
||||||
|
const database = db();
|
||||||
|
const now = new Date();
|
||||||
|
const insertMessage = database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO messages
|
||||||
|
(chatroom_id, local_id, sender, content, time, timestamp, type, date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const insertStats = database.prepare(`
|
||||||
|
INSERT INTO daily_stats (chatroom_id, date, total, top_senders, by_hour, refreshed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(chatroom_id, date) DO UPDATE SET
|
||||||
|
total = excluded.total,
|
||||||
|
top_senders = excluded.top_senders,
|
||||||
|
by_hour = excluded.by_hour,
|
||||||
|
refreshed_at = excluded.refreshed_at
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.transaction(() => {
|
||||||
|
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setDate(now.getDate() - dayOffset);
|
||||||
|
const date = ymd(d);
|
||||||
|
for (let gi = 0; gi < GROUPS.length; gi++) {
|
||||||
|
const group = GROUPS[gi];
|
||||||
|
const count = Math.max(8, 42 - dayOffset * 2 + gi * 5);
|
||||||
|
const byHour = Array.from({ length: 24 }, (_, hour) => ({ hour, count: hour >= 9 && hour <= 23 ? Math.floor(count / 15) + ((hour + gi) % 3) : 0 }));
|
||||||
|
const topSenders = SENDERS.slice(0, 3).map((sender, index) => ({ sender, count: Math.max(1, Math.floor(count / (index + 2))) }));
|
||||||
|
insertStats.run(group.id, date, count, JSON.stringify(topSenders), JSON.stringify(byHour), Date.now());
|
||||||
|
for (let i = 0; i < Math.min(count, 18); i++) {
|
||||||
|
const localId = dayOffset * 10000 + gi * 1000 + i + 1;
|
||||||
|
const hour = 9 + ((i + gi) % 12);
|
||||||
|
const minute = (i * 7) % 60;
|
||||||
|
const time = `${date} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
|
||||||
|
const timestamp = Math.floor(new Date(time).getTime() / 1000);
|
||||||
|
insertMessage.run(group.id, localId, SENDERS[(i + gi) % SENDERS.length], CONTENTS[(i + gi + dayOffset) % CONTENTS.length], time, timestamp, 'text', date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
writeConfig({
|
||||||
|
demoMode: true,
|
||||||
|
setupCompleted: true,
|
||||||
|
privacyConfirmed: true,
|
||||||
|
myNicknames: ['你的微信名'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groups: GROUPS.length, days: 14 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { GroupRow } from './groups';
|
||||||
|
|
||||||
|
export function classifyGroupHeuristic(name: string, summary: string, groups: GroupRow[]) {
|
||||||
|
const text = `${name} ${summary}`.toLowerCase();
|
||||||
|
const lookup = (target: string) => groups.find((g) => g.name.toLowerCase().includes(target.toLowerCase()));
|
||||||
|
|
||||||
|
if (/vibe.?coding|coding|代码|编程|developer|dev|cli|mcp|skills?|github|开源|agent|gpt|claude|llm/i.test(text)) {
|
||||||
|
const t = lookup('AI / Coding');
|
||||||
|
if (t) return { group_id: t.id, group_name: t.name, reason: 'AI / Coding keywords' };
|
||||||
|
}
|
||||||
|
if (/工具|产品|插件|内测|api|chrome|notion|obsidian|飞书|workflow|workspace|效率/i.test(text)) {
|
||||||
|
const t = lookup('Tools');
|
||||||
|
if (t) return { group_id: t.id, group_name: t.name, reason: 'Tools / product keywords' };
|
||||||
|
}
|
||||||
|
if (/文章|公众号|日报|newsletter|读者|知识库|教程|报告|访谈|paper|论文/i.test(text)) {
|
||||||
|
const t = lookup('Articles');
|
||||||
|
if (t) return { group_id: t.id, group_name: t.name, reason: 'Articles / knowledge keywords' };
|
||||||
|
}
|
||||||
|
if (/商业|营销|增长|seo|geo|销售|客户|采购|团购|合作|商务|创业|投资/i.test(text)) {
|
||||||
|
const t = lookup('Business');
|
||||||
|
if (t) return { group_id: t.id, group_name: t.name, reason: 'Business keywords' };
|
||||||
|
}
|
||||||
|
if (/活动|报名|直播|大会|线下|分享会|训练营|课程|会议|meetup|workshop/i.test(text)) {
|
||||||
|
const t = lookup('Events');
|
||||||
|
if (t) return { group_id: t.id, group_name: t.name, reason: 'Event keywords' };
|
||||||
|
}
|
||||||
|
if (/研究|学术|论文|paper|模型|实验|benchmark|评测/i.test(text)) {
|
||||||
|
const t = lookup('Research');
|
||||||
|
if (t) return { group_id: t.id, group_name: t.name, reason: 'Research keywords' };
|
||||||
|
}
|
||||||
|
if (/生活|阅读|运动|小区|邻里|钓鱼|健身|跑步|英语|校友|投资主题/i.test(text)) {
|
||||||
|
const t = lookup('Lifestyle');
|
||||||
|
if (t) return { group_id: t.id, group_name: t.name, reason: 'Lifestyle keywords' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveGroupIds(
|
||||||
|
name: string,
|
||||||
|
summary: string,
|
||||||
|
explicitIds: number[],
|
||||||
|
groups: GroupRow[],
|
||||||
|
): number[] {
|
||||||
|
if (explicitIds.length > 0) return explicitIds;
|
||||||
|
const guess = classifyGroupHeuristic(name, summary, groups);
|
||||||
|
return guess ? [guess.group_id] : [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { db } from './db';
|
||||||
|
|
||||||
|
export interface GroupRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
emoji: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: number;
|
||||||
|
member_count?: number;
|
||||||
|
message_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listGroups(): GroupRow[] {
|
||||||
|
return db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT g.*,
|
||||||
|
(SELECT COUNT(*) FROM group_tags t WHERE t.group_id = g.id) AS member_count
|
||||||
|
FROM groups g
|
||||||
|
ORDER BY g.sort_order ASC, g.id ASC`,
|
||||||
|
)
|
||||||
|
.all() as GroupRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGroup(input: { name: string; color: string; emoji?: string }) {
|
||||||
|
const max = db().prepare('SELECT COALESCE(MAX(sort_order), 0) AS m FROM groups').get() as {
|
||||||
|
m: number;
|
||||||
|
};
|
||||||
|
const stmt = db().prepare(
|
||||||
|
'INSERT INTO groups (name, color, emoji, sort_order, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
);
|
||||||
|
const info = stmt.run(input.name, input.color, input.emoji ?? null, max.m + 1, Date.now());
|
||||||
|
return Number(info.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGroup(id: number) {
|
||||||
|
db().prepare('DELETE FROM groups WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagGroup(chatroomId: string, groupId: number) {
|
||||||
|
db()
|
||||||
|
.prepare('INSERT OR IGNORE INTO group_tags (chatroom_id, group_id) VALUES (?, ?)')
|
||||||
|
.run(chatroomId, groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function untagGroup(chatroomId: string, groupId: number) {
|
||||||
|
db()
|
||||||
|
.prepare('DELETE FROM group_tags WHERE chatroom_id = ? AND group_id = ?')
|
||||||
|
.run(chatroomId, groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagsForChatroom(chatroomId: string): number[] {
|
||||||
|
const rows = db()
|
||||||
|
.prepare('SELECT group_id FROM group_tags WHERE chatroom_id = ?')
|
||||||
|
.all(chatroomId) as Array<{ group_id: number }>;
|
||||||
|
return rows.map((r) => r.group_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chatroomsForGroup(groupId: number): string[] {
|
||||||
|
const rows = db()
|
||||||
|
.prepare('SELECT chatroom_id FROM group_tags WHERE group_id = ?')
|
||||||
|
.all(groupId) as Array<{ chatroom_id: string }>;
|
||||||
|
return rows.map((r) => r.chatroom_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAllTags(): Array<{ chatroom_id: string; group_id: number }> {
|
||||||
|
return db()
|
||||||
|
.prepare('SELECT chatroom_id, group_id FROM group_tags')
|
||||||
|
.all() as Array<{ chatroom_id: string; group_id: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFavorite(chatroomId: string): boolean {
|
||||||
|
const r = db()
|
||||||
|
.prepare('SELECT 1 AS x FROM favorites WHERE chatroom_id = ?')
|
||||||
|
.get(chatroomId) as { x: number } | undefined;
|
||||||
|
return !!r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFavorites(): string[] {
|
||||||
|
return (
|
||||||
|
db().prepare('SELECT chatroom_id FROM favorites ORDER BY starred_at DESC').all() as Array<{
|
||||||
|
chatroom_id: string;
|
||||||
|
}>
|
||||||
|
).map((r) => r.chatroom_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFavorite(chatroomId: string, fav: boolean) {
|
||||||
|
if (fav) {
|
||||||
|
db()
|
||||||
|
.prepare('INSERT OR IGNORE INTO favorites (chatroom_id, starred_at) VALUES (?, ?)')
|
||||||
|
.run(chatroomId, Date.now());
|
||||||
|
} else {
|
||||||
|
db().prepare('DELETE FROM favorites WHERE chatroom_id = ?').run(chatroomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { db } from './db';
|
||||||
|
import { wxSessions } from './wx';
|
||||||
|
import { cache } from './cache';
|
||||||
|
|
||||||
|
const MAX_MESSAGES = 5000;
|
||||||
|
const MAX_ITEMS_PER_KIND = 24;
|
||||||
|
const MAX_TITLE_FETCHES = 8;
|
||||||
|
const TITLE_FETCH_TIMEOUT_MS = 1400;
|
||||||
|
const MAX_TITLE_GENERATION_ITEMS = 80;
|
||||||
|
const CODEX_TIMEOUT_MS = Number(process.env.WECHAT_RADAR_LINK_CODEX_TIMEOUT_MS ?? 180_000);
|
||||||
|
const CODEX_MODEL = process.env.WECHAT_RADAR_CODEX_MODEL;
|
||||||
|
const LINK_INTELLIGENCE_CACHE_VERSION = 'v6';
|
||||||
|
const LINK_INTELLIGENCE_CACHE_TTL_SECONDS = 60 * 60 * 24;
|
||||||
|
|
||||||
|
const TOOL_HINT_RE =
|
||||||
|
/工具|开源|项目|产品|官网|体验|注册|插件|脚手架|模型|智能体|Agent|Claude|Gemini|Codex|API|CLI|MCP|浏览器|Demo|教程|指南|workflow|workspace/i;
|
||||||
|
|
||||||
|
const ARTICLE_HOSTS = [
|
||||||
|
'zhuanlan.zhihu.com',
|
||||||
|
'www.zhihu.com',
|
||||||
|
'www.toutiao.com',
|
||||||
|
'www.sohu.com',
|
||||||
|
'page.om.qq.com',
|
||||||
|
'www.163.com',
|
||||||
|
'mparticle.uc.cn',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOOL_HOST_HINTS = [
|
||||||
|
'github.com',
|
||||||
|
'huggingface.co',
|
||||||
|
'replicate.com',
|
||||||
|
'vercel.app',
|
||||||
|
'netlify.app',
|
||||||
|
'feishu.cn',
|
||||||
|
'larksuite.com',
|
||||||
|
'notion.site',
|
||||||
|
'notion.so',
|
||||||
|
'docs.google.com',
|
||||||
|
'my.feishu.cn',
|
||||||
|
];
|
||||||
|
|
||||||
|
const IGNORED_HOSTS = [
|
||||||
|
'support.weixin.qq.com',
|
||||||
|
'wx.qlogo.cn',
|
||||||
|
'wxapp.tc.qq.com',
|
||||||
|
'res.wx.qq.com',
|
||||||
|
'mmbiz.qpic.cn',
|
||||||
|
];
|
||||||
|
|
||||||
|
type LinkKind = 'article' | 'tool';
|
||||||
|
|
||||||
|
interface MessageLinkRow {
|
||||||
|
chatroom_id: string;
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkIntelligenceItem {
|
||||||
|
kind: LinkKind;
|
||||||
|
url: string;
|
||||||
|
canonical_url: string;
|
||||||
|
title: string;
|
||||||
|
domain: string;
|
||||||
|
count: number;
|
||||||
|
group_count: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
sources: Array<{
|
||||||
|
chatroom_id: string;
|
||||||
|
chat_name: string;
|
||||||
|
sender: string;
|
||||||
|
time: string;
|
||||||
|
local_id: number;
|
||||||
|
snippet: string;
|
||||||
|
}>;
|
||||||
|
dedupe_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkIntelligenceResult {
|
||||||
|
date: string;
|
||||||
|
articles: LinkIntelligenceItem[];
|
||||||
|
tools: LinkIntelligenceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkIntelligenceOptions {
|
||||||
|
refresh?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratedLinkTitle {
|
||||||
|
canonical_url: string;
|
||||||
|
title: string;
|
||||||
|
group_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratedLinkTitleResponse {
|
||||||
|
items: GeneratedLinkTitle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK_TITLE_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
canonical_url: { type: 'string' },
|
||||||
|
title: { type: 'string' },
|
||||||
|
group_key: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['canonical_url', 'title', 'group_key'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['items'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeHtmlEntities(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanUrl(raw: string): string {
|
||||||
|
return decodeHtmlEntities(raw)
|
||||||
|
.replace(/[),,。;;!?!?、\]}>]+$/g, '')
|
||||||
|
.replace(/\.{3,}$/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(raw: string): string | null {
|
||||||
|
if (raw.includes('...') || raw.includes('…')) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(cleanUrl(raw));
|
||||||
|
u.hash = '';
|
||||||
|
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
|
||||||
|
u.searchParams.delete(key);
|
||||||
|
}
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUrls(content: string): string[] {
|
||||||
|
const decoded = decodeHtmlEntities(content);
|
||||||
|
const urls = new Set<string>();
|
||||||
|
|
||||||
|
for (const m of decoded.matchAll(/imgsourceurl="([^"]+)"/g)) {
|
||||||
|
try {
|
||||||
|
urls.add(decodeURIComponent(m[1]));
|
||||||
|
} catch {
|
||||||
|
urls.add(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of decoded.matchAll(/https?:\/\/[^\s<>"']+/g)) {
|
||||||
|
urls.add(cleanUrl(m[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(urls).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainOf(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeChatArticle(url: URL): boolean {
|
||||||
|
return url.hostname === 'mp.weixin.qq.com' && (
|
||||||
|
(url.pathname.startsWith('/s/') && url.pathname.length > 3) ||
|
||||||
|
url.searchParams.has('__biz') ||
|
||||||
|
url.searchParams.has('mid') ||
|
||||||
|
url.searchParams.has('sn')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArticleLink(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const host = u.hostname.replace(/^www\./, '');
|
||||||
|
if (isWeChatArticle(u)) return true;
|
||||||
|
if ((host === 'x.com' || host === 'twitter.com') && /\/status\/\d{12,}/.test(u.pathname)) return true;
|
||||||
|
if ((host === 'youtube.com' || host === 'youtu.be') && (u.pathname === '/watch' || host === 'youtu.be')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ARTICLE_HOSTS.includes(host);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolLink(url: string, content: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const host = u.hostname.replace(/^www\./, '');
|
||||||
|
if (
|
||||||
|
host === 'x.com' ||
|
||||||
|
host === 'twitter.com' ||
|
||||||
|
(host === 'mp.weixin.qq.com' && !isWeChatArticle(u)) ||
|
||||||
|
/meeting\.tencent\.com$/.test(host) ||
|
||||||
|
IGNORED_HOSTS.some((h) => host === h || host.endsWith(`.${h}`))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isArticleLink(url)) return false;
|
||||||
|
if (TOOL_HOST_HINTS.some((h) => host === h || host.endsWith(`.${h}`))) return true;
|
||||||
|
return TOOL_HINT_RE.test(content);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanSnippet(content: string): string {
|
||||||
|
return decodeHtmlEntities(content)
|
||||||
|
.replace(/<\?xml[\s\S]+?<\/msg>/g, '')
|
||||||
|
.replace(/https?:\/\/\S+/g, '')
|
||||||
|
.replace(/\[引用\]/g, '')
|
||||||
|
.replace(/↳/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromContext(content: string, url: string): string {
|
||||||
|
const decoded = decodeHtmlEntities(content);
|
||||||
|
const withoutXml = decoded.replace(/<\?xml[\s\S]+?<\/msg>/g, ' ');
|
||||||
|
const lines = withoutXml
|
||||||
|
.split(/\n+/)
|
||||||
|
.map((line) =>
|
||||||
|
line
|
||||||
|
.replace(url, '')
|
||||||
|
.replace(/https?:\/\/\S+/g, '')
|
||||||
|
.replace(/\[引用\]/g, '')
|
||||||
|
.replace(/↳/g, '')
|
||||||
|
.trim(),
|
||||||
|
)
|
||||||
|
.filter((line) => line.length >= 4 && line.length <= 90);
|
||||||
|
|
||||||
|
const preferred = lines.find((line) => !/^[@#\d\s::-]+$/.test(line));
|
||||||
|
return preferred ?? domainOf(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTitle(raw: string): string {
|
||||||
|
return decodeHtmlEntities(raw)
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/ - 微信公众平台$/, '')
|
||||||
|
.replace(/_哔哩哔哩_bilibili$/, '')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTitle(url: string): Promise<string | null> {
|
||||||
|
const cacheKey = `link-title:${url}`;
|
||||||
|
const cached = cache.get(cacheKey) as string | undefined;
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), TITLE_FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
signal: ctl.signal,
|
||||||
|
headers: {
|
||||||
|
'user-agent':
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126 Safari/537.36',
|
||||||
|
accept: 'text/html,application/xhtml+xml',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const html = await r.text();
|
||||||
|
const title =
|
||||||
|
html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i)?.[1] ??
|
||||||
|
html.match(/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i)?.[1] ??
|
||||||
|
html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ??
|
||||||
|
null;
|
||||||
|
if (!title) return null;
|
||||||
|
const decoded = decodeTitle(title);
|
||||||
|
if (decoded) cache.set(cacheKey, decoded, 60 * 60 * 24);
|
||||||
|
return decoded || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateTitles(items: LinkIntelligenceItem[]) {
|
||||||
|
const needsTitle = items
|
||||||
|
.filter((item) => item.title === item.domain || item.title.length < 8)
|
||||||
|
.slice(0, MAX_TITLE_FETCHES);
|
||||||
|
await Promise.all(
|
||||||
|
needsTitle.map(async (item) => {
|
||||||
|
const title = await fetchTitle(item.url);
|
||||||
|
if (title) item.title = title;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonOutput<T>(raw: string): T {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as T;
|
||||||
|
} catch {
|
||||||
|
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
if (fenced) return JSON.parse(fenced[1]) as T;
|
||||||
|
const obj = trimmed.match(/\{[\s\S]*\}/);
|
||||||
|
if (obj) return JSON.parse(obj[0]) as T;
|
||||||
|
throw new Error('codex returned non-JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCodexJson<T>(prompt: string, schema: unknown, timeoutMs = CODEX_TIMEOUT_MS): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wechat-links-'));
|
||||||
|
const schemaPath = join(dir, 'schema.json');
|
||||||
|
const outPath = join(dir, 'response.json');
|
||||||
|
writeFileSync(schemaPath, JSON.stringify(schema), 'utf8');
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-a',
|
||||||
|
'never',
|
||||||
|
'exec',
|
||||||
|
'--sandbox',
|
||||||
|
'read-only',
|
||||||
|
'--ephemeral',
|
||||||
|
'--ignore-rules',
|
||||||
|
'--output-schema',
|
||||||
|
schemaPath,
|
||||||
|
'--output-last-message',
|
||||||
|
outPath,
|
||||||
|
];
|
||||||
|
if (CODEX_MODEL) args.push('--model', CODEX_MODEL);
|
||||||
|
args.push('-');
|
||||||
|
|
||||||
|
const proc = spawn('codex', args, {
|
||||||
|
env: { ...process.env, NO_COLOR: '1' },
|
||||||
|
stdio: ['pipe', 'ignore', 'pipe'],
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
proc.kill('SIGTERM');
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
reject(new Error('codex CLI timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
proc.stderr.on('data', (d) => (stderr += d.toString()));
|
||||||
|
proc.on('error', (e) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
try {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`codex exit ${code}: ${stderr.slice(0, 800)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(parseJsonOutput<T>(readFileSync(outPath, 'utf8')));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proc.stdin.write(prompt);
|
||||||
|
proc.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackDedupeKey(item: LinkIntelligenceItem): string {
|
||||||
|
const title = item.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, '')
|
||||||
|
.slice(0, 40);
|
||||||
|
return title || item.canonical_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTitleGenerationPrompt(items: LinkIntelligenceItem[]): string {
|
||||||
|
const rows = items
|
||||||
|
.map((item) =>
|
||||||
|
JSON.stringify({
|
||||||
|
canonical_url: item.canonical_url,
|
||||||
|
kind: item.kind,
|
||||||
|
domain: item.domain,
|
||||||
|
current_title: item.title,
|
||||||
|
snippets: item.sources.slice(0, 3).map((s) => s.snippet).filter(Boolean),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `你是微信群链接情报的标题整理器。请为每个链接生成适合列表展示的中文标题,并给出语义去重 key。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- title 要短、具体、可点击,优先保留产品名、文章主题、工具名或资料名。
|
||||||
|
- 不要直接复制整段聊天长句;去掉寒暄、@人名、表情和“老师”等噪声。
|
||||||
|
- 文章链接 title 像文章标题;工具/资源 title 像工具名、项目名、资料库名或活动名。
|
||||||
|
- group_key 用于去重:同一个工具、同一篇文章、同一组资料更新、同一活动报名,即便 URL 不同,也给相同 group_key。
|
||||||
|
- group_key 使用小写英文/数字/短横线;无法判断时用域名加核心标题。
|
||||||
|
- canonical_url 必须原样来自输入;不要新增、删除或编造 URL。
|
||||||
|
|
||||||
|
只输出严格 JSON:
|
||||||
|
{"items":[{"canonical_url":"...","title":"...","group_key":"..."}]}
|
||||||
|
|
||||||
|
输入 JSONL:
|
||||||
|
${rows}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateTitlesAndKeys(items: LinkIntelligenceItem[]) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
try {
|
||||||
|
const response = await runCodexJson<GeneratedLinkTitleResponse>(
|
||||||
|
buildTitleGenerationPrompt(items),
|
||||||
|
LINK_TITLE_SCHEMA,
|
||||||
|
);
|
||||||
|
const byUrl = new Map(response.items.map((item) => [item.canonical_url, item]));
|
||||||
|
for (const item of items) {
|
||||||
|
const generated = byUrl.get(item.canonical_url);
|
||||||
|
if (!generated) {
|
||||||
|
item.dedupe_key = fallbackDedupeKey(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
item.title = generated.title.trim().slice(0, 80) || item.title;
|
||||||
|
item.dedupe_key = generated.group_key.trim().toLowerCase() || fallbackDedupeKey(item);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const item of items) item.dedupe_key = fallbackDedupeKey(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDuplicateItems(items: LinkIntelligenceItem[]): LinkIntelligenceItem[] {
|
||||||
|
const merged = new Map<string, LinkIntelligenceItem>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = `${item.kind}:${item.dedupe_key ?? fallbackDedupeKey(item)}`;
|
||||||
|
const existing = merged.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
merged.set(key, { ...item, sources: [...item.sources] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.count += item.count;
|
||||||
|
existing.last_seen = existing.last_seen > item.last_seen ? existing.last_seen : item.last_seen;
|
||||||
|
existing.first_seen = existing.first_seen < item.first_seen ? existing.first_seen : item.first_seen;
|
||||||
|
for (const source of item.sources) {
|
||||||
|
if (!existing.sources.some((s) => s.chatroom_id === source.chatroom_id && s.local_id === source.local_id)) {
|
||||||
|
existing.sources.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing.group_count = new Set(existing.sources.map((s) => s.chatroom_id)).size;
|
||||||
|
if (item.count > existing.count) {
|
||||||
|
existing.url = item.url;
|
||||||
|
existing.canonical_url = item.canonical_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(merged.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultCacheKey(date: string) {
|
||||||
|
return `link-intelligence:${date}:${LINK_INTELLIGENCE_CACHE_VERSION}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPersistedLinkIntelligence(date: string): LinkIntelligenceResult | null {
|
||||||
|
const row = db()
|
||||||
|
.prepare('SELECT payload FROM link_intelligence_cache WHERE date = ? AND version = ?')
|
||||||
|
.get(date, LINK_INTELLIGENCE_CACHE_VERSION) as { payload: string } | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.payload) as LinkIntelligenceResult;
|
||||||
|
if (parsed.date !== date || !Array.isArray(parsed.articles) || !Array.isArray(parsed.tools)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePersistedLinkIntelligence(result: LinkIntelligenceResult) {
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO link_intelligence_cache (date, version, payload, generated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(date, version) DO UPDATE SET
|
||||||
|
payload = excluded.payload,
|
||||||
|
generated_at = excluded.generated_at`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
result.date,
|
||||||
|
LINK_INTELLIGENCE_CACHE_VERSION,
|
||||||
|
JSON.stringify(result),
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDailyLinkIntelligence(date: string) {
|
||||||
|
cache.del(resultCacheKey(date));
|
||||||
|
db()
|
||||||
|
.prepare('DELETE FROM link_intelligence_cache WHERE date = ? AND version = ?')
|
||||||
|
.run(date, LINK_INTELLIGENCE_CACHE_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDailyLinkIntelligence(
|
||||||
|
date: string,
|
||||||
|
options: LinkIntelligenceOptions = {},
|
||||||
|
): Promise<LinkIntelligenceResult> {
|
||||||
|
const refresh = options.refresh ?? false;
|
||||||
|
const key = resultCacheKey(date);
|
||||||
|
const cached = cache.get(key) as LinkIntelligenceResult | undefined;
|
||||||
|
if (cached && !refresh) return cached;
|
||||||
|
|
||||||
|
if (!refresh) {
|
||||||
|
const persisted = readPersistedLinkIntelligence(date);
|
||||||
|
if (persisted) {
|
||||||
|
cache.set(key, persisted, LINK_INTELLIGENCE_CACHE_TTL_SECONDS);
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT chatroom_id, local_id, sender, content, time, timestamp, type
|
||||||
|
FROM messages
|
||||||
|
WHERE date = ?
|
||||||
|
AND content LIKE '%http%'
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(date, MAX_MESSAGES) as MessageLinkRow[];
|
||||||
|
|
||||||
|
const sessions = await wxSessions(500).catch(() => []);
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
for (const s of sessions) names.set(s.username, s.chat);
|
||||||
|
|
||||||
|
const buckets = new Map<string, LinkIntelligenceItem>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const raw of extractUrls(row.content)) {
|
||||||
|
const canonical = normalizeUrl(raw);
|
||||||
|
if (!canonical) continue;
|
||||||
|
|
||||||
|
const kind: LinkKind | null = isArticleLink(canonical)
|
||||||
|
? 'article'
|
||||||
|
: isToolLink(canonical, row.content)
|
||||||
|
? 'tool'
|
||||||
|
: null;
|
||||||
|
if (!kind) continue;
|
||||||
|
|
||||||
|
const key = `${kind}:${canonical}`;
|
||||||
|
const existing = buckets.get(key);
|
||||||
|
const source = {
|
||||||
|
chatroom_id: row.chatroom_id,
|
||||||
|
chat_name: names.get(row.chatroom_id) ?? row.chatroom_id,
|
||||||
|
sender: row.sender,
|
||||||
|
time: row.time,
|
||||||
|
local_id: row.local_id,
|
||||||
|
snippet: cleanSnippet(row.content),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.count++;
|
||||||
|
existing.last_seen = existing.last_seen > row.time ? existing.last_seen : row.time;
|
||||||
|
existing.first_seen = existing.first_seen < row.time ? existing.first_seen : row.time;
|
||||||
|
if (!existing.sources.some((s) => s.chatroom_id === row.chatroom_id && s.local_id === row.local_id)) {
|
||||||
|
existing.sources.push(source);
|
||||||
|
}
|
||||||
|
existing.group_count = new Set(existing.sources.map((s) => s.chatroom_id)).size;
|
||||||
|
} else {
|
||||||
|
buckets.set(key, {
|
||||||
|
kind,
|
||||||
|
url: raw,
|
||||||
|
canonical_url: canonical,
|
||||||
|
title: titleFromContext(row.content, raw),
|
||||||
|
domain: domainOf(canonical),
|
||||||
|
count: 1,
|
||||||
|
group_count: 1,
|
||||||
|
first_seen: row.time,
|
||||||
|
last_seen: row.time,
|
||||||
|
sources: [source],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortItems = (kind: LinkKind, limit = MAX_ITEMS_PER_KIND) =>
|
||||||
|
Array.from(buckets.values())
|
||||||
|
.filter((item) => item.kind === kind)
|
||||||
|
.sort((a, b) => b.count - a.count || b.group_count - a.group_count || b.last_seen.localeCompare(a.last_seen))
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
const articleCandidates = sortItems('article', MAX_TITLE_GENERATION_ITEMS);
|
||||||
|
const toolCandidates = sortItems('tool', MAX_TITLE_GENERATION_ITEMS);
|
||||||
|
await Promise.all([hydrateTitles(articleCandidates), hydrateTitles(toolCandidates)]);
|
||||||
|
await generateTitlesAndKeys([...articleCandidates, ...toolCandidates]);
|
||||||
|
|
||||||
|
const articles = mergeDuplicateItems(articleCandidates)
|
||||||
|
.sort((a, b) => b.count - a.count || b.group_count - a.group_count || b.last_seen.localeCompare(a.last_seen))
|
||||||
|
.slice(0, MAX_ITEMS_PER_KIND);
|
||||||
|
const tools = mergeDuplicateItems(toolCandidates)
|
||||||
|
.sort((a, b) => b.count - a.count || b.group_count - a.group_count || b.last_seen.localeCompare(a.last_seen))
|
||||||
|
.slice(0, MAX_ITEMS_PER_KIND);
|
||||||
|
|
||||||
|
const result = { date, articles, tools };
|
||||||
|
writePersistedLinkIntelligence(result);
|
||||||
|
cache.set(key, result, LINK_INTELLIGENCE_CACHE_TTL_SECONDS);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
import { db } from './db';
|
||||||
|
import { readConfig } from './config';
|
||||||
|
import { wxHistory } from './wx';
|
||||||
|
import type { WxMessage } from './wx-types';
|
||||||
|
|
||||||
|
export interface MentionRow {
|
||||||
|
chatroom_id: string;
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
seen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMention(content: string, nicknames: string[]): boolean {
|
||||||
|
if (!content) return false;
|
||||||
|
return mentionNeedles(nicknames).some((n) => content.includes(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedNicknames(nicknames: string[]): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(nicknames.map((n) => n.trim()).filter((n) => n.length > 0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionNeedles(nicknames: string[]): string[] {
|
||||||
|
return normalizedNicknames(nicknames).map((n) => `@${n}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionPredicate(column: string, nicknames: string[]) {
|
||||||
|
const needles = mentionNeedles(nicknames);
|
||||||
|
return {
|
||||||
|
sql: needles.map(() => `instr(${column}, ?) > 0`).join(' OR ') || '0',
|
||||||
|
params: needles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMessageState(signature: string) {
|
||||||
|
const row = db()
|
||||||
|
.prepare('SELECT COUNT(*) AS count, COALESCE(MAX(timestamp), 0) AS maxTimestamp FROM messages')
|
||||||
|
.get() as { count: number; maxTimestamp: number };
|
||||||
|
return {
|
||||||
|
signature,
|
||||||
|
messageCount: row.count,
|
||||||
|
maxTimestamp: row.maxTimestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionSignature(nicknames: string[]): string {
|
||||||
|
return JSON.stringify(normalizedNicknames(nicknames));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMentionIndexState() {
|
||||||
|
const row = db()
|
||||||
|
.prepare("SELECT value FROM meta WHERE key = 'mention_index_state'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.value) as ReturnType<typeof currentMessageState>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMentionIndexState(state: ReturnType<typeof currentMessageState>) {
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO meta (key, value)
|
||||||
|
VALUES ('mention_index_state', ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
)
|
||||||
|
.run(JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rebuildMentionIndexFromMessages(): number {
|
||||||
|
const cfg = readConfig();
|
||||||
|
const signature = mentionSignature(cfg.myNicknames);
|
||||||
|
const state = currentMessageState(signature);
|
||||||
|
const predicate = mentionPredicate('content', cfg.myNicknames);
|
||||||
|
|
||||||
|
const tx = db().transaction(() => {
|
||||||
|
if (!predicate.params.length) {
|
||||||
|
db().prepare('DELETE FROM mentions').run();
|
||||||
|
writeMentionIndexState(state);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
db()
|
||||||
|
.prepare(`DELETE FROM mentions WHERE NOT (${predicate.sql})`)
|
||||||
|
.run(...predicate.params);
|
||||||
|
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR IGNORE INTO mentions
|
||||||
|
(chatroom_id, local_id, sender, content, time, timestamp, seen)
|
||||||
|
SELECT chatroom_id, local_id, sender, content, time, timestamp, 0
|
||||||
|
FROM messages
|
||||||
|
WHERE ${predicate.sql}`,
|
||||||
|
)
|
||||||
|
.run(...predicate.params);
|
||||||
|
|
||||||
|
writeMentionIndexState(state);
|
||||||
|
const row = db().prepare('SELECT COUNT(*) AS n FROM mentions').get() as { n: number };
|
||||||
|
return row.n;
|
||||||
|
});
|
||||||
|
|
||||||
|
return tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMentionIndexCurrent() {
|
||||||
|
const cfg = readConfig();
|
||||||
|
const state = currentMessageState(mentionSignature(cfg.myNicknames));
|
||||||
|
const indexed = readMentionIndexState();
|
||||||
|
if (
|
||||||
|
indexed?.signature === state.signature &&
|
||||||
|
indexed.messageCount === state.messageCount &&
|
||||||
|
indexed.maxTimestamp === state.maxTimestamp
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rebuildMentionIndexFromMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanMentions(
|
||||||
|
chatroomId: string,
|
||||||
|
since: string,
|
||||||
|
until: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const cfg = readConfig();
|
||||||
|
if (!cfg.myNicknames.length) return 0;
|
||||||
|
|
||||||
|
let messages: WxMessage[] = [];
|
||||||
|
try {
|
||||||
|
messages = await wxHistory(chatroomId, since, until, 5000);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsert = db().prepare(`
|
||||||
|
INSERT OR REPLACE INTO mentions
|
||||||
|
(chatroom_id, local_id, sender, content, time, timestamp, seen)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, COALESCE((SELECT seen FROM mentions WHERE chatroom_id = ? AND local_id = ?), 0))
|
||||||
|
`);
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
const insert = db().transaction((items: WxMessage[]) => {
|
||||||
|
for (const m of items) {
|
||||||
|
if (!isMention(m.content, cfg.myNicknames)) continue;
|
||||||
|
upsert.run(
|
||||||
|
chatroomId,
|
||||||
|
m.local_id,
|
||||||
|
m.sender,
|
||||||
|
m.content,
|
||||||
|
m.time,
|
||||||
|
m.timestamp,
|
||||||
|
chatroomId,
|
||||||
|
m.local_id,
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
insert(messages);
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMentions(limit = 100): MentionRow[] {
|
||||||
|
ensureMentionIndexCurrent();
|
||||||
|
return db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT chatroom_id, local_id, sender, content, time, timestamp, seen FROM mentions ORDER BY timestamp DESC LIMIT ?',
|
||||||
|
)
|
||||||
|
.all(limit) as MentionRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countMentions(): number {
|
||||||
|
ensureMentionIndexCurrent();
|
||||||
|
const row = db().prepare('SELECT COUNT(*) AS n FROM mentions').get() as { n: number };
|
||||||
|
return row.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countMentionsSince(unixSeconds: number): number {
|
||||||
|
ensureMentionIndexCurrent();
|
||||||
|
const row = db()
|
||||||
|
.prepare('SELECT COUNT(*) AS n FROM mentions WHERE timestamp >= ?')
|
||||||
|
.get(unixSeconds) as { n: number };
|
||||||
|
return row.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countMentionsBetween(sinceUnixSeconds: number, untilUnixSeconds: number): number {
|
||||||
|
ensureMentionIndexCurrent();
|
||||||
|
const row = db()
|
||||||
|
.prepare('SELECT COUNT(*) AS n FROM mentions WHERE timestamp >= ? AND timestamp <= ?')
|
||||||
|
.get(sinceUnixSeconds, untilUnixSeconds) as { n: number };
|
||||||
|
return row.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markMentionsSeen(chatroomId?: string) {
|
||||||
|
if (chatroomId) {
|
||||||
|
db().prepare('UPDATE mentions SET seen = 1 WHERE chatroom_id = ?').run(chatroomId);
|
||||||
|
} else {
|
||||||
|
db().prepare('UPDATE mentions SET seen = 1').run();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { db } from './db';
|
||||||
|
import type { WxMessage } from './wx-types';
|
||||||
|
|
||||||
|
export interface MessageRow extends WxMessage {
|
||||||
|
chatroom_id: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_TYPES = new Set(['系统', 'system']);
|
||||||
|
const REVOKE_RE = /撤回了一条消息|recalled a message/i;
|
||||||
|
|
||||||
|
export function dateOfMessage(m: WxMessage): string {
|
||||||
|
if (m.time && m.time.length >= 10) return m.time.slice(0, 10);
|
||||||
|
if (m.timestamp) {
|
||||||
|
const d = new Date(m.timestamp * 1000);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const da = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${mo}-${da}`;
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bulkInsertMessages(chatroomId: string, messages: WxMessage[]): number {
|
||||||
|
if (messages.length === 0) return 0;
|
||||||
|
const stmt = db().prepare(`
|
||||||
|
INSERT OR IGNORE INTO messages
|
||||||
|
(chatroom_id, local_id, sender, content, time, timestamp, type, date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
let inserted = 0;
|
||||||
|
const tx = db().transaction((msgs: WxMessage[]) => {
|
||||||
|
for (const m of msgs) {
|
||||||
|
if (SYSTEM_TYPES.has(m.type) && REVOKE_RE.test(m.content)) continue;
|
||||||
|
const r = stmt.run(
|
||||||
|
chatroomId,
|
||||||
|
m.local_id,
|
||||||
|
m.sender ?? '',
|
||||||
|
m.content ?? '',
|
||||||
|
m.time ?? '',
|
||||||
|
m.timestamp ?? 0,
|
||||||
|
m.type ?? '',
|
||||||
|
dateOfMessage(m),
|
||||||
|
);
|
||||||
|
if (r.changes > 0) inserted++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx(messages);
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMessagesForDate(chatroomId: string, date: string, limit = 1000): MessageRow[] {
|
||||||
|
return db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT chatroom_id, local_id, sender, content, time, timestamp, type, date
|
||||||
|
FROM messages
|
||||||
|
WHERE chatroom_id = ? AND date = ?
|
||||||
|
ORDER BY timestamp ASC, local_id ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(chatroomId, date, limit) as MessageRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyStatsAggregate {
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
by_hour: Array<{ hour: number; count: number }>;
|
||||||
|
top_senders: Array<{ sender: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateDailyStats(chatroomId: string, dates: string[]): DailyStatsAggregate[] {
|
||||||
|
if (dates.length === 0) return [];
|
||||||
|
const placeholders = dates.map(() => '?').join(',');
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT date, sender, timestamp, type
|
||||||
|
FROM messages
|
||||||
|
WHERE chatroom_id = ? AND date IN (${placeholders})`,
|
||||||
|
)
|
||||||
|
.all(chatroomId, ...dates) as Array<{
|
||||||
|
date: string;
|
||||||
|
sender: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const byDate = new Map<string, { total: number; senders: Map<string, number>; hours: number[] }>();
|
||||||
|
for (const d of dates) byDate.set(d, { total: 0, senders: new Map(), hours: new Array(24).fill(0) });
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const slot = byDate.get(r.date);
|
||||||
|
if (!slot) continue;
|
||||||
|
slot.total++;
|
||||||
|
slot.senders.set(r.sender, (slot.senders.get(r.sender) ?? 0) + 1);
|
||||||
|
if (r.timestamp) {
|
||||||
|
const h = new Date(r.timestamp * 1000).getHours();
|
||||||
|
if (h >= 0 && h < 24) slot.hours[h]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates.map((date) => {
|
||||||
|
const s = byDate.get(date)!;
|
||||||
|
const top = Array.from(s.senders.entries())
|
||||||
|
.map(([sender, count]) => ({ sender, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 10);
|
||||||
|
const by_hour = s.hours.map((count, hour) => ({ hour, count }));
|
||||||
|
return { date, total: s.total, by_hour, top_senders: top };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyncState(chatroomId: string) {
|
||||||
|
return db()
|
||||||
|
.prepare('SELECT * FROM sync_state WHERE chatroom_id = ?')
|
||||||
|
.get(chatroomId) as
|
||||||
|
| {
|
||||||
|
chatroom_id: string;
|
||||||
|
last_synced_at: number;
|
||||||
|
first_message_date: string | null;
|
||||||
|
last_message_date: string | null;
|
||||||
|
total_messages: number;
|
||||||
|
status: string;
|
||||||
|
last_error: string | null;
|
||||||
|
failed_chunks: number;
|
||||||
|
empty_chunks: number;
|
||||||
|
total_chunks: number;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncStatus = 'ok' | 'partial' | 'failed' | 'empty' | 'unknown';
|
||||||
|
|
||||||
|
export function upsertSyncState(
|
||||||
|
chatroomId: string,
|
||||||
|
total: number,
|
||||||
|
firstDate: string | null,
|
||||||
|
lastDate: string | null,
|
||||||
|
meta: {
|
||||||
|
status?: SyncStatus;
|
||||||
|
lastError?: string | null;
|
||||||
|
failedChunks?: number;
|
||||||
|
emptyChunks?: number;
|
||||||
|
totalChunks?: number;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO sync_state (
|
||||||
|
chatroom_id,
|
||||||
|
last_synced_at,
|
||||||
|
first_message_date,
|
||||||
|
last_message_date,
|
||||||
|
total_messages,
|
||||||
|
status,
|
||||||
|
last_error,
|
||||||
|
failed_chunks,
|
||||||
|
empty_chunks,
|
||||||
|
total_chunks
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(chatroom_id) DO UPDATE SET
|
||||||
|
last_synced_at = excluded.last_synced_at,
|
||||||
|
first_message_date = COALESCE(excluded.first_message_date, sync_state.first_message_date),
|
||||||
|
last_message_date = COALESCE(excluded.last_message_date, sync_state.last_message_date),
|
||||||
|
total_messages = excluded.total_messages,
|
||||||
|
status = excluded.status,
|
||||||
|
last_error = excluded.last_error,
|
||||||
|
failed_chunks = excluded.failed_chunks,
|
||||||
|
empty_chunks = excluded.empty_chunks,
|
||||||
|
total_chunks = excluded.total_chunks`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
chatroomId,
|
||||||
|
Date.now(),
|
||||||
|
firstDate,
|
||||||
|
lastDate,
|
||||||
|
total,
|
||||||
|
meta.status ?? 'unknown',
|
||||||
|
meta.lastError ?? null,
|
||||||
|
meta.failedChunks ?? 0,
|
||||||
|
meta.emptyChunks ?? 0,
|
||||||
|
meta.totalChunks ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countMessagesInRange(chatroomId: string, since: string, until: string): number {
|
||||||
|
const r = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT COUNT(*) AS n FROM messages WHERE chatroom_id = ? AND date >= ? AND date <= ?',
|
||||||
|
)
|
||||||
|
.get(chatroomId, since, until) as { n: number };
|
||||||
|
return r.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAllSyncedDates(chatroomId: string): string[] {
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT DISTINCT date FROM messages WHERE chatroom_id = ? ORDER BY date ASC',
|
||||||
|
)
|
||||||
|
.all(chatroomId) as Array<{ date: string }>;
|
||||||
|
return rows.map((r) => r.date);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export type RangeKey = 'day' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
||||||
|
|
||||||
|
const RANGE_KEYS = new Set<RangeKey>(['day', 'week', 'month', 'quarter', 'year', 'custom']);
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
export function isRangeKey(value: string | null | undefined): value is RangeKey {
|
||||||
|
return !!value && RANGE_KEYS.has(value as RangeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRangeKey(value: string | null | undefined, fallback: RangeKey): RangeKey {
|
||||||
|
return isRangeKey(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDate(value: string | null | undefined, fallback = todayStr()): string {
|
||||||
|
if (!value || !DATE_RE.test(value)) return fallback;
|
||||||
|
const [year, month, day] = value.split('-').map(Number);
|
||||||
|
const d = new Date(year, month - 1, day);
|
||||||
|
if (Number.isNaN(d.getTime())) return fallback;
|
||||||
|
return ymd(d) === value ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ymd(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayStr(): string {
|
||||||
|
return ymd(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysBefore(n: number, anchor = todayStr()): string {
|
||||||
|
const [year, month, day] = normalizeDate(anchor).split('-').map(Number);
|
||||||
|
const d = new Date(year, month - 1, day);
|
||||||
|
d.setDate(d.getDate() - n);
|
||||||
|
return ymd(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangeToWindow(range: RangeKey, anchorDate = todayStr()): { since: string; until: string; days: number } {
|
||||||
|
const until = normalizeDate(anchorDate);
|
||||||
|
const map: Record<Exclude<RangeKey, 'custom'>, number> = {
|
||||||
|
day: 0,
|
||||||
|
week: 6,
|
||||||
|
month: 29,
|
||||||
|
quarter: 89,
|
||||||
|
year: 364,
|
||||||
|
};
|
||||||
|
if (range === 'custom') {
|
||||||
|
return { since: daysBefore(6, until), until, days: 7 };
|
||||||
|
}
|
||||||
|
const span = map[range];
|
||||||
|
return { since: daysBefore(span, until), until, days: span + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateList(since: string, until: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const start = new Date(since);
|
||||||
|
const end = new Date(until);
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
out.push(ymd(d));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import pLimit from 'p-limit';
|
||||||
|
import { db } from './db';
|
||||||
|
import { wxHistory, wxStats } from './wx';
|
||||||
|
import {
|
||||||
|
aggregateDailyStats,
|
||||||
|
bulkInsertMessages,
|
||||||
|
upsertSyncState,
|
||||||
|
} from './messages-store';
|
||||||
|
import { rebuildMentionIndexFromMessages } from './mentions';
|
||||||
|
import type { WxStats } from './wx-types';
|
||||||
|
|
||||||
|
export type StatsRow = {
|
||||||
|
chatroom_id: string;
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
top_senders: Array<{ sender: string; count: number }>;
|
||||||
|
by_hour: Array<{ hour: number; count: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCachedStats(chatroomId: string, date: string): StatsRow | null {
|
||||||
|
const row = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT chatroom_id, date, total, top_senders, by_hour FROM daily_stats WHERE chatroom_id = ? AND date = ?',
|
||||||
|
)
|
||||||
|
.get(chatroomId, date) as
|
||||||
|
| {
|
||||||
|
chatroom_id: string;
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
top_senders: string;
|
||||||
|
by_hour: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
chatroom_id: row.chatroom_id,
|
||||||
|
date: row.date,
|
||||||
|
total: row.total,
|
||||||
|
top_senders: JSON.parse(row.top_senders),
|
||||||
|
by_hour: JSON.parse(row.by_hour),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCachedStatsForDate(date: string): StatsRow[] {
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT chatroom_id, date, total, top_senders, by_hour FROM daily_stats WHERE date = ? ORDER BY total DESC',
|
||||||
|
)
|
||||||
|
.all(date) as Array<{
|
||||||
|
chatroom_id: string;
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
top_senders: string;
|
||||||
|
by_hour: string;
|
||||||
|
}>;
|
||||||
|
return rows.map((r) => ({
|
||||||
|
chatroom_id: r.chatroom_id,
|
||||||
|
date: r.date,
|
||||||
|
total: r.total,
|
||||||
|
top_senders: JSON.parse(r.top_senders),
|
||||||
|
by_hour: JSON.parse(r.by_hour),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCachedStatsRange(since: string, until: string): StatsRow[] {
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT chatroom_id, date, total, top_senders, by_hour FROM daily_stats WHERE date >= ? AND date <= ?',
|
||||||
|
)
|
||||||
|
.all(since, until) as Array<{
|
||||||
|
chatroom_id: string;
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
top_senders: string;
|
||||||
|
by_hour: string;
|
||||||
|
}>;
|
||||||
|
return rows.map((r) => ({
|
||||||
|
chatroom_id: r.chatroom_id,
|
||||||
|
date: r.date,
|
||||||
|
total: r.total,
|
||||||
|
top_senders: JSON.parse(r.top_senders),
|
||||||
|
by_hour: JSON.parse(r.by_hour),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsert = () =>
|
||||||
|
db().prepare(`
|
||||||
|
INSERT INTO daily_stats (chatroom_id, date, total, top_senders, by_hour, refreshed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(chatroom_id, date) DO UPDATE SET
|
||||||
|
total = excluded.total,
|
||||||
|
top_senders = excluded.top_senders,
|
||||||
|
by_hour = excluded.by_hour,
|
||||||
|
refreshed_at = excluded.refreshed_at
|
||||||
|
`);
|
||||||
|
|
||||||
|
export function saveStats(row: StatsRow & { refreshed_at?: number }) {
|
||||||
|
upsert().run(
|
||||||
|
row.chatroom_id,
|
||||||
|
row.date,
|
||||||
|
row.total,
|
||||||
|
JSON.stringify(row.top_senders),
|
||||||
|
JSON.stringify(row.by_hour),
|
||||||
|
row.refreshed_at ?? Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RescanProgress {
|
||||||
|
type: 'progress' | 'done' | 'error' | 'start';
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
current?: string;
|
||||||
|
error?: string;
|
||||||
|
inserted_messages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RescanTarget {
|
||||||
|
chatroomId: string;
|
||||||
|
display: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncOptions {
|
||||||
|
targets: RescanTarget[];
|
||||||
|
since: string;
|
||||||
|
until: string;
|
||||||
|
concurrency?: number;
|
||||||
|
onProgress?: (p: RescanProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: split a date range into month chunks ([{since, until}, ...])
|
||||||
|
function monthChunks(since: string, until: string): Array<{ since: string; until: string }> {
|
||||||
|
const chunks: Array<{ since: string; until: string }> = [];
|
||||||
|
const start = new Date(since);
|
||||||
|
const end = new Date(until);
|
||||||
|
let cur = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||||
|
while (cur <= end) {
|
||||||
|
const chunkStart = cur < start ? start : cur;
|
||||||
|
const nextMonth = new Date(cur.getFullYear(), cur.getMonth() + 1, 0); // last day of cur month
|
||||||
|
const chunkEnd = nextMonth > end ? end : nextMonth;
|
||||||
|
chunks.push({
|
||||||
|
since: ymd(chunkStart),
|
||||||
|
until: ymd(chunkEnd),
|
||||||
|
});
|
||||||
|
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ymd(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateList(since: string, until: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const start = new Date(since);
|
||||||
|
const end = new Date(until);
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
out.push(ymd(d));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全量同步:每群按月分批拉 wx history → 本地存 messages → 本地聚合 daily_stats。
|
||||||
|
* 比起逐天调 wx stats 快 30 倍。
|
||||||
|
*/
|
||||||
|
export async function syncFullHistory({
|
||||||
|
targets,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
concurrency = 6,
|
||||||
|
onProgress,
|
||||||
|
}: SyncOptions): Promise<{ ok: number; failed: number; messages: number }> {
|
||||||
|
const limit = pLimit(concurrency);
|
||||||
|
const chunks = monthChunks(since, until);
|
||||||
|
const total = targets.length * chunks.length;
|
||||||
|
let done = 0;
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let totalMessages = 0;
|
||||||
|
const byTarget = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
fetched: number;
|
||||||
|
inserted: number;
|
||||||
|
failedChunks: number;
|
||||||
|
emptyChunks: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
for (const t of targets) {
|
||||||
|
byTarget.set(t.chatroomId, {
|
||||||
|
fetched: 0,
|
||||||
|
inserted: 0,
|
||||||
|
failedChunks: 0,
|
||||||
|
emptyChunks: 0,
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
|
for (const t of targets) {
|
||||||
|
for (const c of chunks) {
|
||||||
|
tasks.push(
|
||||||
|
limit(async () => {
|
||||||
|
const state = byTarget.get(t.chatroomId)!;
|
||||||
|
try {
|
||||||
|
const messages = await wxHistory(t.chatroomId, c.since, c.until, 50_000);
|
||||||
|
const inserted = bulkInsertMessages(t.chatroomId, messages);
|
||||||
|
state.fetched += messages.length;
|
||||||
|
state.inserted += inserted;
|
||||||
|
if (messages.length === 0) state.emptyChunks++;
|
||||||
|
totalMessages += inserted;
|
||||||
|
ok++;
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
state.failedChunks++;
|
||||||
|
state.errors.push(`${c.since}~${c.until}: ${message}`);
|
||||||
|
failed++;
|
||||||
|
onProgress?.({
|
||||||
|
type: 'error',
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
current: `${t.display} ${c.since.slice(0, 7)}`,
|
||||||
|
error: message,
|
||||||
|
inserted_messages: totalMessages,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
done++;
|
||||||
|
onProgress?.({
|
||||||
|
type: 'progress',
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
current: `${t.display} ${c.since.slice(0, 7)}`,
|
||||||
|
inserted_messages: totalMessages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(tasks);
|
||||||
|
|
||||||
|
// Now aggregate daily_stats from the new messages for each target
|
||||||
|
const aggLimit = pLimit(8);
|
||||||
|
const dates = dateList(since, until);
|
||||||
|
await Promise.all(
|
||||||
|
targets.map((t) =>
|
||||||
|
aggLimit(async () => {
|
||||||
|
const buckets = aggregateDailyStats(t.chatroomId, dates);
|
||||||
|
for (const b of buckets) {
|
||||||
|
if (b.total === 0) {
|
||||||
|
// Don't overwrite if we already have non-zero stats from a prior wx-stats run
|
||||||
|
const existing = getCachedStats(t.chatroomId, b.date);
|
||||||
|
if (existing && existing.total > 0) continue;
|
||||||
|
}
|
||||||
|
saveStats({
|
||||||
|
chatroom_id: t.chatroomId,
|
||||||
|
date: b.date,
|
||||||
|
total: b.total,
|
||||||
|
top_senders: b.top_senders,
|
||||||
|
by_hour: b.by_hour,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync_state
|
||||||
|
const firstRow = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT MIN(date) AS d, MAX(date) AS dx, COUNT(*) AS n FROM messages WHERE chatroom_id = ?',
|
||||||
|
)
|
||||||
|
.get(t.chatroomId) as { d: string | null; dx: string | null; n: number };
|
||||||
|
const state = byTarget.get(t.chatroomId)!;
|
||||||
|
const status =
|
||||||
|
state.failedChunks === chunks.length
|
||||||
|
? 'failed'
|
||||||
|
: state.failedChunks > 0
|
||||||
|
? 'partial'
|
||||||
|
: firstRow.n === 0 && state.fetched === 0
|
||||||
|
? 'empty'
|
||||||
|
: 'ok';
|
||||||
|
upsertSyncState(t.chatroomId, firstRow.n, firstRow.d, firstRow.dx, {
|
||||||
|
status,
|
||||||
|
lastError: state.errors.slice(-3).join('\n') || null,
|
||||||
|
failedChunks: state.failedChunks,
|
||||||
|
emptyChunks: state.emptyChunks,
|
||||||
|
totalChunks: chunks.length,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
rebuildMentionIndexFromMessages();
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
type: 'done',
|
||||||
|
done: total,
|
||||||
|
total,
|
||||||
|
inserted_messages: totalMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok, failed, messages: totalMessages };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容旧调用:单天 wx stats 模式(保留以备需要)
|
||||||
|
*/
|
||||||
|
export interface RescanOptions {
|
||||||
|
targets: RescanTarget[];
|
||||||
|
dates: string[];
|
||||||
|
concurrency?: number;
|
||||||
|
onProgress?: (p: RescanProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rescan({
|
||||||
|
targets,
|
||||||
|
dates,
|
||||||
|
concurrency = 5,
|
||||||
|
onProgress,
|
||||||
|
}: RescanOptions): Promise<{ ok: number; failed: number }> {
|
||||||
|
const limit = pLimit(concurrency);
|
||||||
|
const total = targets.length * dates.length;
|
||||||
|
let done = 0;
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
|
for (const t of targets) {
|
||||||
|
for (const d of dates) {
|
||||||
|
tasks.push(
|
||||||
|
limit(async () => {
|
||||||
|
try {
|
||||||
|
const res: WxStats = await wxStats(t.chatroomId, d, d);
|
||||||
|
saveStats({
|
||||||
|
chatroom_id: t.chatroomId,
|
||||||
|
date: d,
|
||||||
|
total: res.total ?? 0,
|
||||||
|
top_senders: res.top_senders ?? [],
|
||||||
|
by_hour: res.by_hour ?? [],
|
||||||
|
});
|
||||||
|
ok++;
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
saveStats({
|
||||||
|
chatroom_id: t.chatroomId,
|
||||||
|
date: d,
|
||||||
|
total: 0,
|
||||||
|
top_senders: [],
|
||||||
|
by_hour: [],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
done++;
|
||||||
|
onProgress?.({ type: 'progress', done, total, current: t.display });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(tasks);
|
||||||
|
onProgress?.({ type: 'done', done, total });
|
||||||
|
return { ok, failed };
|
||||||
|
}
|
||||||
+496
@@ -0,0 +1,496 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { db } from './db';
|
||||||
|
import { wxSessions } from './wx';
|
||||||
|
|
||||||
|
const MIN_MESSAGES_PER_TOPIC = 4;
|
||||||
|
const MIN_MESSAGE_LENGTH = 20;
|
||||||
|
const MAX_MESSAGE_LENGTH = 400;
|
||||||
|
const MAX_MESSAGES_TO_PROCESS = 3000;
|
||||||
|
const MAX_TOPICS_TO_SAVE = 30;
|
||||||
|
const CODEX_CHUNK_SIZE = Number(process.env.WECHAT_RADAR_TOPIC_CHUNK_SIZE ?? 250);
|
||||||
|
const CODEX_TIMEOUT_MS = Number(process.env.WECHAT_RADAR_CODEX_TIMEOUT_MS ?? 300_000);
|
||||||
|
const CODEX_MODEL = process.env.WECHAT_RADAR_CODEX_MODEL;
|
||||||
|
const TOPICS_PER_CHUNK = 12;
|
||||||
|
|
||||||
|
interface SourceMsg {
|
||||||
|
chatroom_id: string;
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LlmTopic {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
message_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LlmTopicResponse {
|
||||||
|
topics: LlmTopic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type TopicWithMembers = {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
members: SourceMsg[];
|
||||||
|
groupSet: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanContent(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/\[图片\]\s*local_id=\d+/g, '')
|
||||||
|
.replace(/\[引用\][^\n]*\n?/g, '')
|
||||||
|
.replace(/\[小程序\][^\n]*/g, '')
|
||||||
|
.replace(/↳\s*[^\n]*/g, '')
|
||||||
|
.replace(/<\?xml[\s\S]+?\?>[\s\S]*?<\/msg>/g, '')
|
||||||
|
.replace(/https?:\/\/\S+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这些消息整体就是占位符 / wrapper,无实质内容
|
||||||
|
const PLACEHOLDER_PATTERNS = [
|
||||||
|
/^\[链接\]\s*当前版本不支持/,
|
||||||
|
/^\[文件\]\s*[^\s]+\.\w+\s*$/,
|
||||||
|
/^\[视频\]\s*$/,
|
||||||
|
/^\[音频\]\s*$/,
|
||||||
|
/^\[语音\]\s*$/,
|
||||||
|
/^\[表情\]\s*$/,
|
||||||
|
/^\[图片\]\s*$/,
|
||||||
|
/^\[位置\]/,
|
||||||
|
/^\[名片\]/,
|
||||||
|
/^\[小程序\]\s*[^\s]*\s*$/,
|
||||||
|
/^\[转账\]/,
|
||||||
|
/^\[红包\]/,
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPlaceholderOnly(content: string): boolean {
|
||||||
|
if (!content) return true;
|
||||||
|
return PLACEHOLDER_PATTERNS.some((p) => p.test(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCandidateMessages(date: string): SourceMsg[] {
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT chatroom_id, local_id, sender, content, time, timestamp
|
||||||
|
FROM messages
|
||||||
|
WHERE date = ?
|
||||||
|
AND type IN ('文本', '链接/文件')
|
||||||
|
AND length(content) >= ?
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(date, MIN_MESSAGE_LENGTH, MAX_MESSAGES_TO_PROCESS) as SourceMsg[];
|
||||||
|
|
||||||
|
// 1. 过滤占位符 + 清洗 + 长度筛选
|
||||||
|
const cleaned = rows
|
||||||
|
.map((r) => ({ ...r, content: cleanContent(r.content).slice(0, MAX_MESSAGE_LENGTH) }))
|
||||||
|
.filter((r) => !isPlaceholderOnly(r.content) && r.content.length >= MIN_MESSAGE_LENGTH);
|
||||||
|
|
||||||
|
// 2. 去重:相同内容(同一条转发消息)只保留第一次出现
|
||||||
|
// 这是真信号(同一篇文章被多群转发)但不应该堆成「话题」— 简化为信源(前 3 条群即可)
|
||||||
|
const seen = new Map<string, SourceMsg>();
|
||||||
|
for (const r of cleaned) {
|
||||||
|
const key = r.content.slice(0, 80); // 前 80 字相同 ≈ 同一条转发
|
||||||
|
if (!seen.has(key)) seen.set(key, r);
|
||||||
|
}
|
||||||
|
return Array.from(seen.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOPIC_RESPONSE_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
topics: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string' },
|
||||||
|
summary: { type: 'string' },
|
||||||
|
message_ids: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['title', 'summary', 'message_ids'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['topics'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function sourceId(m: SourceMsg): string {
|
||||||
|
return `${m.chatroom_id}#${m.local_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk<T>(items: T[], size: number): T[][] {
|
||||||
|
const out: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonOutput<T>(raw: string): T {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as T;
|
||||||
|
} catch {
|
||||||
|
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
if (fenced) return JSON.parse(fenced[1]) as T;
|
||||||
|
const obj = trimmed.match(/\{[\s\S]*\}/);
|
||||||
|
if (obj) return JSON.parse(obj[0]) as T;
|
||||||
|
throw new Error('codex returned non-JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCodexJson<T>(prompt: string, timeoutMs = CODEX_TIMEOUT_MS): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wechat-topics-'));
|
||||||
|
const schemaPath = join(dir, 'schema.json');
|
||||||
|
const outPath = join(dir, 'response.json');
|
||||||
|
writeFileSync(schemaPath, JSON.stringify(TOPIC_RESPONSE_SCHEMA), 'utf8');
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-a',
|
||||||
|
'never',
|
||||||
|
'exec',
|
||||||
|
'--sandbox',
|
||||||
|
'read-only',
|
||||||
|
'--ephemeral',
|
||||||
|
'--ignore-rules',
|
||||||
|
'--output-schema',
|
||||||
|
schemaPath,
|
||||||
|
'--output-last-message',
|
||||||
|
outPath,
|
||||||
|
];
|
||||||
|
if (CODEX_MODEL) args.push('--model', CODEX_MODEL);
|
||||||
|
args.push('-');
|
||||||
|
|
||||||
|
const proc = spawn(
|
||||||
|
'codex',
|
||||||
|
args,
|
||||||
|
{ env: { ...process.env, NO_COLOR: '1' }, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
proc.kill('SIGTERM');
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
reject(new Error('codex CLI timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
proc.stdout.on('data', (d) => (stdout += d.toString()));
|
||||||
|
proc.stderr.on('data', (d) => (stderr += d.toString()));
|
||||||
|
proc.on('error', (e) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
try {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`codex exit ${code}: ${stderr.slice(0, 800)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = readFileSync(outPath, 'utf8') || stdout;
|
||||||
|
resolve(parseJsonOutput<T>(raw));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proc.stdin.write(prompt);
|
||||||
|
proc.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessagesForPrompt(messages: SourceMsg[], groupNameMap: Map<string, string>): string {
|
||||||
|
return messages
|
||||||
|
.map((m) =>
|
||||||
|
JSON.stringify({
|
||||||
|
id: sourceId(m),
|
||||||
|
group: groupNameMap.get(m.chatroom_id) ?? m.chatroom_id,
|
||||||
|
sender: m.sender,
|
||||||
|
time: m.time,
|
||||||
|
content: m.content,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtractionPrompt(
|
||||||
|
date: string,
|
||||||
|
messages: SourceMsg[],
|
||||||
|
groupNameMap: Map<string, string>,
|
||||||
|
maxTopics: number,
|
||||||
|
): string {
|
||||||
|
return `你是微信群「话题雷达」的聚合引擎。请直接用 LLM 判断语义相关性,找出 ${date} 的主要讨论话题。
|
||||||
|
|
||||||
|
任务要求:
|
||||||
|
- 只做话题聚合,不要逐条摘要。
|
||||||
|
- 合并同一事件、产品、工具、论文、观点、问题及其追问/回应/转述。
|
||||||
|
- 优先保留跨群出现的话题;同一群内高密度连续讨论也可以保留。
|
||||||
|
- 忽略问候、纯闲聊、广告、无上下文碎片、纯占位内容和过泛的「AI 很火」类讨论。
|
||||||
|
- 每个话题至少包含 ${MIN_MESSAGES_PER_TOPIC} 条消息。
|
||||||
|
- 最多输出 ${maxTopics} 个话题,按重要性排序。
|
||||||
|
- title 用 8-15 个汉字,优先写产品名/事件名/讨论焦点。
|
||||||
|
- summary 用 1-2 句中文说明大家在讨论什么。
|
||||||
|
- message_ids 必须只使用输入消息的 id;不要编造 id;同一个 id 不要重复。
|
||||||
|
|
||||||
|
只输出严格 JSON,格式:
|
||||||
|
{"topics":[{"title":"...","summary":"...","message_ids":["群id#local_id"]}]}
|
||||||
|
|
||||||
|
输入消息为 JSONL:
|
||||||
|
${formatMessagesForPrompt(messages, groupNameMap)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMergePrompt(date: string, drafts: LlmTopic[], maxTopics: number): string {
|
||||||
|
const lines = drafts
|
||||||
|
.map((t, i) =>
|
||||||
|
JSON.stringify({
|
||||||
|
id: `draft-${i + 1}`,
|
||||||
|
title: t.title,
|
||||||
|
summary: t.summary,
|
||||||
|
message_ids: t.message_ids,
|
||||||
|
message_count: t.message_ids.length,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `下面是 ${date} 分批得到的话题草稿。请继续用 LLM 完成最终跨批合并。
|
||||||
|
|
||||||
|
任务要求:
|
||||||
|
- 合并语义相同或强相关的话题草稿,message_ids 取并集。
|
||||||
|
- 删除过泛、重复、证据不足的话题。
|
||||||
|
- 每个最终话题至少包含 ${MIN_MESSAGES_PER_TOPIC} 条消息。
|
||||||
|
- 最多输出 ${maxTopics} 个最终话题,按重要性排序。
|
||||||
|
- title 用 8-15 个汉字,summary 用 1-2 句中文。
|
||||||
|
- message_ids 必须来自输入草稿,不要编造。
|
||||||
|
|
||||||
|
只输出严格 JSON:
|
||||||
|
{"topics":[{"title":"...","summary":"...","message_ids":["群id#local_id"]}]}
|
||||||
|
|
||||||
|
话题草稿 JSONL:
|
||||||
|
${lines}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTopics(rawTopics: LlmTopic[], messageMap: Map<string, SourceMsg>): TopicWithMembers[] {
|
||||||
|
const out: TopicWithMembers[] = [];
|
||||||
|
const seenSignatures = new Set<string>();
|
||||||
|
|
||||||
|
for (const raw of rawTopics) {
|
||||||
|
const ids = Array.from(new Set((raw.message_ids ?? []).filter((id) => messageMap.has(id))));
|
||||||
|
if (ids.length < MIN_MESSAGES_PER_TOPIC) continue;
|
||||||
|
|
||||||
|
const members = ids.map((id) => messageMap.get(id)!).sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
const signature = ids.slice().sort().join('|');
|
||||||
|
if (seenSignatures.has(signature)) continue;
|
||||||
|
seenSignatures.add(signature);
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
title: (raw.title || members[0].content.slice(0, 16) || '未命名话题').slice(0, 80),
|
||||||
|
summary: (raw.summary || '').slice(0, 400),
|
||||||
|
members,
|
||||||
|
groupSet: new Set(members.map((m) => m.chatroom_id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.sort((a, b) => b.members.length - a.members.length).slice(0, MAX_TOPICS_TO_SAVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aggregateWithCodex(
|
||||||
|
date: string,
|
||||||
|
messages: SourceMsg[],
|
||||||
|
groupNameMap: Map<string, string>,
|
||||||
|
onProgress?: (p: TopicProgress) => void,
|
||||||
|
): Promise<TopicWithMembers[]> {
|
||||||
|
const messageMap = new Map(messages.map((m) => [sourceId(m), m]));
|
||||||
|
const chunks = chunk(messages, Math.max(50, CODEX_CHUNK_SIZE));
|
||||||
|
const drafts: LlmTopic[] = [];
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
type: 'llm',
|
||||||
|
done: 0,
|
||||||
|
total: chunks.length,
|
||||||
|
message: `Codex CLI 聚合 ${messages.length} 条消息…`,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const response = await runCodexJson<LlmTopicResponse>(
|
||||||
|
buildExtractionPrompt(date, chunks[i], groupNameMap, TOPICS_PER_CHUNK),
|
||||||
|
);
|
||||||
|
drafts.push(...(response.topics ?? []));
|
||||||
|
onProgress?.({
|
||||||
|
type: 'llm',
|
||||||
|
done: i + 1,
|
||||||
|
total: chunks.length,
|
||||||
|
message: `Codex CLI 分批聚合 ${i + 1}/${chunks.length}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drafts.length === 0) return [];
|
||||||
|
|
||||||
|
if (chunks.length > 1) {
|
||||||
|
onProgress?.({
|
||||||
|
type: 'llm',
|
||||||
|
done: chunks.length,
|
||||||
|
total: chunks.length,
|
||||||
|
message: `Codex CLI 合并 ${drafts.length} 个话题草稿…`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const final =
|
||||||
|
chunks.length === 1
|
||||||
|
? { topics: drafts }
|
||||||
|
: await runCodexJson<LlmTopicResponse>(buildMergePrompt(date, drafts, MAX_TOPICS_TO_SAVE));
|
||||||
|
|
||||||
|
return normalizeTopics(final.topics ?? [], messageMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicProgress {
|
||||||
|
type: 'load' | 'llm' | 'save' | 'done' | 'error';
|
||||||
|
done?: number;
|
||||||
|
total?: number;
|
||||||
|
count?: number;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildTopicsForDate(
|
||||||
|
date: string,
|
||||||
|
onProgress?: (p: TopicProgress) => void,
|
||||||
|
): Promise<{ topics: number; messages: number }> {
|
||||||
|
onProgress?.({ type: 'load', message: '加载当日消息…' });
|
||||||
|
const msgs = loadCandidateMessages(date);
|
||||||
|
if (msgs.length === 0) {
|
||||||
|
onProgress?.({ type: 'done', count: 0 });
|
||||||
|
return { topics: 0, messages: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await wxSessions(500).catch(() => []);
|
||||||
|
const groupNameMap = new Map<string, string>();
|
||||||
|
for (const s of sessions) groupNameMap.set(s.username, s.chat);
|
||||||
|
|
||||||
|
const valid = await aggregateWithCodex(date, msgs, groupNameMap, onProgress);
|
||||||
|
|
||||||
|
// 清空当日旧话题
|
||||||
|
db().prepare('DELETE FROM topics WHERE date = ?').run(date);
|
||||||
|
|
||||||
|
let savedTopics = 0;
|
||||||
|
let savedMessages = 0;
|
||||||
|
for (let i = 0; i < valid.length; i++) {
|
||||||
|
const c = valid[i];
|
||||||
|
onProgress?.({
|
||||||
|
type: 'save',
|
||||||
|
done: i + 1,
|
||||||
|
total: valid.length,
|
||||||
|
message: c.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertTopic = db().prepare(
|
||||||
|
'INSERT INTO topics (date, title, summary, message_count, group_count, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
);
|
||||||
|
const insertMsg = db().prepare(
|
||||||
|
'INSERT OR IGNORE INTO topic_messages (topic_id, chatroom_id, local_id, score) VALUES (?, ?, ?, ?)',
|
||||||
|
);
|
||||||
|
|
||||||
|
const tx = db().transaction(() => {
|
||||||
|
const info = insertTopic.run(
|
||||||
|
date,
|
||||||
|
c.title,
|
||||||
|
c.summary,
|
||||||
|
c.members.length,
|
||||||
|
c.groupSet.size,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
const tid = Number(info.lastInsertRowid);
|
||||||
|
for (let index = 0; index < c.members.length; index++) {
|
||||||
|
const member = c.members[index];
|
||||||
|
insertMsg.run(tid, member.chatroom_id, member.local_id, 1 - index / 1000);
|
||||||
|
savedMessages++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
savedTopics++;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.({ type: 'done', count: savedTopics });
|
||||||
|
return { topics: savedTopics, messages: savedMessages };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicListItem {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
message_count: number;
|
||||||
|
group_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTopics(date: string): TopicListItem[] {
|
||||||
|
return db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, date, title, summary, message_count, group_count FROM topics WHERE date = ? ORDER BY message_count DESC',
|
||||||
|
)
|
||||||
|
.all(date) as TopicListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicDetail extends TopicListItem {
|
||||||
|
messages: Array<{
|
||||||
|
chatroom_id: string;
|
||||||
|
chat_name: string;
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: string;
|
||||||
|
score: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopicDetail(id: number): Promise<TopicDetail | null> {
|
||||||
|
const topic = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, date, title, summary, message_count, group_count FROM topics WHERE id = ?',
|
||||||
|
)
|
||||||
|
.get(id) as TopicListItem | undefined;
|
||||||
|
if (!topic) return null;
|
||||||
|
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT m.chatroom_id, m.local_id, m.sender, m.content, m.time, m.timestamp, m.type, tm.score
|
||||||
|
FROM topic_messages tm
|
||||||
|
JOIN messages m ON m.chatroom_id = tm.chatroom_id AND m.local_id = tm.local_id
|
||||||
|
WHERE tm.topic_id = ?
|
||||||
|
ORDER BY tm.score DESC, m.timestamp ASC`,
|
||||||
|
)
|
||||||
|
.all(id) as Array<{
|
||||||
|
chatroom_id: string;
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: string;
|
||||||
|
score: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const sessions = await wxSessions(500).catch(() => []);
|
||||||
|
const nameMap = new Map<string, string>();
|
||||||
|
for (const s of sessions) nameMap.set(s.username, s.chat);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...topic,
|
||||||
|
messages: rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
chat_name: nameMap.get(r.chatroom_id) ?? r.chatroom_id,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import * as fsp from 'node:fs/promises';
|
||||||
|
|
||||||
|
// Node 22+ ships fs.promises.glob but TS types lag behind
|
||||||
|
const glob = (fsp as unknown as {
|
||||||
|
glob: (pattern: string, opts: { cwd: string }) => AsyncIterable<string>;
|
||||||
|
}).glob;
|
||||||
|
|
||||||
|
const WX_CACHE_ROOT = join(
|
||||||
|
/*turbopackIgnore: true*/ homedir(),
|
||||||
|
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files',
|
||||||
|
);
|
||||||
|
|
||||||
|
let _userDirCache: string | null = null;
|
||||||
|
|
||||||
|
/** 找到当前微信用户目录(取最近修改的) */
|
||||||
|
function findUserDir(): string | null {
|
||||||
|
if (_userDirCache && existsSync(/*turbopackIgnore: true*/ _userDirCache)) return _userDirCache;
|
||||||
|
if (!existsSync(/*turbopackIgnore: true*/ WX_CACHE_ROOT)) return null;
|
||||||
|
const entries = readdirSync(/*turbopackIgnore: true*/ WX_CACHE_ROOT, { withFileTypes: true })
|
||||||
|
.filter((e) => e.isDirectory() && e.name !== 'all_users' && e.name !== 'Backup')
|
||||||
|
.map((e) => {
|
||||||
|
const p = join(/*turbopackIgnore: true*/ WX_CACHE_ROOT, e.name);
|
||||||
|
return { p, mtime: statSync(/*turbopackIgnore: true*/ p).mtimeMs };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
_userDirCache = entries[0].p;
|
||||||
|
return _userDirCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出按月分的子目录,按时间倒序(最近月份优先) */
|
||||||
|
function listMonthDirs(userDir: string): string[] {
|
||||||
|
const cacheDir = join(/*turbopackIgnore: true*/ userDir, 'cache');
|
||||||
|
if (!existsSync(/*turbopackIgnore: true*/ cacheDir)) return [];
|
||||||
|
return readdirSync(/*turbopackIgnore: true*/ cacheDir)
|
||||||
|
.filter((d) => /^\d{4}-\d{2}$/.test(d))
|
||||||
|
.sort((a, b) => b.localeCompare(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedImage {
|
||||||
|
path: string;
|
||||||
|
type: 'hd' | 'mid' | 'thumb';
|
||||||
|
format: 'png' | 'jpeg' | 'gif' | 'bmp' | 'bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检测文件 magic bytes */
|
||||||
|
function detectFormat(path: string): ResolvedImage['format'] {
|
||||||
|
try {
|
||||||
|
const fd = readFileSync(/*turbopackIgnore: true*/ path, { flag: 'r' });
|
||||||
|
const h = fd.subarray(0, 4);
|
||||||
|
if (h[0] === 0xff && h[1] === 0xd8) return 'jpeg';
|
||||||
|
if (h[0] === 0x89 && h[1] === 0x50 && h[2] === 0x4e && h[3] === 0x47) return 'png';
|
||||||
|
if (h[0] === 0x47 && h[1] === 0x49 && h[2] === 0x46) return 'gif';
|
||||||
|
if (h[0] === 0x42 && h[1] === 0x4d) return 'bmp';
|
||||||
|
} catch {}
|
||||||
|
return 'bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 月份 → { localId → ResolvedImage } 索引(懒加载)
|
||||||
|
const monthIndexCache = new Map<string, Map<number, ResolvedImage>>();
|
||||||
|
const monthIndexLoading = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
async function buildMonthIndex(userDir: string, month: string): Promise<void> {
|
||||||
|
if (monthIndexCache.has(month)) return;
|
||||||
|
const existing = monthIndexLoading.get(month);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const p = (async () => {
|
||||||
|
const monthRoot = join(/*turbopackIgnore: true*/ userDir, 'cache', month, 'Message');
|
||||||
|
if (!existsSync(/*turbopackIgnore: true*/ monthRoot)) {
|
||||||
|
monthIndexCache.set(month, new Map());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = new Map<number, ResolvedImage>();
|
||||||
|
const priority: Record<ResolvedImage['type'], number> = { hd: 3, mid: 2, thumb: 1 };
|
||||||
|
|
||||||
|
const consider = (path: string, type: ResolvedImage['type']) => {
|
||||||
|
const m = /\/(\d+)_/.exec(path);
|
||||||
|
if (!m) return;
|
||||||
|
const id = Number(m[1]);
|
||||||
|
const cur = idx.get(id);
|
||||||
|
if (!cur || priority[type] > priority[cur.type]) {
|
||||||
|
// 推迟 detectFormat 到实际请求时
|
||||||
|
idx.set(id, { path, type, format: 'bin' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for await (const p of glob('*/ImageTemp/*hd_temp_convert', { cwd: monthRoot })) {
|
||||||
|
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'hd');
|
||||||
|
}
|
||||||
|
for await (const p of glob('*/ImageTemp/*mid_temp_convert', { cwd: monthRoot })) {
|
||||||
|
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'mid');
|
||||||
|
}
|
||||||
|
for await (const p of glob('*/Thumb/*thumb.jpg', { cwd: monthRoot })) {
|
||||||
|
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'thumb');
|
||||||
|
}
|
||||||
|
monthIndexCache.set(month, idx);
|
||||||
|
})();
|
||||||
|
|
||||||
|
monthIndexLoading.set(month, p);
|
||||||
|
await p;
|
||||||
|
monthIndexLoading.delete(month);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 local_id 在 wx 缓存找图。优先 hint 月份,否则按月扫到旧。
|
||||||
|
* 用懒加载的内存索引加速:每月扫一次后命中 ~1ms。
|
||||||
|
*/
|
||||||
|
export async function resolveWxImage(
|
||||||
|
localId: number,
|
||||||
|
hintMonth?: string,
|
||||||
|
): Promise<ResolvedImage | null> {
|
||||||
|
const userDir = findUserDir();
|
||||||
|
if (!userDir) return null;
|
||||||
|
|
||||||
|
const months = listMonthDirs(userDir);
|
||||||
|
if (months.length === 0) return null;
|
||||||
|
|
||||||
|
const ordered = hintMonth && months.includes(hintMonth)
|
||||||
|
? [hintMonth, ...months.filter((m) => m !== hintMonth)]
|
||||||
|
: months;
|
||||||
|
|
||||||
|
for (const m of ordered) {
|
||||||
|
await buildMonthIndex(userDir, m);
|
||||||
|
const idx = monthIndexCache.get(m);
|
||||||
|
if (!idx) continue;
|
||||||
|
const hit = idx.get(localId);
|
||||||
|
if (hit) {
|
||||||
|
return { ...hit, format: detectFormat(hit.path) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME: Record<ResolvedImage['format'], string> = {
|
||||||
|
png: 'image/png',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
bin: 'application/octet-stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mimeFor(format: ResolvedImage['format']): string {
|
||||||
|
return MIME[format];
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export interface WxSession {
|
||||||
|
chat: string;
|
||||||
|
chat_type: 'private' | 'group';
|
||||||
|
is_group: boolean;
|
||||||
|
last_msg_type: string;
|
||||||
|
last_sender: string;
|
||||||
|
summary: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
unread: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxStatsBucket {
|
||||||
|
hour: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxStatsSender {
|
||||||
|
sender: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxStatsType {
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxStats {
|
||||||
|
chat: string;
|
||||||
|
chat_type: 'private' | 'group';
|
||||||
|
is_group: boolean;
|
||||||
|
username: string;
|
||||||
|
total: number;
|
||||||
|
by_hour: WxStatsBucket[];
|
||||||
|
by_type: WxStatsType[];
|
||||||
|
top_senders: WxStatsSender[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxMessage {
|
||||||
|
local_id: number;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxNewMessage extends WxMessage {
|
||||||
|
username: string;
|
||||||
|
chat?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxMember {
|
||||||
|
username: string;
|
||||||
|
nickname?: string;
|
||||||
|
display_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WxDaemonStatus {
|
||||||
|
running: boolean;
|
||||||
|
pid?: number;
|
||||||
|
uptime_seconds?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import type {
|
||||||
|
WxDaemonStatus,
|
||||||
|
WxMember,
|
||||||
|
WxMessage,
|
||||||
|
WxNewMessage,
|
||||||
|
WxSession,
|
||||||
|
WxStats,
|
||||||
|
} from './wx-types';
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
const DEFAULT_OPTS = {
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
timeout: 60_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function wxRaw(args: string[], opts = DEFAULT_OPTS): Promise<string> {
|
||||||
|
const { stdout } = await run('wx', args, opts);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wxJson<T>(args: string[], opts = DEFAULT_OPTS): Promise<T> {
|
||||||
|
const stdout = await wxRaw([...args, '--json'], opts);
|
||||||
|
return JSON.parse(stdout) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wxSessions(limit = 500): Promise<WxSession[]> {
|
||||||
|
return wxJson<WxSession[]>(['sessions', '-n', String(limit)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wxStats(
|
||||||
|
chat: string,
|
||||||
|
since: string,
|
||||||
|
until: string,
|
||||||
|
): Promise<WxStats> {
|
||||||
|
return wxJson<WxStats>(['stats', chat, '--since', since, '--until', until]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wxHistory(
|
||||||
|
chat: string,
|
||||||
|
since: string,
|
||||||
|
until: string,
|
||||||
|
limit = 1000,
|
||||||
|
): Promise<WxMessage[]> {
|
||||||
|
return wxJson<WxMessage[]>([
|
||||||
|
'history',
|
||||||
|
chat,
|
||||||
|
'--since',
|
||||||
|
since,
|
||||||
|
'--until',
|
||||||
|
until,
|
||||||
|
'-n',
|
||||||
|
String(limit),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wxNewMessages(limit = 50): Promise<WxNewMessage[]> {
|
||||||
|
return wxJson<WxNewMessage[]>(['new-messages', '-n', String(limit)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wxMembers(chat: string): Promise<WxMember[]> {
|
||||||
|
return wxJson<WxMember[]>(['members', chat]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wxDaemonStatus(): Promise<WxDaemonStatus> {
|
||||||
|
try {
|
||||||
|
const out = await wxRaw(['daemon', 'status']);
|
||||||
|
const lower = out.toLowerCase();
|
||||||
|
const running = lower.includes('running') || lower.includes('运行');
|
||||||
|
const pidMatch = out.match(/pid[^\d]*(\d+)/i);
|
||||||
|
return {
|
||||||
|
running,
|
||||||
|
pid: pidMatch ? Number(pidMatch[1]) : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { running: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wxAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await run('wx', ['--version'], { timeout: 5_000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "wechat-radar",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"demo:seed": "node scripts/seed_demo.cjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"echarts-for-react": "^3.0.6",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"next": "16.2.6",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"node-cache": "^5.1.2",
|
||||||
|
"p-limit": "^7.3.0",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/node-cache": "^4.2.5",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"description": "Local-first WeChat group intelligence dashboard",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
Generated
+4489
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- better-sqlite3
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,286 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
const { execFile } = require('node:child_process');
|
||||||
|
const { homedir } = require('node:os');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
const { promisify } = require('node:util');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
const DATA_DIR = process.env.WECHAT_RADAR_DATA_DIR || join(homedir(), '.wechat-radar');
|
||||||
|
const DB_PATH = join(DATA_DIR, 'radar.db');
|
||||||
|
|
||||||
|
const SYSTEM_TYPES = new Set(['system', '系统']);
|
||||||
|
const REVOKE_RE = /撤回了一条消息|recalled a message/i;
|
||||||
|
|
||||||
|
function arg(name, fallback) {
|
||||||
|
const i = process.argv.indexOf(name);
|
||||||
|
if (i === -1) return fallback;
|
||||||
|
return process.argv[i + 1] || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFlag(name) {
|
||||||
|
return process.argv.includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ymd(d) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBefore(n) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - n);
|
||||||
|
return ymd(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateOfMessage(m) {
|
||||||
|
if (m.time && m.time.length >= 10) return m.time.slice(0, 10);
|
||||||
|
if (m.timestamp) return ymd(new Date(m.timestamp * 1000));
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateList(since, until) {
|
||||||
|
const out = [];
|
||||||
|
const start = new Date(since);
|
||||||
|
const end = new Date(until);
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
out.push(ymd(d));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthChunks(since, until) {
|
||||||
|
const chunks = [];
|
||||||
|
const start = new Date(since);
|
||||||
|
const end = new Date(until);
|
||||||
|
let cur = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||||
|
while (cur <= end) {
|
||||||
|
const chunkStart = cur < start ? start : cur;
|
||||||
|
const monthEnd = new Date(cur.getFullYear(), cur.getMonth() + 1, 0);
|
||||||
|
const chunkEnd = monthEnd > end ? end : monthEnd;
|
||||||
|
chunks.push({ since: ymd(chunkStart), until: ymd(chunkEnd) });
|
||||||
|
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureColumn(db, table, name, definition) {
|
||||||
|
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||||
|
if (rows.some((r) => r.name === name)) return;
|
||||||
|
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSchema(db) {
|
||||||
|
ensureColumn(db, 'sync_state', 'status', "TEXT NOT NULL DEFAULT 'unknown'");
|
||||||
|
ensureColumn(db, 'sync_state', 'last_error', 'TEXT');
|
||||||
|
ensureColumn(db, 'sync_state', 'failed_chunks', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn(db, 'sync_state', 'empty_chunks', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn(db, 'sync_state', 'total_chunks', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wxJson(args, opts = {}) {
|
||||||
|
const { stdout } = await run('wx', [...args, '--json'], {
|
||||||
|
maxBuffer: 256 * 1024 * 1024,
|
||||||
|
timeout: 180_000,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
return JSON.parse(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeInserters(db) {
|
||||||
|
const insertMessage = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO messages
|
||||||
|
(chatroom_id, local_id, sender, content, time, timestamp, type, date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const insertStats = db.prepare(`
|
||||||
|
INSERT INTO daily_stats (chatroom_id, date, total, top_senders, by_hour, refreshed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(chatroom_id, date) DO UPDATE SET
|
||||||
|
total = excluded.total,
|
||||||
|
top_senders = excluded.top_senders,
|
||||||
|
by_hour = excluded.by_hour,
|
||||||
|
refreshed_at = excluded.refreshed_at
|
||||||
|
`);
|
||||||
|
const upsertSync = db.prepare(`
|
||||||
|
INSERT INTO sync_state (
|
||||||
|
chatroom_id,
|
||||||
|
last_synced_at,
|
||||||
|
first_message_date,
|
||||||
|
last_message_date,
|
||||||
|
total_messages,
|
||||||
|
status,
|
||||||
|
last_error,
|
||||||
|
failed_chunks,
|
||||||
|
empty_chunks,
|
||||||
|
total_chunks
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(chatroom_id) DO UPDATE SET
|
||||||
|
last_synced_at = excluded.last_synced_at,
|
||||||
|
first_message_date = COALESCE(excluded.first_message_date, sync_state.first_message_date),
|
||||||
|
last_message_date = COALESCE(excluded.last_message_date, sync_state.last_message_date),
|
||||||
|
total_messages = excluded.total_messages,
|
||||||
|
status = excluded.status,
|
||||||
|
last_error = excluded.last_error,
|
||||||
|
failed_chunks = excluded.failed_chunks,
|
||||||
|
empty_chunks = excluded.empty_chunks,
|
||||||
|
total_chunks = excluded.total_chunks
|
||||||
|
`);
|
||||||
|
return { insertMessage, insertStats, upsertSync };
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMessages(db, stmt, chatroomId, messages) {
|
||||||
|
let inserted = 0;
|
||||||
|
const tx = db.transaction((rows) => {
|
||||||
|
for (const m of rows) {
|
||||||
|
if (SYSTEM_TYPES.has(m.type) && REVOKE_RE.test(m.content || '')) continue;
|
||||||
|
const r = stmt.run(
|
||||||
|
chatroomId,
|
||||||
|
m.local_id,
|
||||||
|
m.sender || '',
|
||||||
|
m.content || '',
|
||||||
|
m.time || '',
|
||||||
|
m.timestamp || 0,
|
||||||
|
m.type || '',
|
||||||
|
dateOfMessage(m),
|
||||||
|
);
|
||||||
|
if (r.changes > 0) inserted++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx(messages);
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregate(db, insertStats, chatroomId, dates) {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT date, sender, timestamp
|
||||||
|
FROM messages
|
||||||
|
WHERE chatroom_id = ? AND date >= ? AND date <= ?`,
|
||||||
|
)
|
||||||
|
.all(chatroomId, dates[0], dates[dates.length - 1]);
|
||||||
|
const byDate = new Map();
|
||||||
|
for (const d of dates) byDate.set(d, { total: 0, senders: new Map(), hours: new Array(24).fill(0) });
|
||||||
|
for (const r of rows) {
|
||||||
|
const slot = byDate.get(r.date);
|
||||||
|
if (!slot) continue;
|
||||||
|
slot.total++;
|
||||||
|
slot.senders.set(r.sender, (slot.senders.get(r.sender) || 0) + 1);
|
||||||
|
if (r.timestamp) {
|
||||||
|
const h = new Date(r.timestamp * 1000).getHours();
|
||||||
|
if (h >= 0 && h < 24) slot.hours[h]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const [date, s] of byDate.entries()) {
|
||||||
|
const top = Array.from(s.senders.entries())
|
||||||
|
.map(([sender, count]) => ({ sender, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 10);
|
||||||
|
insertStats.run(
|
||||||
|
chatroomId,
|
||||||
|
date,
|
||||||
|
s.total,
|
||||||
|
JSON.stringify(top),
|
||||||
|
JSON.stringify(s.hours.map((count, hour) => ({ hour, count }))),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const since = arg('--since', daysBefore(Number(arg('--days', '30')) - 1));
|
||||||
|
const until = arg('--until', ymd(new Date()));
|
||||||
|
const activeDays = Number(arg('--active-days', '7'));
|
||||||
|
const only = arg('--only', '');
|
||||||
|
const includeExisting = hasFlag('--include-existing');
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const activeSince = nowSeconds - activeDays * 86400;
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
ensureSchema(db);
|
||||||
|
const { insertMessage, insertStats, upsertSync } = makeInserters(db);
|
||||||
|
const sessions = (await wxJson(['sessions', '-n', '500'])).filter((s) => s.is_group);
|
||||||
|
|
||||||
|
const existing = new Map(
|
||||||
|
db.prepare('SELECT chatroom_id, total_messages FROM sync_state').all().map((r) => [r.chatroom_id, r.total_messages]),
|
||||||
|
);
|
||||||
|
const candidates = sessions.filter((s) => {
|
||||||
|
if (only && !s.chat.includes(only) && !s.username.includes(only)) return false;
|
||||||
|
if (!includeExisting && existing.has(s.username) && existing.get(s.username) > 0) return false;
|
||||||
|
return s.timestamp >= activeSince;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks = monthChunks(since, until);
|
||||||
|
const dates = dateList(since, until);
|
||||||
|
console.log(`Backfilling ${candidates.length} groups from ${since} to ${until}`);
|
||||||
|
|
||||||
|
for (const [index, group] of candidates.entries()) {
|
||||||
|
let fetched = 0;
|
||||||
|
let inserted = 0;
|
||||||
|
let failedChunks = 0;
|
||||||
|
let emptyChunks = 0;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const c of chunks) {
|
||||||
|
try {
|
||||||
|
const messages = await wxJson([
|
||||||
|
'history',
|
||||||
|
group.username,
|
||||||
|
'--since',
|
||||||
|
c.since,
|
||||||
|
'--until',
|
||||||
|
c.until,
|
||||||
|
'-n',
|
||||||
|
'50000',
|
||||||
|
]);
|
||||||
|
fetched += messages.length;
|
||||||
|
if (messages.length === 0) emptyChunks++;
|
||||||
|
inserted += insertMessages(db, insertMessage, group.username, messages);
|
||||||
|
} catch (e) {
|
||||||
|
failedChunks++;
|
||||||
|
errors.push(`${c.since}~${c.until}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregate(db, insertStats, group.username, dates);
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT COUNT(*) AS n, MIN(date) AS first_date, MAX(date) AS last_date FROM messages WHERE chatroom_id = ?')
|
||||||
|
.get(group.username);
|
||||||
|
const status =
|
||||||
|
failedChunks === chunks.length
|
||||||
|
? 'failed'
|
||||||
|
: failedChunks > 0
|
||||||
|
? 'partial'
|
||||||
|
: row.n === 0 && fetched === 0
|
||||||
|
? 'empty'
|
||||||
|
: 'ok';
|
||||||
|
upsertSync.run(
|
||||||
|
group.username,
|
||||||
|
Date.now(),
|
||||||
|
row.first_date,
|
||||||
|
row.last_date,
|
||||||
|
row.n,
|
||||||
|
status,
|
||||||
|
errors.slice(-3).join('\n') || null,
|
||||||
|
failedChunks,
|
||||||
|
emptyChunks,
|
||||||
|
chunks.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${index + 1}/${candidates.length} ${group.chat} fetched=${fetched} inserted=${inserted} total=${row.n} status=${status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const { existsSync, mkdirSync, writeFileSync } = require('node:fs');
|
||||||
|
const { homedir } = require('node:os');
|
||||||
|
const { dirname, join } = require('node:path');
|
||||||
|
|
||||||
|
const dataDir = process.env.WECHAT_RADAR_DATA_DIR || join(homedir(), '.wechat-radar');
|
||||||
|
const dbPath = join(dataDir, 'radar.db');
|
||||||
|
if (!existsSync(dirname(dbPath))) mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, color TEXT NOT NULL, emoji TEXT, sort_order INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_stats (chatroom_id TEXT NOT NULL, date TEXT NOT NULL, total INTEGER NOT NULL, top_senders TEXT NOT NULL, by_hour TEXT NOT NULL, refreshed_at INTEGER NOT NULL, PRIMARY KEY (chatroom_id, date));
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (chatroom_id TEXT NOT NULL, local_id INTEGER NOT NULL, sender TEXT NOT NULL, content TEXT NOT NULL, time TEXT NOT NULL, timestamp INTEGER NOT NULL, type TEXT NOT NULL, date TEXT NOT NULL, PRIMARY KEY (chatroom_id, local_id));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON daily_stats(date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
['AI / Coding', '#7dd3a8', '💻'], ['Tools', '#f59e0b', '🛠️'], ['Articles', '#06b6d4', '📚'],
|
||||||
|
['Business', '#10b981', '💼'], ['Events', '#f97316', '📅'], ['Research', '#a855f7', '🔬'], ['Lifestyle', '#fb7185', '🏠'],
|
||||||
|
];
|
||||||
|
const now = Date.now();
|
||||||
|
const insertGroup = db.prepare('INSERT OR IGNORE INTO groups (name, color, emoji, sort_order, created_at) VALUES (?, ?, ?, ?, ?)');
|
||||||
|
categories.forEach((g, i) => insertGroup.run(g[0], g[1], g[2], i, now));
|
||||||
|
|
||||||
|
const groups = [
|
||||||
|
['demo-ai@chatroom', 'AI 产品讨论群'], ['demo-coding@chatroom', 'Vibe Coding 交流群'],
|
||||||
|
['demo-tools@chatroom', '效率工具分享群'], ['demo-business@chatroom', 'AI 商业增长群'], ['demo-life@chatroom', '生活与阅读群'],
|
||||||
|
];
|
||||||
|
const senders = ['Alex', 'Ming', 'Luna', 'Kai', 'River', 'Yuki', 'Chen'];
|
||||||
|
const contents = [
|
||||||
|
'有没有适合团队知识库的 AI 工具?最好支持飞书和 Notion,同步成本低一点。',
|
||||||
|
'实测 Codex 处理中型前端改版很稳,关键是先给它足够清楚的验收标准。',
|
||||||
|
'分享一个开源项目 https://github.com/example/agent-workflow 可以把多 Agent 编排可视化。',
|
||||||
|
'这篇文章值得读:AI Agent 落地为什么卡在组织流程 https://mp.weixin.qq.com/s/demo-agent-org',
|
||||||
|
'下周有一个 AI 工具内测名额,想找 20 个真实团队试用,感兴趣可以报名。',
|
||||||
|
'GEO 和 SEO 的差别今天讨论很多,核心不是关键词,而是结构化证据和可信来源。',
|
||||||
|
'有没有人熟悉 Chrome Extension 上架流程?需要一个 checklist。',
|
||||||
|
'@你的微信名 这个话题你可能有经验:如何把群聊素材整理成公众号选题?',
|
||||||
|
'新的语音转文字工具体验不错 https://example.com/voice-note 支持批量导出 Markdown。',
|
||||||
|
'今天最值得关注的是 AI 工具开始从个人效率走向团队工作流。',
|
||||||
|
];
|
||||||
|
function ymd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
|
||||||
|
const insertMessage = db.prepare('INSERT OR IGNORE INTO messages (chatroom_id, local_id, sender, content, time, timestamp, type, date) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||||
|
const insertStats = db.prepare('INSERT INTO daily_stats (chatroom_id, date, total, top_senders, by_hour, refreshed_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(chatroom_id, date) DO UPDATE SET total = excluded.total, top_senders = excluded.top_senders, by_hour = excluded.by_hour, refreshed_at = excluded.refreshed_at');
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
|
||||||
|
const d = new Date(); d.setDate(d.getDate() - dayOffset);
|
||||||
|
const date = ymd(d);
|
||||||
|
for (let gi = 0; gi < groups.length; gi++) {
|
||||||
|
const [chatroomId] = groups[gi];
|
||||||
|
const count = Math.max(8, 42 - dayOffset * 2 + gi * 5);
|
||||||
|
const byHour = Array.from({ length: 24 }, (_, hour) => ({ hour, count: hour >= 9 && hour <= 23 ? Math.floor(count / 15) + ((hour + gi) % 3) : 0 }));
|
||||||
|
const topSenders = senders.slice(0, 3).map((sender, index) => ({ sender, count: Math.max(1, Math.floor(count / (index + 2))) }));
|
||||||
|
insertStats.run(chatroomId, date, count, JSON.stringify(topSenders), JSON.stringify(byHour), Date.now());
|
||||||
|
for (let i = 0; i < Math.min(count, 18); i++) {
|
||||||
|
const localId = dayOffset * 10000 + gi * 1000 + i + 1;
|
||||||
|
const hour = 9 + ((i + gi) % 12);
|
||||||
|
const minute = (i * 7) % 60;
|
||||||
|
const time = `${date} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
|
||||||
|
insertMessage.run(chatroomId, localId, senders[(i + gi) % senders.length], contents[(i + gi + dayOffset) % contents.length], time, Math.floor(new Date(time).getTime() / 1000), 'text', date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
writeFileSync(join(dataDir, 'config.json'), JSON.stringify({ myNicknames: ['你的微信名'], defaultRange: 'week', rescanConcurrency: 5, privacyConfirmed: true, setupCompleted: true, demoMode: true, defaultSyncDays: 7 }, null, 2));
|
||||||
|
console.log(`Seeded demo data at ${dbPath}`);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user