Initial open source release

This commit is contained in:
joeseesun
2026-05-24 13:42:56 +08:00
commit 8abb29e13e
75 changed files with 13298 additions and 0 deletions
+496
View File
@@ -0,0 +1,496 @@
import { spawn } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { db } from './db';
import { wxSessions } from './wx';
const MIN_MESSAGES_PER_TOPIC = 4;
const MIN_MESSAGE_LENGTH = 20;
const MAX_MESSAGE_LENGTH = 400;
const MAX_MESSAGES_TO_PROCESS = 3000;
const MAX_TOPICS_TO_SAVE = 30;
const CODEX_CHUNK_SIZE = Number(process.env.WECHAT_RADAR_TOPIC_CHUNK_SIZE ?? 250);
const CODEX_TIMEOUT_MS = Number(process.env.WECHAT_RADAR_CODEX_TIMEOUT_MS ?? 300_000);
const CODEX_MODEL = process.env.WECHAT_RADAR_CODEX_MODEL;
const TOPICS_PER_CHUNK = 12;
interface SourceMsg {
chatroom_id: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
}
interface LlmTopic {
title: string;
summary: string;
message_ids: string[];
}
interface LlmTopicResponse {
topics: LlmTopic[];
}
type TopicWithMembers = {
title: string;
summary: string;
members: SourceMsg[];
groupSet: Set<string>;
};
function cleanContent(s: string): string {
return s
.replace(/\[图片\]\s*local_id=\d+/g, '')
.replace(/\[引用\][^\n]*\n?/g, '')
.replace(/\[小程序\][^\n]*/g, '')
.replace(/↳\s*[^\n]*/g, '')
.replace(/<\?xml[\s\S]+?\?>[\s\S]*?<\/msg>/g, '')
.replace(/https?:\/\/\S+/g, ' ')
.trim();
}
// 这些消息整体就是占位符 / wrapper,无实质内容
const PLACEHOLDER_PATTERNS = [
/^\[链接\]\s*当前版本不支持/,
/^\[文件\]\s*[^\s]+\.\w+\s*$/,
/^\[视频\]\s*$/,
/^\[音频\]\s*$/,
/^\[语音\]\s*$/,
/^\[表情\]\s*$/,
/^\[图片\]\s*$/,
/^\[位置\]/,
/^\[名片\]/,
/^\[小程序\]\s*[^\s]*\s*$/,
/^\[转账\]/,
/^\[红包\]/,
];
function isPlaceholderOnly(content: string): boolean {
if (!content) return true;
return PLACEHOLDER_PATTERNS.some((p) => p.test(content));
}
function loadCandidateMessages(date: string): SourceMsg[] {
const rows = db()
.prepare(
`SELECT chatroom_id, local_id, sender, content, time, timestamp
FROM messages
WHERE date = ?
AND type IN ('文本', '链接/文件')
AND length(content) >= ?
ORDER BY timestamp ASC
LIMIT ?`,
)
.all(date, MIN_MESSAGE_LENGTH, MAX_MESSAGES_TO_PROCESS) as SourceMsg[];
// 1. 过滤占位符 + 清洗 + 长度筛选
const cleaned = rows
.map((r) => ({ ...r, content: cleanContent(r.content).slice(0, MAX_MESSAGE_LENGTH) }))
.filter((r) => !isPlaceholderOnly(r.content) && r.content.length >= MIN_MESSAGE_LENGTH);
// 2. 去重:相同内容(同一条转发消息)只保留第一次出现
// 这是真信号(同一篇文章被多群转发)但不应该堆成「话题」— 简化为信源(前 3 条群即可)
const seen = new Map<string, SourceMsg>();
for (const r of cleaned) {
const key = r.content.slice(0, 80); // 前 80 字相同 ≈ 同一条转发
if (!seen.has(key)) seen.set(key, r);
}
return Array.from(seen.values());
}
const TOPIC_RESPONSE_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
topics: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
title: { type: 'string' },
summary: { type: 'string' },
message_ids: {
type: 'array',
items: { type: 'string' },
},
},
required: ['title', 'summary', 'message_ids'],
},
},
},
required: ['topics'],
};
function sourceId(m: SourceMsg): string {
return `${m.chatroom_id}#${m.local_id}`;
}
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
function parseJsonOutput<T>(raw: string): T {
const trimmed = raw.trim();
try {
return JSON.parse(trimmed) as T;
} catch {
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fenced) return JSON.parse(fenced[1]) as T;
const obj = trimmed.match(/\{[\s\S]*\}/);
if (obj) return JSON.parse(obj[0]) as T;
throw new Error('codex returned non-JSON');
}
}
function runCodexJson<T>(prompt: string, timeoutMs = CODEX_TIMEOUT_MS): Promise<T> {
return new Promise((resolve, reject) => {
const dir = mkdtempSync(join(tmpdir(), 'wechat-topics-'));
const schemaPath = join(dir, 'schema.json');
const outPath = join(dir, 'response.json');
writeFileSync(schemaPath, JSON.stringify(TOPIC_RESPONSE_SCHEMA), 'utf8');
const args = [
'-a',
'never',
'exec',
'--sandbox',
'read-only',
'--ephemeral',
'--ignore-rules',
'--output-schema',
schemaPath,
'--output-last-message',
outPath,
];
if (CODEX_MODEL) args.push('--model', CODEX_MODEL);
args.push('-');
const proc = spawn(
'codex',
args,
{ env: { ...process.env, NO_COLOR: '1' }, stdio: ['pipe', 'pipe', 'pipe'] },
);
let stdout = '';
let stderr = '';
const t = setTimeout(() => {
proc.kill('SIGTERM');
rmSync(dir, { recursive: true, force: true });
reject(new Error('codex CLI timeout'));
}, timeoutMs);
proc.stdout.on('data', (d) => (stdout += d.toString()));
proc.stderr.on('data', (d) => (stderr += d.toString()));
proc.on('error', (e) => {
clearTimeout(t);
rmSync(dir, { recursive: true, force: true });
reject(e);
});
proc.on('close', (code) => {
clearTimeout(t);
try {
if (code !== 0) {
reject(new Error(`codex exit ${code}: ${stderr.slice(0, 800)}`));
return;
}
const raw = readFileSync(outPath, 'utf8') || stdout;
resolve(parseJsonOutput<T>(raw));
} catch (e) {
reject(e);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
proc.stdin.write(prompt);
proc.stdin.end();
});
}
function formatMessagesForPrompt(messages: SourceMsg[], groupNameMap: Map<string, string>): string {
return messages
.map((m) =>
JSON.stringify({
id: sourceId(m),
group: groupNameMap.get(m.chatroom_id) ?? m.chatroom_id,
sender: m.sender,
time: m.time,
content: m.content,
}),
)
.join('\n');
}
function buildExtractionPrompt(
date: string,
messages: SourceMsg[],
groupNameMap: Map<string, string>,
maxTopics: number,
): string {
return `你是微信群「话题雷达」的聚合引擎。请直接用 LLM 判断语义相关性,找出 ${date} 的主要讨论话题。
任务要求:
- 只做话题聚合,不要逐条摘要。
- 合并同一事件、产品、工具、论文、观点、问题及其追问/回应/转述。
- 优先保留跨群出现的话题;同一群内高密度连续讨论也可以保留。
- 忽略问候、纯闲聊、广告、无上下文碎片、纯占位内容和过泛的「AI 很火」类讨论。
- 每个话题至少包含 ${MIN_MESSAGES_PER_TOPIC} 条消息。
- 最多输出 ${maxTopics} 个话题,按重要性排序。
- title 用 8-15 个汉字,优先写产品名/事件名/讨论焦点。
- summary 用 1-2 句中文说明大家在讨论什么。
- message_ids 必须只使用输入消息的 id;不要编造 id;同一个 id 不要重复。
只输出严格 JSON,格式:
{"topics":[{"title":"...","summary":"...","message_ids":["群id#local_id"]}]}
输入消息为 JSONL
${formatMessagesForPrompt(messages, groupNameMap)}`;
}
function buildMergePrompt(date: string, drafts: LlmTopic[], maxTopics: number): string {
const lines = drafts
.map((t, i) =>
JSON.stringify({
id: `draft-${i + 1}`,
title: t.title,
summary: t.summary,
message_ids: t.message_ids,
message_count: t.message_ids.length,
}),
)
.join('\n');
return `下面是 ${date} 分批得到的话题草稿。请继续用 LLM 完成最终跨批合并。
任务要求:
- 合并语义相同或强相关的话题草稿,message_ids 取并集。
- 删除过泛、重复、证据不足的话题。
- 每个最终话题至少包含 ${MIN_MESSAGES_PER_TOPIC} 条消息。
- 最多输出 ${maxTopics} 个最终话题,按重要性排序。
- title 用 8-15 个汉字,summary 用 1-2 句中文。
- message_ids 必须来自输入草稿,不要编造。
只输出严格 JSON
{"topics":[{"title":"...","summary":"...","message_ids":["群id#local_id"]}]}
话题草稿 JSONL
${lines}`;
}
function normalizeTopics(rawTopics: LlmTopic[], messageMap: Map<string, SourceMsg>): TopicWithMembers[] {
const out: TopicWithMembers[] = [];
const seenSignatures = new Set<string>();
for (const raw of rawTopics) {
const ids = Array.from(new Set((raw.message_ids ?? []).filter((id) => messageMap.has(id))));
if (ids.length < MIN_MESSAGES_PER_TOPIC) continue;
const members = ids.map((id) => messageMap.get(id)!).sort((a, b) => a.timestamp - b.timestamp);
const signature = ids.slice().sort().join('|');
if (seenSignatures.has(signature)) continue;
seenSignatures.add(signature);
out.push({
title: (raw.title || members[0].content.slice(0, 16) || '未命名话题').slice(0, 80),
summary: (raw.summary || '').slice(0, 400),
members,
groupSet: new Set(members.map((m) => m.chatroom_id)),
});
}
return out.sort((a, b) => b.members.length - a.members.length).slice(0, MAX_TOPICS_TO_SAVE);
}
async function aggregateWithCodex(
date: string,
messages: SourceMsg[],
groupNameMap: Map<string, string>,
onProgress?: (p: TopicProgress) => void,
): Promise<TopicWithMembers[]> {
const messageMap = new Map(messages.map((m) => [sourceId(m), m]));
const chunks = chunk(messages, Math.max(50, CODEX_CHUNK_SIZE));
const drafts: LlmTopic[] = [];
onProgress?.({
type: 'llm',
done: 0,
total: chunks.length,
message: `Codex CLI 聚合 ${messages.length} 条消息…`,
});
for (let i = 0; i < chunks.length; i++) {
const response = await runCodexJson<LlmTopicResponse>(
buildExtractionPrompt(date, chunks[i], groupNameMap, TOPICS_PER_CHUNK),
);
drafts.push(...(response.topics ?? []));
onProgress?.({
type: 'llm',
done: i + 1,
total: chunks.length,
message: `Codex CLI 分批聚合 ${i + 1}/${chunks.length}`,
});
}
if (drafts.length === 0) return [];
if (chunks.length > 1) {
onProgress?.({
type: 'llm',
done: chunks.length,
total: chunks.length,
message: `Codex CLI 合并 ${drafts.length} 个话题草稿…`,
});
}
const final =
chunks.length === 1
? { topics: drafts }
: await runCodexJson<LlmTopicResponse>(buildMergePrompt(date, drafts, MAX_TOPICS_TO_SAVE));
return normalizeTopics(final.topics ?? [], messageMap);
}
export interface TopicProgress {
type: 'load' | 'llm' | 'save' | 'done' | 'error';
done?: number;
total?: number;
count?: number;
message?: string;
error?: string;
}
export async function buildTopicsForDate(
date: string,
onProgress?: (p: TopicProgress) => void,
): Promise<{ topics: number; messages: number }> {
onProgress?.({ type: 'load', message: '加载当日消息…' });
const msgs = loadCandidateMessages(date);
if (msgs.length === 0) {
onProgress?.({ type: 'done', count: 0 });
return { topics: 0, messages: 0 };
}
const sessions = await wxSessions(500).catch(() => []);
const groupNameMap = new Map<string, string>();
for (const s of sessions) groupNameMap.set(s.username, s.chat);
const valid = await aggregateWithCodex(date, msgs, groupNameMap, onProgress);
// 清空当日旧话题
db().prepare('DELETE FROM topics WHERE date = ?').run(date);
let savedTopics = 0;
let savedMessages = 0;
for (let i = 0; i < valid.length; i++) {
const c = valid[i];
onProgress?.({
type: 'save',
done: i + 1,
total: valid.length,
message: c.title,
});
const insertTopic = db().prepare(
'INSERT INTO topics (date, title, summary, message_count, group_count, created_at) VALUES (?, ?, ?, ?, ?, ?)',
);
const insertMsg = db().prepare(
'INSERT OR IGNORE INTO topic_messages (topic_id, chatroom_id, local_id, score) VALUES (?, ?, ?, ?)',
);
const tx = db().transaction(() => {
const info = insertTopic.run(
date,
c.title,
c.summary,
c.members.length,
c.groupSet.size,
Date.now(),
);
const tid = Number(info.lastInsertRowid);
for (let index = 0; index < c.members.length; index++) {
const member = c.members[index];
insertMsg.run(tid, member.chatroom_id, member.local_id, 1 - index / 1000);
savedMessages++;
}
});
tx();
savedTopics++;
}
onProgress?.({ type: 'done', count: savedTopics });
return { topics: savedTopics, messages: savedMessages };
}
export interface TopicListItem {
id: number;
date: string;
title: string;
summary: string;
message_count: number;
group_count: number;
}
export function listTopics(date: string): TopicListItem[] {
return db()
.prepare(
'SELECT id, date, title, summary, message_count, group_count FROM topics WHERE date = ? ORDER BY message_count DESC',
)
.all(date) as TopicListItem[];
}
export interface TopicDetail extends TopicListItem {
messages: Array<{
chatroom_id: string;
chat_name: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
score: number;
}>;
}
export async function getTopicDetail(id: number): Promise<TopicDetail | null> {
const topic = db()
.prepare(
'SELECT id, date, title, summary, message_count, group_count FROM topics WHERE id = ?',
)
.get(id) as TopicListItem | undefined;
if (!topic) return null;
const rows = db()
.prepare(
`SELECT m.chatroom_id, m.local_id, m.sender, m.content, m.time, m.timestamp, m.type, tm.score
FROM topic_messages tm
JOIN messages m ON m.chatroom_id = tm.chatroom_id AND m.local_id = tm.local_id
WHERE tm.topic_id = ?
ORDER BY tm.score DESC, m.timestamp ASC`,
)
.all(id) as Array<{
chatroom_id: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
score: number;
}>;
const sessions = await wxSessions(500).catch(() => []);
const nameMap = new Map<string, string>();
for (const s of sessions) nameMap.set(s.username, s.chat);
return {
...topic,
messages: rows.map((r) => ({
...r,
chat_name: nameMap.get(r.chatroom_id) ?? r.chatroom_id,
})),
};
}