From de7ec99fed90ba3c5644fbe1ac233ff981cc53f7 Mon Sep 17 00:00:00 2001 From: joeseesun <85378058@qq.com> Date: Tue, 26 May 2026 09:10:04 +0800 Subject: [PATCH] Publish updated WeChat Radar --- .env.example | 6 + README.md | 214 +++++--- app/api/ai-classify/route.ts | 163 +++++- app/api/dates/route.ts | 18 + app/api/dbinfo/route.ts | 4 +- app/api/message-links/backfill/route.ts | 24 + app/api/message-links/raw/route.ts | 52 ++ app/api/message-links/resolve/route.ts | 48 ++ app/api/recover/route.ts | 4 +- app/api/rescan/route.ts | 33 ++ app/api/search/route.ts | 177 ++++++ app/api/sessions/route.ts | 1 - app/api/wx-image/route.ts | 2 +- app/classify/page.tsx | 2 +- app/globals.css | 123 ++++- app/groups/[id]/page.tsx | 85 +-- app/groups/page.tsx | 4 +- app/layout.tsx | 10 +- app/links/page.tsx | 102 +++- app/page.tsx | 20 +- app/reports/groups/[id]/page.tsx | 602 ++++++++++++++++++++ app/signals/page.tsx | 2 +- app/topics/page.tsx | 15 +- components/ActiveGroupsList.tsx | 89 +-- components/GlobalSearch.tsx | 141 +++++ components/IntelligenceBrief.tsx | 702 ++++++++++++------------ components/Sidebar.tsx | 23 +- components/ThemeToggle.tsx | 98 ++++ components/TopBar.tsx | 34 +- docs/assets/product-preview.svg | 64 +++ eslint.config.mjs | 1 - lib/dashboard-intelligence.ts | 100 ++-- lib/db.ts | 77 ++- lib/group-classifier.ts | 92 +++- lib/link-intelligence.ts | 91 ++- lib/message-links.ts | 290 ++++++++++ lib/messages-store.ts | 10 + lib/topics.ts | 51 +- lib/wx-image.ts | 32 +- next.config.ts | 4 +- package.json | 1 + pnpm-workspace.yaml | 2 - scripts/backup-cockpit-db.mjs | 32 ++ scripts/seed_demo.cjs | 1 + 44 files changed, 2900 insertions(+), 746 deletions(-) create mode 100644 app/api/dates/route.ts create mode 100644 app/api/message-links/backfill/route.ts create mode 100644 app/api/message-links/raw/route.ts create mode 100644 app/api/message-links/resolve/route.ts create mode 100644 app/api/search/route.ts create mode 100644 app/reports/groups/[id]/page.tsx create mode 100644 components/GlobalSearch.tsx create mode 100644 components/ThemeToggle.tsx create mode 100644 docs/assets/product-preview.svg create mode 100644 lib/message-links.ts create mode 100644 scripts/backup-cockpit-db.mjs diff --git a/.env.example b/.env.example index caa6c63..ad8e117 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,9 @@ WECHAT_RADAR_DEMO=0 # Optional model name used by Codex CLI based topic/link summarization. WECHAT_RADAR_CODEX_MODEL= + +# Optional. Tune Codex CLI aggregation. +WECHAT_RADAR_TOPIC_CHUNK_SIZE=250 +WECHAT_RADAR_CODEX_TIMEOUT_MS=300000 +WECHAT_RADAR_LINK_CODEX_TIMEOUT_MS=180000 +WECHAT_RADAR_AUTO_TOPIC_DAYS=31 diff --git a/README.md b/README.md index cdd6d92..d7eb0d5 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,186 @@ -# 微信雷达(WeChat Radar) +# WeChat Radar -本地优先的微信群聊情报看板。它从本机 `wx-cli` 或 demo 数据中提取趋势、关键链接、行动机会、人物雷达和内容选题,帮助你从大量微信群消息里发现真正有用的信号。 +> 群太多,真正有价值的消息却总是被淹没。 +> WeChat Radar turns noisy WeChat groups into a local-first intelligence dashboard. -## 特性 +[![GitHub stars](https://img.shields.io/github/stars/joeseesun/wechat-radar?style=social)](https://github.com/joeseesun/wechat-radar/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/joeseesun/wechat-radar?style=social)](https://github.com/joeseesun/wechat-radar/network/members) +[![Issues](https://img.shields.io/github/issues/joeseesun/wechat-radar)](https://github.com/joeseesun/wechat-radar/issues) +[![Last commit](https://img.shields.io/github/last-commit/joeseesun/wechat-radar)](https://github.com/joeseesun/wechat-radar/commits/main) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -- 首页情报工作台:今日值得出手、趋势升温、异常信号、链接精选、人物雷达、内容选题。 -- 链接情报:聚合最近一天出现的文章、工具和资源,按重复度和跨群扩散排序。 -- 话题雷达:按日期构建跨群话题,并查看相关原始消息。 -- 群列表与群详情:查看群活跃度、每日趋势、Top 发言人和完整消息。 -- 本地 SQLite:默认数据目录 `~/.wechat-radar`。 -- 首次启动向导:配置你的微信名、检查 `wx-cli`、确认隐私、可一键使用示例数据。 +![WeChat Radar product preview](docs/assets/product-preview.svg) -## 运行要求 +**[中文](#中文) | [English](#english)** -- macOS -- Node.js 20+ -- pnpm -- 可选:[`wx-cli`](https://github.com/jackwener/wx-cli),用于读取本机微信数据 +--- -没有 `wx-cli` 也可以使用 demo 模式预览界面。 + + +## 中文 + +WeChat Radar 是一个本地优先的微信群聊情报看板。它把群消息、话题、链接、@我的消息和高信号人物聚合成一个可按日期查看的工作台。 + +你得到的不是“聊天记录列表”,而是每天可以直接处理的情报: + +- 今日优先看:消息、文章、工具、异动分区展示 +- 话题雷达:用 Codex CLI 按天聚合跨群话题 +- 链接情报:文章/工具资源去重,生成可读标题 +- 群日报:每天活跃群可生成摘要报告,方便复制给 AI 继续处理 +- 本地存储:聊天数据落到你自己的 SQLite,不上传到第三方服务 +- 明暗主题:默认奶白色浅色主题,也支持深色模式 ## 快速开始 ```bash +git clone https://github.com/joeseesun/wechat-radar.git +cd wechat-radar pnpm install pnpm rebuild better-sqlite3 pnpm dev ``` -访问 [http://localhost:3000](http://localhost:3000)。首次访问会进入 `/setup`。 +打开 [http://localhost:3000](http://localhost:3000)。首次进入会跳到 `/setup`,按页面提示填写你的微信名、确认隐私说明,也可以先启用 demo 数据体验。 -## Demo 模式 +## 前置条件 -如果你还没有配置 `wx-cli`,可以在 `/setup` 勾选“使用示例数据体验”。也可以命令行生成示例数据: +- [ ] macOS,且已登录微信 4.x +- [ ] Node.js 20+:`node --version` +- [ ] pnpm:`corepack enable && pnpm --version` +- [ ] wx-cli:`wx --version` +- [ ] wx daemon 正在运行:`wx daemon status` +- [ ] 如果要让话题聚合更好,安装并登录 Codex CLI:`codex --version` -```bash -pnpm demo:seed -pnpm dev -``` - -示例数据会写入 `~/.wechat-radar/radar.db`。 +wx-cli 可参考原项目安装与初始化:[jackwener/wx-cli](https://github.com/jackwener/wx-cli)。 ## 配置 -可以通过 `.env.local` 配置: +默认数据目录是 `~/.wechat-radar/`,不会写进项目目录。 -```env +你可以用环境变量覆盖: + +```bash +cp .env.example .env.local +``` + +常用配置: + +```bash WECHAT_RADAR_DATA_DIR=~/.wechat-radar -WECHAT_RADAR_MY_NAMES=你的微信名,你的群昵称 +WECHAT_RADAR_MY_NAMES=张三,San Zhang,zhangsan WECHAT_RADAR_DEMO=0 WECHAT_RADAR_CODEX_MODEL= ``` -也可以在首次启动向导中配置,配置会保存在: +也可以直接在 `/setup` 页面配置。配置会写入 `~/.wechat-radar/config.json`。 + +## 使用方式 + +1. 进入首页,选择日期或时间范围。 +2. 点击“重扫”同步当前范围消息。 +3. 点击“全量同步”拉取更长历史。 +4. 打开“话题雷达”查看跨群主题。 +5. 打开“链接情报”查看文章和工具资源。 +6. 在活跃群列表点击“日报”查看单群日报。 + +你可以这样和 AI 配合: + +- “把今天所有 Codex 相关话题整理成一篇博客大纲。” +- “复制这个群日报,帮我提炼值得回复的机会。” +- “把链接情报里的工具做成一张试用优先级表。” + +## 数据与隐私 + +WeChat Radar 默认只在本机读写数据: + +- `~/.wechat-radar/radar.db`:SQLite 主数据库 +- `~/.wechat-radar/config.json`:本地配置 +- `~/.wechat-radar/backups/`:可选备份 + +安全设计: + +- wx-cli 调用使用 `child_process.execFile` 参数数组,不拼 shell +- SQLite 使用 prepared statements +- 页面只以 React 文本节点渲染聊天内容 +- 不把微信密钥、会话、数据库、模型缓存提交进仓库 + +重要提醒:这个项目会读取你本机微信数据。请确认你的使用方式符合微信客户端规则、当地法律、群成员隐私预期和你所在组织的合规要求。不要把包含真实聊天内容的数据库或截图上传到公开仓库。 + +## 项目结构 ```text -~/.wechat-radar/config.json +app/ Next.js App Router 页面与 API +components/ 看板、侧边栏、图表、消息渲染组件 +lib/ wx-cli 封装、SQLite、话题/链接聚合逻辑 +scripts/ 本地维护脚本 +docs/assets/ README 图片与公开素材 ``` -关键配置: +## 常见问题 -| 配置 | 说明 | -|---|---| -| `WECHAT_RADAR_DATA_DIR` | 本地数据目录,默认 `~/.wechat-radar` | -| `WECHAT_RADAR_MY_NAMES` | 用于识别 @我的多个昵称,逗号分隔 | -| `WECHAT_RADAR_DEMO` | 设置为 `1` 时启用 demo 模式 | -| `WECHAT_RADAR_CODEX_MODEL` | 可选,Codex CLI 话题/链接整理使用 | +| 问题 | 解决方法 | +| --- | --- | +| `wx daemon 未运行` | 先运行 `wx daemon start`,再刷新页面。 | +| `better-sqlite3` native 模块报错 | 运行 `pnpm rebuild better-sqlite3`。 | +| 首页没有数据 | 先完成 `/setup`,确认 `wx sessions --json` 有输出,然后点击“重扫”。 | +| 话题雷达为空 | 打开对应日期会自动构建;也可以点击“构建话题”。需要本机可运行 `codex`。 | +| 不想读取真实微信 | 在 `/setup` 勾选 demo 模式,或设置 `WECHAT_RADAR_DEMO=1`。 | -## wx-cli 接入 +## 致谢 -确认 `wx` 命令可用: +- [jackwener/wx-cli](https://github.com/jackwener/wx-cli):本项目依赖它读取本机微信数据。 +- [Next.js](https://nextjs.org/)、[ECharts](https://echarts.apache.org/)、[better-sqlite3](https://github.com/WiseLibs/better-sqlite3)。 + +--- + + + +## English + +WeChat Radar is a local-first intelligence dashboard for WeChat groups. It turns noisy group chats into daily briefings, cross-group topics, link intelligence, mentions, and per-group reports. + +### Features + +- Daily dashboard for messages, links, tools, anomalies, and people +- Codex CLI powered topic clustering by date +- Link intelligence with generated titles and deduplication +- Per-group daily reports with copy-friendly output +- Local SQLite storage by default +- Light and dark themes + +### Install ```bash -wx --version -wx daemon status -wx sessions -n 10 --json +git clone https://github.com/joeseesun/wechat-radar.git +cd wechat-radar +pnpm install +pnpm rebuild better-sqlite3 +pnpm dev ``` -如果 daemon 没运行,请按你的 `wx-cli` 文档启动或修复。 +Open [http://localhost:3000](http://localhost:3000). The first run redirects to `/setup`, where you can configure your WeChat display names and privacy confirmation, or enable demo mode. -## 数据存储 +### Requirements -默认数据目录: +- [ ] macOS with WeChat 4.x logged in +- [ ] Node.js 20+ +- [ ] pnpm +- [ ] wx-cli initialized and running +- [ ] Optional: Codex CLI for better topic/link summaries -```text -~/.wechat-radar/ -├── radar.db -└── config.json -``` +### Privacy -数据库包含: +By default, runtime data is stored locally under `~/.wechat-radar/`. The app does not upload your chat database. You are responsible for using it in a way that respects WeChat rules, local laws, group privacy expectations, and organizational compliance. -- `messages`:同步后的本地消息。 -- `daily_stats`:每日聚合统计。 -- `mentions`:@我的索引。 -- `groups` / `group_tags`:本地分组。 -- `topics` / `topic_messages`:话题雷达结果。 -- `link_intelligence_cache`:链接情报缓存。 +### Troubleshooting -不要把 `radar.db`、`.env.local` 或日志提交到 Git。 +| Problem | Fix | +| --- | --- | +| wx daemon is not running | Run `wx daemon start`. | +| better-sqlite3 fails to load | Run `pnpm rebuild better-sqlite3`. | +| No dashboard data | Finish `/setup`, confirm `wx sessions --json` works, then click rescan. | +| Topic radar is empty | Open the date or click build topics; make sure `codex` is available. | -## 常用命令 +## License -```bash -pnpm dev # 本地开发 -pnpm build # 生产构建 -pnpm lint # 代码检查 -pnpm demo:seed # 写入示例数据 -``` - -## 隐私说明 - -微信雷达默认只读本机数据,并把处理结果写入本地 SQLite。项目本身不提供云端服务,也不会自动上传聊天记录。 - -你需要自行确认读取、保存、处理聊天数据符合当地法律、平台规则和群成员预期。更多见 [PRIVACY.md](./PRIVACY.md)。 - -## 开源协议 - -MIT,见 [LICENSE](./LICENSE)。 +MIT diff --git a/app/api/ai-classify/route.ts b/app/api/ai-classify/route.ts index c4f6d78..ad6e0ec 100644 --- a/app/api/ai-classify/route.ts +++ b/app/api/ai-classify/route.ts @@ -2,7 +2,6 @@ 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'; @@ -16,6 +15,166 @@ interface Suggestion { reason: string; } +function classifyHeuristic(name: string, summary: string, groups: ReturnType) { + const lookup = (target: string) => groups.find((g) => g.name.includes(target)); + + // 顺序很重要 — 高优先规则先匹配 + // 1. 蝗虫团(最强信号) + if (/蝗虫团|huangchong/i.test(name)) { + const t = lookup('蝗虫'); + if (t) return { group_id: t.id, group_name: t.name, reason: '蝗虫团系列' }; + } + + // 2. 自营/读者群 + if ( + /自营|用户群|粉丝群|公众号读者|读者群/.test(name) || + /自营|用户群|粉丝群|公众号读者|读者群/.test(summary) + ) { + const t = lookup('自营/读者群'); + if (t) return { group_id: t.id, group_name: t.name, reason: '自营 / 读者群' }; + } + + // 3. WaytoAGI + if (/waytoagi|通往agi|通往ai|通往 ai/i.test(name)) { + const t = lookup('WaytoAGI'); + if (t) return { group_id: t.id, group_name: t.name, reason: 'WaytoAGI 系列' }; + } + + // 4. HowOneAI + if (/howoneai|howone/i.test(name)) { + const t = lookup('HowOneAI'); + if (t) return { group_id: t.id, group_name: t.name, reason: 'HowOneAI 系列' }; + } + + // 5. Vibe Coding / 编程 + if ( + /vibe.?coding|vibecoding|vibe first|cherry studio|cli|claude.?skills|clawdbot|codepilot|mcp|cola|geoflow|refly|camel|eigent|thinkinai|skills|all in cli/i.test( + name, + ) + ) { + const t = lookup('Vibe Coding'); + if (t) return { group_id: t.id, group_name: t.name, reason: '编程 / Skills / CLI' }; + } + + // 6. AI 学术 / 论文 / 未来硅世界 + if ( + /学术|论文|paper|未来硅世界|研究室|nixy|simonlin|博文视点|《|knowledge|灵枭/i.test( + name, + ) + ) { + const t = lookup('AI 学术'); + if (t) return { group_id: t.id, group_name: t.name, reason: '学术 / 论文' }; + } + + // 7. AI 商业 / 营销 + if ( + /seo|geo|商业化|营销|kol|gaidn|adg|vip|生财|appsail|tutti|商业|broker|出版|2026 共读群|阅读\d|收付款|社交新品|社群/i.test( + name, + ) + ) { + const t = lookup('AI 商业'); + if (t) return { group_id: t.id, group_name: t.name, reason: '商业 / 营销 / KOL' }; + } + + // 8. AIGC / 内容创作(视频、音乐、图、媒体、AIGC) + if ( + /aigc|图|视频|音乐|spy|拍我ai|ai媒体|ai音视频|创意|graceful|创作|graphic|listenhub|notetomp|youmind|完全ai生成|羊毛|社区|molthuman|aiwriter|短视频|歌|video|music|illustrat|ai春晚|nettalk|dolphin/i.test( + name, + ) + ) { + const t = lookup('AIGC'); + if (t) return { group_id: t.id, group_name: t.name, reason: 'AIGC / 内容创作' }; + } + + // 9. 付费社区 + if ( + /vip|烟花|修饼|传术师|生财有术|沃垠|兔子ai|ai领导力|hicool|早鸟|内测|种子用户|订阅用户|车友|豪车|私董|学员|拍我ai|hosi|hosi.ai|api 渠道|大白|一人公司|小鱼名人|pec/i.test( + name, + ) + ) { + const t = lookup('付费社区'); + if (t) return { group_id: t.id, group_name: t.name, reason: '付费 / 内测 / VIP' }; + } + + // 10. AI 工具用户群(产品周边) + if ( + /用户群|用户中文|内测群|jackywine|mindcode|remio|listenhub|notetomp|cherry|camel|refly|cola|geoflow|hosi|aigocode|appsail|tutti|爱贝壳|内容同步|api/i.test( + name, + ) + ) { + const t = lookup('AI 工具用户群'); + if (t) return { group_id: t.id, group_name: t.name, reason: '工具用户群' }; + } + + // 11. AI 圈社交(散群、神的孩子、明人明言、agi bar、先行者、智能体成精了) + if ( + /神的孩子|明人明言|先行者|agi bar|智能体成精|life hacker|超级玩家|love.*death.*agent|未来趋势|agent橘|新物种|创造营|不息为体|未知书社|新互联网/i.test( + name, + ) + ) { + const t = lookup('AI 圈社交'); + if (t) return { group_id: t.id, group_name: t.name, reason: 'AI 圈社交' }; + } + + // 12. 大佬 / 媒体圈(自媒体、大佬、对接群、媒体) + if ( + /donews|何夕|辛亥|对接群|百度世界|央馆|火山方舟|43talks|tgo|商务|《ai营销/i.test(name) + ) { + const t = lookup('大佬'); + if (t) return { group_id: t.id, group_name: t.name, reason: '大佬 / 媒体圈' }; + } + + // 13. 行业活动(一次性活动群) + if ( + /活动现场|聚餐|筹备组|聚会|开播|直播|线下|大会|分享会|一年五班|落户/i.test(name) + ) { + const t = lookup('行业活动'); + if (t) return { group_id: t.id, group_name: t.name, reason: '一次性活动群' }; + } + + // 14. 生活 / 兴趣(钓友、邻居、果粉、班级) + if ( + /钓友|路亚|果粉|大家庭|班级|班·班|喜相逢|邻里|苑|🏘|楼|班|小区|羽毛球|健身|跑步|徒步|阅读|共读|英语|班级群|6年级|六年级|恩小|对接|车友|校友|曲|歌友|羽毛|篮球/i.test( + name, + ) + ) { + const t = lookup('生活'); + if (t) return { group_id: t.id, group_name: t.name, reason: '生活 / 兴趣' }; + } + + // 15. 粉丝团 / 读者群 → AI 圈社交(除非已经被自营/读者群匹配) + if (/粉丝|fans|读者/i.test(name)) { + const t = lookup('AI 圈社交'); + if (t) return { group_id: t.id, group_name: t.name, reason: '粉丝团 / 读者群' }; + } + + // 16. 财经 / 投资类 + if (/财经|股票|投资|基金|币圈|crypto|trade/i.test(name)) { + const t = lookup('AI 商业'); + if (t) return { group_id: t.id, group_name: t.name, reason: '财经 / 投资' }; + } + + // 17. X / 推特相关 + if (/x boost|twitter|推特|x kol/i.test(name)) { + const t = lookup('AIGC'); + if (t) return { group_id: t.id, group_name: t.name, reason: 'X / 推特运营' }; + } + + // 18. 课程 / 训练营 + if (/课群|训练营|实训|训练|内训|课程|早鸟|2026共创|日历/i.test(name)) { + const t = lookup('付费社区'); + if (t) return { group_id: t.id, group_name: t.name, reason: '课程 / 训练营' }; + } + + // 兜底:含 AI / Agent / Coding 关键词 → AI 圈社交 + if (/ai|agent|gpt|claude|llm|coding|开源/i.test(name)) { + const t = lookup('AI 圈社交'); + if (t) return { group_id: t.id, group_name: t.name, reason: '通用 AI(兜底)' }; + } + + return null; +} + const ApplySchema = z.object({ picks: z.array( z.object({ @@ -41,7 +200,7 @@ export async function GET() { .filter((g) => !tagged.has(g.username)) .slice(0, 200) .map((g) => { - const guess = classifyGroupHeuristic(g.chat, g.summary, groups); + const guess = classifyHeuristic(g.chat, g.summary, groups); return { chatroom_id: g.username, name: g.chat, diff --git a/app/api/dates/route.ts b/app/api/dates/route.ts new file mode 100644 index 0000000..3572bd9 --- /dev/null +++ b/app/api/dates/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const rows = db() + .prepare( + `SELECT date, COUNT(*) AS count + FROM messages + GROUP BY date + ORDER BY date DESC + LIMIT 90`, + ) + .all() as Array<{ date: string; count: number }>; + + return NextResponse.json({ ok: true, dates: rows }); +} diff --git a/app/api/dbinfo/route.ts b/app/api/dbinfo/route.ts index 67fda80..7dee7be 100644 --- a/app/api/dbinfo/route.ts +++ b/app/api/dbinfo/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { homedir } from 'node:os'; +import { DATA_DIR } from '@/lib/config'; 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 dataDir = DATA_DIR; const dbPath = join(dataDir, 'radar.db'); const dbSize = existsSync(dbPath) ? statSync(dbPath).size : 0; const counts = { diff --git a/app/api/message-links/backfill/route.ts b/app/api/message-links/backfill/route.ts new file mode 100644 index 0000000..695a3cc --- /dev/null +++ b/app/api/message-links/backfill/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { backfillMessageLinks } from '@/lib/message-links'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +export async function POST(req: NextRequest) { + const body = (await req.json().catch(() => ({}))) as { + since?: string; + until?: string; + }; + + if (body.since && !DATE_RE.test(body.since)) { + return NextResponse.json({ ok: false, error: 'invalid since' }, { status: 400 }); + } + if (body.until && !DATE_RE.test(body.until)) { + return NextResponse.json({ ok: false, error: 'invalid until' }, { status: 400 }); + } + + const result = backfillMessageLinks(body.since, body.until); + return NextResponse.json({ ok: true, ...result }); +} diff --git a/app/api/message-links/raw/route.ts b/app/api/message-links/raw/route.ts new file mode 100644 index 0000000..e3ec59b --- /dev/null +++ b/app/api/message-links/raw/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { wxSessions } from '@/lib/wx'; +import { todayStr } from '@/lib/range'; + +export const dynamic = 'force-dynamic'; + +type RawLinkRow = { + chatroom_id: string; + local_id: number; + sender: string; + time: string; + url: string; + canonical_url: string; + title: string | null; + domain: string; + source: string; + raw_kind: string; +}; + +export async function GET(req: NextRequest) { + const date = new URL(req.url).searchParams.get('date') ?? todayStr(); + const names = await groupNames(); + const rows = db() + .prepare( + `SELECT chatroom_id, local_id, sender, time, url, canonical_url, title, domain, source, raw_kind + FROM message_links + WHERE date = ? + AND canonical_url LIKE '%://mp.weixin.qq.com/%' + ORDER BY timestamp DESC + LIMIT 200`, + ) + .all(date) as RawLinkRow[]; + + return NextResponse.json({ + ok: true, + date, + links: rows.map((row) => ({ + ...row, + chat_name: names.get(row.chatroom_id) ?? row.chatroom_id, + })), + }); +} + +async function groupNames() { + const names = new Map(); + try { + const sessions = await wxSessions(500); + for (const s of sessions) names.set(s.username, s.chat); + } catch {} + return names; +} diff --git a/app/api/message-links/resolve/route.ts b/app/api/message-links/resolve/route.ts new file mode 100644 index 0000000..fda5dea --- /dev/null +++ b/app/api/message-links/resolve/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { upsertResolvedLinkForMessage } from '@/lib/message-links'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + const body = (await req.json().catch(() => ({}))) as { + chatroom_id?: string; + local_id?: number; + url?: string; + title?: string; + description?: string; + source?: 'public_search' | 'manual'; + confidence?: number; + }; + + if (!body.chatroom_id || !Number.isInteger(body.local_id) || !body.url) { + return NextResponse.json( + { ok: false, error: 'chatroom_id, local_id and url are required' }, + { status: 400 }, + ); + } + + const localId = body.local_id; + if (localId === undefined) { + return NextResponse.json({ ok: false, error: 'local_id is required' }, { status: 400 }); + } + + const source = body.source ?? 'manual'; + if (source !== 'manual' && source !== 'public_search') { + return NextResponse.json({ ok: false, error: 'invalid source' }, { status: 400 }); + } + + const result = upsertResolvedLinkForMessage({ + chatroom_id: body.chatroom_id, + local_id: localId, + url: body.url, + title: body.title, + description: body.description, + source, + confidence: body.confidence, + }); + + if (!result.ok) { + return NextResponse.json(result, { status: 400 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/app/api/recover/route.ts b/app/api/recover/route.ts index a39b53e..d0a137e 100644 --- a/app/api/recover/route.ts +++ b/app/api/recover/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { DATA_DIR } from '@/lib/config'; 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 dataDir = DATA_DIR; const dest = join(dataDir, 'radar-recovered.db'); try { db().pragma('wal_checkpoint(TRUNCATE)'); diff --git a/app/api/rescan/route.ts b/app/api/rescan/route.ts index b962ac9..3d325f9 100644 --- a/app/api/rescan/route.ts +++ b/app/api/rescan/route.ts @@ -4,6 +4,7 @@ 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'; +import { buildTopicsForDate } from '@/lib/topics'; export const dynamic = 'force-dynamic'; export const maxDuration = 1800; // 30 min @@ -64,6 +65,14 @@ export async function POST(req: NextRequest) { concurrency, onProgress: (p) => send(p), }); + + const topicDates = datesBetween(since, until).slice(-autoTopicDays(body.full)); + send({ type: 'topics_start', dates: topicDates.length }); + for (const date of topicDates) { + send({ type: 'topics_date', date, message: '开始构建话题…' }); + await buildTopicsForDate(date, (p) => send({ ...p, type: `topics_${p.type}`, date })); + } + cache.del(CK.sessions()); send({ type: 'finished', @@ -87,3 +96,27 @@ export async function POST(req: NextRequest) { }, }); } + +function datesBetween(since: string, until: string): string[] { + const out: string[] = []; + const start = parseLocalDate(since); + const end = parseLocalDate(until); + for (const d = start; d.getTime() <= end.getTime(); d.setDate(d.getDate() + 1)) { + out.push(formatLocalDate(d)); + } + return out; +} + +function parseLocalDate(date: string): Date { + const [y, m, d] = date.split('-').map(Number); + return new Date(y, (m || 1) - 1, d || 1); +} + +function formatLocalDate(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function autoTopicDays(full?: boolean): number { + const configured = Number(process.env.WECHAT_RADAR_AUTO_TOPIC_DAYS ?? (full ? 14 : 31)); + return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 31; +} diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..620b806 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { wxSessions } from '@/lib/wx'; + +export const dynamic = 'force-dynamic'; + +type SearchResult = { + id: string; + type: 'group' | 'topic' | 'person' | 'message' | 'link'; + title: string; + subtitle: string; + href: string; + external?: boolean; +}; + +type MessageRow = { + chatroom_id: string; + sender: string; + content: string; + date: string; + time: string; +}; + +type TopicRow = { + id: number; + date: string; + title: string; + summary: string | null; + message_count: number; + group_count: number; +}; + +type LinkRow = { + canonical_url: string; + title: string | null; + domain: string; + date: string; +}; + +type PersonRow = { + sender: string; + hits: number; + groups: number; + latest: string; +}; + +export async function GET(req: NextRequest) { + const q = (new URL(req.url).searchParams.get('q') ?? '').trim(); + if (q.length < 2) return NextResponse.json({ ok: true, results: [] }); + + const like = `%${q}%`; + const nameMap = await loadGroupNames(); + const results: SearchResult[] = []; + + for (const [chatroomId, name] of nameMap) { + if (!name.toLowerCase().includes(q.toLowerCase())) continue; + results.push({ + id: `group:${chatroomId}`, + type: 'group', + title: name, + subtitle: chatroomId, + href: `/groups/${encodeURIComponent(chatroomId)}`, + }); + if (results.length >= 8) break; + } + + const topics = db() + .prepare( + `SELECT id, date, title, summary, message_count, group_count + FROM topics + WHERE title LIKE ? OR COALESCE(summary, '') LIKE ? + ORDER BY date DESC, message_count DESC + LIMIT 8`, + ) + .all(like, like) as TopicRow[]; + for (const t of topics) { + results.push({ + id: `topic:${t.id}`, + type: 'topic', + title: t.title, + subtitle: `${t.date} · ${t.message_count} 条 · ${t.group_count} 群${t.summary ? ` · ${t.summary}` : ''}`, + href: `/topics?date=${t.date}`, + }); + } + + const people = db() + .prepare( + `SELECT sender, COUNT(*) AS hits, COUNT(DISTINCT chatroom_id) AS groups, MAX(date) AS latest + FROM messages + WHERE sender LIKE ? + GROUP BY sender + ORDER BY hits DESC + LIMIT 8`, + ) + .all(like) as PersonRow[]; + for (const p of people) { + results.push({ + id: `person:${p.sender}`, + type: 'person', + title: p.sender, + subtitle: `${p.hits} 条消息 · ${p.groups} 个群 · 最近 ${p.latest}`, + href: `/signals?q=${encodeURIComponent(p.sender)}`, + }); + } + + const messages = db() + .prepare( + `SELECT chatroom_id, sender, content, date, time + FROM messages + WHERE content LIKE ? OR sender LIKE ? + ORDER BY timestamp DESC + LIMIT 10`, + ) + .all(like, like) as MessageRow[]; + for (const m of messages) { + results.push({ + id: `message:${m.chatroom_id}:${m.time}:${m.sender}`, + type: 'message', + title: compact(m.content || m.sender, 80), + subtitle: `${nameMap.get(m.chatroom_id) ?? m.chatroom_id} · ${m.sender} · ${m.time}`, + href: `/groups/${encodeURIComponent(m.chatroom_id)}?date=${m.date}`, + }); + } + + const links = db() + .prepare( + `SELECT canonical_url, title, domain, MAX(date) AS date + FROM message_links + WHERE canonical_url LIKE ? OR COALESCE(title, '') LIKE ? OR domain LIKE ? + GROUP BY canonical_url + ORDER BY MAX(timestamp) DESC + LIMIT 8`, + ) + .all(like, like, like) as LinkRow[]; + for (const l of links) { + results.push({ + id: `link:${l.canonical_url}`, + type: 'link', + title: l.title || l.canonical_url, + subtitle: `${l.domain} · ${l.date}`, + href: l.canonical_url, + external: true, + }); + } + + return NextResponse.json({ ok: true, results: results.slice(0, 32) }); +} + +async function loadGroupNames(): Promise> { + const names = new Map(); + try { + const sessions = await wxSessions(500); + for (const s of sessions) { + if (s.is_group) names.set(s.username, s.chat); + } + } catch {} + + const local = db() + .prepare( + `SELECT chatroom_id, COUNT(*) AS n + FROM messages + GROUP BY chatroom_id + ORDER BY n DESC + LIMIT 500`, + ) + .all() as Array<{ chatroom_id: string }>; + for (const row of local) { + if (!names.has(row.chatroom_id)) names.set(row.chatroom_id, row.chatroom_id); + } + return names; +} + +function compact(s: string, max: number): string { + const text = s.replace(/\s+/g, ' ').trim(); + if (text.length <= max) return text; + return `${text.slice(0, max - 1)}…`; +} diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index 1e56638..b213764 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -70,7 +70,6 @@ export async function GET() { } } - async function loadSessionsSafe(limit: number): Promise { if (readConfig().demoMode) return listLocalSessionsFallback(limit); const cached = cache.get(CK.sessions()) as WxSession[] | undefined; diff --git a/app/api/wx-image/route.ts b/app/api/wx-image/route.ts index 0c1904f..2ad653c 100644 --- a/app/api/wx-image/route.ts +++ b/app/api/wx-image/route.ts @@ -30,7 +30,7 @@ export async function GET(req: NextRequest) { return new Response('image not found in wx cache', { status: 404 }); } - const buf = await readFile(/*turbopackIgnore: true*/ found.path); + const buf = await readFile(found.path); return new Response(new Uint8Array(buf), { headers: { 'Content-Type': mimeFor(found.format), diff --git a/app/classify/page.tsx b/app/classify/page.tsx index c3fd640..1c762ba 100644 --- a/app/classify/page.tsx +++ b/app/classify/page.tsx @@ -72,7 +72,7 @@ export default function ClassifyPage() {
-
+
diff --git a/app/globals.css b/app/globals.css index b37c223..c527919 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,16 +1,41 @@ @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; + --bg: #f1efea; + --bg-2: #e8e5de; + --surface: #faf9f6; + --surface-2: #f1eee8; + --surface-3: #e5dfd6; + --border: #d8d2c8; + --border-soft: rgba(46, 42, 34, 0.12); + --text: #171814; + --text-2: #5e6459; + --text-3: #8b8b80; + --accent: #28745b; + --accent-2: #3a9d71; + --accent-soft: rgba(40, 116, 91, 0.12); + --warn: #9c6728; + --warn-soft: rgba(156, 103, 40, 0.12); + --danger: #b34242; + --danger-soft: rgba(179, 66, 66, 0.12); + --shadow: 0 18px 55px rgba(55, 47, 34, 0.09); + --chrome-bg: rgba(250, 249, 246, 0.86); + --sidebar-bg: rgba(248, 246, 239, 0.92); + --control-bg: rgba(250, 249, 246, 0.78); + --input-scheme: light; +} + +[data-theme='dark'] { + --bg: #080b0a; + --bg-2: #0d1110; + --surface: #111511; + --surface-2: #171c17; + --surface-3: #20261f; + --border: #293029; + --border-soft: rgba(175, 190, 167, 0.13); + --text: #eef2eb; + --text-2: #b6bdb1; + --text-3: #80887d; --accent: #7dd3a8; --accent-2: #46b978; --accent-soft: rgba(125, 211, 168, 0.13); @@ -18,7 +43,11 @@ --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); + --shadow: 0 18px 48px rgba(0, 0, 0, 0.34); + --chrome-bg: rgba(8, 11, 10, 0.84); + --sidebar-bg: rgba(8, 11, 10, 0.9); + --control-bg: rgba(17, 22, 18, 0.86); + --input-scheme: dark; } @theme inline { @@ -53,11 +82,38 @@ body { body { background: - linear-gradient(120deg, rgba(125, 211, 168, 0.055), transparent 34%), - linear-gradient(180deg, rgba(213, 162, 83, 0.075), transparent 30%), + linear-gradient(120deg, rgba(40, 116, 91, 0.08), transparent 34%), + linear-gradient(180deg, rgba(156, 103, 40, 0.08), transparent 30%), var(--bg); } +button, +input, +select, +textarea { + color: var(--text); +} + +input, +select, +textarea { + color-scheme: var(--input-scheme); +} + +input::placeholder, +textarea::placeholder { + color: var(--text-3); +} + +:where(a, button, input, select, textarea):focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 58%, transparent); + outline-offset: 2px; +} + +[data-theme='dark'] body { + background: var(--bg); +} + ::-webkit-scrollbar { width: 8px; height: 8px; @@ -66,22 +122,27 @@ body { background: transparent; } ::-webkit-scrollbar-thumb { - background: #26362d; + background: color-mix(in srgb, var(--text-3) 42%, transparent); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: #355044; + background: color-mix(in srgb, var(--accent) 55%, transparent); } .card { background: - linear-gradient(180deg, rgba(237, 241, 232, 0.035), transparent 48%), + linear-gradient(180deg, rgba(255, 255, 255, 0.35), transparent 48%), var(--surface); border: 1px solid var(--border-soft); border-radius: 8px; box-shadow: var(--shadow); } +[data-theme='dark'] .card { + background: var(--surface); + box-shadow: var(--shadow); +} + .btn { display: inline-flex; align-items: center; @@ -89,8 +150,8 @@ body { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border-soft); - background: rgba(16, 24, 18, 0.86); - color: var(--text-2); + background: color-mix(in srgb, var(--control-bg) 86%, var(--surface)); + color: var(--text); font-size: 13px; cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, opacity 0.15s; @@ -116,19 +177,26 @@ body { } .btn-primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); - color: #07120c; + color: #fdfbf7; border-color: var(--accent); font-weight: 600; } .btn-primary:hover { - background: linear-gradient(180deg, #98e2bb, var(--accent-2)); - color: #07120c; + background: linear-gradient(180deg, var(--accent-2), var(--accent)); + color: #fdfbf7; +} + +[data-theme='dark'] .btn-primary, +[data-theme='dark'] .btn-primary:hover { + background: var(--accent); + color: #06100b; } .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; + background: color-mix(in srgb, var(--control-bg) 88%, var(--surface)); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.28) inset; + color: var(--text); } .report-kicker { @@ -144,3 +212,12 @@ body { background: var(--accent-soft); color: var(--accent); } + +.theme-date-input { + color-scheme: var(--input-scheme); +} + +.report-title { + font-family: "Noto Serif SC", "Songti SC", Georgia, serif; + letter-spacing: 0; +} diff --git a/app/groups/[id]/page.tsx b/app/groups/[id]/page.tsx index 35d0f82..aec7372 100644 --- a/app/groups/[id]/page.tsx +++ b/app/groups/[id]/page.tsx @@ -6,7 +6,18 @@ 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 { + ArrowLeft, + BarChart3, + Calendar, + Check, + Copy, + History, + ListFilter, + MessageSquare, + Star, + Trophy, +} from 'lucide-react'; import type { EChartsOption } from 'echarts'; const ReactECharts = dynamicImport(() => import('echarts-for-react'), { ssr: false }); @@ -60,6 +71,7 @@ export default function GroupDetailPage({ const [fav, setFav] = useState(false); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); const load = async (d: string) => { setLoading(true); @@ -112,6 +124,24 @@ export default function GroupDetailPage({ }); }; + const copyMessages = async () => { + const messages = data?.recent ?? []; + if (messages.length === 0) return; + const title = data?.stats?.chat ?? chatroomId; + const text = [ + `# ${title} ${date}`, + '', + ...messages.map((m) => { + const time = m.time.slice(11, 16); + const content = m.content.replace(/\s+/g, ' ').trim(); + return `[${time}] ${m.sender}: ${content}`; + }), + ].join('\n'); + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }; + const hourOption: EChartsOption | null = data?.stats ? { grid: { top: 20, right: 16, bottom: 28, left: 36 }, @@ -177,20 +207,11 @@ export default function GroupDetailPage({ } : 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 (
-
+
@@ -208,27 +229,12 @@ export default function GroupDetailPage({
- {dateOptions.length > 0 ? ( - - ) : ( - setDate(e.target.value)} - className="bg-transparent text-[12px] outline-none [color-scheme:dark]" - /> - )} + setDate(e.target.value)} + className="theme-date-input bg-transparent text-[12px] outline-none" + />
-
- {loading ? '加载中…' : `共 ${data?.recent.length ?? 0} 条`} +
+ +
+ {loading ? '加载中…' : `共 ${data?.recent.length ?? 0} 条`} +
{!loading && data?.recent && data.recent.length === 0 ? ( diff --git a/app/groups/page.tsx b/app/groups/page.tsx index d45592f..9f68ae9 100644 --- a/app/groups/page.tsx +++ b/app/groups/page.tsx @@ -99,7 +99,7 @@ function GroupsListContent() {
-
+
Group Directory
{title}
@@ -178,7 +178,7 @@ function GroupsListFallback() {
-
+
Group Directory
所有群
diff --git a/app/layout.tsx b/app/layout.tsx index ef12871..bc4720a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,8 +2,8 @@ import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "微信雷达", - description: "本地优先的微信群聊情报看板", + title: "WeChat Radar", + description: "Local-first WeChat group intelligence dashboard", }; export default function RootLayout({ @@ -12,8 +12,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + + + {children} + ); } diff --git a/app/links/page.tsx b/app/links/page.tsx index fc2be63..a37927f 100644 --- a/app/links/page.tsx +++ b/app/links/page.tsx @@ -22,6 +22,7 @@ type LinkInsight = { time: string; local_id: number; snippet: string; + source: string; }>; }; @@ -32,6 +33,20 @@ type LinkInsightResp = { tools: LinkInsight[]; }; +type RawWechatLink = { + chatroom_id: string; + chat_name: string; + local_id: number; + sender: string; + time: string; + url: string; + canonical_url: string; + title: string | null; + domain: string; + source: string; + raw_kind: string; +}; + function localToday(): string { const d = new Date(); const y = d.getFullYear(); @@ -43,6 +58,7 @@ function localToday(): string { export default function LinksPage() { const [date, setDate] = useState(() => localToday()); const [links, setLinks] = useState(null); + const [rawLinks, setRawLinks] = useState([]); const [refreshing, setRefreshing] = useState(false); useEffect(() => { @@ -53,6 +69,11 @@ export default function LinksPage() { const j = (await r.json()) as LinkInsightResp; if (!cancelled && j.ok) setLinks(j); } catch {} + try { + const r = await fetch(`/api/message-links/raw?date=${date}`, { cache: 'no-store' }); + const j = (await r.json()) as { ok: boolean; links?: RawWechatLink[] }; + if (!cancelled && j.ok) setRawLinks(j.links ?? []); + } catch {} })(); return () => { cancelled = true; @@ -76,7 +97,7 @@ export default function LinksPage() {
-
+
Link Intelligence
@@ -96,7 +117,7 @@ export default function LinksPage() { type="date" value={date} onChange={(e) => setDate(e.target.value)} - className="bg-transparent text-[12px] outline-none [color-scheme:dark]" + className="theme-date-input bg-transparent text-[12px] outline-none" />
-
+
} @@ -129,12 +150,68 @@ export default function LinksPage() { loading={loading} empty="当天还没有工具链接" /> +
); } +function RawWechatPanel({ + date, + items, + loading, +}: { + date: string; + items: RawWechatLink[]; + loading: boolean; +}) { + return ( +
+
+
+ + 微信文章链接 +
+
{loading ? '加载中' : `${items.length} 条`}
+
+
+ {loading ? ( +
加载中…
+ ) : items.length === 0 ? ( +
当天没有解析到微信文章链接
+ ) : ( +
+ {items.map((item) => ( +
+ +
+ {item.title || item.domain || item.raw_kind} +
+
+ {item.url} +
+
+
+ + {item.chat_name} · {item.sender} + + + {item.raw_kind} + +
+
+ ))} +
+ )} +
+
+ ); +} + function LinkInsightPanel({ title, icon, @@ -191,7 +268,10 @@ function LinkInsightRow({ item, date }: { item: LinkInsight; date: string }) { {item.title} - {item.domain} + + {sourceLabel(first?.source)} + {item.domain} + @@ -214,3 +294,17 @@ function LinkInsightRow({ item, date }: { item: LinkInsight; date: string }) {
); } + +function sourceLabel(source?: string): string { + if (source === 'wechat_raw') return '微信原文'; + if (source === 'public_search') return '公开补链'; + if (source === 'manual') return '手动补链'; + return '网页链接'; +} + +function sourceClass(source?: string): string { + const base = 'shrink-0 rounded px-1.5 py-0.5'; + if (source === 'wechat_raw') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`; + if (source === 'public_search' || source === 'manual') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`; + return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`; +} diff --git a/app/page.tsx b/app/page.tsx index b13e9bc..8508915 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import Sidebar from '@/components/Sidebar'; -import TopBar, { type RangeKey, type RefreshMode } from '@/components/TopBar'; +import TopBar, { type RangeKey } from '@/components/TopBar'; import StatGrid, { type CardsData } from '@/components/StatGrid'; import TrendChart, { type TrendPoint } from '@/components/TrendChart'; import ActiveGroupsList, { type ActiveGroup } from '@/components/ActiveGroupsList'; @@ -24,13 +24,11 @@ type StatsResponse = { export default function Page() { const [range, setRange] = useState('month'); const [date, setDate] = useState(() => localToday()); - const [mode, setMode] = useState('auto'); const [stats, setStats] = useState(null); const [rescanning, setRescanning] = useState(false); const [rescanInfo, setRescanInfo] = useState(undefined); const [setupChecked, setSetupChecked] = useState(false); - useEffect(() => { let cancelled = false; (async () => { @@ -113,6 +111,12 @@ export default function Page() { setRescanInfo( `完成 · ${evt.messages ?? evt.inserted_messages ?? 0} 条消息已入库`, ); + } else if (evt.type === 'topics_start') { + setRescanInfo(`消息已入库 · 开始构建 ${evt.dates} 天话题`); + } else if (evt.type === 'topics_date') { + setRescanInfo(`构建话题 · ${evt.date}`); + } else if (typeof evt.type === 'string' && evt.type.startsWith('topics_')) { + setRescanInfo(`构建话题 · ${evt.date}${evt.message ? ` · ${evt.message}` : ''}`); } } catch {} } @@ -128,7 +132,11 @@ export default function Page() { ); if (!setupChecked) { - return
加载配置…
; + return ( +
+ 加载配置… +
+ ); } return ( @@ -141,8 +149,6 @@ export default function Page() { date={date} onRangeChange={setRange} onDateChange={setDate} - mode={mode} - onModeChange={setMode} rescanning={rescanning} onRescan={() => runRescan(false)} onFullSync={() => runRescan(true)} @@ -166,7 +172,7 @@ export default function Page() {
- +
diff --git a/app/reports/groups/[id]/page.tsx b/app/reports/groups/[id]/page.tsx new file mode 100644 index 0000000..0e0c82d --- /dev/null +++ b/app/reports/groups/[id]/page.tsx @@ -0,0 +1,602 @@ +'use client'; + +import { use, useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import Sidebar from '@/components/Sidebar'; +import { ArrowLeft, Check, Copy, ExternalLink, FileText, Link2, MessageSquare, Trophy } from 'lucide-react'; + +type Detail = { + ok: boolean; + chatroom_id: string; + date: string; + stats: { + chat: string; + total: number; + top_senders: Array<{ sender: string; count: number }>; + } | null; + recent: Array<{ + local_id: number; + sender: string; + content: string; + time: string; + type: string; + }>; +}; + +export const dynamic = 'force-dynamic'; + +export default function GroupDailyReportPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const chatroomId = decodeURIComponent(id); + const date = useSearchParams().get('date') ?? localToday(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + try { + const r = await fetch(`/api/group/${encodeURIComponent(chatroomId)}?date=${date}&limit=500`, { + cache: 'no-store', + }); + const j = (await r.json()) as Detail; + if (!cancelled) setData(j); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [chatroomId, date]); + + const reportText = useMemo(() => buildReportText(data, chatroomId, date), [data, chatroomId, date]); + + const copyReport = useCallback(async () => { + await navigator.clipboard.writeText(reportText); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }, [reportText]); + + const title = data?.stats?.chat ?? chatroomId; + const report = useMemo(() => analyzeReport(data?.recent ?? []), [data?.recent]); + + return ( +
+ +
+
+
+ + + +
+
Group Daily Report
+

{title}

+
+ {date} · {loading ? '加载中…' : `${data?.stats?.total ?? 0} 条消息`} · 日报地址 +
+
+
+
+ + + + 完整消息 + +
+
+ +
+
+
+ + 日报摘要 +
+

+ {loading + ? '正在生成日报视图…' + : `今天这个群共产生 ${data?.stats?.total ?? 0} 条消息,按摘要策略提炼为核心主题、证据引用、资源链接和可跟进项。`} +

+
+ +
+
+
+
+ + 核心主题 +
+
{report.topics.length} 个
+
+
+ {report.topics.length === 0 ? ( +
暂无可聚合主题
+ ) : ( + report.topics.map((topic, i) => ( +
+
+

+ {i + 1}. {topic.title} +

+ + {topic.count} 条 + +
+
+ 聊了什么: + {topic.what} +
+
+ 为什么重要: + {topic.why} +
+ {topic.quote && ( +
+ {topic.quote.sender}:「{topic.quote.text}」 +
+ )} +
+ )) + )} +
+
+ +
+
+
+ + 工具/文章/链接 +
+
+ {report.links.length === 0 ? ( +
暂无链接
+ ) : ( + report.links.slice(0, 10).map((link) => ( + +
{link.title}
+
+ {link.url} + {(link.count > 1 || link.urlCount > 1) && ( + + 合并 {link.count} 次 / {link.urlCount} 链接 + + )} +
+
+ )) + )} +
+
+ +
+
+ + 可跟进 +
+
+ {report.followups.length === 0 ? ( +
暂无明确问题
+ ) : ( + report.followups.slice(0, 8).map((m) => ( +
+ {m.sender}: + {cleanMessage(m.content)} +
+ )) + )} +
+
+ +
+
+ + Top 发言人 +
+
+ {(data?.stats?.top_senders ?? []).slice(0, 8).map((sender, i) => ( +
+ {i + 1}. {sender.sender} + {sender.count} +
+ ))} +
+
+
+
+
+
+
+ ); +} + +type ReportMessage = Detail['recent'][number]; + +type TopicReport = { + title: string; + count: number; + what: string; + why: string; + quote?: { sender: string; text: string }; +}; + +type TopicRule = { + title: string; + re: RegExp; + why: string; + priority: number; +}; + +const TOPIC_RULES: TopicRule[] = [ + { + title: 'AI 编程与 Agent 工作流', + re: /codex|claude code|agent|mcp|skills?|cli|vibe.?coding|编程|代码/i, + why: '这类讨论通常直接影响个人和团队的生产方式,适合沉淀成工作流、工具清单或实践经验。', + priority: 5, + }, + { + title: '新工具、新模型与产品体验', + re: /工具|产品|模型|内测|API|插件|开源|github|huggingface|modelscope/i, + why: '群里对工具和模型的实测反馈,比单纯发布新闻更接近真实采用价值,适合优先试用和收藏。', + priority: 4, + }, + { + title: '活动、报名与社群机会', + re: /报名|活动|直播|大会|训练营|课程|线下|名额|招募/i, + why: '这些信息有时间窗口,错过就失效,适合当天判断是否参与、转发或对接。', + priority: 6, + }, + { + title: '内容创作、AIGC 与传播', + re: /内容|小红书|公众号|视频|图像|封面|传播|选题|日报|文章/i, + why: '这类讨论可以转化为公开内容、选题储备或素材库,对后续写作和传播有复用价值。', + priority: 3, + }, + { + title: '需求、问题与可回复线索', + re: /求推荐|有没有|谁有|怎么|如何|能不能|可不可以|问题|方案/i, + why: '明确问题背后往往有真实需求,适合用你的经验、资源或产品线索去回应。', + priority: 7, + }, +]; + +function analyzeReport(messages: ReportMessage[]) { + const clean = messages + .map((m) => ({ ...m, content: cleanMessage(m.content) })) + .map((m) => ({ ...m, content: meaningfulMessageText(m.content) })) + .filter((m) => m.content.length >= 6 && !isLowValueMessage(m.content)); + const topics = clusterTopics(clean); + + const links = extractLinks(clean).slice(0, 24); + const followups = clean + .filter((m) => /求推荐|有没有|谁有|怎么|如何|能不能|可不可以|请教|帮忙/i.test(m.content)) + .slice(0, 12); + + return { + topics: topics.sort((a, b) => b.count - a.count).slice(0, 5), + links, + followups, + }; +} + +function clusterTopics(messages: ReportMessage[]): TopicReport[] { + const assigned = new Set(); + const clusters = new Map(); + + for (const m of messages) { + if (assigned.has(m.local_id)) continue; + const rule = bestTopicRule(m.content); + if (!rule) continue; + const key = discussionKey(m.content, rule.title); + const existing = clusters.get(key); + if (existing) { + existing.messages.push(m); + } else { + clusters.set(key, { rule, messages: [m] }); + } + assigned.add(m.local_id); + } + + return Array.from(clusters.values()) + .map(({ rule, messages: hits }) => { + const unique = dedupeMessages(hits); + const representative = unique + .slice() + .sort((a, b) => scoreMessage(b.content) - scoreMessage(a.content))[0]; + return { + title: specificTopicTitle(representative?.content ?? rule.title, rule.title), + count: unique.length, + what: summarizeTopic(unique), + why: rule.why, + quote: representative + ? { sender: representative.sender, text: stripUrls(representative.content).slice(0, 120) } + : undefined, + }; + }) + .filter((topic) => topic.count >= 2 || scoreMessage(topic.what) >= 8) + .sort((a, b) => b.count - a.count || b.what.length - a.what.length) + .slice(0, 5); +} + +function bestTopicRule(content: string): TopicRule | null { + const matches = TOPIC_RULES.filter((rule) => rule.re.test(content)); + if (matches.length === 0) return null; + return matches.sort((a, b) => b.priority - a.priority)[0]; +} + +function dedupeMessages(messages: ReportMessage[]): ReportMessage[] { + const seen = new Set(); + const out: ReportMessage[] = []; + for (const m of messages) { + const key = messageFingerprint(m.content); + if (seen.has(key)) continue; + seen.add(key); + out.push(m); + } + return out; +} + +function discussionKey(content: string, fallback: string): string { + const url = firstUrl(content); + if (url) return `url:${normalizeResourceUrl(url)}`; + const title = bracketTitle(content); + if (title) return `title:${textFingerprint(title)}`; + return `text:${textFingerprint(content).slice(0, 48) || fallback}`; +} + +function messageFingerprint(content: string): string { + const url = firstUrl(content); + if (url) return `url:${normalizeResourceUrl(url)}`; + return textFingerprint(stripUrls(content)).slice(0, 80); +} + +function textFingerprint(text: string): string { + return text + .toLowerCase() + .replace(/https?:\/\/\S+/g, ' ') + .replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, '') + .slice(0, 120); +} + +function firstUrl(content: string): string | null { + const match = content.match(/https?:\/\/[^\s<>"']+/); + return match ? match[0].replace(/[),,。;;!?!?、\]}>]+$/g, '') : null; +} + +function bracketTitle(content: string): string | null { + const match = content.match(/\[(?:链接|链接\/文件)\]\s*([^\n。;;]{4,80})/); + const title = match?.[1]?.trim(); + if (!title || isMeaninglessTitle(title)) return null; + return title; +} + +function specificTopicTitle(content: string, fallback: string): string { + const title = bracketTitle(content); + if (title) return title.replace(/^【|】$/g, '').slice(0, 28); + const cleaned = meaningfulMessageText(content) + .replace(/^\[引用\]\s*/g, '') + .replace(/^\[(?:链接|链接\/文件)\]\s*/g, '') + .replace(/\s+/g, ' ') + .trim(); + const product = cleaned.match(/(Claude Code|GPT-Image-2|ChatGPT|Codex|MCP|InkOS|Munger Models|awesome-mac|Sloth-Poster-Den|Bloome|Stitch)/i)?.[0]; + if (product) return `${product} 相关讨论`.slice(0, 28); + return (cleaned.split(/[。!?!?;;]/)[0] || fallback).slice(0, 28); +} + +function summarizeTopic(messages: ReportMessage[]): string { + const top = messages + .slice() + .sort((a, b) => scoreMessage(b.content) - scoreMessage(a.content)) + .slice(0, 3) + .map((m) => meaningfulMessageText(m.content)) + .filter((text) => text.length >= 6 && !isMeaninglessTitle(text)); + if (top.length === 0) return '当天有零散讨论,但缺少足够完整的上下文。'; + return top.join(';').slice(0, 220); +} + +function scoreMessage(content: string): number { + let score = Math.min(content.length / 18, 8); + if (/https?:\/\/||github|工具|模型|报名|求推荐/i.test(content)) score += 4; + if (/我觉得|实测|经验|方案|问题|原因|为什么/i.test(content)) score += 2; + return score; +} + +function extractLinks(messages: ReportMessage[]) { + const groups = new Map; count: number; score: number }>(); + for (const m of messages) { + for (const match of m.content.matchAll(/https?:\/\/[^\s<>"']+/g)) { + const url = match[0].replace(/[),,。;;!?!?、\]}>]+$/g, ''); + if (isNoiseUrl(url)) continue; + const title = titleFromMessage(m.content, url); + if (isMeaninglessTitle(title)) continue; + const key = linkGroupKey(title, url); + const current = groups.get(key); + const nextScore = scoreMessage(m.content); + if (current) { + current.urls.add(url); + current.count++; + if (nextScore > current.score) { + current.title = title; + current.score = nextScore; + } + } else { + groups.set(key, { title, urls: new Set([url]), count: 1, score: nextScore }); + } + } + } + const rows = Array.from(groups.entries()) + .map(([key, item]) => ({ + key, + title: item.title, + url: Array.from(item.urls)[0], + count: item.count, + urlCount: item.urls.size, + })); + + const byUrl = new Map(); + for (const row of rows) { + const urlKey = normalizeResourceUrl(row.url); + const current = byUrl.get(urlKey); + if (!current) { + byUrl.set(urlKey, row); + continue; + } + current.key = `${current.key}|${row.key}`; + current.count += row.count; + current.urlCount = Math.max(current.urlCount, row.urlCount); + if (row.title.length > current.title.length) current.title = row.title; + } + + return Array.from(byUrl.values()) + .sort((a, b) => b.count - a.count || b.urlCount - a.urlCount); +} + +function titleFromMessage(content: string, url: string): string { + const text = meaningfulMessageText(content) + .replace(/^\[(?:链接|链接\/文件)\]\s*/g, '') + .replace(/\s+/g, ' ') + .trim(); + return (text || domainFromUrl(url)).slice(0, 96); +} + +function linkGroupKey(title: string, url: string): string { + const titleKey = textFingerprint(title).slice(0, 48); + if (titleKey.length >= 12) return `title:${titleKey}`; + return `url:${normalizeResourceUrl(url)}`; +} + +function normalizeResourceUrl(url: string): string { + try { + const u = new URL(url); + u.hash = ''; + for (const key of Array.from(u.searchParams.keys())) { + if (/^(utm_|spm|from|share_|vd_source|scene|clicktime|enterid)/i.test(key)) { + u.searchParams.delete(key); + } + } + return `${u.hostname}${u.pathname}${u.searchParams.toString() ? `?${u.searchParams.toString()}` : ''}`; + } catch { + return url; + } +} + +function domainFromUrl(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +function stripUrls(content: string): string { + return content.replace(/https?:\/\/\S+/g, '').trim(); +} + +const MEANINGLESS_TITLE_RE = + /^(?:\[?链接\]?\s*)?(?:当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)|当前版本不支持|请升级至最新版本)[。.]?$/i; + +const NOISE_URL_HOSTS = new Set([ + 'support.weixin.qq.com', + 'wx.qlogo.cn', + 'dldir1v6.qq.com', + 'finder.video.qq.com', +]); + +function meaningfulMessageText(content: string): string { + const withoutUrls = stripUrls(content) + .replace(/&/g, '&') + .replace(/\[(?:链接|链接\/文件)\]\s*(?:当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)|当前版本不支持展示该内容,请升级至最新版本)[。.]?/gi, ' ') + .replace(/当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)[。.]?/gi, ' ') + .replace(/\[图片\]\s*local_id=\d+/gi, ' ') + .replace(/\b[a-f0-9]{24,}\b/gi, ' ') + .replace(/\b\d{12,}\b/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + const parts = withoutUrls + .split(/↳|\\n|\n/) + .map((part) => + part + .replace(/^\[引用\]\s*/g, '') + .replace(/^\[(?:链接|链接\/文件|图片|视频|表情)\]\s*/g, '') + .replace(/\s+/g, ' ') + .trim(), + ) + .filter((part) => part.length >= 6 && !isMeaninglessTitle(part)); + + return (parts[0] ?? withoutUrls).trim(); +} + +function isMeaninglessTitle(title: string): boolean { + const text = stripUrls(title) + .replace(/^\[(?:链接|链接\/文件|图片|视频|表情)\]\s*/g, '') + .replace(/\s+/g, ' ') + .trim(); + if (!text) return true; + if (MEANINGLESS_TITLE_RE.test(text)) return true; + return /^(?:xml_url_text|网页链接|视频号|finder|wx\.qlogo\.cn)$/i.test(text); +} + +function isLowValueMessage(content: string): boolean { + const text = meaningfulMessageText(content); + if (isMeaninglessTitle(text)) return true; + return /^[\d\s._-]+$/.test(text) || text.length < 6; +} + +function isNoiseUrl(url: string): boolean { + try { + const host = new URL(url).hostname.toLowerCase(); + return NOISE_URL_HOSTS.has(host); + } catch { + return false; + } +} + +function buildReportText(data: Detail | null, chatroomId: string, date: string): string { + const title = data?.stats?.chat ?? chatroomId; + const messages = data?.recent ?? []; + const report = analyzeReport(messages); + return [ + `# ${title} ${date} 群日报`, + '', + `今天共 ${data?.stats?.total ?? 0} 条消息。`, + '', + '## 核心主题', + ...report.topics.flatMap((topic, i) => [ + '', + `### ${i + 1}. ${topic.title}`, + `聊了什么:${topic.what}`, + `为什么重要:${topic.why}`, + topic.quote ? `引用/证据:${topic.quote.sender}:「${topic.quote.text}」` : '', + ]), + '', + '## 工具/文章/链接', + ...report.links.map((link) => `- ${link.title}: ${link.url}`), + '', + '## 未解决/可跟进', + ...report.followups.map((m) => `- ${m.sender}: ${cleanMessage(m.content)}`), + ].filter(Boolean).join('\n'); +} + +function cleanMessage(content: string): string { + return content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function localToday(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} diff --git a/app/signals/page.tsx b/app/signals/page.tsx index 35f65b4..3485491 100644 --- a/app/signals/page.tsx +++ b/app/signals/page.tsx @@ -76,7 +76,7 @@ export default function SignalsPage() {
-
+
Live Signals
diff --git a/app/topics/page.tsx b/app/topics/page.tsx index 2b4971f..c928b3d 100644 --- a/app/topics/page.tsx +++ b/app/topics/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import Sidebar from '@/components/Sidebar'; import MessageContent from '@/components/MessageContent'; @@ -42,6 +42,7 @@ export default function TopicsPage() { const [detail, setDetail] = useState<{ topic: Topic; messages: TopicMessage[] } | null>(null); const [busy, setBusy] = useState(false); const [info, setInfo] = useState(undefined); + const autoBuildDates = useRef(new Set()); const reload = useCallback(async () => { try { @@ -148,11 +149,17 @@ export default function TopicsPage() { } }, [date, reload]); + useEffect(() => { + if (busy || topics.length > 0 || autoBuildDates.current.has(date)) return; + autoBuildDates.current.add(date); + build(); + }, [build, busy, date, topics.length]); + return (
-
+
Cross-Group Topics
@@ -170,7 +177,7 @@ export default function TopicsPage() { type="date" value={date} onChange={(e) => setDate(e.target.value)} - className="bg-transparent text-[12px] outline-none [color-scheme:dark]" + className="theme-date-input bg-transparent text-[12px] outline-none" />
- -
- - - +
+ + +
- -
- - - -
- -
); } -function ActionPanel({ +function QueueBlock({ + label, + items, + empty, +}: { + label: string; + items: QueueItem[]; + empty: string; +}) { + return ( +
+
{label}
+
+ {items.length === 0 ? ( +
{empty}
+ ) : ( + items.map((item, i) => ( + + )) + )} +
+
+ ); +} + +function QueueLink({ item }: { item: QueueItem }) { + const content = ( + <> + {item.title} + {item.href ? ( + item.external ? ( + + ) : ( + + ) + ) : null} + + ); + const className = + 'flex items-start gap-2 rounded px-1.5 py-1 text-[12px] leading-5 text-[var(--text-2)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--text)]'; + + if (!item.href) return
{content}
; + if (item.external) { + return ( + + {content} + + ); + } + return ( + + {content} + + ); +} + +function MustReadPanel({ date, items, - fallback, + actions, }: { date: string; - items: DashboardActionItem[]; - fallback: DashboardOpportunityItem[]; + items: DashboardSignalItem[]; + actions: DashboardActionItem[]; }) { - const displayItems = - items.length > 0 - ? items - : fallback.map((item) => ({ - ...item, - why: '包含明确需求或行动线索,适合进入上下文判断', - urgency: 'medium' as const, - })); + const promoted = mergeSignals(actions, items).slice(0, 7); return ( -
+
} - title="今日值得出手" - meta={`${displayItems.length} 条`} + icon={} + title="关键话题与消息" + meta={`${promoted.length} 条`} /> - {displayItems.length === 0 ? ( - + {promoted.length === 0 ? ( + ) : ( -
- {displayItems.slice(0, 6).map((item) => ( +
+ {promoted.map((item, index) => ( -
- - {item.action} + + {index + 1} + + + + + {item.title} + + {'action' in item && ( + + {(item as DashboardActionItem).action} + + )} - {urgencyText(item.urgency)} -
-
{item.title}
-
{item.why}
+ + + {item.chat_name} · {item.sender} + + {item.time.slice(11)} + + + {'why' in item ? (item as DashboardActionItem).why : item.reasons.join(' / ')} + + + ))}
@@ -223,53 +329,112 @@ function ActionPanel({ ); } -function TopicLifecyclePanel({ topics }: { topics: DashboardTopicLifecycle[] }) { +function ResourcePanel({ + articles, + tools, +}: { + articles: DashboardLinkHighlight[]; + tools: DashboardLinkHighlight[]; +}) { return ( -
+
} - title="趋势升温" - meta={`${topics.length} 个话题`} + icon={} + title="链接情报" + meta={`${articles.length + tools.length} 条`} /> - {topics.length === 0 ? ( - - ) : ( -
- {topics.slice(0, 6).map((topic) => ( -
-
-
-
{topic.title}
-
{topic.reason}
-
- {topicStatusText(topic.status)} -
-
- {topic.today_count} 条 - {topic.group_count} 群 - 均值 {topic.previous_avg} -
-
- ))} -
- )} +
+ } title="文章 / 内容" items={articles.slice(0, 5)} /> + } title="工具 / 资源" items={tools.slice(0, 5)} /> +
); } -function AnomalyPanel({ anomalies }: { anomalies: DashboardAnomalySignal[] }) { +function ResourceColumn({ + icon, + title, + items, +}: { + icon: ReactNode; + title: string; + items: DashboardLinkHighlight[]; +}) { return ( -
+
+
+ {icon} + {title} +
+ {items.length === 0 ? ( +
+ 暂无 +
+ ) : ( + + )} +
+ ); +} + +function sourceLabel(source: string): string { + if (source === 'wechat_raw') return '微信原文'; + if (source === 'public_search') return '公开补链'; + if (source === 'manual') return '手动补链'; + return '网页链接'; +} + +function sourceClass(source: string): string { + const base = 'shrink-0 rounded px-1.5 py-0.5'; + if (source === 'wechat_raw') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`; + if (source === 'public_search' || source === 'manual') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`; + return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`; +} + +function WatchPanel({ + date, + anomalies, + people, +}: { + date: string; + anomalies: DashboardAnomalySignal[]; + people: DashboardPeopleRadar[]; +}) { + return ( +
} - title="异常信号" - meta={`${anomalies.length} 条`} + title="需要盯一下" + meta={`${anomalies.length} 个异动`} /> - {anomalies.length === 0 ? ( - - ) : ( -
- {anomalies.slice(0, 6).map((item) => { +
+ {anomalies.length === 0 ? ( + + ) : ( + anomalies.slice(0, 4).map((item) => { const body = ( <>
@@ -281,260 +446,100 @@ function AnomalyPanel({ anomalies }: { anomalies: DashboardAnomalySignal[] }) {
); - return item.href ? ( - + if (!item.href) { + return ( +
+ {body} +
+ ); + } + if (item.href.startsWith('http')) { + return ( +
+ {body} + + ); + } + return ( + {body} - - ) : ( -
- {body} -
+ ); - })} -
- )} -
- ); -} + }) + )} +
-function LinkHighlightPanel({ items }: { items: DashboardLinkHighlight[] }) { - return ( -
- } - title="链接精选" - meta={`${items.length} 条`} - /> - {items.length === 0 ? ( - - ) : ( - - )} -
- ); -} - -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 ( -
- } - title="人物雷达" - meta={`${displayPeople.length} 人`} - /> - {displayPeople.length === 0 ? ( - - ) : ( -
- {displayPeople.slice(0, 7).map((person, index) => ( -
- - {index + 1} - -
+
+
高信号人物
+
+ {people.slice(0, 4).map((person) => ( +
+
{person.sender}
-
- {person.role} · {person.reason} -
+
{person.score}
-
-
{person.score}
-
{person.group_count} 群
+
+ {person.role} · {person.reason} · {date}
))}
- )} +
); } -function ContentIdeaPanel({ ideas }: { ideas: DashboardContentIdea[] }) { - return ( -
- } - title="内容选题" - meta={`${ideas.length} 个`} - /> - {ideas.length === 0 ? ( - - ) : ( -
- {ideas.slice(0, 6).map((idea) => ( -
-
-
{idea.title}
- - {idea.suggested_channel} - -
-
{idea.angle}
-
证据:{idea.evidence}
-
- ))} -
- )} -
- ); +function mergeSignals(actions: DashboardActionItem[], mustRead: DashboardSignalItem[]) { + const seen = new Set(); + const out: Array = []; + for (const item of [...actions.slice(0, 4), ...mustRead]) { + const key = `${item.chatroom_id}:${item.local_id}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } + return out; } -function MustReadPanel({ date, items }: { date: string; items: DashboardSignalItem[] }) { - return ( -
- } - title="最值得关注" - meta={`${items.length} 条高信号`} - /> - {items.length === 0 ? ( - - ) : ( -
- {items.slice(0, 5).map((item, index) => ( - - ))} -
- )} -
- ); -} - -function SignalRow({ - item, - date, - rank, -}: { - item: DashboardSignalItem; - date: string; - rank: number; -}) { - return ( - - - {rank} - - - - {item.title} - - {item.score} - - - - - {item.chat_name} · {item.sender} - - {item.time.slice(11)} - - - {item.reasons.slice(0, 3).map((reason) => ( - - {reason} - - ))} - - - - - ); +function buildSummary( + data: DashboardIntelligence, + articles: DashboardLinkHighlight[], + tools: DashboardLinkHighlight[], +): string { + const lines = [`${data.date || '今日'} 情报队列`]; + lines.push(`消息:${data.must_read.slice(0, 2).map((item) => item.title).join(';') || '暂无'}`); + lines.push(`文章:${articles.slice(0, 2).map((item) => item.title).join(';') || '暂无'}`); + lines.push(`工具:${tools.slice(0, 2).map((item) => item.title).join(';') || '暂无'}`); + lines.push(`异动:${data.anomalies.slice(0, 2).map((item) => item.title).join(';') || '暂无'}`); + return lines.join('\n'); } function PanelTitle({ icon, title, meta }: { icon: ReactNode; title: string; meta: string }) { return ( -
+
{icon} {title}
-
{meta}
+
{meta}
); } -function EmptyState({ text }: { text: string }) { +function EmptyState({ text, compact }: { text: string; compact?: boolean }) { return ( -
+
{text}
); } -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)]`; @@ -542,21 +547,6 @@ function urgencyClass(urgency: DashboardActionItem['urgency']): string { 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 '中'; @@ -569,15 +559,3 @@ function severityClass(severity: DashboardAnomalySignal['severity']): string { 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 = { - '机会/需求': '包含合作、采购、报名、求推荐或找资源等可行动信号', - '工具/产品': '提到了工具、产品、模型、插件、项目或技术栈', - 链接信号: '包含可跳转链接,适合进入原文或资源查看', - 可跟进: '含有联系、报名、评估、试用、帮忙等行动线索', - 长观点: '消息长度较高,可能包含完整观点或经验复盘', - 问题: '包含明确问题,可能适合回复或继续追踪', - }; - return map[reason] ?? reason; -} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 0207dd3..7cc55fb 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -8,11 +8,10 @@ import { Folder, Inbox, LayoutDashboard, - Activity, Sparkles, Link2, - Settings, } from 'lucide-react'; +import ThemeToggle from './ThemeToggle'; type Category = { id: number; @@ -68,21 +67,16 @@ export default function Sidebar() { }, []); return ( -