From 11e530e5d4c4b4febf20135e6d8c2dc6494a6294 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 9 Apr 2026 23:48:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=81=E8=81=8A=E4=B8=8B=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=B8=BB=E5=8A=A8=E6=80=A7=E5=88=86=E6=9E=90=E8=A7=86?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipc/chat.ts | 20 + electron/main/worker/dbWorker.ts | 2 + electron/main/worker/query/advanced/index.ts | 10 + .../worker/query/advanced/relationship.ts | 458 ++++++++++++ electron/main/worker/query/index.ts | 10 + electron/main/worker/workerManager.ts | 4 + electron/preload/apis/chat.ts | 12 + electron/preload/index.d.ts | 6 + src/i18n/locales/en-US/analysis.json | 3 +- src/i18n/locales/en-US/views.json | 66 ++ src/i18n/locales/ja-JP/analysis.json | 3 +- src/i18n/locales/ja-JP/views.json | 66 ++ src/i18n/locales/zh-CN/analysis.json | 3 +- src/i18n/locales/zh-CN/views.json | 66 ++ src/i18n/locales/zh-TW/analysis.json | 3 +- src/i18n/locales/zh-TW/views.json | 66 ++ src/pages/private-chat/components/ViewTab.vue | 12 +- .../view/RelationshipMetricCard.vue | 74 ++ .../components/view/RelationshipView.vue | 662 ++++++++++++++++++ .../settings/components/AI/useAIConfigForm.ts | 7 +- src/types/analysis.ts | 72 ++ src/utils/chatlabSiteLocale.ts | 13 + 22 files changed, 1628 insertions(+), 10 deletions(-) create mode 100644 electron/main/worker/query/advanced/relationship.ts create mode 100644 src/pages/private-chat/components/view/RelationshipMetricCard.vue create mode 100644 src/pages/private-chat/components/view/RelationshipView.vue create mode 100644 src/utils/chatlabSiteLocale.ts diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts index f5840f2a..7d9c792b 100644 --- a/electron/main/ipc/chat.ts +++ b/electron/main/ipc/chat.ts @@ -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 } + } + } + ) + // ==================== 成员管理 ==================== /** diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index 1e6705a8..28bf09e5 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -33,6 +33,7 @@ import { getMentionGraph, getLaughAnalysis, getClusterGraph, + getRelationshipStats, searchMessages, deepSearchMessages, getMessageContext, @@ -174,6 +175,7 @@ const syncHandlers: Record 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), diff --git a/electron/main/worker/query/advanced/index.ts b/electron/main/worker/query/advanced/index.ts index fde9f8a0..936cdc5b 100644 --- a/electron/main/worker/query/advanced/index.ts +++ b/electron/main/worker/query/advanced/index.ts @@ -17,3 +17,13 @@ export type { ClusterGraphLink, ClusterGraphOptions, } from './social' + +// 关系分析(私聊主动性) +export { getRelationshipStats } from './relationship' +export type { + RelationshipStats, + RelationshipMonthStats, + IceBreakerItem, + ResponseLatencyMember, + PerseveranceMember, +} from './relationship' diff --git a/electron/main/worker/query/advanced/relationship.ts b/electron/main/worker/query/advanced/relationship.ts new file mode 100644 index 00000000..af7317bd --- /dev/null +++ b/electron/main/worker/query/advanced/relationship.ts @@ -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() + 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; closeMap: Map; totalSessions: number } + >() + const memberInitTotals = new Map() + const memberCloseTotals = new Map() + const iceBreakMap = new Map>() + 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() + 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, + sessionIds: number[], + memberNames: Map, + 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() + const dtTotals = new Map() + + // Monthly + const monthlyRespMap = new Map>() + const monthlyDtMap = new Map>() + + 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}` +} diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts index 39fc2c3b..aca6b0ce 100644 --- a/electron/main/worker/query/index.ts +++ b/electron/main/worker/query/index.ts @@ -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, diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 5afd23c8..fff85eeb 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -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 { + return sendToWorker('getRelationshipStats', { sessionId, filter, options }) +} + export async function getAllSessions(): Promise { return sendToWorker('getAllSessions', {}) } diff --git a/electron/preload/apis/chat.ts b/electron/preload/apis/chat.ts index 4e167f6d..92760d11 100644 --- a/electron/preload/apis/chat.ts +++ b/electron/preload/apis/chat.ts @@ -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 => { + return ipcRenderer.invoke('chat:getRelationshipStats', sessionId, filter, options) + }, + // ==================== 成员管理 ==================== /** diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 29a71b82..67c3c4f0 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -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 getClusterGraph: (sessionId: string, filter?: TimeFilter, options?: ClusterGraphOptions) => Promise getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise + getRelationshipStats: ( + sessionId: string, + filter?: TimeFilter, + options?: { perseveranceThreshold?: number } + ) => Promise // 成员管理 getMembers: (sessionId: string) => Promise getMembersPaginated: ( diff --git a/src/i18n/locales/en-US/analysis.json b/src/i18n/locales/en-US/analysis.json index 695da8fe..23f9244c 100644 --- a/src/i18n/locales/en-US/analysis.json +++ b/src/i18n/locales/en-US/analysis.json @@ -54,7 +54,8 @@ "view": { "message": "Messages", "interaction": "Interactions", - "ranking": "Rankings" + "ranking": "Rankings", + "relationship": "Relationship" }, "quotes": { "wordcloud": "Word Cloud", diff --git a/src/i18n/locales/en-US/views.json b/src/i18n/locales/en-US/views.json index fb5ffde8..b48c9c1c 100644 --- a/src/i18n/locales/en-US/views.json +++ b/src/i18n/locales/en-US/views.json @@ -55,6 +55,72 @@ "graphHint": "{nodes} members, {links} interactions", "noInteraction": "No mention interaction data" }, + "relationship": { + "loading": "Analyzing relationship dynamics…", + "noIndex": { + "title": "Session Index Required", + "description": "Relationship analysis requires session index data. Please generate the session index from the overview page first." + }, + "empty": { + "title": "No Relationship Data", + "description": "No sessions found in the selected time range." + }, + "overview": { + "title": "Overall Summary" + }, + "monthly": { + "title": "Monthly Breakdown" + }, + "trend": { + "title": "Initiative Trend", + "hint": "Blue line shows {name}'s monthly initiative ratio; 50% line is the balanced baseline" + }, + "initiator": "Start Conversations", + "closer": "End Conversations", + "initiated": "Initiated {count} times", + "closed": "Closed {count} times", + "totalSessions": "{count} total sessions", + "times": " times", + "noActivity": "No activity this month", + "monthFormat": "{month}/{year}", + "monthDetail": "Init {init} / Close {close}", + "iceBreaker": { + "title": "Ice Breaker", + "hint": "Times breaking silence after 24+ hours of inactivity", + "count": "{count} ice breaks", + "total": "{count} total ice breaks" + }, + "responseLatency": { + "title": "Response Time", + "hint": "Average time to reply after the other person sends a message", + "count": "{count} replies", + "seconds": "{n}s", + "minutes": "{n} min", + "hours": "{n}h", + "hoursMinutes": "{h}h {m}min" + }, + "perseverance": { + "title": "Perseverance", + "hint": "Times sending another message before the other person replied", + "hintWithThreshold": "Times sending another message when the other person hasn't replied within {threshold}", + "thresholdMinutes": "{n} min", + "count": "{count} times", + "total": "{count} total", + "threshold": "Gap", + "empty": "No perseverance records with this threshold" + }, + "labels": { + "mutualPursuit": "Mutual Pursuit", + "silentGuardian": "Silent Guardian", + "devotedHeart": "Devoted Heart", + "distantBond": "Distant Bond", + "wellMatched": "Well Matched", + "missingYou": "{name} Misses You", + "monologue": "{name}'s Monologue", + "peacefulSilence": "Peaceful Silence", + "fleetingThoughts": "Fleeting Thoughts" + } + }, "timeline": { "title": "Timeline View", "description": "Message time distribution, active period trends and other visualizations" diff --git a/src/i18n/locales/ja-JP/analysis.json b/src/i18n/locales/ja-JP/analysis.json index 2e084aaa..80d8e5ae 100644 --- a/src/i18n/locales/ja-JP/analysis.json +++ b/src/i18n/locales/ja-JP/analysis.json @@ -54,7 +54,8 @@ "view": { "message": "メッセージ", "interaction": "インタラクション分析", - "ranking": "ランキング" + "ranking": "ランキング", + "relationship": "関係" }, "quotes": { "wordcloud": "ワードクラウド", diff --git a/src/i18n/locales/ja-JP/views.json b/src/i18n/locales/ja-JP/views.json index 6767a290..31a34625 100644 --- a/src/i18n/locales/ja-JP/views.json +++ b/src/i18n/locales/ja-JP/views.json @@ -55,6 +55,72 @@ "graphHint": "全 {nodes} 名のメンバー、{links} 件のインタラクション", "noInteraction": "メンションデータがありません" }, + "relationship": { + "loading": "関係動態を分析中…", + "noIndex": { + "title": "セッションインデックスが必要です", + "description": "関係分析にはセッションインデックスが必要です。概要ページからセッションインデックスを生成してください。" + }, + "empty": { + "title": "関係データがありません", + "description": "選択した期間内にセッションが見つかりません。" + }, + "overview": { + "title": "全体概要" + }, + "monthly": { + "title": "月別明細" + }, + "trend": { + "title": "主動性の推移", + "hint": "青い線は {name} の月別主動発起率。50% 線がバランス基準です" + }, + "initiator": "話題を始める", + "closer": "話題を終える", + "initiated": "{count} 回発起", + "closed": "{count} 回終了", + "totalSessions": "合計 {count} セッション", + "times": "回", + "noActivity": "今月はやり取りなし", + "monthFormat": "{year}年{month}月", + "monthDetail": "発起 {init} / 終了 {close}", + "iceBreaker": { + "title": "アイスブレイカー", + "hint": "24時間以上の沈黙を破って話しかけた回数", + "count": "{count} 回の破氷", + "total": "合計 {count} 回の破氷" + }, + "responseLatency": { + "title": "返信時間", + "hint": "相手がメッセージを送った後、平均何分で返信するか", + "count": "{count} 回の返信", + "seconds": "{n} 秒", + "minutes": "{n} 分", + "hours": "{n} 時間", + "hoursMinutes": "{h} 時間 {m} 分" + }, + "perseverance": { + "title": "粘り強さ", + "hint": "相手が返信する前にもう一通メッセージを送った回数", + "hintWithThreshold": "相手が{threshold}以内に返信しないうちに、もう一通メッセージを送った回数", + "thresholdMinutes": "{n}分", + "count": "{count} 回", + "total": "合計 {count} 回", + "threshold": "間隔", + "empty": "この閾値では粘り強さの記録がありません" + }, + "labels": { + "mutualPursuit": "双方向の想い", + "silentGuardian": "静かな守り手", + "devotedHeart": "一途な想い", + "distantBond": "遠い絆", + "wellMatched": "互角", + "missingYou": "{name} が恋しがっている", + "monologue": "{name} の独白", + "peacefulSilence": "穏やかな沈黙", + "fleetingThoughts": "ふとした思い出" + } + }, "timeline": { "title": "タイムライン表示", "description": "メッセージの時間分布や活動周期の変化を可視化します" diff --git a/src/i18n/locales/zh-CN/analysis.json b/src/i18n/locales/zh-CN/analysis.json index 30c4d80a..28cfc865 100644 --- a/src/i18n/locales/zh-CN/analysis.json +++ b/src/i18n/locales/zh-CN/analysis.json @@ -54,7 +54,8 @@ "view": { "message": "消息", "interaction": "互动分析", - "ranking": "榜单" + "ranking": "榜单", + "relationship": "关系" }, "quotes": { "wordcloud": "词云", diff --git a/src/i18n/locales/zh-CN/views.json b/src/i18n/locales/zh-CN/views.json index 9ca85118..e2523bc0 100644 --- a/src/i18n/locales/zh-CN/views.json +++ b/src/i18n/locales/zh-CN/views.json @@ -55,6 +55,72 @@ "graphHint": "共 {nodes} 位成员,{links} 条互动关系", "noInteraction": "暂无艾特互动数据" }, + "relationship": { + "loading": "正在分析关系动态…", + "noIndex": { + "title": "需要会话索引", + "description": "关系分析依赖会话索引数据,请先在总览页生成会话索引。" + }, + "empty": { + "title": "暂无关系数据", + "description": "当前时间范围内没有会话记录。" + }, + "overview": { + "title": "整体概览" + }, + "monthly": { + "title": "月度明细" + }, + "trend": { + "title": "主动性趋势", + "hint": "蓝色线为 {name} 的月度主动发起占比,50% 线为均衡基准" + }, + "initiator": "发起话题", + "closer": "结束话题", + "initiated": "发起 {count} 次", + "closed": "结束 {count} 次", + "totalSessions": "共 {count} 次会话", + "times": "次", + "noActivity": "本月无互动", + "monthFormat": "{year}年{month}月", + "monthDetail": "发起 {init} / 结束 {close}", + "iceBreaker": { + "title": "破冰达人", + "hint": "超过 24 小时沉默后主动打破僵局的次数", + "count": "{count} 次破冰", + "total": "共 {count} 次破冰" + }, + "responseLatency": { + "title": "响应时延", + "hint": "当对方发来消息后,平均多久回复", + "count": "{count} 次回复", + "seconds": "{n} 秒", + "minutes": "{n} 分钟", + "hours": "{n} 小时", + "hoursMinutes": "{h} 小时 {m} 分钟" + }, + "perseverance": { + "title": "锲而不舍", + "hint": "对方还没回复就又发了一条消息的次数", + "hintWithThreshold": "对方{threshold}内还没回复就又发了一次消息的次数", + "thresholdMinutes": "{n}分钟", + "count": "{count} 次", + "total": "共 {count} 次", + "threshold": "间隔", + "empty": "在当前阈值下没有锲而不舍的记录" + }, + "labels": { + "mutualPursuit": "双向奔赴", + "silentGuardian": "默默守候", + "devotedHeart": "一往情深", + "distantBond": "若即若离", + "wellMatched": "势均力敌", + "missingYou": "{name} 在想念", + "monologue": "{name} 的独白", + "peacefulSilence": "各自安好", + "fleetingThoughts": "偶尔想起" + } + }, "timeline": { "title": "时间线视图", "description": "消息时间分布、活跃周期变化趋势等可视化内容" diff --git a/src/i18n/locales/zh-TW/analysis.json b/src/i18n/locales/zh-TW/analysis.json index 0796df34..65fbdb64 100644 --- a/src/i18n/locales/zh-TW/analysis.json +++ b/src/i18n/locales/zh-TW/analysis.json @@ -54,7 +54,8 @@ "view": { "message": "訊息", "interaction": "互動分析", - "ranking": "榜單" + "ranking": "榜單", + "relationship": "關係" }, "quotes": { "wordcloud": "詞雲", diff --git a/src/i18n/locales/zh-TW/views.json b/src/i18n/locales/zh-TW/views.json index f628c84d..827e6588 100644 --- a/src/i18n/locales/zh-TW/views.json +++ b/src/i18n/locales/zh-TW/views.json @@ -55,6 +55,72 @@ "graphHint": "共 {nodes} 位成員,{links} 條互動關係", "noInteraction": "暫無艾特互動資料" }, + "relationship": { + "loading": "正在分析關係動態…", + "noIndex": { + "title": "需要對話索引", + "description": "關係分析依賴對話索引資料,請先在總覽頁面生成對話索引。" + }, + "empty": { + "title": "暫無關係資料", + "description": "目前時間範圍內沒有對話記錄。" + }, + "overview": { + "title": "整體概覽" + }, + "monthly": { + "title": "月度明細" + }, + "trend": { + "title": "主動性趨勢", + "hint": "藍色線為 {name} 的月度主動發起佔比,50% 線為均衡基準" + }, + "initiator": "發起話題", + "closer": "結束話題", + "initiated": "發起 {count} 次", + "closed": "結束 {count} 次", + "totalSessions": "共 {count} 次對話", + "times": "次", + "noActivity": "本月無互動", + "monthFormat": "{year}年{month}月", + "monthDetail": "發起 {init} / 結束 {close}", + "iceBreaker": { + "title": "破冰達人", + "hint": "超過 24 小時沉默後主動打破僵局的次數", + "count": "{count} 次破冰", + "total": "共 {count} 次破冰" + }, + "responseLatency": { + "title": "回覆時延", + "hint": "收到對方訊息後平均多久回覆", + "count": "{count} 次回覆", + "seconds": "{n} 秒", + "minutes": "{n} 分鐘", + "hours": "{n} 小時", + "hoursMinutes": "{h} 小時 {m} 分鐘" + }, + "perseverance": { + "title": "鍥而不捨", + "hint": "對方還沒回覆就又發了一則訊息的次數", + "hintWithThreshold": "對方{threshold}內還沒回覆就又發了一次訊息的次數", + "thresholdMinutes": "{n}分鐘", + "count": "{count} 次", + "total": "共 {count} 次", + "threshold": "間隔", + "empty": "在目前閾值下沒有鍥而不捨的記錄" + }, + "labels": { + "mutualPursuit": "雙向奔赴", + "silentGuardian": "默默守候", + "devotedHeart": "一往情深", + "distantBond": "若即若離", + "wellMatched": "勢均力敵", + "missingYou": "{name} 在想念", + "monologue": "{name} 的獨白", + "peacefulSilence": "各自安好", + "fleetingThoughts": "偶爾想起" + } + }, "timeline": { "title": "時間軸檢視", "description": "以視覺化方式呈現訊息時間分布與活躍週期變化" diff --git a/src/pages/private-chat/components/ViewTab.vue b/src/pages/private-chat/components/ViewTab.vue index 2a37b9ee..f6bf9d8a 100644 --- a/src/pages/private-chat/components/ViewTab.vue +++ b/src/pages/private-chat/components/ViewTab.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import { SubTabs } from '@/components/UI' import UserSelect from '@/components/common/UserSelect.vue' import MessageView from '@openchatlab/chart-message/MessageView.vue' +import RelationshipView from './view/RelationshipView.vue' const { t } = useI18n() @@ -17,14 +18,14 @@ const props = defineProps<{ timeFilter?: TimeFilter }>() -// 子 Tab 配置(私聊只有消息视图) const subTabs = computed(() => [ { id: 'message', label: t('analysis.subTabs.view.message'), icon: 'i-heroicons-chat-bubble-left-right' }, + { id: 'relationship', label: t('analysis.subTabs.view.relationship'), icon: 'i-heroicons-heart' }, ]) const activeSubTab = ref('message') -// 成员筛选 +// 成员筛选(仅用于消息视图) const selectedMemberId = ref(null) // 构建 timeFilter(含 memberId) @@ -39,7 +40,7 @@ const viewTimeFilter = computed(() => ({ @@ -47,6 +48,11 @@ const viewTimeFilter = computed(() => ({
+
diff --git a/src/pages/private-chat/components/view/RelationshipMetricCard.vue b/src/pages/private-chat/components/view/RelationshipMetricCard.vue new file mode 100644 index 00000000..d3279fbc --- /dev/null +++ b/src/pages/private-chat/components/view/RelationshipMetricCard.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/pages/private-chat/components/view/RelationshipView.vue b/src/pages/private-chat/components/view/RelationshipView.vue new file mode 100644 index 00000000..99569617 --- /dev/null +++ b/src/pages/private-chat/components/view/RelationshipView.vue @@ -0,0 +1,662 @@ + + + diff --git a/src/pages/settings/components/AI/useAIConfigForm.ts b/src/pages/settings/components/AI/useAIConfigForm.ts index 5041ae78..a0cd805c 100644 --- a/src/pages/settings/components/AI/useAIConfigForm.ts +++ b/src/pages/settings/components/AI/useAIConfigForm.ts @@ -392,9 +392,10 @@ export function useAIConfigForm(props: { const finalName = generateName() const isReasoning = formData.value.isReasoningModel - const persistCustomModels = isCompatMode.value && compatModels.value.length > 0 - ? compatModels.value.map((m) => ({ id: m.id, name: m.name })) - : undefined + const persistCustomModels = + isCompatMode.value && compatModels.value.length > 0 + ? compatModels.value.map((m) => ({ id: m.id, name: m.name })) + : undefined if (props.mode.value === 'add') { const result = await window.llmApi.addConfig({ name: finalName, diff --git a/src/types/analysis.ts b/src/types/analysis.ts index 648cec5f..20c96eca 100644 --- a/src/types/analysis.ts +++ b/src/types/analysis.ts @@ -576,3 +576,75 @@ export interface ClusterGraphData { communities: ClusterGraphCommunity[] stats: ClusterGraphStats } + +// ==================== 关系主动性分析(私聊) ==================== + +export interface RelationshipMonthStats { + month: string + members: Array<{ + memberId: number + name: string + initiateCount: number + closeCount: number + }> + 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 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 +} diff --git a/src/utils/chatlabSiteLocale.ts b/src/utils/chatlabSiteLocale.ts new file mode 100644 index 00000000..6611a3b3 --- /dev/null +++ b/src/utils/chatlabSiteLocale.ts @@ -0,0 +1,13 @@ +const LOCALE_PATH_MAP: Record = { + 'en-US': 'en', + 'zh-TW': 'zh-TW', + 'ja-JP': 'ja', +} + +/** + * 将应用 locale 转为 chatlab.fun 站点的路径前缀。 + * zh-CN 为默认语言,返回空字符串(无前缀)。 + */ +export function getChatlabSiteLocalePath(locale: string): string { + return LOCALE_PATH_MAP[locale] ?? '' +}