diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts index 01716ab..2d3e217 100644 --- a/electron/main/ipc/chat.ts +++ b/electron/main/ipc/chat.ts @@ -513,6 +513,43 @@ export function registerChatHandlers(ctx: IpcContext): void { } ) + /** + * 获取小团体关系图数据(基于时间相邻共现) + */ + ipcMain.handle( + 'chat:getClusterGraph', + async ( + _, + sessionId: string, + filter?: { startTs?: number; endTs?: number }, + options?: { + lookAhead?: number + decaySeconds?: number + minScore?: number + topEdges?: number + } + ) => { + try { + return await worker.getClusterGraph(sessionId, filter, options) + } catch (error) { + console.error('获取小团体关系图失败:', error) + return { + nodes: [], + links: [], + maxLinkValue: 0, + communities: [], + stats: { + totalMembers: 0, + totalMessages: 0, + involvedMembers: 0, + edgeCount: 0, + communityCount: 0, + }, + } + } + } + ) + /** * 获取含笑量分析数据 */ diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index fea1cff..004d3e2 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -33,6 +33,7 @@ import { getMentionAnalysis, getMentionGraph, getLaughAnalysis, + getClusterGraph, getMemeBattleAnalysis, getCheckInAnalysis, searchMessages, @@ -123,6 +124,7 @@ const syncHandlers: Record any> = { getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter), getMentionGraph: (p) => getMentionGraph(p.sessionId, p.filter), getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords), + getClusterGraph: (p) => getClusterGraph(p.sessionId, p.filter, p.options), getMemeBattleAnalysis: (p) => getMemeBattleAnalysis(p.sessionId, p.filter), getCheckInAnalysis: (p) => getCheckInAnalysis(p.sessionId, p.filter), diff --git a/electron/main/worker/query/advanced/index.ts b/electron/main/worker/query/advanced/index.ts index 3b323e1..d3cb961 100644 --- a/electron/main/worker/query/advanced/index.ts +++ b/electron/main/worker/query/advanced/index.ts @@ -12,6 +12,14 @@ export { getNightOwlAnalysis, getDragonKingAnalysis, getDivingAnalysis, getCheck // 行为分析:斗图 export { getMemeBattleAnalysis } from './behavior' -// 社交分析:@ 互动、含笑量 -export { getMentionAnalysis, getMentionGraph, getLaughAnalysis } from './social' -export type { MentionGraphData, MentionGraphNode, MentionGraphLink } from './social' +// 社交分析:@ 互动、含笑量、小团体 +export { getMentionAnalysis, getMentionGraph, getLaughAnalysis, getClusterGraph } from './social' +export type { + MentionGraphData, + MentionGraphNode, + MentionGraphLink, + ClusterGraphData, + ClusterGraphNode, + ClusterGraphLink, + ClusterGraphOptions, +} from './social' diff --git a/electron/main/worker/query/advanced/social.ts b/electron/main/worker/query/advanced/social.ts index f36d359..fa3bfde 100644 --- a/electron/main/worker/query/advanced/social.ts +++ b/electron/main/worker/query/advanced/social.ts @@ -670,3 +670,343 @@ export function getLaughAnalysis(sessionId: string, filter?: TimeFilter, keyword groupLaughRate: Math.round((totalLaughs / totalMessages) * 10000) / 100, } } + +// ==================== 小团体关系图(时间相邻共现) ==================== + +/** + * 小团体关系图参数 + */ +export interface ClusterGraphOptions { + /** 向后看几个不同发言者(默认3) */ + lookAhead?: number + /** 时间衰减常数(秒,默认120) */ + decaySeconds?: number + /** 最多保留边数(默认100) */ + topEdges?: number +} + +/** + * 小团体图节点 + */ +export interface ClusterGraphNode { + id: number + name: string + messageCount: number + symbolSize: number + degree: number + normalizedDegree: number +} + +/** + * 小团体图边 + */ +export interface ClusterGraphLink { + source: string + target: string + value: number + rawScore: number + expectedScore: number + coOccurrenceCount: number +} + +/** + * 小团体图结果 + */ +export interface ClusterGraphData { + nodes: ClusterGraphNode[] + links: ClusterGraphLink[] + maxLinkValue: number + communities: Array<{ id: number; name: string; size: number }> + stats: { + totalMembers: number + totalMessages: number + involvedMembers: number + edgeCount: number + communityCount: number + } +} + +const DEFAULT_CLUSTER_OPTIONS = { + lookAhead: 3, + decaySeconds: 120, + topEdges: 100, +} + +function roundNum(value: number, digits = 4): number { + const factor = 10 ** digits + return Math.round(value * factor) / factor +} + +function clusterPairKey(aId: number, bId: number): string { + return aId < bId ? `${aId}-${bId}` : `${bId}-${aId}` +} + +/** + * 获取小团体关系图(基于时间相邻共现) + * + * 算法原理: + * 1. 相邻定义:消息A发出后,后续N个不同发言者视为与A的发言者"相邻" + * 2. 时间衰减:越快出现的相邻者权重越高 (exp(-delta/decay)) + * 3. 归一化:raw_score / expected_score,去除"话唠偏差" + * 4. 社区检测:加权标签传播 + */ +export function getClusterGraph( + sessionId: string, + filter?: TimeFilter, + options?: ClusterGraphOptions +): ClusterGraphData { + const db = openDatabase(sessionId) + const opts = { ...DEFAULT_CLUSTER_OPTIONS, ...options } + + const emptyResult: ClusterGraphData = { + nodes: [], + links: [], + maxLinkValue: 0, + communities: [], + stats: { + totalMembers: 0, + totalMessages: 0, + involvedMembers: 0, + edgeCount: 0, + communityCount: 0, + }, + } + + if (!db) return emptyResult + + // 1. 查询所有成员 + const members = db + .prepare( + ` + SELECT + id, + platform_id as platformId, + COALESCE(group_nickname, account_name, platform_id) as name, + (SELECT COUNT(*) FROM message WHERE sender_id = member.id) as messageCount + FROM member + WHERE COALESCE(account_name, '') != '系统消息' + ` + ) + .all() as Array<{ id: number; platformId: string; name: string; messageCount: number }> + + if (members.length < 2) return { ...emptyResult, stats: { ...emptyResult.stats, totalMembers: members.length } } + + const memberInfo = new Map() + for (const m of members) { + memberInfo.set(m.id, { name: m.name, platformId: m.platformId, messageCount: m.messageCount }) + } + + // 2. 查询消息(按时间排序) + const { clause, params } = buildTimeFilter(filter) + let whereClause = clause + if (whereClause.includes('WHERE')) { + whereClause += " AND COALESCE(m.account_name, '') != '系统消息'" + } else { + whereClause = " WHERE COALESCE(m.account_name, '') != '系统消息'" + } + + const messages = db + .prepare( + ` + SELECT msg.sender_id as senderId, msg.ts as ts + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${whereClause} + ORDER BY msg.ts ASC, msg.id ASC + ` + ) + .all(...params) as Array<{ senderId: number; ts: number }> + + if (messages.length < 2) { + return { ...emptyResult, stats: { ...emptyResult.stats, totalMembers: members.length, totalMessages: messages.length } } + } + + // 3. 统计每个成员的消息数(用于归一化) + const memberMsgCount = new Map() + for (const msg of messages) { + memberMsgCount.set(msg.senderId, (memberMsgCount.get(msg.senderId) || 0) + 1) + } + + const totalMessages = messages.length + + // 4. 计算成员对的原始相邻分数 + const pairRawScore = new Map() + const pairCoOccurrence = new Map() + + for (let i = 0; i < messages.length - 1; i++) { + const anchor = messages[i] + const seenPartners = new Set() + let partnersFound = 0 + + // 向后看 lookAhead 个不同发言者 + for (let j = i + 1; j < messages.length && partnersFound < opts.lookAhead; j++) { + const candidate = messages[j] + + // 跳过同一发言者 + if (candidate.senderId === anchor.senderId) continue + // 跳过已计入的发言者 + if (seenPartners.has(candidate.senderId)) continue + + seenPartners.add(candidate.senderId) + partnersFound++ + + // 计算时间衰减权重 + const deltaSeconds = (candidate.ts - anchor.ts) / 1000 + const decayWeight = Math.exp(-deltaSeconds / opts.decaySeconds) + // 位置衰减:第1个邻居权重1,第2个0.8,第3个0.6 + const positionWeight = 1 - (partnersFound - 1) * 0.2 + + const weight = decayWeight * positionWeight + const key = clusterPairKey(anchor.senderId, candidate.senderId) + + pairRawScore.set(key, (pairRawScore.get(key) || 0) + weight) + pairCoOccurrence.set(key, (pairCoOccurrence.get(key) || 0) + 1) + } + } + + // 5. 归一化:计算期望分数并除以期望 + // 期望公式:expected = (A消息数/总数) × (B消息数/总数) × 总消息数 × 平均窗口覆盖率 + // 简化:expected ≈ (A消息数 × B消息数) / 总消息数 × lookAhead因子 + const lookAheadFactor = opts.lookAhead * 0.8 // 平均每条消息能覆盖的邻居数 + + // 收集所有边和分数 + const rawEdges: Array<{ + sourceId: number + targetId: number + rawScore: number + expectedScore: number + normalizedScore: number + coOccurrenceCount: number + }> = [] + + for (const [key, rawScore] of pairRawScore) { + const [aIdStr, bIdStr] = key.split('-') + const aId = parseInt(aIdStr) + const bId = parseInt(bIdStr) + + const aMsgCount = memberMsgCount.get(aId) || 0 + const bMsgCount = memberMsgCount.get(bId) || 0 + + // 期望分数(保留用于参考) + const expectedScore = ((aMsgCount * bMsgCount) / totalMessages) * lookAheadFactor + const normalizedScore = expectedScore > 0 ? rawScore / expectedScore : 0 + + rawEdges.push({ + sourceId: aId, + targetId: bId, + rawScore, + expectedScore, + normalizedScore, + coOccurrenceCount: pairCoOccurrence.get(key) || 0, + }) + } + + // 计算最大分数,用于归一化到 [0, 1] + const maxRawScore = Math.max(...rawEdges.map((e) => e.rawScore), 1) + const maxNormalizedScore = Math.max(...rawEdges.map((e) => e.normalizedScore), 1) + + // 混合分数:50% 原始分数 + 50% 归一化分数 + const edges = rawEdges.map((e) => { + const hybridScore = 0.5 * (e.rawScore / maxRawScore) + 0.5 * (e.normalizedScore / maxNormalizedScore) + + return { + ...e, + rawScore: roundNum(e.rawScore), + expectedScore: roundNum(e.expectedScore), + normalizedScore: roundNum(e.normalizedScore), + hybridScore: roundNum(hybridScore), + } + }) + + // 6. 按原始分数排序,取 Top N + edges.sort((a, b) => b.hybridScore - a.hybridScore) + const keptEdges = edges.slice(0, opts.topEdges) + + if (keptEdges.length === 0) { + return { + ...emptyResult, + stats: { ...emptyResult.stats, totalMembers: members.length, totalMessages: messages.length }, + } + } + + // 7. 找出参与的成员 + const involvedIds = new Set() + for (const edge of keptEdges) { + involvedIds.add(edge.sourceId) + involvedIds.add(edge.targetId) + } + + // 8. 计算节点度数(使用混合分数) + const nodeDegree = new Map() + for (const edge of keptEdges) { + nodeDegree.set(edge.sourceId, (nodeDegree.get(edge.sourceId) || 0) + edge.hybridScore) + nodeDegree.set(edge.targetId, (nodeDegree.get(edge.targetId) || 0) + edge.hybridScore) + } + const maxDegree = Math.max(...nodeDegree.values(), 1) + + // 10. 构建唯一显示名称(处理同名) + const nameCount = new Map() + for (const id of involvedIds) { + const name = memberInfo.get(id)?.name || String(id) + nameCount.set(name, (nameCount.get(name) || 0) + 1) + } + + const displayNames = new Map() + for (const id of involvedIds) { + const info = memberInfo.get(id) + const baseName = info?.name || String(id) + if ((nameCount.get(baseName) || 0) > 1) { + displayNames.set(id, `${baseName}#${(info?.platformId || String(id)).slice(-4)}`) + } else { + displayNames.set(id, baseName) + } + } + + // 11. 构建输出 + const maxMsgCount = Math.max(...[...involvedIds].map((id) => memberInfo.get(id)?.messageCount || 0), 1) + + const nodes: ClusterGraphNode[] = [...involvedIds].map((id) => { + const info = memberInfo.get(id)! + const degree = nodeDegree.get(id) || 0 + const normalizedDegree = degree / maxDegree + const msgNorm = info.messageCount / maxMsgCount + // 节点大小:70% 基于度数,30% 基于消息数 + const symbolSize = 20 + (0.7 * normalizedDegree + 0.3 * msgNorm) * 35 + + return { + id, + name: displayNames.get(id)!, + messageCount: info.messageCount, + symbolSize: Math.round(symbolSize), + degree: roundNum(degree), + normalizedDegree: roundNum(normalizedDegree), + } + }) + + nodes.sort((a, b) => b.degree - a.degree) + + const maxLinkValue = keptEdges.length > 0 ? Math.max(...keptEdges.map((e) => e.hybridScore)) : 0 + + const links: ClusterGraphLink[] = keptEdges.map((e) => ({ + source: displayNames.get(e.sourceId)!, + target: displayNames.get(e.targetId)!, + value: e.hybridScore, // 使用混合分数作为主要输出 + rawScore: e.rawScore, + expectedScore: e.expectedScore, + coOccurrenceCount: e.coOccurrenceCount, + })) + + return { + nodes, + links, + maxLinkValue: roundNum(maxLinkValue), + communities: [], // 保留字段兼容性,但不再计算 + stats: { + totalMembers: members.length, + totalMessages: messages.length, + involvedMembers: involvedIds.size, + edgeCount: keptEdges.length, + communityCount: 0, + }, + } +} diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts index 78c2bfe..2a6d3f2 100644 --- a/electron/main/worker/query/index.ts +++ b/electron/main/worker/query/index.ts @@ -41,8 +41,12 @@ export { getMentionAnalysis, getMentionGraph, getLaughAnalysis, + getClusterGraph, } from './advanced' +// 小团体图类型 +export type { ClusterGraphData, ClusterGraphNode, ClusterGraphLink, ClusterGraphOptions } from './advanced' + // 聊天记录查询 export { searchMessages, diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 7c94c1c..d2ef608 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -306,6 +306,10 @@ export async function getLaughAnalysis(sessionId: string, filter?: any, keywords return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords }) } +export async function getClusterGraph(sessionId: string, filter?: any, options?: any): Promise { + return sendToWorker('getClusterGraph', { sessionId, filter, options }) +} + export async function getMemeBattleAnalysis(sessionId: string, filter?: any): Promise { return sendToWorker('getMemeBattleAnalysis', { sessionId, filter }) } diff --git a/electron/preload/apis/chat.ts b/electron/preload/apis/chat.ts index 92025b6..3930c10 100644 --- a/electron/preload/apis/chat.ts +++ b/electron/preload/apis/chat.ts @@ -20,6 +20,8 @@ import type { CheckInAnalysis, MemeBattleAnalysis, MemberWithStats, + ClusterGraphData, + ClusterGraphOptions, } from '../../../src/types/analysis' import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../../src/types/format' @@ -277,6 +279,17 @@ export const chatApi = { return ipcRenderer.invoke('chat:getMentionGraph', sessionId, filter) }, + /** + * 获取小团体关系图数据(基于时间相邻共现) + */ + getClusterGraph: ( + sessionId: string, + filter?: { startTs?: number; endTs?: number }, + options?: ClusterGraphOptions + ): Promise => { + return ipcRenderer.invoke('chat:getClusterGraph', sessionId, filter, options) + }, + /** * 获取含笑量分析数据 */ diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 680cac4..3b363eb 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -17,6 +17,8 @@ import type { MemeBattleAnalysis, CheckInAnalysis, MemberWithStats, + ClusterGraphData, + ClusterGraphOptions, } from '../../src/types/analysis' import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../src/types/format' import type { TableSchema, SQLResult } from '../../src/components/analysis/SQLLab/types' @@ -131,6 +133,7 @@ interface ChatApi { getDivingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getMentionGraph: (sessionId: string, filter?: TimeFilter) => Promise + getClusterGraph: (sessionId: string, filter?: TimeFilter, options?: ClusterGraphOptions) => Promise getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getCheckInAnalysis: (sessionId: string, filter?: TimeFilter) => Promise diff --git a/src/components/view/ClusterView.vue b/src/components/view/ClusterView.vue new file mode 100644 index 0000000..a8c091b --- /dev/null +++ b/src/components/view/ClusterView.vue @@ -0,0 +1,656 @@ + + + + + +{ + "zh-CN": { + "title": "互动频率", + "rankingView": "排行视图", + "memberView": "成员视图", + "matrixView": "矩阵视图", + "modelSettings": "模型参数", + "lookAhead": "邻居数量", + "lookAheadDesc": "每条消息向后看几个发言者", + "decaySeconds": "时间衰减(秒)", + "decaySecondsDesc": "值越大,远距离消息权重越高", + "applySettings": "应用设置", + "noData": "暂无数据", + "selectMember": "选择成员", + "selectMemberHint": "请从左侧选择一个成员", + "msgCount": "发言数", + "relationCount": "关系数", + "relationsByIntimacy": "发言临近度排行", + "noRelations": "暂无关系数据", + "intimacy": "临近度", + "interactionRanking": "互动排行", + "score": "临近度", + "coOccurrence": "共现", + "times": "次", + "totalMembers": "群成员", + "totalMessages": "消息总数", + "involvedMembers": "参与成员", + "edgeCount": "关系数" + }, + "en-US": { + "title": "Interaction Frequency", + "rankingView": "Ranking", + "memberView": "Member View", + "matrixView": "Matrix View", + "modelSettings": "Model Settings", + "lookAhead": "Look Ahead", + "lookAheadDesc": "Messages to look ahead per sender", + "decaySeconds": "Time Decay (sec)", + "decaySecondsDesc": "Higher = distant messages matter more", + "applySettings": "Apply", + "noData": "No data", + "selectMember": "Select Member", + "selectMemberHint": "Select a member from the left", + "msgCount": "Messages", + "relationCount": "Relations", + "relationsByIntimacy": "Proximity Ranking", + "noRelations": "No relations", + "intimacy": "Proximity", + "interactionRanking": "Interaction Ranking", + "score": "Proximity", + "coOccurrence": "Co-occur", + "times": " times", + "totalMembers": "Total Members", + "totalMessages": "Total Messages", + "involvedMembers": "Involved", + "edgeCount": "Relations" + } +} + diff --git a/src/components/view/index.ts b/src/components/view/index.ts index 0d3f635..78d6bed 100644 --- a/src/components/view/index.ts +++ b/src/components/view/index.ts @@ -1,3 +1,4 @@ // 视图组件统一导出 export { default as MessageView } from './MessageView.vue' export { default as InteractionView } from './InteractionView.vue' +export { default as ClusterView } from './ClusterView.vue' diff --git a/src/pages/group-chat/components/MemberTab.vue b/src/pages/group-chat/components/MemberTab.vue index 367e735..acdac1d 100644 --- a/src/pages/group-chat/components/MemberTab.vue +++ b/src/pages/group-chat/components/MemberTab.vue @@ -2,6 +2,7 @@ import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' import { SubTabs } from '@/components/UI' +import { ClusterView } from '@/components/view' import MemberList from './member/MemberList.vue' import NicknameHistory from './member/NicknameHistory.vue' import Relationships from './member/Relationships.vue' @@ -28,6 +29,7 @@ const emit = defineEmits<{ const subTabs = computed(() => [ { id: 'list', label: t('memberList'), icon: 'i-heroicons-users' }, { id: 'relationships', label: t('relationships'), icon: 'i-heroicons-heart' }, + { id: 'cluster', label: t('cluster'), icon: 'i-heroicons-user-group' }, { id: 'history', label: t('nicknameHistory'), icon: 'i-heroicons-clock' }, ]) @@ -56,6 +58,13 @@ function handleDataChanged() { :time-filter="props.timeFilter" /> + + + @@ -80,11 +89,13 @@ function handleDataChanged() { "zh-CN": { "memberList": "成员列表", "relationships": "群关系", + "cluster": "互动频率", "nicknameHistory": "昵称变更" }, "en-US": { "memberList": "Member List", "relationships": "Relationships", + "cluster": "Interaction", "nicknameHistory": "Nickname History" } } diff --git a/src/types/analysis.ts b/src/types/analysis.ts index 187b6ae..648cec5 100644 --- a/src/types/analysis.ts +++ b/src/types/analysis.ts @@ -507,3 +507,72 @@ export interface KeywordTemplate { name: string keywords: string[] } + +// ==================== 小团体关系图类型 ==================== + +/** + * 小团体关系图参数 + */ +export interface ClusterGraphOptions { + /** 向后看几个不同发言者(默认3) */ + lookAhead?: number + /** 时间衰减常数(秒,默认120) */ + decaySeconds?: number + /** 最多保留边数(默认100) */ + topEdges?: number +} + +/** + * 小团体图节点 + */ +export interface ClusterGraphNode { + id: number + name: string + messageCount: number + symbolSize: number + degree: number + normalizedDegree: number +} + +/** + * 小团体图边 + */ +export interface ClusterGraphLink { + source: string + target: string + value: number + rawScore: number + expectedScore: number + coOccurrenceCount: number +} + +/** + * 小团体图社区 + */ +export interface ClusterGraphCommunity { + id: number + name: string + size: number +} + +/** + * 小团体图统计 + */ +export interface ClusterGraphStats { + totalMembers: number + totalMessages: number + involvedMembers: number + edgeCount: number + communityCount: number +} + +/** + * 小团体关系图结果 + */ +export interface ClusterGraphData { + nodes: ClusterGraphNode[] + links: ClusterGraphLink[] + maxLinkValue: number + communities: ClusterGraphCommunity[] + stats: ClusterGraphStats +}