feat: 私聊下新增主动性分析视图

This commit is contained in:
digua
2026-04-09 23:48:33 +08:00
committed by digua
parent 0b89076e40
commit 11e530e5d4
22 changed files with 1628 additions and 10 deletions
+20
View File
@@ -598,6 +598,26 @@ export function registerChatHandlers(ctx: IpcContext): void {
}
)
/**
* 获取关系主动性分析数据(私聊专属)
*/
ipcMain.handle(
'chat:getRelationshipStats',
async (
_,
sessionId: string,
filter?: { startTs?: number; endTs?: number },
options?: { perseveranceThreshold?: number }
) => {
try {
return await worker.getRelationshipStats(sessionId, filter, options)
} catch (error) {
console.error('Failed to get relationship stats:', error)
return { months: [], members: [], totalSessions: 0, hasSessionIndex: false }
}
}
)
// ==================== 成员管理 ====================
/**
+2
View File
@@ -33,6 +33,7 @@ import {
getMentionGraph,
getLaughAnalysis,
getClusterGraph,
getRelationshipStats,
searchMessages,
deepSearchMessages,
getMessageContext,
@@ -174,6 +175,7 @@ const syncHandlers: Record<string, (payload: any) => any> = {
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),
getRelationshipStats: (p) => getRelationshipStats(p.sessionId, p.filter, p.options),
// AI 查询
searchMessages: (p) => searchMessages(p.sessionId, p.keywords, p.filter, p.limit, p.offset, p.senderId),
@@ -17,3 +17,13 @@ export type {
ClusterGraphLink,
ClusterGraphOptions,
} from './social'
// 关系分析(私聊主动性)
export { getRelationshipStats } from './relationship'
export type {
RelationshipStats,
RelationshipMonthStats,
IceBreakerItem,
ResponseLatencyMember,
PerseveranceMember,
} from './relationship'
@@ -0,0 +1,458 @@
/**
* 关系分析模块(私聊专属)
* 基于会话索引统计双方的主动发起、收尾、破冰、响应时延、锲而不舍行为
*/
import { openDatabase, type TimeFilter } from '../../core'
interface MemberMonthCount {
memberId: number
name: string
initiateCount: number
closeCount: number
}
export interface RelationshipMonthStats {
month: string
members: MemberMonthCount[]
totalSessions: number
}
export interface IceBreakerItem {
month: string
memberId: number
name: string
count: number
}
export interface ResponseLatencyMember {
memberId: number
name: string
avgResponseTime: number
totalResponses: number
}
export interface PerseveranceMember {
memberId: number
name: string
totalDoubleTexts: number
}
export interface MonthlyResponseLatency {
month: string
members: Array<{
memberId: number
name: string
avgResponseTime: number
responseCount: number
}>
}
export interface MonthlyPerseverance {
month: string
members: Array<{
memberId: number
name: string
doubleTextCount: number
}>
}
export interface RelationshipOptions {
perseveranceThreshold?: number
}
export interface RelationshipStats {
months: RelationshipMonthStats[]
members: Array<{
memberId: number
name: string
totalInitiateCount: number
totalCloseCount: number
}>
totalSessions: number
hasSessionIndex: boolean
iceBreakers: IceBreakerItem[]
totalIceBreaks: number
responseLatency: ResponseLatencyMember[]
perseverance: PerseveranceMember[]
totalDoubleTexts: number
monthlyResponseLatency: MonthlyResponseLatency[]
monthlyPerseverance: MonthlyPerseverance[]
perseveranceThreshold: number
}
const ICE_BREAK_THRESHOLD = 24 * 60 * 60
const DEFAULT_PERSEVERANCE_THRESHOLD = 300 // 5 minutes
export function getRelationshipStats(
sessionId: string,
filter?: TimeFilter,
options?: RelationshipOptions
): RelationshipStats {
const perseveranceThreshold = options?.perseveranceThreshold ?? DEFAULT_PERSEVERANCE_THRESHOLD
const db = openDatabase(sessionId)
const emptyResult: RelationshipStats = {
months: [],
members: [],
totalSessions: 0,
hasSessionIndex: false,
iceBreakers: [],
totalIceBreaks: 0,
responseLatency: [],
perseverance: [],
totalDoubleTexts: 0,
monthlyResponseLatency: [],
monthlyPerseverance: [],
perseveranceThreshold,
}
if (!db) return emptyResult
const sessionCount = db.prepare('SELECT COUNT(*) as count FROM chat_session').get() as { count: number } | undefined
if (!sessionCount || sessionCount.count === 0) {
return emptyResult
}
const timeConditions: string[] = []
const params: (number | string)[] = []
if (filter?.startTs !== undefined) {
timeConditions.push('cs.start_ts >= ?')
params.push(filter.startTs)
}
if (filter?.endTs !== undefined) {
timeConditions.push('cs.start_ts <= ?')
params.push(filter.endTs)
}
const whereClause = timeConditions.length > 0 ? `WHERE ${timeConditions.join(' AND ')}` : ''
// ==================== Session-level ====================
const sessionRows = db
.prepare(
`
SELECT
cs.id AS session_id,
cs.start_ts,
cs.end_ts,
(
SELECT m.sender_id
FROM message_context mc
JOIN message m ON m.id = mc.message_id
WHERE mc.session_id = cs.id
ORDER BY m.ts ASC, m.id ASC
LIMIT 1
) AS initiator_id,
(
SELECT m.sender_id
FROM message_context mc
JOIN message m ON m.id = mc.message_id
WHERE mc.session_id = cs.id
ORDER BY m.ts DESC, m.id DESC
LIMIT 1
) AS closer_id
FROM chat_session cs
${whereClause}
ORDER BY cs.start_ts ASC
`
)
.all(...params) as Array<{
session_id: number
start_ts: number
end_ts: number
initiator_id: number | null
closer_id: number | null
}>
const memberNames = new Map<number, string>()
const memberRows = db
.prepare('SELECT id, COALESCE(group_nickname, account_name, platform_id) as name FROM member')
.all() as Array<{ id: number; name: string }>
for (const row of memberRows) {
memberNames.set(row.id, row.name)
}
const monthMap = new Map<
string,
{ initiateMap: Map<number, number>; closeMap: Map<number, number>; totalSessions: number }
>()
const memberInitTotals = new Map<number, number>()
const memberCloseTotals = new Map<number, number>()
const iceBreakMap = new Map<string, Map<number, number>>()
let totalIceBreaks = 0
let prevEndTs: number | null = null
for (const row of sessionRows) {
const month = toLocalMonth(row.start_ts)
if (!monthMap.has(month)) {
monthMap.set(month, { initiateMap: new Map(), closeMap: new Map(), totalSessions: 0 })
}
const ms = monthMap.get(month)!
ms.totalSessions++
if (row.initiator_id !== null) {
ms.initiateMap.set(row.initiator_id, (ms.initiateMap.get(row.initiator_id) ?? 0) + 1)
memberInitTotals.set(row.initiator_id, (memberInitTotals.get(row.initiator_id) ?? 0) + 1)
}
if (row.closer_id !== null) {
ms.closeMap.set(row.closer_id, (ms.closeMap.get(row.closer_id) ?? 0) + 1)
memberCloseTotals.set(row.closer_id, (memberCloseTotals.get(row.closer_id) ?? 0) + 1)
}
if (prevEndTs !== null && row.initiator_id !== null) {
if (row.start_ts - prevEndTs > ICE_BREAK_THRESHOLD) {
if (!iceBreakMap.has(month)) iceBreakMap.set(month, new Map())
const mMap = iceBreakMap.get(month)!
mMap.set(row.initiator_id, (mMap.get(row.initiator_id) ?? 0) + 1)
totalIceBreaks++
}
}
prevEndTs = row.end_ts
}
const allMemberIds = new Set<number>()
for (const id of memberInitTotals.keys()) allMemberIds.add(id)
for (const id of memberCloseTotals.keys()) allMemberIds.add(id)
const monthKeys = Array.from(monthMap.keys()).sort((a, b) => b.localeCompare(a))
const months: RelationshipMonthStats[] = monthKeys.map((month) => {
const ms = monthMap.get(month)!
const members: MemberMonthCount[] = Array.from(allMemberIds).map((memberId) => ({
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
initiateCount: ms.initiateMap.get(memberId) ?? 0,
closeCount: ms.closeMap.get(memberId) ?? 0,
}))
return { month, members, totalSessions: ms.totalSessions }
})
const totalSessions = sessionRows.length
const members = Array.from(allMemberIds)
.map((memberId) => ({
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
totalInitiateCount: memberInitTotals.get(memberId) ?? 0,
totalCloseCount: memberCloseTotals.get(memberId) ?? 0,
}))
.sort((a, b) => b.totalInitiateCount - a.totalInitiateCount)
const iceBreakers: IceBreakerItem[] = []
for (const month of monthKeys) {
const mMap = iceBreakMap.get(month)
if (!mMap) continue
for (const [memberId, count] of mMap) {
iceBreakers.push({ month, memberId, name: memberNames.get(memberId) ?? `Unknown(${memberId})`, count })
}
}
// ==================== Message-level ====================
const sessionIdList = sessionRows.map((r) => r.session_id)
const msgStats = queryMessageLevelStats(db, sessionIdList, memberNames, perseveranceThreshold)
return {
months,
members,
totalSessions,
hasSessionIndex: true,
iceBreakers,
totalIceBreaks,
...msgStats,
perseveranceThreshold,
}
}
function queryMessageLevelStats(
db: ReturnType<typeof openDatabase>,
sessionIds: number[],
memberNames: Map<number, string>,
perseveranceThreshold: number
): {
responseLatency: ResponseLatencyMember[]
perseverance: PerseveranceMember[]
totalDoubleTexts: number
monthlyResponseLatency: MonthlyResponseLatency[]
monthlyPerseverance: MonthlyPerseverance[]
} {
const empty = {
responseLatency: [],
perseverance: [],
totalDoubleTexts: 0,
monthlyResponseLatency: [],
monthlyPerseverance: [],
}
if (!db || sessionIds.length === 0) return empty
const BATCH_SIZE = 500
// Overall
const responseTotals = new Map<number, { sum: number; count: number }>()
const dtTotals = new Map<number, number>()
// Monthly
const monthlyRespMap = new Map<string, Map<number, { sum: number; count: number }>>()
const monthlyDtMap = new Map<string, Map<number, number>>()
for (let i = 0; i < sessionIds.length; i += BATCH_SIZE) {
const batch = sessionIds.slice(i, i + BATCH_SIZE)
const placeholders = batch.map(() => '?').join(',')
// 响应时延(overall + monthly
const latencyRows = db
.prepare(
`
WITH msg_lag AS (
SELECT
m.sender_id,
m.ts,
LAG(m.sender_id) OVER (PARTITION BY mc.session_id ORDER BY m.ts, m.id) AS prev_sender_id,
LAG(m.ts) OVER (PARTITION BY mc.session_id ORDER BY m.ts, m.id) AS prev_ts
FROM message_context mc
JOIN message m ON m.id = mc.message_id
WHERE mc.session_id IN (${placeholders})
AND m.type = 0
)
SELECT
strftime('%Y-%m', datetime(ts, 'unixepoch', 'localtime')) AS month,
sender_id AS responder_id,
SUM(ts - prev_ts) AS total_time,
COUNT(*) AS response_count
FROM msg_lag
WHERE prev_sender_id IS NOT NULL AND sender_id != prev_sender_id
GROUP BY month, responder_id
`
)
.all(...batch) as Array<{
month: string
responder_id: number
total_time: number
response_count: number
}>
for (const row of latencyRows) {
// overall
const existing = responseTotals.get(row.responder_id)
if (existing) {
existing.sum += row.total_time
existing.count += row.response_count
} else {
responseTotals.set(row.responder_id, { sum: row.total_time, count: row.response_count })
}
// monthly
if (!monthlyRespMap.has(row.month)) monthlyRespMap.set(row.month, new Map())
const mMap = monthlyRespMap.get(row.month)!
const mExisting = mMap.get(row.responder_id)
if (mExisting) {
mExisting.sum += row.total_time
mExisting.count += row.response_count
} else {
mMap.set(row.responder_id, { sum: row.total_time, count: row.response_count })
}
}
// 锲而不舍(overall + monthly),带时间阈值
const dtRows = db
.prepare(
`
WITH msg_lag AS (
SELECT
m.sender_id,
m.ts,
LAG(m.sender_id) OVER (PARTITION BY mc.session_id ORDER BY m.ts, m.id) AS prev_sender_id,
LAG(m.ts) OVER (PARTITION BY mc.session_id ORDER BY m.ts, m.id) AS prev_ts
FROM message_context mc
JOIN message m ON m.id = mc.message_id
WHERE mc.session_id IN (${placeholders})
AND m.type = 0
)
SELECT
strftime('%Y-%m', datetime(ts, 'unixepoch', 'localtime')) AS month,
sender_id,
COUNT(*) AS double_text_count
FROM msg_lag
WHERE prev_sender_id IS NOT NULL
AND sender_id = prev_sender_id
AND (ts - prev_ts) >= ?
GROUP BY month, sender_id
`
)
.all(...batch, perseveranceThreshold) as Array<{
month: string
sender_id: number
double_text_count: number
}>
for (const row of dtRows) {
// overall
dtTotals.set(row.sender_id, (dtTotals.get(row.sender_id) ?? 0) + row.double_text_count)
// monthly
if (!monthlyDtMap.has(row.month)) monthlyDtMap.set(row.month, new Map())
const mMap = monthlyDtMap.get(row.month)!
mMap.set(row.sender_id, (mMap.get(row.sender_id) ?? 0) + row.double_text_count)
}
}
// Build overall results
const responseLatency: ResponseLatencyMember[] = Array.from(responseTotals.entries())
.map(([memberId, { sum, count }]) => ({
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
avgResponseTime: Math.round(sum / count),
totalResponses: count,
}))
.sort((a, b) => a.avgResponseTime - b.avgResponseTime)
let totalDoubleTexts = 0
const perseverance: PerseveranceMember[] = Array.from(dtTotals.entries())
.map(([memberId, count]) => {
totalDoubleTexts += count
return {
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
totalDoubleTexts: count,
}
})
.sort((a, b) => b.totalDoubleTexts - a.totalDoubleTexts)
// Build monthly response latency
const monthlyResponseLatency: MonthlyResponseLatency[] = Array.from(monthlyRespMap.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, mMap]) => ({
month,
members: Array.from(mMap.entries())
.map(([memberId, { sum, count }]) => ({
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
avgResponseTime: Math.round(sum / count),
responseCount: count,
}))
.sort((a, b) => a.avgResponseTime - b.avgResponseTime),
}))
// Build monthly perseverance
const monthlyPerseverance: MonthlyPerseverance[] = Array.from(monthlyDtMap.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, mMap]) => ({
month,
members: Array.from(mMap.entries())
.map(([memberId, count]) => ({
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
doubleTextCount: count,
}))
.sort((a, b) => b.doubleTextCount - a.doubleTextCount),
}))
return { responseLatency, perseverance, totalDoubleTexts, monthlyResponseLatency, monthlyPerseverance }
}
function toLocalMonth(ts: number): string {
const d = new Date(ts * 1000)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
+10
View File
@@ -36,11 +36,21 @@ export {
getMentionGraph,
getLaughAnalysis,
getClusterGraph,
getRelationshipStats,
} from './advanced'
// 小团体图类型
export type { ClusterGraphData, ClusterGraphNode, ClusterGraphLink, ClusterGraphOptions } from './advanced'
// 关系分析类型
export type {
RelationshipStats,
RelationshipMonthStats,
IceBreakerItem,
ResponseLatencyMember,
PerseveranceMember,
} from './advanced'
// 聊天记录查询
export {
searchMessages,
+4
View File
@@ -329,6 +329,10 @@ export async function getClusterGraph(sessionId: string, filter?: any, options?:
return sendToWorker('getClusterGraph', { sessionId, filter, options })
}
export async function getRelationshipStats(sessionId: string, filter?: any, options?: any): Promise<any> {
return sendToWorker('getRelationshipStats', { sessionId, filter, options })
}
export async function getAllSessions(): Promise<any[]> {
return sendToWorker('getAllSessions', {})
}
+12
View File
@@ -16,6 +16,7 @@ import type {
MemberWithStats,
ClusterGraphData,
ClusterGraphOptions,
RelationshipStats,
} from '../../../src/types/analysis'
import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../../src/types/format'
@@ -295,6 +296,17 @@ export const chatApi = {
return ipcRenderer.invoke('chat:getLaughAnalysis', sessionId, filter, keywords)
},
/**
* 获取关系主动性分析数据(私聊专属)
*/
getRelationshipStats: (
sessionId: string,
filter?: { startTs?: number; endTs?: number },
options?: { perseveranceThreshold?: number }
): Promise<RelationshipStats> => {
return ipcRenderer.invoke('chat:getRelationshipStats', sessionId, filter, options)
},
// ==================== 成员管理 ====================
/**
+6
View File
@@ -14,6 +14,7 @@ import type {
MemberWithStats,
ClusterGraphData,
ClusterGraphOptions,
RelationshipStats,
} from '../../src/types/analysis'
import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../src/types/format'
import type { TableSchema, SQLResult } from '../../src/components/analysis/SQLLab/types'
@@ -141,6 +142,11 @@ interface ChatApi {
getMentionGraph: (sessionId: string, filter?: TimeFilter) => Promise<MentionGraphData>
getClusterGraph: (sessionId: string, filter?: TimeFilter, options?: ClusterGraphOptions) => Promise<ClusterGraphData>
getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise<LaughAnalysis>
getRelationshipStats: (
sessionId: string,
filter?: TimeFilter,
options?: { perseveranceThreshold?: number }
) => Promise<RelationshipStats>
// 成员管理
getMembers: (sessionId: string) => Promise<MemberWithStats[]>
getMembersPaginated: (