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({
-
- {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) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
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 (
+
+
+
+
+
+
+
+
+
+ 日报摘要
+
+
+ {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.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"
/>