feat: 新增互动分析

This commit is contained in:
digua
2026-01-22 01:12:04 +08:00
parent 7afe5a0152
commit 69b8c5593e
13 changed files with 674 additions and 3 deletions
+15
View File
@@ -511,6 +511,21 @@ export function registerChatHandlers(ctx: IpcContext): void {
}
)
/**
* 获取 @ 互动关系图数据
*/
ipcMain.handle(
'chat:getMentionGraph',
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => {
try {
return await worker.getMentionGraph(sessionId, filter)
} catch (error) {
console.error('获取 @ 互动关系图失败:', error)
return { nodes: [], links: [], maxLinkValue: 0 }
}
}
)
/**
* 获取含笑量分析数据
*/
+2
View File
@@ -32,6 +32,7 @@ import {
getDivingAnalysis,
getMonologueAnalysis,
getMentionAnalysis,
getMentionGraph,
getLaughAnalysis,
getMemeBattleAnalysis,
getCheckInAnalysis,
@@ -115,6 +116,7 @@ const syncHandlers: Record<string, (payload: any) => any> = {
getDivingAnalysis: (p) => getDivingAnalysis(p.sessionId, p.filter),
getMonologueAnalysis: (p) => getMonologueAnalysis(p.sessionId, p.filter),
getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter),
getMentionGraph: (p) => getMentionGraph(p.sessionId, p.filter),
getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords),
getMemeBattleAnalysis: (p) => getMemeBattleAnalysis(p.sessionId, p.filter),
getCheckInAnalysis: (p) => getCheckInAnalysis(p.sessionId, p.filter),
+2 -1
View File
@@ -13,5 +13,6 @@ export { getNightOwlAnalysis, getDragonKingAnalysis, getDivingAnalysis, getCheck
export { getMonologueAnalysis, getMemeBattleAnalysis } from './behavior'
// 社交分析:@ 互动、含笑量
export { getMentionAnalysis, getLaughAnalysis } from './social'
export { getMentionAnalysis, getMentionGraph, getLaughAnalysis } from './social'
export type { MentionGraphData, MentionGraphNode, MentionGraphLink } from './social'
@@ -306,6 +306,174 @@ export function getMentionAnalysis(sessionId: string, filter?: TimeFilter): any
}
}
// ==================== @ 互动关系图数据 ====================
export interface MentionGraphNode {
id: number
name: string
value: number // 消息数量(用于节点大小)
symbolSize: number // 节点大小
}
export interface MentionGraphLink {
source: string // 发起者名称
target: string // 被艾特者名称
value: number // @ 次数
}
export interface MentionGraphData {
nodes: MentionGraphNode[]
links: MentionGraphLink[]
maxLinkValue: number // 最大边权重(用于归一化)
}
/**
* 获取 @ 互动关系图数据(用于 ECharts Graph
*/
export function getMentionGraph(sessionId: string, filter?: TimeFilter): MentionGraphData {
const db = openDatabase(sessionId)
const emptyResult: MentionGraphData = { nodes: [], links: [], maxLinkValue: 0 }
if (!db) return emptyResult
// 1. 查询所有成员信息和消息数量(不过滤消息数为 0 的成员,因为可能被 @ 但没发消息)
const { clause, params } = buildTimeFilter(filter)
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,
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; messageCount: number }>
if (members.length === 0) return emptyResult
// 2. 构建昵称到成员ID的映射
const nameToMemberId = new Map<string, number>()
const memberIdToInfo = new Map<number, { name: string; messageCount: number }>()
for (const member of members) {
memberIdToInfo.set(member.id, { name: member.name, messageCount: member.messageCount })
nameToMemberId.set(member.name, member.id)
// 查询历史昵称
const history = db
.prepare(`SELECT name FROM member_name_history WHERE member_id = ?`)
.all(member.id) as Array<{ name: string }>
for (const h of history) {
if (!nameToMemberId.has(h.name)) {
nameToMemberId.set(h.name, member.id)
}
}
}
// 3. 查询包含 @ 的消息
let whereClause = clause
if (whereClause.includes('WHERE')) {
whereClause +=
" AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'"
} else {
whereClause =
" WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'"
}
const messages = db
.prepare(
`
SELECT msg.sender_id as senderId, msg.content
FROM message msg
JOIN member m ON msg.sender_id = m.id
${whereClause}
`
)
.all(...params) as Array<{ senderId: number; content: string }>
// 4. 解析 @ 并构建关系矩阵
const mentionMatrix = new Map<number, Map<number, number>>()
const mentionRegex = /@([^\s@]+)/g
for (const msg of messages) {
const matches = msg.content.matchAll(mentionRegex)
const mentionedInThisMsg = new Set<number>()
for (const match of matches) {
const mentionedName = match[1]
const mentionedId = nameToMemberId.get(mentionedName)
if (mentionedId && mentionedId !== msg.senderId && !mentionedInThisMsg.has(mentionedId)) {
mentionedInThisMsg.add(mentionedId)
if (!mentionMatrix.has(msg.senderId)) {
mentionMatrix.set(msg.senderId, new Map())
}
const fromMap = mentionMatrix.get(msg.senderId)!
fromMap.set(mentionedId, (fromMap.get(mentionedId) || 0) + 1)
}
}
}
// 5. 构建 nodes(只包含有互动的成员)
const involvedMemberIds = new Set<number>()
for (const [fromId, toMap] of mentionMatrix.entries()) {
involvedMemberIds.add(fromId)
for (const toId of toMap.keys()) {
involvedMemberIds.add(toId)
}
}
const maxMessageCount = Math.max(...members.filter((m) => involvedMemberIds.has(m.id)).map((m) => m.messageCount), 1)
const nodes: MentionGraphNode[] = []
for (const memberId of involvedMemberIds) {
const info = memberIdToInfo.get(memberId)
if (info) {
// 节点大小根据消息数量计算(20-60 范围)
const symbolSize = 20 + (info.messageCount / maxMessageCount) * 40
nodes.push({
id: memberId,
name: info.name,
value: info.messageCount,
symbolSize: Math.round(symbolSize),
})
}
}
// 6. 构建 links(使用 name 而非 ID,便于前端 ECharts 匹配)
const links: MentionGraphLink[] = []
let maxLinkValue = 0
for (const [fromId, toMap] of mentionMatrix.entries()) {
const fromInfo = memberIdToInfo.get(fromId)
if (!fromInfo) continue
for (const [toId, count] of toMap.entries()) {
const toInfo = memberIdToInfo.get(toId)
if (!toInfo) continue
links.push({
source: fromInfo.name,
target: toInfo.name,
value: count,
})
maxLinkValue = Math.max(maxLinkValue, count)
}
}
return { nodes, links, maxLinkValue }
}
// ==================== 含笑量分析 ====================
/**
+1
View File
@@ -35,6 +35,7 @@ export {
getMonologueAnalysis,
getMemeBattleAnalysis,
getMentionAnalysis,
getMentionGraph,
getLaughAnalysis,
} from './advanced'
+4
View File
@@ -273,6 +273,10 @@ export async function getMentionAnalysis(sessionId: string, filter?: any): Promi
return sendToWorker('getMentionAnalysis', { sessionId, filter })
}
export async function getMentionGraph(sessionId: string, filter?: any): Promise<any> {
return sendToWorker('getMentionGraph', { sessionId, filter })
}
export async function getLaughAnalysis(sessionId: string, filter?: any, keywords?: string[]): Promise<any> {
return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords })
}
+8
View File
@@ -28,6 +28,13 @@ interface TimeFilter {
memberId?: number | null // 成员筛选,null 表示全部成员
}
// @ 互动关系图数据
interface MentionGraphData {
nodes: Array<{ id: number; name: string; value: number; symbolSize: number }>
links: Array<{ source: string; target: string; value: number }>
maxLinkValue: number
}
// 迁移相关类型
interface MigrationInfo {
version: number
@@ -106,6 +113,7 @@ interface ChatApi {
getDivingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<DivingAnalysis>
getMonologueAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MonologueAnalysis>
getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MentionAnalysis>
getMentionGraph: (sessionId: string, filter?: TimeFilter) => Promise<MentionGraphData>
getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise<LaughAnalysis>
getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MemeBattleAnalysis>
getCheckInAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<CheckInAnalysis>
+14
View File
@@ -311,6 +311,20 @@ const chatApi = {
return ipcRenderer.invoke('chat:getMentionAnalysis', sessionId, filter)
},
/**
* 获取 @ 互动关系图数据
*/
getMentionGraph: (
sessionId: string,
filter?: { startTs?: number; endTs?: number }
): Promise<{
nodes: Array<{ id: number; name: string; value: number; symbolSize: number }>
links: Array<{ source: string; target: string; value: number }>
maxLinkValue: number
}> => {
return ipcRenderer.invoke('chat:getMentionGraph', sessionId, filter)
},
/**
* 获取含笑量分析数据
*/
+268
View File
@@ -0,0 +1,268 @@
<script setup lang="ts">
/**
* ECharts 关系图组件(支持 circular 和 force 布局)
*/
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts/core'
import { GraphChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useDark } from '@vueuse/core'
import type { EChartsOption } from 'echarts'
// 注册必要的组件
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
type ECOption = EChartsOption
export interface GraphNode {
id: number | string
name: string
value?: number
symbolSize?: number
category?: number
}
export interface GraphLink {
source: string
target: string
value?: number
}
export interface GraphData {
nodes: GraphNode[]
links: GraphLink[]
maxLinkValue?: number
}
interface Props {
data: GraphData
height?: number | string
layout?: 'circular' | 'force' // 布局类型
directed?: boolean // 是否显示箭头(有向图)
}
const props = withDefaults(defineProps<Props>(), {
height: 400,
layout: 'circular',
directed: false,
})
// 计算高度样式
const heightStyle = computed(() => {
if (typeof props.height === 'number') {
return `${props.height}px`
}
return props.height
})
const isDark = useDark()
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// 丰富的调色板(为每个节点分配不同颜色)
const colorPalette = [
'#ee4567', // 粉色(主题色)
'#5470c6', // 蓝色
'#91cc75', // 绿色
'#fac858', // 黄色
'#ee6666', // 红色
'#73c0de', // 青色
'#9a60b4', // 紫色
'#fc8452', // 橙色
'#3ba272', // 深绿
'#ea7ccc', // 粉紫
'#6e7074', // 灰色
'#546570', // 深灰蓝
]
// 节点名称到颜色的映射
const nodeColorMap = computed(() => {
const map = new Map<string, string>()
props.data.nodes.forEach((node, index) => {
map.set(node.name, colorPalette[index % colorPalette.length])
})
return map
})
// 计算边的宽度(根据 value 归一化)
function getLinkWidth(value: number, maxValue: number): number {
if (maxValue <= 0) return 1
// 宽度范围 1-6
return 1 + (value / maxValue) * 5
}
const option = computed<ECOption>(() => {
const maxLinkValue = props.data.maxLinkValue || Math.max(...props.data.links.map((l) => l.value || 1), 1)
return {
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)',
textStyle: {
color: isDark.value ? '#e5e7eb' : '#374151',
},
formatter: (params: any) => {
if (params.dataType === 'node') {
return `<b>${params.data.name}</b><br/>消息数: ${params.data.value || 0}`
} else if (params.dataType === 'edge') {
return `${params.data.source}${params.data.target}<br/>艾特次数: ${params.data.value || 0}`
}
return ''
},
},
// 动画效果
animationDuration: 1000,
animationDurationUpdate: 500,
animationEasingUpdate: 'quinticInOut',
series: [
{
type: 'graph',
layout: props.layout,
circular: props.layout === 'circular' ? { rotateLabel: true } : undefined,
force:
props.layout === 'force'
? {
repulsion: 300,
gravity: 0.1,
edgeLength: [80, 200],
friction: 0.6,
}
: undefined,
roam: true,
scaleLimit: {
min: 0.3, // 最小缩放 30%
max: 3, // 最大缩放 300%
},
draggable: true,
label: {
show: true,
position: 'right',
formatter: '{b}',
color: isDark.value ? '#e5e7eb' : '#374151',
fontSize: 11,
fontWeight: 500,
},
edgeSymbol: props.directed ? ['none', 'arrow'] : ['none', 'none'],
edgeSymbolSize: props.directed ? [0, 10] : [0, 0],
lineStyle: {
curveness: 0.3, // 始终使用曲线
opacity: 0.5,
},
emphasis: {
focus: 'adjacency',
label: {
show: true,
fontSize: 13,
fontWeight: 600,
},
lineStyle: {
width: 4,
opacity: 0.9,
},
itemStyle: {
shadowBlur: 15,
shadowColor: 'rgba(0, 0, 0, 0.3)',
},
},
// 节点数据
data: props.data.nodes.map((node) => {
const color = nodeColorMap.value.get(node.name) || colorPalette[0]
return {
name: node.name,
value: node.value,
symbolSize: node.symbolSize || 30,
// circular 布局显示所有标签,force 布局只显示大节点的标签
label: {
show: props.layout === 'circular' ? true : (node.symbolSize || 30) > 30,
},
itemStyle: {
color: color,
borderColor: '#fff',
borderWidth: 2,
shadowBlur: 5,
shadowColor: `${color}66`, // 同色系阴影
},
}
}),
// 连接线数据(颜色跟随源节点)
links: props.data.links.map((link) => {
const sourceColor = nodeColorMap.value.get(link.source) || colorPalette[0]
return {
source: link.source,
target: link.target,
value: link.value,
lineStyle: {
color: sourceColor,
width: getLinkWidth(link.value || 1, maxLinkValue),
},
}
}),
},
],
}
})
// 初始化图表
function initChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value, isDark.value ? 'dark' : undefined, {
renderer: 'canvas',
})
chartInstance.setOption(option.value)
}
// 更新图表
function updateChart() {
if (!chartInstance) return
chartInstance.setOption(option.value, { notMerge: true })
}
// 响应窗口大小变化
function handleResize() {
chartInstance?.resize()
}
// 重置视图(居中 + 重置缩放)
function resetView() {
if (!chartInstance) return
chartInstance.dispatchAction({
type: 'restore',
})
}
// 暴露方法给父组件
defineExpose({
resetView,
})
// 监听数据和主题变化
watch(
[() => props.data, () => props.layout, () => props.directed, isDark],
() => {
if (chartInstance) {
updateChart()
} else {
initChart()
}
},
{ deep: true }
)
onMounted(() => {
initChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<template>
<div ref="chartRef" :style="{ height: heightStyle, width: '100%' }" />
</template>
+2
View File
@@ -7,6 +7,7 @@ export { default as EChartBar } from './EChartBar.vue'
export { default as EChartLine } from './EChartLine.vue'
export { default as EChartHeatmap } from './EChartHeatmap.vue'
export { default as EChartCalendar } from './EChartCalendar.vue'
export { default as EChartGraph } from './EChartGraph.vue'
// 其他组件
export { default as RankList } from './RankList.vue'
@@ -21,6 +22,7 @@ export type { EChartBarData } from './EChartBar.vue'
export type { EChartLineData } from './EChartLine.vue'
export type { EChartHeatmapData } from './EChartHeatmap.vue'
export type { CalendarData as EChartCalendarData } from './EChartCalendar.vue'
export type { GraphData as EChartGraphData, GraphNode, GraphLink } from './EChartGraph.vue'
// 其他类型
export type { RankItem } from './RankList.vue'
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
/**
* 互动分析视图(群聊专属)
* 展示成员间的 @ 互动关系图
*/
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { EChartGraph } from '@/components/charts'
import type { EChartGraphData } from '@/components/charts'
const { t } = useI18n()
interface TimeFilter {
startTs?: number
endTs?: number
}
// Props
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
memberId?: number | null
}>()
// 数据状态
const isLoading = ref(true)
const graphData = ref<EChartGraphData>({ nodes: [], links: [], maxLinkValue: 0 })
// 布局切换
const layoutType = ref<'circular' | 'force'>('circular')
// 方向切换(有向图 vs 无向图)
const showDirection = ref(false)
// 图表引用
const graphRef = ref<InstanceType<typeof EChartGraph> | null>(null)
// 重置视图
function handleResetView() {
graphRef.value?.resetView()
}
// 合并 timeFilter 和 memberId 的 filter
const effectiveFilter = computed(() => ({
...props.timeFilter,
memberId: props.memberId,
}))
// 加载数据
async function loadData() {
if (!props.sessionId) return
isLoading.value = true
try {
const data = await window.chatApi.getMentionGraph(props.sessionId, effectiveFilter.value)
graphData.value = {
nodes: data.nodes,
links: data.links,
maxLinkValue: data.maxLinkValue,
}
} catch (error) {
console.error('加载互动关系图数据失败:', error)
} finally {
isLoading.value = false
}
}
// 监听 props 变化
watch(
() => [props.sessionId, props.timeFilter, props.memberId],
() => {
loadData()
},
{ immediate: true, deep: true }
)
</script>
<template>
<div class="flex h-full flex-col">
<!-- 顶部工具栏仅右上角控制 -->
<div class="flex items-center justify-end px-4 py-2">
<div class="flex items-center gap-4">
<!-- 布局切换 -->
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ t('layout') }}:</span>
<UButtonGroup size="xs">
<UButton
:color="layoutType === 'circular' ? 'primary' : 'neutral'"
:variant="layoutType === 'circular' ? 'solid' : 'ghost'"
@click="layoutType = 'circular'"
>
{{ t('circular') }}
</UButton>
<UButton
:color="layoutType === 'force' ? 'primary' : 'neutral'"
:variant="layoutType === 'force' ? 'solid' : 'ghost'"
@click="layoutType = 'force'"
>
{{ t('force') }}
</UButton>
</UButtonGroup>
</div>
<!-- 方向切换 -->
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ t('directed') }}:</span>
<USwitch v-model="showDirection" size="xs" />
</div>
<!-- 重置视图 -->
<UButton
size="xs"
color="neutral"
variant="ghost"
icon="i-heroicons-arrow-path"
@click="handleResetView"
>
{{ t('reset') }}
</UButton>
</div>
</div>
<!-- 图表区域全屏 -->
<div class="flex-1 min-h-0 relative">
<!-- 加载状态 -->
<div v-if="isLoading" class="absolute inset-0 flex items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
</div>
<!-- 图表 -->
<template v-else>
<div v-if="graphData.nodes.length > 0" class="h-full">
<EChartGraph
ref="graphRef"
:data="graphData"
:layout="layoutType"
:directed="showDirection"
:height="'100%'"
/>
<!-- 底部统计信息 -->
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-gray-100/80 px-3 py-1 text-xs text-gray-500 backdrop-blur-sm dark:bg-gray-800/80 dark:text-gray-400">
{{ t('graphHint', { nodes: graphData.nodes.length, links: graphData.links.length }) }}
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-gray-400">
<div class="text-center">
<UIcon name="i-heroicons-user-group" class="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600" />
<p class="mt-2 text-sm">{{ t('noInteraction') }}</p>
</div>
</div>
</template>
</div>
</div>
</template>
<i18n>
{
"zh-CN": {
"mentionGraph": "艾特互动关系图",
"layout": "布局",
"circular": "环形",
"force": "力导向",
"directed": "有向",
"reset": "重置",
"graphHint": "共 {nodes} 位成员,{links} 条互动关系",
"noInteraction": "暂无艾特互动数据"
},
"en-US": {
"mentionGraph": "Mention Interaction Graph",
"layout": "Layout",
"circular": "Circular",
"force": "Force",
"directed": "Directed",
"reset": "Reset",
"graphHint": "{nodes} members, {links} interactions",
"noInteraction": "No mention interaction data"
}
}
</i18n>
+1
View File
@@ -2,4 +2,5 @@
export { default as MessageView } from './MessageView.vue'
export { default as WordcloudView } from './WordcloudView.vue'
export { default as PortraitView } from './PortraitView.vue'
export { default as InteractionView } from './InteractionView.vue'
+11 -2
View File
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubTabs } from '@/components/UI'
import { MessageView, WordcloudView, PortraitView } from '@/components/view'
import { MessageView, WordcloudView, PortraitView, InteractionView } from '@/components/view'
import UserSelect from '@/components/common/UserSelect.vue'
const { t } = useI18n()
@@ -18,9 +18,10 @@ const props = defineProps<{
timeFilter?: TimeFilter
}>()
// 子 Tab 配置
// 子 Tab 配置(群聊专属:包含互动分析)
const subTabs = computed(() => [
{ id: 'message', label: t('message'), icon: 'i-heroicons-chat-bubble-left-right' },
{ id: 'interaction', label: t('interaction'), icon: 'i-heroicons-arrows-right-left' },
{ id: 'wordcloud', label: t('wordcloud'), icon: 'i-heroicons-cloud' },
{ id: 'portrait', label: t('portrait'), icon: 'i-heroicons-user-circle' },
])
@@ -49,6 +50,12 @@ const selectedMemberId = ref<number | null>(null)
:time-filter="props.timeFilter"
:member-id="selectedMemberId"
/>
<InteractionView
v-else-if="activeSubTab === 'interaction'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
:member-id="selectedMemberId"
/>
<WordcloudView
v-else-if="activeSubTab === 'wordcloud'"
:session-id="props.sessionId"
@@ -82,11 +89,13 @@ const selectedMemberId = ref<number | null>(null)
{
"zh-CN": {
"message": "消息",
"interaction": "互动分析",
"wordcloud": "词云",
"portrait": "对话画像"
},
"en-US": {
"message": "Messages",
"interaction": "Interactions",
"wordcloud": "Word Cloud",
"portrait": "Chat Portrait"
}