'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')}`; }