From 36fc538c058ec1549f1cc39f9360e6b9c817e533 Mon Sep 17 00:00:00 2001 From: n-WN <30841158+n-WN@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:16:12 +0800 Subject: [PATCH 1/4] feat(interaction): implement relationship graph model and IPC APIs --- electron/main/ipc/chat.ts | 54 +- electron/main/worker/dbWorker.ts | 2 + electron/main/worker/query/advanced/index.ts | 14 +- electron/main/worker/query/advanced/social.ts | 653 +++++++++++++++++- electron/main/worker/query/index.ts | 1 + electron/main/worker/workerManager.ts | 4 + electron/preload/apis/chat.ts | 15 +- electron/preload/index.d.ts | 7 + src/types/analysis.ts | 71 ++ 9 files changed, 815 insertions(+), 6 deletions(-) diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts index 01716ab..370632d 100644 --- a/electron/main/ipc/chat.ts +++ b/electron/main/ipc/chat.ts @@ -503,7 +503,7 @@ export function registerChatHandlers(ctx: IpcContext): void { */ ipcMain.handle( 'chat:getMentionGraph', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { + async (_, sessionId: string, filter?: { startTs?: number; endTs?: number; memberId?: number | null }) => { try { return await worker.getMentionGraph(sessionId, filter) } catch (error) { @@ -513,6 +513,58 @@ export function registerChatHandlers(ctx: IpcContext): void { } ) + /** + * 获取成员关系模型图(@ + 时间相邻共现) + */ + ipcMain.handle( + 'chat:getRelationshipGraph', + async ( + _, + sessionId: string, + filter?: { startTs?: number; endTs?: number; memberId?: number | null }, + options?: { + mentionWeight?: number + temporalWeight?: number + reciprocityWeight?: number + windowSeconds?: number + decaySeconds?: number + minScore?: number + minTemporalTurns?: number + topEdges?: number + } + ) => { + try { + return await worker.getRelationshipGraph(sessionId, filter, options) + } catch (error) { + console.error('获取成员关系模型图失败:', error) + return { + nodes: [], + links: [], + maxLinkValue: 0, + communities: [], + stats: { + totalMembers: 0, + involvedMembers: 0, + rawEdgeCount: 0, + keptEdges: 0, + maxMentionCount: 0, + maxTemporalScore: 0, + }, + options: { + mentionWeight: 0.45, + temporalWeight: 0.4, + reciprocityWeight: 0.15, + windowSeconds: 300, + decaySeconds: 120, + minScore: 0.12, + minTemporalTurns: 2, + topEdges: 120, + }, + } + } + } + ) + /** * 获取含笑量分析数据 */ diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index fea1cff..3f3ece1 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -32,6 +32,7 @@ import { getDivingAnalysis, getMentionAnalysis, getMentionGraph, + getRelationshipGraph, getLaughAnalysis, getMemeBattleAnalysis, getCheckInAnalysis, @@ -122,6 +123,7 @@ const syncHandlers: Record any> = { getDivingAnalysis: (p) => getDivingAnalysis(p.sessionId, p.filter), getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter), getMentionGraph: (p) => getMentionGraph(p.sessionId, p.filter), + getRelationshipGraph: (p) => getRelationshipGraph(p.sessionId, p.filter, p.options), getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords), 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..6f03a31 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, getRelationshipGraph, getLaughAnalysis } from './social' +export type { + MentionGraphData, + MentionGraphNode, + MentionGraphLink, + RelationshipGraphData, + RelationshipGraphNode, + RelationshipGraphLink, + RelationshipGraphOptions, +} from './social' diff --git a/electron/main/worker/query/advanced/social.ts b/electron/main/worker/query/advanced/social.ts index f36d359..cb98639 100644 --- a/electron/main/worker/query/advanced/social.ts +++ b/electron/main/worker/query/advanced/social.ts @@ -1,6 +1,6 @@ /** * 社交分析模块 - * 包含:@ 互动分析、含笑量分析 + * 包含:@ 互动分析、成员关系模型、含笑量分析 */ import { openDatabase, buildTimeFilter, type TimeFilter } from '../../core' @@ -474,6 +474,657 @@ export function getMentionGraph(sessionId: string, filter?: TimeFilter): Mention return { nodes, links, maxLinkValue } } +// ==================== 成员关系模型(@ + 时间共现) ==================== + +export interface RelationshipGraphOptions { + mentionWeight?: number + temporalWeight?: number + reciprocityWeight?: number + windowSeconds?: number + decaySeconds?: number + minScore?: number + minTemporalTurns?: number + topEdges?: number +} + +interface NormalizedRelationshipGraphOptions { + mentionWeight: number + temporalWeight: number + reciprocityWeight: number + windowSeconds: number + decaySeconds: number + minScore: number + minTemporalTurns: number + topEdges: number +} + +interface RelationshipPairStat { + aId: number + bId: number + mentionAB: number + mentionBA: number + mentionTotal: number + temporalTurns: number + temporalScore: number + temporalDeltaSum: number +} + +export interface RelationshipGraphNode { + id: number + name: string + value: number + symbolSize: number + category: number + messageCount: number + weightedDegree: number + totalMentions: number + communitySize: number +} + +export interface RelationshipGraphLink { + source: string + target: string + value: number + mentionCount: number + temporalTurns: number + temporalScore: number + reciprocity: number + avgDeltaSec: number | null +} + +export interface RelationshipGraphData { + nodes: RelationshipGraphNode[] + links: RelationshipGraphLink[] + maxLinkValue: number + communities: Array<{ id: number; name: string; size: number }> + stats: { + totalMembers: number + involvedMembers: number + rawEdgeCount: number + keptEdges: number + maxMentionCount: number + maxTemporalScore: number + } + options: NormalizedRelationshipGraphOptions +} + +const DEFAULT_RELATIONSHIP_OPTIONS: NormalizedRelationshipGraphOptions = { + mentionWeight: 0.45, + temporalWeight: 0.4, + reciprocityWeight: 0.15, + windowSeconds: 300, + decaySeconds: 120, + minScore: 0.12, + minTemporalTurns: 2, + topEdges: 120, +} + +function roundNumber(value: number, digits = 4): number { + const factor = 10 ** digits + return Math.round(value * factor) / factor +} + +function normalizeRelationshipOptions(options?: RelationshipGraphOptions): NormalizedRelationshipGraphOptions { + const merged = { + ...DEFAULT_RELATIONSHIP_OPTIONS, + ...(options || {}), + } + + const mentionWeight = Number(merged.mentionWeight) + const temporalWeight = Number(merged.temporalWeight) + const reciprocityWeight = Number(merged.reciprocityWeight) + const totalWeight = mentionWeight + temporalWeight + reciprocityWeight + + const normalizedWeights = + totalWeight > 0 + ? { + mentionWeight: mentionWeight / totalWeight, + temporalWeight: temporalWeight / totalWeight, + reciprocityWeight: reciprocityWeight / totalWeight, + } + : { + mentionWeight: DEFAULT_RELATIONSHIP_OPTIONS.mentionWeight, + temporalWeight: DEFAULT_RELATIONSHIP_OPTIONS.temporalWeight, + reciprocityWeight: DEFAULT_RELATIONSHIP_OPTIONS.reciprocityWeight, + } + + return { + ...normalizedWeights, + windowSeconds: + Number.isFinite(Number(merged.windowSeconds)) && Number(merged.windowSeconds) > 0 + ? Number(merged.windowSeconds) + : DEFAULT_RELATIONSHIP_OPTIONS.windowSeconds, + decaySeconds: + Number.isFinite(Number(merged.decaySeconds)) && Number(merged.decaySeconds) > 0 + ? Number(merged.decaySeconds) + : DEFAULT_RELATIONSHIP_OPTIONS.decaySeconds, + minScore: + Number.isFinite(Number(merged.minScore)) && Number(merged.minScore) >= 0 + ? Number(merged.minScore) + : DEFAULT_RELATIONSHIP_OPTIONS.minScore, + minTemporalTurns: + Number.isFinite(Number(merged.minTemporalTurns)) && Number(merged.minTemporalTurns) >= 0 + ? Math.floor(Number(merged.minTemporalTurns)) + : DEFAULT_RELATIONSHIP_OPTIONS.minTemporalTurns, + topEdges: + Number.isFinite(Number(merged.topEdges)) && Number(merged.topEdges) > 0 + ? Math.floor(Number(merged.topEdges)) + : DEFAULT_RELATIONSHIP_OPTIONS.topEdges, + } +} + +function parseAliases(raw: string | null | undefined): string[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) { + return parsed.map((item) => String(item).trim()).filter(Boolean) + } + } catch { + // ignore parse failures + } + return [] +} + +function cleanMentionToken(token: string): string { + return token.trim().replace(/[),.:;!?,。!?、)】》」]+$/g, '') +} + +function pairKey(aId: number, bId: number): string { + const left = Math.min(aId, bId) + const right = Math.max(aId, bId) + return `${left}-${right}` +} + +function getOrCreatePair(store: Map, aId: number, bId: number): RelationshipPairStat { + const key = pairKey(aId, bId) + let pair = store.get(key) + if (!pair) { + pair = { + aId: Math.min(aId, bId), + bId: Math.max(aId, bId), + mentionAB: 0, + mentionBA: 0, + mentionTotal: 0, + temporalTurns: 0, + temporalScore: 0, + temporalDeltaSum: 0, + } + store.set(key, pair) + } + return pair +} + +function runWeightedLabelPropagation( + nodeIds: number[], + weightedEdges: Array<{ sourceId: number; targetId: number; weight: number }> +): { + nodeToCommunity: Map + communities: Array<{ id: number; name: string; size: number }> +} { + const adjacency = new Map>() + const weightedDegree = new Map() + + for (const nodeId of nodeIds) { + adjacency.set(nodeId, new Map()) + weightedDegree.set(nodeId, 0) + } + + for (const edge of weightedEdges) { + if (!adjacency.has(edge.sourceId) || !adjacency.has(edge.targetId)) continue + adjacency.get(edge.sourceId)!.set(edge.targetId, (adjacency.get(edge.sourceId)!.get(edge.targetId) || 0) + edge.weight) + adjacency.get(edge.targetId)!.set(edge.sourceId, (adjacency.get(edge.targetId)!.get(edge.sourceId) || 0) + edge.weight) + weightedDegree.set(edge.sourceId, (weightedDegree.get(edge.sourceId) || 0) + edge.weight) + weightedDegree.set(edge.targetId, (weightedDegree.get(edge.targetId) || 0) + edge.weight) + } + + const labels = new Map() + for (const nodeId of nodeIds) labels.set(nodeId, String(nodeId)) + + const iterationOrder = [...nodeIds].sort((a, b) => (weightedDegree.get(b) || 0) - (weightedDegree.get(a) || 0)) + const maxIterations = 25 + for (let i = 0; i < maxIterations; i++) { + let changed = false + + for (const nodeId of iterationOrder) { + const neighbors = adjacency.get(nodeId) + if (!neighbors || neighbors.size === 0) continue + + const labelScores = new Map() + for (const [neighborId, w] of neighbors.entries()) { + const neighborLabel = labels.get(neighborId) + if (!neighborLabel) continue + labelScores.set(neighborLabel, (labelScores.get(neighborLabel) || 0) + w) + } + + let bestLabel = labels.get(nodeId)! + let bestScore = -1 + for (const [label, score] of labelScores.entries()) { + if (score > bestScore || (score === bestScore && label < bestLabel)) { + bestScore = score + bestLabel = label + } + } + + if (bestLabel !== labels.get(nodeId)) { + labels.set(nodeId, bestLabel) + changed = true + } + } + + if (!changed) break + } + + const communitySizeByLabel = new Map() + for (const label of labels.values()) { + communitySizeByLabel.set(label, (communitySizeByLabel.get(label) || 0) + 1) + } + + const sortedLabels = [...communitySizeByLabel.entries()].sort((a, b) => b[1] - a[1]) + const labelToCategoryId = new Map() + sortedLabels.forEach(([label], index) => labelToCategoryId.set(label, index)) + + const nodeToCommunity = new Map() + for (const [nodeId, label] of labels.entries()) { + nodeToCommunity.set(nodeId, labelToCategoryId.get(label) || 0) + } + + const communities = sortedLabels.map(([label, size], index) => ({ + id: index, + name: `Community ${index + 1}`, + size, + })) + + return { nodeToCommunity, communities } +} + +function uniqueDisplayNames( + memberIds: number[], + memberInfo: Map +): Map { + const nameCount = new Map() + for (const memberId of memberIds) { + const baseName = memberInfo.get(memberId)?.name || String(memberId) + nameCount.set(baseName, (nameCount.get(baseName) || 0) + 1) + } + + const result = new Map() + for (const memberId of memberIds) { + const info = memberInfo.get(memberId) + const baseName = info?.name || String(memberId) + if ((nameCount.get(baseName) || 0) > 1) { + const suffix = (info?.platformId || String(memberId)).slice(-4) + result.set(memberId, `${baseName}#${suffix}`) + } else { + result.set(memberId, baseName) + } + } + return result +} + +/** + * 获取成员关系图(融合 @ 互动 + 时间相邻共现 + 互惠度) + * + * 设计依据: + * 1) Granovetter 的弱连接观点(弱连接作为桥梁) + * 2) 关系强度可由互动频率、时间模式、互惠性联合估计 + */ +export function getRelationshipGraph( + sessionId: string, + filter?: TimeFilter, + options?: RelationshipGraphOptions +): RelationshipGraphData { + const db = openDatabase(sessionId) + const normalizedOptions = normalizeRelationshipOptions(options) + const selectedMemberId = typeof filter?.memberId === 'number' ? filter.memberId : null + + const emptyResult: RelationshipGraphData = { + nodes: [], + links: [], + maxLinkValue: 0, + communities: [], + stats: { + totalMembers: 0, + involvedMembers: 0, + rawEdgeCount: 0, + keptEdges: 0, + maxMentionCount: 0, + maxTemporalScore: 0, + }, + options: normalizedOptions, + } + + if (!db) return emptyResult + + // 关系模型中的 memberId 表示“关系聚焦对象”,不应限制消息源查询 + const timeRangeFilter: TimeFilter | undefined = + filter && (filter.startTs !== undefined || filter.endTs !== undefined) + ? { startTs: filter.startTs, endTs: filter.endTs } + : undefined + const { clause, params } = buildTimeFilter(timeRangeFilter, 'msg') + const msgFilterBase = clause ? clause.replace('WHERE', 'AND') : '' + const msgFilterWithSystem = msgFilterBase + " AND COALESCE(m.account_name, '') != '系统消息'" + + const members = db + .prepare( + ` + SELECT + m.id, + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + COALESCE(m.aliases, '[]') as aliases, + COUNT(msg.id) as messageCount + FROM member m + LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem} + WHERE COALESCE(m.account_name, '') != '系统消息' + GROUP BY m.id + ` + ) + .all(...params) as Array<{ + id: number + platformId: string + name: string + aliases: string + messageCount: number + }> + + if (members.length === 0) return emptyResult + + emptyResult.stats.totalMembers = members.length + + const memberInfoById = new Map() + + const historyRows = db.prepare(`SELECT member_id as memberId, name FROM member_name_history`).all() as Array<{ + memberId: number + name: string | null + }> + const historyNamesByMemberId = new Map() + for (const row of historyRows) { + if (!historyNamesByMemberId.has(row.memberId)) { + historyNamesByMemberId.set(row.memberId, []) + } + historyNamesByMemberId.get(row.memberId)!.push(row.name) + } + + // 名称歧义会导致误判,这里只保留“唯一可映射”名称 + const nameBuckets = new Map>() + const addNameCandidate = (name: string | null | undefined, memberId: number) => { + if (typeof name !== 'string') return + const normalizedName = name.trim().toLowerCase() + if (!normalizedName) return + if (!nameBuckets.has(normalizedName)) { + nameBuckets.set(normalizedName, new Set()) + } + nameBuckets.get(normalizedName)!.add(memberId) + } + + for (const member of members) { + memberInfoById.set(member.id, { + name: member.name, + platformId: member.platformId, + messageCount: member.messageCount, + }) + addNameCandidate(member.name, member.id) + + const historyNames = historyNamesByMemberId.get(member.id) || [] + for (const historyName of historyNames) { + addNameCandidate(historyName, member.id) + } + + const aliases = parseAliases(member.aliases) + for (const alias of aliases) { + addNameCandidate(alias, member.id) + } + } + + const nameToMemberId = new Map() + for (const [name, memberIds] of nameBuckets.entries()) { + if (memberIds.size !== 1) continue + const memberId = memberIds.values().next().value as number + nameToMemberId.set(name, memberId) + } + + 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.id as id, + msg.sender_id as senderId, + msg.ts as ts, + msg.content as content + 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<{ + id: number + senderId: number + ts: number + content: string | null + }> + + if (messages.length === 0) return emptyResult + + const pairStats = new Map() + const mentionRegex = /@([^\s@]+)/g + + for (const msg of messages) { + const content = msg.content || '' + if (!content.includes('@')) continue + + const mentionedInThisMessage = new Set() + for (const match of content.matchAll(mentionRegex)) { + const rawName = match[1] + const mentionedName = cleanMentionToken(rawName).toLowerCase() + const mentionedId = nameToMemberId.get(mentionedName) + if (!mentionedId || mentionedId === msg.senderId || mentionedInThisMessage.has(mentionedId)) continue + mentionedInThisMessage.add(mentionedId) + + const pair = getOrCreatePair(pairStats, msg.senderId, mentionedId) + if (msg.senderId === pair.aId && mentionedId === pair.bId) { + pair.mentionAB += 1 + } else { + pair.mentionBA += 1 + } + pair.mentionTotal += 1 + } + } + + for (let i = 0; i < messages.length - 1; i++) { + const anchor = messages[i] + const seenPartner = new Set() + + for (let j = i + 1; j < messages.length; j++) { + const candidate = messages[j] + const deltaSeconds = candidate.ts - anchor.ts + if (deltaSeconds <= 0) continue + if (deltaSeconds > normalizedOptions.windowSeconds) break + if (anchor.senderId === candidate.senderId || seenPartner.has(candidate.senderId)) continue + + seenPartner.add(candidate.senderId) + const pair = getOrCreatePair(pairStats, anchor.senderId, candidate.senderId) + const temporalWeight = Math.exp(-deltaSeconds / normalizedOptions.decaySeconds) + pair.temporalTurns += 1 + pair.temporalScore += temporalWeight + pair.temporalDeltaSum += deltaSeconds + } + } + + const allPairs = [...pairStats.values()] + const maxMentionCount = Math.max(...allPairs.map((p) => p.mentionTotal), 0) + const maxTemporalScore = Math.max(...allPairs.map((p) => p.temporalScore), 0) + + const rawEdges: Array<{ + sourceId: number + targetId: number + score: number + mentionCount: number + temporalTurns: number + temporalScore: number + reciprocity: number + avgDeltaSec: number | null + }> = [] + + for (const pair of allPairs) { + const mentionNorm = + maxMentionCount > 0 ? Math.log1p(pair.mentionTotal) / Math.log1p(maxMentionCount) : 0 + const temporalNorm = + maxTemporalScore > 0 ? Math.log1p(pair.temporalScore) / Math.log1p(maxTemporalScore) : 0 + const reciprocity = + pair.mentionTotal > 0 ? Math.min(pair.mentionAB, pair.mentionBA) / Math.max(pair.mentionAB, pair.mentionBA) : 0 + + const score = + normalizedOptions.mentionWeight * mentionNorm + + normalizedOptions.temporalWeight * temporalNorm + + normalizedOptions.reciprocityWeight * reciprocity + + const hasSignal = pair.mentionTotal > 0 || pair.temporalTurns >= normalizedOptions.minTemporalTurns + if (!hasSignal || score < normalizedOptions.minScore) continue + + rawEdges.push({ + sourceId: pair.aId, + targetId: pair.bId, + score, + mentionCount: pair.mentionTotal, + temporalTurns: pair.temporalTurns, + temporalScore: pair.temporalScore, + reciprocity, + avgDeltaSec: pair.temporalTurns > 0 ? pair.temporalDeltaSum / pair.temporalTurns : null, + }) + } + + rawEdges.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score + if (b.mentionCount !== a.mentionCount) return b.mentionCount - a.mentionCount + return b.temporalScore - a.temporalScore + }) + + const scopedEdges = + selectedMemberId !== null + ? rawEdges.filter((edge) => edge.sourceId === selectedMemberId || edge.targetId === selectedMemberId) + : rawEdges + + const keptEdges = scopedEdges.slice(0, normalizedOptions.topEdges) + if (keptEdges.length === 0) { + return { + ...emptyResult, + stats: { + ...emptyResult.stats, + rawEdgeCount: scopedEdges.length, + maxMentionCount, + maxTemporalScore: roundNumber(maxTemporalScore), + }, + } + } + + const involvedMemberIdSet = new Set() + const weightedDegree = new Map() + const totalMentionsByMember = new Map() + let maxEdgeScore = 0 + + for (const edge of keptEdges) { + involvedMemberIdSet.add(edge.sourceId) + involvedMemberIdSet.add(edge.targetId) + weightedDegree.set(edge.sourceId, (weightedDegree.get(edge.sourceId) || 0) + edge.score) + weightedDegree.set(edge.targetId, (weightedDegree.get(edge.targetId) || 0) + edge.score) + totalMentionsByMember.set(edge.sourceId, (totalMentionsByMember.get(edge.sourceId) || 0) + edge.mentionCount) + totalMentionsByMember.set(edge.targetId, (totalMentionsByMember.get(edge.targetId) || 0) + edge.mentionCount) + maxEdgeScore = Math.max(maxEdgeScore, edge.score) + } + + const involvedMemberIds = [...involvedMemberIdSet] + const maxWeightedDegree = Math.max(...[...weightedDegree.values()], 1) + const maxMessageCount = Math.max( + ...involvedMemberIds.map((memberId) => memberInfoById.get(memberId)?.messageCount || 0), + 1 + ) + + const displayNames = uniqueDisplayNames( + involvedMemberIds, + new Map( + [...memberInfoById.entries()].map(([id, info]) => [id, { name: info.name, platformId: info.platformId }]) + ) + ) + + const { nodeToCommunity, communities } = runWeightedLabelPropagation( + involvedMemberIds, + keptEdges.map((edge) => ({ + sourceId: edge.sourceId, + targetId: edge.targetId, + weight: edge.score, + })) + ) + + const communitySize = new Map() + for (const [_, categoryId] of nodeToCommunity.entries()) { + communitySize.set(categoryId, (communitySize.get(categoryId) || 0) + 1) + } + + const nodes: RelationshipGraphNode[] = involvedMemberIds + .map((memberId) => { + const info = memberInfoById.get(memberId) + if (!info) return null + const degree = weightedDegree.get(memberId) || 0 + const degreeNorm = degree / maxWeightedDegree + const messageNorm = info.messageCount / maxMessageCount + const symbolSize = 20 + (0.65 * degreeNorm + 0.35 * messageNorm) * 38 + const category = nodeToCommunity.get(memberId) || 0 + return { + id: memberId, + name: displayNames.get(memberId) || info.name, + value: roundNumber(degree, 4), + symbolSize: Math.round(symbolSize), + category, + messageCount: info.messageCount, + weightedDegree: roundNumber(degree, 4), + totalMentions: totalMentionsByMember.get(memberId) || 0, + communitySize: communitySize.get(category) || 1, + } + }) + .filter((item): item is RelationshipGraphNode => item !== null) + .sort((a, b) => b.weightedDegree - a.weightedDegree) + + const links: RelationshipGraphLink[] = keptEdges.map((edge) => ({ + source: displayNames.get(edge.sourceId) || String(edge.sourceId), + target: displayNames.get(edge.targetId) || String(edge.targetId), + value: roundNumber(edge.score, 4), + mentionCount: edge.mentionCount, + temporalTurns: edge.temporalTurns, + temporalScore: roundNumber(edge.temporalScore, 4), + reciprocity: roundNumber(edge.reciprocity, 4), + avgDeltaSec: edge.avgDeltaSec === null ? null : roundNumber(edge.avgDeltaSec, 2), + })) + + return { + nodes, + links, + maxLinkValue: roundNumber(maxEdgeScore, 4), + communities: communities.map((community) => ({ + id: community.id, + name: community.name, + size: community.size, + })), + stats: { + totalMembers: members.length, + involvedMembers: involvedMemberIds.length, + rawEdgeCount: scopedEdges.length, + keptEdges: keptEdges.length, + maxMentionCount, + maxTemporalScore: roundNumber(maxTemporalScore, 4), + }, + options: normalizedOptions, + } +} + // ==================== 含笑量分析 ==================== /** diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts index f586d72..1612cff 100644 --- a/electron/main/worker/query/index.ts +++ b/electron/main/worker/query/index.ts @@ -39,6 +39,7 @@ export { getMemeBattleAnalysis, getMentionAnalysis, getMentionGraph, + getRelationshipGraph, getLaughAnalysis, } from './advanced' diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 7c94c1c..0e04bca 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -302,6 +302,10 @@ export async function getMentionGraph(sessionId: string, filter?: any): Promise< return sendToWorker('getMentionGraph', { sessionId, filter }) } +export async function getRelationshipGraph(sessionId: string, filter?: any, options?: any): Promise { + return sendToWorker('getRelationshipGraph', { sessionId, filter, options }) +} + export async function getLaughAnalysis(sessionId: string, filter?: any, keywords?: string[]): Promise { return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords }) } diff --git a/electron/preload/apis/chat.ts b/electron/preload/apis/chat.ts index 92025b6..27eaaad 100644 --- a/electron/preload/apis/chat.ts +++ b/electron/preload/apis/chat.ts @@ -16,6 +16,8 @@ import type { DragonKingAnalysis, DivingAnalysis, MentionAnalysis, + RelationshipGraphData, + RelationshipGraphOptions, LaughAnalysis, CheckInAnalysis, MemeBattleAnalysis, @@ -268,7 +270,7 @@ export const chatApi = { */ getMentionGraph: ( sessionId: string, - filter?: { startTs?: number; endTs?: number } + filter?: { startTs?: number; endTs?: number; memberId?: number | null } ): Promise<{ nodes: Array<{ id: number; name: string; value: number; symbolSize: number }> links: Array<{ source: string; target: string; value: number }> @@ -277,6 +279,17 @@ export const chatApi = { return ipcRenderer.invoke('chat:getMentionGraph', sessionId, filter) }, + /** + * 获取成员关系模型图(@ + 时间相邻共现 + 互惠度) + */ + getRelationshipGraph: ( + sessionId: string, + filter?: { startTs?: number; endTs?: number; memberId?: number | null }, + options?: RelationshipGraphOptions + ): Promise => { + return ipcRenderer.invoke('chat:getRelationshipGraph', sessionId, filter, options) + }, + /** * 获取含笑量分析数据 */ diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 680cac4..9b38382 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -13,6 +13,8 @@ import type { DragonKingAnalysis, DivingAnalysis, MentionAnalysis, + RelationshipGraphData, + RelationshipGraphOptions, LaughAnalysis, MemeBattleAnalysis, CheckInAnalysis, @@ -131,6 +133,11 @@ interface ChatApi { getDivingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getMentionGraph: (sessionId: string, filter?: TimeFilter) => Promise + getRelationshipGraph: ( + sessionId: string, + filter?: TimeFilter, + options?: RelationshipGraphOptions + ) => 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/types/analysis.ts b/src/types/analysis.ts index 187b6ae..86bd069 100644 --- a/src/types/analysis.ts +++ b/src/types/analysis.ts @@ -379,6 +379,77 @@ export interface MentionAnalysis { memberDetails: MemberMentionDetail[] } +/** + * 成员关系模型参数(@ + 时间共现 + 互惠度) + */ +export interface RelationshipGraphOptions { + mentionWeight?: number + temporalWeight?: number + reciprocityWeight?: number + windowSeconds?: number + decaySeconds?: number + minScore?: number + minTemporalTurns?: number + topEdges?: number +} + +/** + * 成员关系图节点 + */ +export interface RelationshipGraphNode { + id: number + name: string + value: number // 节点综合权重(加权度) + symbolSize: number + category: number // 社区编号 + messageCount: number + weightedDegree: number + totalMentions: number + communitySize: number +} + +/** + * 成员关系图边 + */ +export interface RelationshipGraphLink { + source: string + target: string + value: number // 关系强度 + mentionCount: number + temporalTurns: number + temporalScore: number + reciprocity: number + avgDeltaSec: number | null +} + +/** + * 成员关系图分析结果 + */ +export interface RelationshipGraphData { + nodes: RelationshipGraphNode[] + links: RelationshipGraphLink[] + maxLinkValue: number + communities: Array<{ id: number; name: string; size: number }> + stats: { + totalMembers: number + involvedMembers: number + rawEdgeCount: number + keptEdges: number + maxMentionCount: number + maxTemporalScore: number + } + options: { + mentionWeight: number + temporalWeight: number + reciprocityWeight: number + windowSeconds: number + decaySeconds: number + minScore: number + minTemporalTurns: number + topEdges: number + } +} + // ==================== 含笑量分析类型 ==================== /** From ff4108517962c89d5b750288760d00575838c8b2 Mon Sep 17 00:00:00 2001 From: n-WN <30841158+n-WN@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:16:25 +0800 Subject: [PATCH 2/4] fix(database): skip non-chat sqlite files in migration and session scan --- electron/main/database/core.ts | 9 +++++++++ electron/main/worker/query/basic.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/electron/main/database/core.ts b/electron/main/database/core.ts index 2bf4d83..4caf840 100644 --- a/electron/main/database/core.ts +++ b/electron/main/database/core.ts @@ -411,6 +411,15 @@ export function checkMigrationNeeded(): { const db = new Database(dbPath, { readonly: true }) db.pragma('journal_mode = WAL') + // 仅迁移聊天会话数据库,跳过其他业务库(如 AI 会话索引库) + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }> + const tableSet = new Set(tables.map((t) => t.name)) + const isChatSessionDb = tableSet.has('meta') && tableSet.has('member') && tableSet.has('message') + if (!isChatSessionDb) { + db.close() + continue + } + // 获取当前 schema_version const metaTableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> const hasVersionColumn = metaTableInfo.some((col) => col.name === 'schema_version') diff --git a/electron/main/worker/query/basic.ts b/electron/main/worker/query/basic.ts index bd95af7..b2bc63d 100644 --- a/electron/main/worker/query/basic.ts +++ b/electron/main/worker/query/basic.ts @@ -489,6 +489,22 @@ export function getAllSessions(): any[] { const db = new Database(dbPath) db.pragma('journal_mode = WAL') + // 跳过非聊天会话数据库(例如内部索引库) + const hasChatMeta = db + .prepare("SELECT 1 as ok FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1") + .get() as { ok: number } | undefined + const hasMemberTable = db + .prepare("SELECT 1 as ok FROM sqlite_master WHERE type='table' AND name='member' LIMIT 1") + .get() as { ok: number } | undefined + const hasMessageTable = db + .prepare("SELECT 1 as ok FROM sqlite_master WHERE type='table' AND name='message' LIMIT 1") + .get() as { ok: number } | undefined + + if (!hasChatMeta || !hasMemberTable || !hasMessageTable) { + db.close() + continue + } + const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined if (meta) { From aa54637a4eb2183a3d01309a26a4b9e90a79acca Mon Sep 17 00:00:00 2001 From: n-WN <30841158+n-WN@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:16:39 +0800 Subject: [PATCH 3/4] feat(ui): redesign relationship graph with focus and insight panel --- src/components/charts/EChartGraph.vue | 201 +++++- src/components/common/Sidebar.vue | 13 +- src/components/view/InteractionView.vue | 908 +++++++++++++++++++++--- src/pages/group-chat/index.vue | 15 + 4 files changed, 1009 insertions(+), 128 deletions(-) diff --git a/src/components/charts/EChartGraph.vue b/src/components/charts/EChartGraph.vue index f5fdfd6..a10f0a8 100644 --- a/src/components/charts/EChartGraph.vue +++ b/src/components/charts/EChartGraph.vue @@ -21,18 +21,28 @@ export interface GraphNode { value?: number symbolSize?: number category?: number + messageCount?: number + weightedDegree?: number + totalMentions?: number + communitySize?: number } export interface GraphLink { source: string target: string value?: number + mentionCount?: number + temporalTurns?: number + temporalScore?: number + reciprocity?: number + avgDeltaSec?: number | null } export interface GraphData { nodes: GraphNode[] links: GraphLink[] maxLinkValue?: number + categories?: Array<{ name: string }> } interface Props { @@ -40,14 +50,24 @@ interface Props { height?: number | string layout?: 'circular' | 'force' // 布局类型 directed?: boolean // 是否显示箭头(有向图) + showLegend?: boolean + neon?: boolean + selectedNode?: string | null } const props = withDefaults(defineProps(), { height: 400, layout: 'circular', directed: false, + showLegend: false, + neon: false, + selectedNode: null, }) +const emit = defineEmits<{ + (event: 'node-click', nodeName: string | null): void +}>() + // 计算高度样式 const heightStyle = computed(() => { if (typeof props.height === 'number') { @@ -62,18 +82,18 @@ let chartInstance: echarts.ECharts | null = null // 丰富的调色板(为每个节点分配不同颜色) const colorPalette = [ - '#ee4567', // 粉色(主题色) - '#5470c6', // 蓝色 - '#91cc75', // 绿色 - '#fac858', // 黄色 - '#ee6666', // 红色 - '#73c0de', // 青色 - '#9a60b4', // 紫色 - '#fc8452', // 橙色 - '#3ba272', // 深绿 - '#ea7ccc', // 粉紫 - '#6e7074', // 灰色 - '#546570', // 深灰蓝 + '#ee4567', // pink-500 + '#f7758c', // pink-400 + '#8b5cf6', // violet + '#6366f1', // indigo + '#14b8a6', // teal + '#3b82f6', // blue + '#22c55e', // green + '#f97316', // orange + '#eab308', // yellow + '#ec4899', // pink-600 + '#06b6d4', // cyan + '#64748b', // slate ] // 去重后的节点(ECharts 要求节点名称唯一) @@ -88,42 +108,103 @@ const uniqueNodes = computed(() => { }) }) +const graphCategories = computed(() => { + if (props.data.categories && props.data.categories.length > 0) { + return props.data.categories + } + const categorySet = new Set() + for (const node of uniqueNodes.value) { + if (typeof node.category === 'number') categorySet.add(node.category) + } + return [...categorySet].sort((a, b) => a - b).map((id) => ({ name: `Community ${id + 1}` })) +}) + // 节点名称到颜色的映射 const nodeColorMap = computed(() => { const map = new Map() uniqueNodes.value.forEach((node, index) => { - map.set(node.name, colorPalette[index % colorPalette.length]) + const colorIdx = typeof node.category === 'number' ? node.category : index + map.set(node.name, colorPalette[colorIdx % colorPalette.length]) }) return map }) +const selectedAdjacency = computed(() => { + if (!props.selectedNode) return new Set() + const neighbors = new Set() + for (const link of props.data.links) { + if (link.source === props.selectedNode) neighbors.add(link.target) + if (link.target === props.selectedNode) neighbors.add(link.source) + } + return neighbors +}) + // 计算边的宽度(根据 value 归一化) function getLinkWidth(value: number, maxValue: number): number { if (maxValue <= 0) return 1 - // 宽度范围 1-6 - return 1 + (value / maxValue) * 5 + // 宽度范围约 1-4.5,避免低权重边过于抢眼 + return 1 + (value / maxValue) * 3.5 +} + +function getMetricLine(label: string, value: unknown): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null + return `${label}: ${value}` } const option = computed(() => { const maxLinkValue = props.data.maxLinkValue || Math.max(...props.data.links.map((l) => l.value || 1), 1) return { + backgroundColor: 'transparent', tooltip: { trigger: 'item', - backgroundColor: isDark.value ? 'rgba(30, 30, 30, 0.9)' : 'rgba(255, 255, 255, 0.95)', - borderColor: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)', + backgroundColor: isDark.value ? 'rgba(12, 18, 26, 0.94)' : 'rgba(255, 255, 255, 0.96)', + borderColor: isDark.value ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.08)', textStyle: { - color: isDark.value ? '#e5e7eb' : '#374151', + color: isDark.value ? '#e2e8f0' : '#334155', }, formatter: (params: any) => { if (params.dataType === 'node') { - return `${params.data.name}
消息数: ${params.data.value || 0}` + const lines: string[] = [`${params.data.name}`] + const messageLine = getMetricLine('Messages', params.data.messageCount) + const degreeLine = getMetricLine('Weight', params.data.weightedDegree) + const mentionLine = getMetricLine('@ Mentions', params.data.totalMentions) + const communityLine = getMetricLine('Community Size', params.data.communitySize) + if (messageLine) lines.push(messageLine) + if (degreeLine) lines.push(degreeLine) + if (mentionLine) lines.push(mentionLine) + if (communityLine) lines.push(communityLine) + return lines.join('
') } else if (params.dataType === 'edge') { - return `${params.data.source} → ${params.data.target}
艾特次数: ${params.data.value || 0}` + const lines: string[] = [ + `${params.data.source} ↔ ${params.data.target}`, + `Strength: ${params.data.value || 0}`, + ] + const mentionLine = getMetricLine('@ Interactions', params.data.mentionCount) + const temporalLine = getMetricLine('Temporal Turns', params.data.temporalTurns) + const reciprocityLine = getMetricLine('Reciprocity', params.data.reciprocity) + if (mentionLine) lines.push(mentionLine) + if (temporalLine) lines.push(temporalLine) + if (reciprocityLine) lines.push(reciprocityLine) + return lines.join('
') } return '' }, }, + legend: + props.showLegend && graphCategories.value.length > 0 + ? { + top: 0, + left: 'center', + itemWidth: 10, + itemHeight: 10, + textStyle: { + color: isDark.value ? '#94a3b8' : '#475569', + fontSize: 11, + }, + data: graphCategories.value.map((item) => item.name), + } + : undefined, // 动画效果 animationDuration: 1000, animationDurationUpdate: 500, @@ -136,13 +217,17 @@ const option = computed(() => { force: props.layout === 'force' ? { - repulsion: 300, - gravity: 0.1, - edgeLength: [80, 200], + initLayout: 'circular', + repulsion: 360, + gravity: 0.28, + edgeLength: [32, 150], friction: 0.6, + layoutAnimation: true, } : undefined, roam: true, + progressiveThreshold: 1200, + progressive: 240, scaleLimit: { min: 0.3, // 最小缩放 30% max: 3, // 最大缩放 300% @@ -159,8 +244,10 @@ const option = computed(() => { edgeSymbol: props.directed ? ['none', 'arrow'] : ['none', 'none'], edgeSymbolSize: props.directed ? [0, 10] : [0, 0], lineStyle: { - curveness: 0.3, // 始终使用曲线 - opacity: 0.5, + color: 'source', + curveness: 0.28, + opacity: 0.28, + width: 1.5, }, emphasis: { focus: 'adjacency', @@ -170,46 +257,78 @@ const option = computed(() => { fontWeight: 600, }, lineStyle: { - width: 4, - opacity: 0.9, + width: 4.5, + opacity: 0.95, }, itemStyle: { shadowBlur: 15, shadowColor: 'rgba(0, 0, 0, 0.3)', }, }, + blur: { + itemStyle: { + opacity: 0.22, + }, + lineStyle: { + opacity: 0.04, + }, + label: { + opacity: 0.22, + }, + }, // 节点数据(使用去重后的节点) data: uniqueNodes.value.map((node) => { const color = nodeColorMap.value.get(node.name) || colorPalette[0] + const hasSelection = Boolean(props.selectedNode) + const isSelected = props.selectedNode === node.name + const isAdjacent = selectedAdjacency.value.has(node.name) + const isContextNode = !hasSelection || isSelected || isAdjacent + const baseSize = node.symbolSize || 30 return { + ...node, + id: String(node.id), name: node.name, value: node.value, - symbolSize: node.symbolSize || 30, + category: node.category, + symbolSize: isSelected ? baseSize + 6 : baseSize, // circular 布局显示所有标签,force 布局只显示大节点的标签 label: { - show: props.layout === 'circular' ? true : (node.symbolSize || 30) > 30, + show: hasSelection ? isContextNode : props.layout === 'circular' ? true : baseSize > 35, + color: isDark.value ? '#f1f5f9' : '#1e293b', + textBorderColor: isDark.value ? '#0f172a' : '#ffffff', + textBorderWidth: isSelected ? 2 : 1.5, }, itemStyle: { color: color, - borderColor: '#fff', - borderWidth: 2, - shadowBlur: 5, - shadowColor: `${color}66`, // 同色系阴影 + borderColor: isDark.value ? '#1e293b' : '#ffffff', + borderWidth: isSelected ? 4 : baseSize > 20 ? 3 : 1.5, + shadowBlur: props.neon ? (isSelected ? 26 : 20) : isSelected ? 12 : 6, + shadowColor: props.neon ? color : `${color}66`, + opacity: isContextNode ? 1 : 0.22, }, } }), + categories: graphCategories.value, // 连接线数据(颜色跟随源节点,过滤掉引用不存在节点的链接) links: props.data.links .filter((link) => nodeColorMap.value.has(link.source) && nodeColorMap.value.has(link.target)) .map((link) => { const sourceColor = nodeColorMap.value.get(link.source) || colorPalette[0] + const weight = link.value || 1 + const baseOpacity = maxLinkValue > 0 ? 0.2 + (weight / maxLinkValue) * 0.5 : 0.3 + const isContextLink = + !props.selectedNode || link.source === props.selectedNode || link.target === props.selectedNode return { + ...link, source: link.source, target: link.target, value: link.value, lineStyle: { color: sourceColor, - width: getLinkWidth(link.value || 1, maxLinkValue), + width: isContextLink + ? getLinkWidth(weight, maxLinkValue) + (props.selectedNode ? 0.8 : 0) + : Math.max(0.8, getLinkWidth(weight, maxLinkValue) * 0.45), + opacity: isContextLink ? baseOpacity : 0.03, }, } }), @@ -226,6 +345,18 @@ function initChart() { renderer: 'canvas', }) chartInstance.setOption(option.value) + + chartInstance.on('click', (params: any) => { + if (params?.dataType === 'node') { + emit('node-click', params?.data?.name || null) + } + }) + + chartInstance.getZr().on('click', (event: any) => { + if (!event?.target) { + emit('node-click', null) + } + }) } // 更新图表 @@ -254,7 +385,7 @@ defineExpose({ // 监听数据和主题变化 watch( - [() => props.data, () => props.layout, () => props.directed, isDark], + [() => props.data, () => props.layout, () => props.directed, () => props.selectedNode, isDark], () => { if (chartInstance) { updateChart() diff --git a/src/components/common/Sidebar.vue b/src/components/common/Sidebar.vue index 9f8fca3..11a0118 100644 --- a/src/components/common/Sidebar.vue +++ b/src/components/common/Sidebar.vue @@ -292,7 +292,18 @@ function getSessionAvatar(session: AnalysisSession): string | null { ? 'justify-center cursor-pointer h-13 w-13 rounded-full ml-3.5' : 'cursor-pointer w-full rounded-full', ]" - @click="router.push({ name: getSessionRouteName(session), params: { id: session.id } })" + @click=" + router.push({ + name: getSessionRouteName(session), + params: { id: session.id }, + query: + route.name === 'group-chat' && + getSessionRouteName(session) === 'group-chat' && + (route.query.tab as string) === 'view' + ? { tab: 'view' } + : undefined, + }) + " > diff --git a/src/components/view/InteractionView.vue b/src/components/view/InteractionView.vue index d468412..a72fb88 100644 --- a/src/components/view/InteractionView.vue +++ b/src/components/view/InteractionView.vue @@ -1,12 +1,16 @@ { "zh-CN": { - "mentionGraph": "艾特互动关系图", - "layout": "布局", - "circular": "环形", - "force": "力导向", + "controls": { + "title": "互动关系模型", + "description": "基于 {'@'} 互动、相邻时间共现与互惠度推测成员关系" + }, + "section": { + "relationshipTitle": "关系模型图谱", + "relationshipDescription": "用于推测成员之间潜在亲近关系", + "mentionTitle": "{'@'} 互动图谱", + "mentionDescription": "仅展示显式 {'@'} 互动边" + }, + "mode": { + "relationship": "关系模型", + "mention": "艾特图" + }, + "viewMode": { + "core": "核心关系", + "full": "全部关系" + }, + "layout": { + "force": "力导向", + "circular": "环形" + }, "directed": "有向", - "reset": "重置", - "graphHint": "共 {nodes} 位成员,{links} 条互动关系", - "noInteraction": "暂无艾特互动数据" + "legend": "图例", + "resetView": "重置视图", + "modelSettings": "模型参数", + "weightTitle": "特征权重(会自动归一化)", + "weightTotal": "权重总和:{value}", + "weight": { + "mention": "{'@'} 互动", + "temporal": "时间相邻", + "reciprocity": "互惠度" + }, + "windowSeconds": "时间窗口(秒)", + "decaySeconds": "衰减常数(秒)", + "minScore": "最小关系分", + "minTemporalTurns": "最小时序共现", + "topEdges": "最多边数", + "minRenderScore": "可视化强度阈值", + "hideIsolates": "隐藏孤立成员", + "insightTitle": "关系洞察", + "selectedNode": "成员焦点", + "clearFocus": "清除焦点", + "focusHint": "点击图中的成员查看其最强联系", + "neighborCount": "可见邻接关系 {count} 条", + "noStrongLinks": "当前筛选下暂无明显关系", + "temporalTurns": "时序共现", + "reciprocity": "互惠度", + "resetModel": "恢复默认", + "apply": "应用参数", + "graphHint": "共 {nodes} 位成员,{links} 条互动边", + "relationshipHint": "共 {nodes} 位成员,{links} 条关系边,{communities} 个社群", + "noInteraction": "暂无互动关系数据", + "loading": "正在加载互动关系图...", + "loadFailedRelationship": "加载关系图失败,请稍后重试", + "loadFailedMention": "加载 {'@'} 互动图失败,请稍后重试", + "stat": { + "members": "成员", + "edges": "边", + "communities": "社群", + "total": "总成员", + "raw": "候选边", + "displayEdges": "展示边", + "coreView": "核心视图", + "maxTemporal": "时序强度峰值 {value}", + "mentionMode": "艾特模式", + "mentionHint": "仅统计 {'@'} 边" + } }, "en-US": { - "mentionGraph": "Mention Interaction Graph", - "layout": "Layout", - "circular": "Circular", - "force": "Force", + "mode": { + "relationship": "Relation Model", + "mention": "Mention Graph" + }, + "viewMode": { + "core": "Core", + "full": "All" + }, + "layout": { + "force": "Force", + "circular": "Circular" + }, + "controls": { + "title": "Interaction Relation Model", + "description": "Estimate member closeness from mentions, temporal adjacency, and reciprocity" + }, + "section": { + "relationshipTitle": "Relationship Graph", + "relationshipDescription": "Potential closeness inferred from behavior signals", + "mentionTitle": "Mention Graph", + "mentionDescription": "Explicit {'@'} mention interactions only" + }, "directed": "Directed", - "reset": "Reset", - "graphHint": "{nodes} members, {links} interactions", - "noInteraction": "No mention interaction data" + "legend": "Legend", + "resetView": "Reset View", + "modelSettings": "Model Settings", + "weightTitle": "Feature weights (auto normalized)", + "weightTotal": "Weight sum: {value}", + "weight": { + "mention": "{'@'} Mention", + "temporal": "Temporal", + "reciprocity": "Reciprocity" + }, + "windowSeconds": "Window (sec)", + "decaySeconds": "Decay (sec)", + "minScore": "Min Score", + "minTemporalTurns": "Min Temporal Turns", + "topEdges": "Max Edges", + "minRenderScore": "Display strength threshold", + "hideIsolates": "Hide isolates", + "insightTitle": "Relationship Insights", + "selectedNode": "Member Focus", + "clearFocus": "Clear", + "focusHint": "Click a member node to inspect strongest links", + "neighborCount": "{count} visible connected links", + "noStrongLinks": "No strong links under current filter", + "temporalTurns": "Temporal turns", + "reciprocity": "Reciprocity", + "resetModel": "Reset Defaults", + "apply": "Apply", + "graphHint": "{nodes} members, {links} interaction edges", + "relationshipHint": "{nodes} members, {links} edges, {communities} communities", + "noInteraction": "No interaction data", + "loading": "Loading interaction graph...", + "loadFailedRelationship": "Failed to load relationship graph", + "loadFailedMention": "Failed to load {'@'} mention graph", + "stat": { + "members": "Members", + "edges": "Edges", + "communities": "Communities", + "total": "Total", + "raw": "Raw edges", + "displayEdges": "Shown edges", + "coreView": "Core view", + "maxTemporal": "Peak temporal {value}", + "mentionMode": "Mention mode", + "mentionHint": "{'@'}-only edges" + } } } diff --git a/src/pages/group-chat/index.vue b/src/pages/group-chat/index.vue index 31ffaa8..3b57731 100644 --- a/src/pages/group-chat/index.vue +++ b/src/pages/group-chat/index.vue @@ -173,6 +173,21 @@ watch( } ) +// 同步主 Tab 到 URL,便于从 home/siderbar 直接恢复到指定功能页 +watch( + () => activeTab.value, + (newTab) => { + if (!newTab) return + if (route.query.tab === newTab) return + router.replace({ + query: { + ...route.query, + tab: newTab, + }, + }) + } +) + // 监听会话变化(切换会话时清空时间范围,等待 TimeSelect 重新拉取) watch( currentSessionId, From def4b4bff1751b8804dcdda5e722a8f8c3c1d84e Mon Sep 17 00:00:00 2001 From: n-WN <30841158+n-WN@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:16:51 +0800 Subject: [PATCH 4/4] docs(tooling): add relationship model CLI generator and guide --- docs/member-relationship-model.md | 67 +++ package.json | 1 + .../generate-member-relationship-graph.cjs | 457 ++++++++++++++++++ 3 files changed, 525 insertions(+) create mode 100644 docs/member-relationship-model.md create mode 100644 scripts/generate-member-relationship-graph.cjs diff --git a/docs/member-relationship-model.md b/docs/member-relationship-model.md new file mode 100644 index 0000000..3c696dd --- /dev/null +++ b/docs/member-relationship-model.md @@ -0,0 +1,67 @@ +# Member Relationship Model (Group Chat) + +## Goal + +Infer closer member relationships in group chat by combining: + +1. Explicit interaction signal (`@mentions`) +2. Temporal adjacency signal (members speaking near each other in time) + +## Scoring + +For each member pair `(A, B)`: + +- `mentionCount(A,B)`: undirected mention count (`A->B + B->A`) +- `temporalTurns(A,B)`: count of adjacent message turns between `A` and `B` within time window +- `temporalScore(A,B)`: `sum(exp(-delta / decaySeconds))` over adjacent turns + +Normalize each signal across all pairs: + +- `mentionNorm = mentionCount / maxMentionCount` +- `temporalNorm = temporalScore / maxTemporalScore` + +Final closeness: + +`closeness = mentionWeight * mentionNorm + temporalWeight * temporalNorm` + +## Default Parameters + +- `windowSeconds = 300` +- `decaySeconds = 120` +- `mentionWeight = 0.6` +- `temporalWeight = 0.4` +- `minScore = 0.12` +- `minTemporalTurns = 2` + +## Current Implementation + +Script: `scripts/generate-member-relationship-graph.cjs` + +Run: + +```bash +pnpm run analyze:relationship +``` + +Outputs: + +- `data/member-relationship-model.json`: nodes, edges, score components +- `data/member-relationship-graph.mmd`: Mermaid graph + +Adjust weights (example: emphasize temporal closeness): + +```bash +node scripts/generate-member-relationship-graph.cjs \ + --mention-weight 0.2 \ + --temporal-weight 0.8 \ + --min-score 0.03 +``` + +## App Integration Status + +Integrated into app pipeline: + +1. Worker query: `electron/main/worker/query/advanced/social.ts` (`getRelationshipGraph`) +2. IPC handler: `electron/main/ipc/chat.ts` (`chat:getRelationshipGraph`) +3. Preload API: `electron/preload/apis/chat.ts` (`chatApi.getRelationshipGraph`) +4. UI: `src/components/view/InteractionView.vue` (new relationship mode + parameter controls) diff --git a/package.json b/package.json index d6f50db..c1112c7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "scripts": { "dev": "electron-vite dev", "preview": "electron-vite preview", + "analyze:relationship": "node scripts/generate-member-relationship-graph.cjs", "format": "prettier --write .", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "build": "electron-vite build", diff --git a/scripts/generate-member-relationship-graph.cjs b/scripts/generate-member-relationship-graph.cjs new file mode 100644 index 0000000..4c6df78 --- /dev/null +++ b/scripts/generate-member-relationship-graph.cjs @@ -0,0 +1,457 @@ +#!/usr/bin/env node +'use strict' + +/** + * Generate member relationship graph from exported chat JSON. + * Model: + * closeness = mentionWeight * normalizedMention + temporalWeight * normalizedTemporal + */ + +const fs = require('fs') +const path = require('path') + +function parseArgs(argv) { + const args = { + input: '', + outputJson: path.join('data', 'member-relationship-model.json'), + outputMermaid: path.join('data', 'member-relationship-graph.mmd'), + windowSeconds: 300, + decaySeconds: 120, + mentionWeight: 0.6, + temporalWeight: 0.4, + minScore: 0.12, + minTemporalTurns: 2, + topEdges: 80, + includeBots: false, + } + + for (let i = 0; i < argv.length; i++) { + const raw = argv[i] + if (!raw.startsWith('--')) continue + const [k, vMaybe] = raw.split('=') + const key = k.slice(2) + const next = vMaybe !== undefined ? vMaybe : argv[i + 1] + const useNext = vMaybe === undefined + + const applyNum = (target) => { + const n = Number(next) + if (!Number.isFinite(n)) throw new Error(`Invalid numeric value for --${key}: ${next}`) + args[target] = n + } + + switch (key) { + case 'input': + args.input = String(next) + if (useNext) i++ + break + case 'output-json': + args.outputJson = String(next) + if (useNext) i++ + break + case 'output-mermaid': + args.outputMermaid = String(next) + if (useNext) i++ + break + case 'window-seconds': + applyNum('windowSeconds') + if (useNext) i++ + break + case 'decay-seconds': + applyNum('decaySeconds') + if (useNext) i++ + break + case 'mention-weight': + applyNum('mentionWeight') + if (useNext) i++ + break + case 'temporal-weight': + applyNum('temporalWeight') + if (useNext) i++ + break + case 'min-score': + applyNum('minScore') + if (useNext) i++ + break + case 'min-temporal-turns': + applyNum('minTemporalTurns') + if (useNext) i++ + break + case 'top-edges': + applyNum('topEdges') + if (useNext) i++ + break + case 'include-bots': + args.includeBots = true + break + default: + throw new Error(`Unknown argument: ${raw}`) + } + } + + const weightSum = args.mentionWeight + args.temporalWeight + if (weightSum <= 0) throw new Error('mentionWeight + temporalWeight must be > 0') + args.mentionWeight /= weightSum + args.temporalWeight /= weightSum + + if (args.windowSeconds <= 0) throw new Error('windowSeconds must be > 0') + if (args.decaySeconds <= 0) throw new Error('decaySeconds must be > 0') + + return args +} + +function pickInputFile(inputArg) { + if (inputArg) return inputArg + const dataDir = path.join(process.cwd(), 'data') + if (!fs.existsSync(dataDir)) { + throw new Error(`data directory not found: ${dataDir}`) + } + const files = fs + .readdirSync(dataDir) + .filter((name) => name.toLowerCase().endsWith('.json')) + .map((name) => path.join(dataDir, name)) + if (files.length === 0) throw new Error(`No JSON file found in ${dataDir}`) + files.sort((a, b) => a.localeCompare(b)) + return files[0] +} + +function normalizeName(candidate, fallback) { + if (typeof candidate === 'string') { + const trimmed = candidate.trim() + if (trimmed) return trimmed + } + return fallback +} + +function upsertMember(map, memberLike) { + const id = String(memberLike?.id || '') + if (!id) return null + const existing = map.get(id) + const name = normalizeName(memberLike?.nickname, normalizeName(memberLike?.name, id)) + if (existing) { + // Prefer nickname when available, else keep existing. + if (name && existing.name === existing.id) existing.name = name + return existing + } + const member = { + id, + name, + isBot: Boolean(memberLike?.isBot), + messageCount: 0, + mentionOut: 0, + mentionIn: 0, + totalCloseness: 0, + degree: 0, + } + map.set(id, member) + return member +} + +function directedKey(fromId, toId) { + return `${fromId}=>${toId}` +} + +function undirectedKey(aId, bId) { + return aId < bId ? `${aId}<->${bId}` : `${bId}<->${aId}` +} + +function parseDirectedKey(key) { + const [from, to] = key.split('=>') + return { from, to } +} + +function round(value, digits = 4) { + const p = 10 ** digits + return Math.round(value * p) / p +} + +function escapeLabel(label) { + return String(label).replace(/"/g, "'") +} + +function buildGraphModel(payload, args, inputPath) { + const messages = Array.isArray(payload?.messages) ? payload.messages : [] + if (messages.length === 0) { + throw new Error('Input JSON has no messages[]') + } + + const members = new Map() + const mentionDirected = new Map() + const timeline = [] + + for (const msg of messages) { + const author = upsertMember(members, msg?.author) + if (!author) continue + if (!args.includeBots && author.isBot) continue + + author.messageCount += 1 + + const ts = Date.parse(msg?.timestamp || '') + if (Number.isFinite(ts)) { + timeline.push({ + messageId: String(msg?.id || ''), + authorId: author.id, + ts, + }) + } + + const seenMentioned = new Set() + const mentions = Array.isArray(msg?.mentions) ? msg.mentions : [] + for (const m of mentions) { + const target = upsertMember(members, m) + if (!target) continue + if (!args.includeBots && target.isBot) continue + if (target.id === author.id) continue + if (seenMentioned.has(target.id)) continue + seenMentioned.add(target.id) + + const key = directedKey(author.id, target.id) + mentionDirected.set(key, (mentionDirected.get(key) || 0) + 1) + author.mentionOut += 1 + target.mentionIn += 1 + } + } + + timeline.sort((a, b) => a.ts - b.ts) + + const pairStats = new Map() + + for (const [key, count] of mentionDirected.entries()) { + const { from, to } = parseDirectedKey(key) + const pKey = undirectedKey(from, to) + const pair = pairStats.get(pKey) || { + aId: from < to ? from : to, + bId: from < to ? to : from, + mentionAB: 0, + mentionBA: 0, + mentionTotal: 0, + temporalTurns: 0, + temporalScore: 0, + temporalDeltaSum: 0, + } + + if (from === pair.aId && to === pair.bId) { + pair.mentionAB += count + } else { + pair.mentionBA += count + } + pair.mentionTotal += count + pairStats.set(pKey, pair) + } + + for (let i = 0; i < timeline.length - 1; i++) { + const anchor = timeline[i] + const seenPartnerInWindow = new Set() + + for (let j = i + 1; j < timeline.length; j++) { + const candidate = timeline[j] + const deltaSec = (candidate.ts - anchor.ts) / 1000 + if (deltaSec <= 0) continue + if (deltaSec > args.windowSeconds) break + if (anchor.authorId === candidate.authorId) continue + if (seenPartnerInWindow.has(candidate.authorId)) continue + + seenPartnerInWindow.add(candidate.authorId) + + const pKey = undirectedKey(anchor.authorId, candidate.authorId) + const pair = pairStats.get(pKey) || { + aId: anchor.authorId < candidate.authorId ? anchor.authorId : candidate.authorId, + bId: anchor.authorId < candidate.authorId ? candidate.authorId : anchor.authorId, + mentionAB: 0, + mentionBA: 0, + mentionTotal: 0, + temporalTurns: 0, + temporalScore: 0, + temporalDeltaSum: 0, + } + + const temporalWeight = Math.exp(-deltaSec / args.decaySeconds) + pair.temporalTurns += 1 + pair.temporalScore += temporalWeight + pair.temporalDeltaSum += deltaSec + pairStats.set(pKey, pair) + } + } + + const pairs = Array.from(pairStats.values()) + const maxMention = Math.max(...pairs.map((p) => p.mentionTotal), 0) + const maxTemporal = Math.max(...pairs.map((p) => p.temporalScore), 0) + + const edges = [] + for (const pair of pairs) { + const mentionNorm = maxMention > 0 ? pair.mentionTotal / maxMention : 0 + const temporalNorm = maxTemporal > 0 ? pair.temporalScore / maxTemporal : 0 + const closeness = args.mentionWeight * mentionNorm + args.temporalWeight * temporalNorm + + const hasSignal = pair.mentionTotal > 0 || pair.temporalTurns >= args.minTemporalTurns + if (!hasSignal || closeness < args.minScore) continue + + const a = members.get(pair.aId) + const b = members.get(pair.bId) + if (!a || !b) continue + + edges.push({ + sourceId: pair.aId, + targetId: pair.bId, + source: a.name, + target: b.name, + value: round(closeness, 4), + mentionCount: pair.mentionTotal, + mentionAB: pair.mentionAB, + mentionBA: pair.mentionBA, + mentionNorm: round(mentionNorm, 4), + temporalTurns: pair.temporalTurns, + temporalScore: round(pair.temporalScore, 4), + temporalNorm: round(temporalNorm, 4), + avgDeltaSec: pair.temporalTurns > 0 ? round(pair.temporalDeltaSum / pair.temporalTurns, 2) : null, + }) + } + + edges.sort((a, b) => { + if (b.value !== a.value) return b.value - a.value + if (b.mentionCount !== a.mentionCount) return b.mentionCount - a.mentionCount + return b.temporalScore - a.temporalScore + }) + + const keptEdges = edges.slice(0, Math.max(1, Math.floor(args.topEdges))) + const involved = new Set() + for (const e of keptEdges) { + involved.add(e.sourceId) + involved.add(e.targetId) + } + + const nodes = Array.from(members.values()) + .filter((m) => involved.has(m.id)) + .map((m) => ({ + id: m.id, + name: m.name, + isBot: m.isBot, + messageCount: m.messageCount, + mentionOut: m.mentionOut, + mentionIn: m.mentionIn, + })) + + const nodeById = new Map(nodes.map((n) => [n.id, n])) + for (const edge of keptEdges) { + const s = nodeById.get(edge.sourceId) + const t = nodeById.get(edge.targetId) + if (s) { + s.degree = (s.degree || 0) + 1 + s.totalCloseness = round((s.totalCloseness || 0) + edge.value, 4) + } + if (t) { + t.degree = (t.degree || 0) + 1 + t.totalCloseness = round((t.totalCloseness || 0) + edge.value, 4) + } + } + + nodes.sort((a, b) => { + const bScore = b.totalCloseness || 0 + const aScore = a.totalCloseness || 0 + if (bScore !== aScore) return bScore - aScore + return (b.messageCount || 0) - (a.messageCount || 0) + }) + + const model = { + meta: { + inputPath, + generatedAt: new Date().toISOString(), + messageCount: messages.length, + timelineCount: timeline.length, + memberCount: members.size, + windowSeconds: args.windowSeconds, + decaySeconds: args.decaySeconds, + mentionWeight: round(args.mentionWeight, 4), + temporalWeight: round(args.temporalWeight, 4), + minScore: args.minScore, + minTemporalTurns: args.minTemporalTurns, + topEdges: args.topEdges, + includeBots: args.includeBots, + dateRange: payload?.dateRange || null, + guild: payload?.guild || null, + channel: payload?.channel || null, + }, + stats: { + maxMentionCount: maxMention, + maxTemporalScore: round(maxTemporal, 4), + rawEdgeCount: edges.length, + keptEdgeCount: keptEdges.length, + keptNodeCount: nodes.length, + }, + nodes, + edges: keptEdges, + topRelations: keptEdges.slice(0, 20), + } + + return model +} + +function ensureParent(filePath) { + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) +} + +function buildMermaid(model) { + const idMap = new Map() + model.nodes.forEach((node, idx) => { + idMap.set(node.id, `n${idx + 1}`) + }) + + const lines = [] + lines.push('%% Auto-generated by scripts/generate-member-relationship-graph.cjs') + lines.push('%% Relation score = mentionWeight * mentionNorm + temporalWeight * temporalNorm') + lines.push('graph LR') + + for (const node of model.nodes) { + const nid = idMap.get(node.id) + const label = `${escapeLabel(node.name)} | msg:${node.messageCount} | deg:${node.degree || 0}` + lines.push(` ${nid}["${label}"]`) + } + + for (const edge of model.edges) { + const s = idMap.get(edge.sourceId) + const t = idMap.get(edge.targetId) + if (!s || !t) continue + const edgeLabel = `S:${edge.value} @:${edge.mentionCount} T:${edge.temporalTurns}` + lines.push(` ${s} ---|"${edgeLabel}"| ${t}`) + } + + return lines.join('\n') + '\n' +} + +function main() { + const args = parseArgs(process.argv.slice(2)) + const inputPath = pickInputFile(args.input) + + const raw = JSON.parse(fs.readFileSync(inputPath, 'utf8')) + const model = buildGraphModel(raw, args, inputPath) + const mermaid = buildMermaid(model) + + ensureParent(args.outputJson) + ensureParent(args.outputMermaid) + + fs.writeFileSync(args.outputJson, JSON.stringify(model, null, 2), 'utf8') + fs.writeFileSync(args.outputMermaid, mermaid, 'utf8') + + const topPreview = model.topRelations.slice(0, 10).map((edge, i) => ({ + rank: i + 1, + pair: `${edge.source} <-> ${edge.target}`, + score: edge.value, + mentionCount: edge.mentionCount, + temporalTurns: edge.temporalTurns, + })) + + console.log(`Input: ${inputPath}`) + console.log(`Output JSON: ${args.outputJson}`) + console.log(`Output Mermaid: ${args.outputMermaid}`) + console.log( + `Members: ${model.meta.memberCount}, Messages: ${model.meta.messageCount}, Nodes: ${model.stats.keptNodeCount}, Edges: ${model.stats.keptEdgeCount}` + ) + console.log('Top relations:') + for (const row of topPreview) { + console.log( + `${String(row.rank).padStart(2, '0')}. ${row.pair} | score=${row.score} | @=${row.mentionCount} | temporal=${row.temporalTurns}` + ) + } +} + +main()