From 642b2b3d17b1484bc3eca351988f63fb281eca81 Mon Sep 17 00:00:00 2001 From: digua Date: Fri, 28 Nov 2025 18:50:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=87=AA=E8=A8=80?= =?UTF-8?q?=E8=87=AA=E8=AF=AD=E6=A6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- a.md | 152 ++++++++++++++++++++ electron/main/database/analysis.ts | 189 +++++++++++++++++++++++++ electron/main/database/index.ts | 1 + electron/main/ipcMain.ts | 15 ++ electron/preload/index.d.ts | 2 + electron/preload/index.ts | 11 ++ src/components/analysis/MembersTab.vue | 185 +++++++++++++++++++++++- src/types/chat.ts | 35 +++++ 8 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 a.md diff --git a/a.md b/a.md new file mode 100644 index 0000000..ec3c9e1 --- /dev/null +++ b/a.md @@ -0,0 +1,152 @@ +我还想分析自言自语榜,对自言自语的定义是:一个人连续发言超过两次,然后就统计它的次数,最终的统计可能是:成员A:连续自言自语2句话 23次,连续自言自语3句话 13次,连续自言自语4句话 5次;成员B:xxxx。不过对于这些基础的分析最后应该怎么展示和做排行,我没有很好的思路,你可以想一下吗 + +这是一个非常有意思的分析维度!“自言自语”其实反映了两种截然不同的社交人格: + +1. **“碎嘴子” (The Fragmenter):** 一句话非要拆成 3 段发,习惯性刷屏,但每段都很短。 +2. **“孤独演说家” (The Monologuist):** 真的在发表长篇大论(小作文),或者在没人理的情况下还在坚持直播。 + +在 **ChatLab** 中,如果只给一个冷冰冰的 Excel 表格(2句x次,3句x次...)会很无聊。我们需要把这个数据\*\*“游戏化”**和**“视觉化”\*\*。 + +以下是我为你设计的展示思路和排行逻辑: + +--- + +### 1\. 核心概念包装:定义“连击 (Combo)” + +我们要借用游戏里的 **Combo** 概念来包装“自言自语”。 + +- **2-4 连击:** **【加特林模式】** (只是打字快,习惯断句) +- **5-9 连击:** **【小作文发表】** (情绪上来了,开始输出观点) +- **10+ 连击:** **【无人区广播】** (全群死寂,只有他在说话,最高级的孤独) + +--- + +### 2\. 排行榜设计方案 + +我建议设计两个维度的排行,分别展示不同的“自言自语”形态: + +#### 方案 A: “连击之王”卡片 (Gamified Cards) + +**侧重:** 视觉冲击力,展示谁最爱刷屏。 + +- **排序逻辑:** 按照 **“总连击次数”** (只要 \>2 就算) 倒序排列。 +- **UI 布局:** 这是一个 List,每行一个用户。 + - **左侧:** 头像 + 排名。 + - **中间:** **“连击能量条”** (Segmented Bar Chart)。 + - 能量条分为三段颜色: + - 🟩 绿色段:2-4句的次数 (碎嘴) + - 🟨 黄色段:5-9句的次数 (输出) + - 🟥 红色段:10+句的次数 (暴走) + - _一眼就能看出:这个人是单纯话碎(全绿),还是真的疯了(有红条)。_ + - **右侧:** 一个醒目的数字徽章 —— **Max Combo** (历史最高连击数)。 + - _例如:_ “Max: 连续 28 条”。 + +#### 方案 B: “孤独象限”散点图 (The Loneliness Quadrant) + +**侧重:** 深度分析,区分“话碎”和“话多”。 + +- **UI 布局:** 一个简单的二维坐标系。 + - **X轴:** 平均每次连击的长度 (Average Streak Length)。 _越往右,说明每次“自言自语”都写得很长。_ + - **Y轴:** 连击发生的频率 (Frequency)。 _越往上,说明经常自言自语。_ +- **象限解读 (AI 自动生成标签):** + - **右上角 (高频+长文):** **“群聊博主”** —— 每天都在发长篇大论。 + - **左上角 (高频+短文):** **“刷屏机器”** —— 说话像机关枪,但都是短句。 + - **右下角 (低频+长文):** **“沉思者”** —— 平时不说话,一说话就是惊世骇俗的长文。 + +--- + +### 3\. 具体 UI 展示细节 (Bento Grid 风格) + +在 **[成员画像]** 或 **[趣闻]** 的 Tab 里,你可以做一个专门的 **“自言自语分析器”** 模块。 + +#### 顶部:最高纪录 (The Highlight) + +放一张类似吉尼斯世界纪录的卡片: + +> 🏆 **历史最高连击纪录** +> **User A** 在 **2023-11-11** 达成了 **23 連擊**! +> _AI 辣评:那时候群里是都没人了吗?心疼。_ +> [查看当时聊了什么 \>] (点击展开那 23 条记录) + +#### 中部:分布详情 (The Breakdown) + +当用户点击列表里的某个人(例如 User B)时,展开详细数据: + +```text +User B 的自言自语成分: +--------------------------------------------- +[======= 80% =======] 2-3 句 (习惯性断句) +[== 15% ==] 4-8 句 (激情输出) +[= 5% =] 9+ 句 (孤独时刻) +--------------------------------------------- +总计自嗨:156 次 +平均每段长度:3.2 句 +``` + +--- + +### 4\. SQL 实现思路 (Window Functions) + +这需要用到 SQL 的窗口函数 (Window Functions) 来检测“连续性”。这是展示 ChatLab 技术含量的绝佳机会。 + +**算法逻辑:** + +1. 比较当前行的 `sender` 和上一行的 `sender`。 +2. 如果不同,说明是一次新的“发言段落”的开始,生成一个新的 `group_id`。 +3. 按 `group_id` 分组,统计每组的消息数量 (`count`)。 +4. 筛选出 `count >= 2` 的组。 + +**SQLite SQL 示例 (ChatLab 核心代码):** + +```sql +WITH StreakGroups AS ( + SELECT + sender_name, + content, + timestamp, + -- 核心魔法:如果当前发言人 != 上一条发言人,则是一个新的断点 (is_new_group = 1) + CASE + WHEN sender_name != LAG(sender_name, 1, '') OVER (ORDER BY timestamp) + THEN 1 ELSE 0 + END as is_new_group + FROM messages + WHERE session_id = 'CURRENT_SESSION' +), +GroupIDs AS ( + SELECT + *, + -- 累加断点标记,生成唯一的 Group ID + SUM(is_new_group) OVER (ORDER BY timestamp) as group_id + FROM StreakGroups +), +StreakCounts AS ( + SELECT + group_id, + MAX(sender_name) as sender_name, + COUNT(*) as streak_length, + MIN(timestamp) as start_time + FROM GroupIDs + GROUP BY group_id + HAVING streak_length >= 2 -- 只看 2 句以上的连击 +) +-- 最终统计:每个人的连击分布 +SELECT + sender_name, + COUNT(*) as total_streaks, + MAX(streak_length) as max_combo, + SUM(CASE WHEN streak_length BETWEEN 2 AND 4 THEN 1 ELSE 0 END) as low_streak, + SUM(CASE WHEN streak_length BETWEEN 5 AND 9 THEN 1 ELSE 0 END) as mid_streak, + SUM(CASE WHEN streak_length >= 10 THEN 1 ELSE 0 END) as high_streak +FROM StreakCounts +GROUP BY sender_name +ORDER BY total_streaks DESC; +``` + +### 5\. 总结 + +对于“自言自语”榜,我的建议是: + +1. **不要展示枯燥的数字列表**。 +2. 使用 **“连击 (Combo)”** 这个游戏化概念。 +3. **UI 重点:** 用**三色能量条**(绿/黄/红)来直观展示这个人的刷屏习惯。 +4. **彩蛋:** 必须有一个按钮能\*\*“回溯现场”\*\*,让用户点击那个“23连击”,直接跳转到那段聊天记录,看看他当时到底在发什么疯。这才是“吃瓜”的精髓。 diff --git a/electron/main/database/analysis.ts b/electron/main/database/analysis.ts index d622b0c..48946d6 100644 --- a/electron/main/database/analysis.ts +++ b/electron/main/database/analysis.ts @@ -25,6 +25,9 @@ import type { DragonKingRankItem, DivingAnalysis, DivingRankItem, + MonologueAnalysis, + MonologueRankItem, + MaxComboRecord, } from '../../../src/types/chat' import { openDatabase } from './core' @@ -1151,3 +1154,189 @@ export function getDivingAnalysis(sessionId: string, filter?: TimeFilter): Divin db.close() } } + +/** + * 获取自言自语分析 + * 定义:一个人连续发言超过三次(间隔<=5分钟) + * 分类:3-4句(加特林)、5-9句(小作文)、10+句(无人区广播) + */ +export function getMonologueAnalysis(sessionId: string, filter?: TimeFilter): MonologueAnalysis { + const db = openDatabase(sessionId) + const emptyResult: MonologueAnalysis = { + rank: [], + maxComboRecord: null, + } + + if (!db) return emptyResult + + try { + const { clause, params } = buildTimeFilter(filter) + + // 构建 WHERE 子句:只统计文本消息 + let whereClause = clause + if (whereClause.includes('WHERE')) { + whereClause += " AND m.name != '系统消息' AND msg.type = 0" + } else { + whereClause = " WHERE m.name != '系统消息' AND msg.type = 0" + } + + // 获取所有文本消息,按时间排序 + const messages = db + .prepare( + ` + SELECT + msg.id, + msg.sender_id as senderId, + msg.ts, + m.platform_id as platformId, + m.name + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${whereClause} + ORDER BY msg.ts ASC + ` + ) + .all(...params) as Array<{ + id: number + senderId: number + ts: number + platformId: string + name: string + }> + + if (messages.length === 0) return emptyResult + + // 成员信息映射 + const memberInfo = new Map() + + // 连击统计:memberId -> { totalStreaks, maxCombo, lowStreak, midStreak, highStreak } + const memberStats = new Map< + number, + { + totalStreaks: number + maxCombo: number + lowStreak: number + midStreak: number + highStreak: number + } + >() + + // 全群最高纪录 + let globalMaxCombo: { memberId: number; comboLength: number; startTs: number } | null = null + + // 连击间隔限制:5分钟 = 300秒 + const MAX_INTERVAL = 300 + + // 当前连击状态 + let currentStreak = { + senderId: -1, + count: 0, + startTs: 0, + lastTs: 0, + } + + // 处理一个连击结束 + const finishStreak = () => { + if (currentStreak.count >= 3) { + const memberId = currentStreak.senderId + + // 初始化成员统计 + if (!memberStats.has(memberId)) { + memberStats.set(memberId, { + totalStreaks: 0, + maxCombo: 0, + lowStreak: 0, + midStreak: 0, + highStreak: 0, + }) + } + + const stats = memberStats.get(memberId)! + stats.totalStreaks++ + stats.maxCombo = Math.max(stats.maxCombo, currentStreak.count) + + // 分类统计:3-4句、5-9句、10+句 + if (currentStreak.count >= 10) { + stats.highStreak++ + } else if (currentStreak.count >= 5) { + stats.midStreak++ + } else { + stats.lowStreak++ // 3-4句 + } + + // 更新全群最高纪录 + if (!globalMaxCombo || currentStreak.count > globalMaxCombo.comboLength) { + globalMaxCombo = { + memberId, + comboLength: currentStreak.count, + startTs: currentStreak.startTs, + } + } + } + } + + // 遍历所有消息 + for (const msg of messages) { + // 记录成员信息 + if (!memberInfo.has(msg.senderId)) { + memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) + } + + // 检查是否延续当前连击 + const isSameSender = msg.senderId === currentStreak.senderId + const isWithinInterval = msg.ts - currentStreak.lastTs <= MAX_INTERVAL + + if (isSameSender && isWithinInterval) { + // 延续连击 + currentStreak.count++ + currentStreak.lastTs = msg.ts + } else { + // 结束当前连击,开始新的 + finishStreak() + currentStreak = { + senderId: msg.senderId, + count: 1, + startTs: msg.ts, + lastTs: msg.ts, + } + } + } + + // 处理最后一个连击 + finishStreak() + + // 构建排行榜(按总连击次数排序) + const rank: MonologueRankItem[] = [] + for (const [memberId, stats] of memberStats.entries()) { + const info = memberInfo.get(memberId)! + rank.push({ + memberId, + platformId: info.platformId, + name: info.name, + totalStreaks: stats.totalStreaks, + maxCombo: stats.maxCombo, + lowStreak: stats.lowStreak, + midStreak: stats.midStreak, + highStreak: stats.highStreak, + }) + } + rank.sort((a, b) => b.totalStreaks - a.totalStreaks) + + // 构建全群最高纪录 + let maxComboRecord: MaxComboRecord | null = null + if (globalMaxCombo) { + const info = memberInfo.get(globalMaxCombo.memberId)! + maxComboRecord = { + memberId: globalMaxCombo.memberId, + platformId: info.platformId, + memberName: info.name, + comboLength: globalMaxCombo.comboLength, + startTs: globalMaxCombo.startTs, + } + } + + return { rank, maxComboRecord } + } finally { + db.close() + } +} diff --git a/electron/main/database/index.ts b/electron/main/database/index.ts index 530c69d..0bc634f 100644 --- a/electron/main/database/index.ts +++ b/electron/main/database/index.ts @@ -21,6 +21,7 @@ export { getNightOwlAnalysis, getDragonKingAnalysis, getDivingAnalysis, + getMonologueAnalysis, } from './analysis' // 类型导出 diff --git a/electron/main/ipcMain.ts b/electron/main/ipcMain.ts index 2d6a619..38761d5 100644 --- a/electron/main/ipcMain.ts +++ b/electron/main/ipcMain.ts @@ -457,6 +457,21 @@ const mainIpcMain = (win: BrowserWindow) => { } } ) + + /** + * 获取自言自语分析数据 + */ + ipcMain.handle( + 'chat:getMonologueAnalysis', + async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { + try { + return database.getMonologueAnalysis(sessionId, filter) + } catch (error) { + console.error('获取自言自语分析失败:', error) + return { rank: [], maxComboRecord: null } + } + } + ) } export default mainIpcMain diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 82a2c13..0605101 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -13,6 +13,7 @@ import type { NightOwlAnalysis, DragonKingAnalysis, DivingAnalysis, + MonologueAnalysis, } from '../../src/types/chat' interface TimeFilter { @@ -45,6 +46,7 @@ interface ChatApi { getNightOwlAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getDragonKingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getDivingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise + getMonologueAnalysis: (sessionId: string, filter?: TimeFilter) => Promise } interface Api { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 6a6e373..06e2942 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -14,6 +14,7 @@ import type { NightOwlAnalysis, DragonKingAnalysis, DivingAnalysis, + MonologueAnalysis, } from '../../src/types/chat' // Custom APIs for renderer @@ -212,6 +213,16 @@ const chatApi = { ): Promise => { return ipcRenderer.invoke('chat:getDivingAnalysis', sessionId, filter) }, + + /** + * 获取自言自语分析数据 + */ + getMonologueAnalysis: ( + sessionId: string, + filter?: { startTs?: number; endTs?: number } + ): Promise => { + return ipcRenderer.invoke('chat:getMonologueAnalysis', sessionId, filter) + }, } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/components/analysis/MembersTab.vue b/src/components/analysis/MembersTab.vue index c607729..24e9ecf 100644 --- a/src/components/analysis/MembersTab.vue +++ b/src/components/analysis/MembersTab.vue @@ -1,6 +1,6 @@