feat: 支持私聊

This commit is contained in:
digua
2025-12-05 23:04:13 +08:00
parent 4fb9255605
commit 344aab37f5
23 changed files with 2057 additions and 87 deletions
+122 -25
View File
@@ -111,10 +111,23 @@ export interface AgentResult {
toolRounds: number
}
// ==================== 提示词配置类型 ====================
/**
* 获取系统提示词
* 用户自定义提示词配置
*/
function getSystemPrompt(): string {
export interface PromptConfig {
/** 角色定义(可编辑区) */
roleDefinition: string
/** 回答要求(可编辑区) */
responseRules: string
}
/**
* 获取系统锁定部分的提示词(工具说明、时间处理等)
* @param chatType 聊天类型 ('group' | 'private')
*/
function getLockedPromptSection(chatType: 'group' | 'private'): string {
const now = new Date()
const currentDate = now.toLocaleDateString('zh-CN', {
year: 'numeric',
@@ -123,39 +136,95 @@ function getSystemPrompt(): string {
weekday: 'long',
})
return `你是一个群聊记录分析助手。当前日期是 ${currentDate}
const isPrivate = chatType === 'private'
const chatTypeDesc = isPrivate ? '私聊记录' : '群聊记录'
你可以使用以下工具来获取群聊数据:
// 成员说明(私聊只有2人)
const memberNote = isPrivate
? `成员查询策略:
- 私聊只有两个人,可以直接获取成员列表
- 当用户提到"对方"、"他/她"时,通过 get_group_members 获取另一方信息
`
: `成员查询策略:
- 当用户提到特定群成员(如"张三说过什么"、"小明的发言"等)时,应先调用 get_group_members 获取成员列表
- 群成员有三种名称:accountNameQQ原始昵称)、groupNickname(群昵称)、aliases(用户自定义别名)
- 通过 get_group_members 的 search 参数可以模糊搜索这三种名称
- 找到成员后,使用其 id 字段作为 search_messages 的 sender_id 参数来获取该成员的发言
`
return `当前日期是 ${currentDate}
你可以使用以下工具来获取${chatTypeDesc}数据:
1. search_messages - 根据关键词搜索聊天记录,可指定 year/month 筛选时间段,也可指定 sender_id 筛选特定成员的发言
2. get_recent_messages - 获取指定时间段的聊天消息,可指定 year 和 month
3. get_member_stats - 获取成员活跃度统计
4. get_time_stats - 获取时间分布统计
5. get_group_members - 获取成员列表,包括 id、QQ号、账号名称、昵称、别名和消息统计
5. get_group_members - 获取成员列表,包括 id、QQ号、账号名称、昵称、别名和消息统计
6. get_member_name_history - 获取成员的昵称变更历史,需要先通过 get_group_members 获取成员 ID
7. get_conversation_between - 获取两个成员之间的对话记录,需要先通过 get_group_members 获取两人的成员 ID
成员查询策略:
- 当用户提到特定群成员(如"张三说过什么"、"小明的发言"等)时,应先调用 get_group_members 获取成员列表
- 群成员有三种名称:accountNameQQ原始昵称)、groupNickname(群昵称)、aliases(用户自定义别名)
- 通过 get_group_members 的 search 参数可以模糊搜索这三种名称
- 找到成员后,使用其 id 字段作为 search_messages 的 sender_id 参数来获取该成员的发言
${memberNote}
时间处理要求:
- 如果用户提到"X月"但没有指定年份,默认使用当前年份(${now.getFullYear()}年)
- 如果当前月份还没到用户提到的月份,则使用去年
- 例如:现在是${now.getFullYear()}${now.getMonth() + 1}月,用户问"10月的聊天"应该查询${now.getMonth() + 1 >= 10 ? now.getFullYear() : now.getFullYear() - 1}年10月
根据用户的问题,选择合适的工具获取数据,然后基于数据给出回答。
根据用户的问题,选择合适的工具获取数据,然后基于数据给出回答。`
}
回答要求:
1. 基于工具返回的数据回答,不要编造信息
/**
* 获取默认的角色定义
*/
function getDefaultRoleDefinition(chatType: 'group' | 'private'): string {
if (chatType === 'private') {
return `你是一个专业的私聊记录分析助手。
你的任务是帮助用户理解和分析他们的私聊记录数据。
注意:这是一个私聊对话,只有两个人参与。你的分析应该关注:
- 两人之间的对话互动
- 谁更主动、谁回复更多
- 对话的主题和内容变化
- 不要使用"群"这个词,使用"对话"或"聊天"`
}
return `你是一个专业的群聊记录分析助手。
你的任务是帮助用户理解和分析他们的群聊记录数据。`
}
/**
* 获取默认的回答要求
*/
function getDefaultResponseRules(): string {
return `1. 基于工具返回的数据回答,不要编造信息
2. 如果数据不足以回答问题,请说明
3. 回答要简洁明了,使用 Markdown 格式
4. 可以引用具体的发言作为证据
5. 对于统计数据,可以适当总结趋势和特点`
}
/**
* 构建完整的系统提示词
* @param chatType 聊天类型 ('group' | 'private')
* @param promptConfig 用户自定义提示词配置(可选)
*/
function buildSystemPrompt(chatType: 'group' | 'private' = 'group', promptConfig?: PromptConfig): string {
// 使用用户配置或默认配置
const roleDefinition = promptConfig?.roleDefinition || getDefaultRoleDefinition(chatType)
const responseRules = promptConfig?.responseRules || getDefaultResponseRules()
// 获取锁定的系统部分
const lockedSection = getLockedPromptSection(chatType)
// 组合完整提示词
return `${roleDefinition}
${lockedSection}
回答要求:
${responseRules}`
}
/**
* Agent 执行器类
* 处理带 Function Calling 的对话流程
@@ -167,10 +236,22 @@ export class Agent {
private toolsUsed: string[] = []
private toolRounds: number = 0
private abortSignal?: AbortSignal
private historyMessages: ChatMessage[] = []
private chatType: 'group' | 'private' = 'group'
private promptConfig?: PromptConfig
constructor(context: ToolContext, config: AgentConfig = {}) {
constructor(
context: ToolContext,
config: AgentConfig = {},
historyMessages: ChatMessage[] = [],
chatType: 'group' | 'private' = 'group',
promptConfig?: PromptConfig
) {
this.context = context
this.abortSignal = config.abortSignal
this.historyMessages = historyMessages
this.chatType = chatType
this.promptConfig = promptConfig
this.config = {
maxToolRounds: config.maxToolRounds ?? 5,
llmOptions: config.llmOptions ?? { temperature: 0.7, maxTokens: 2048 },
@@ -189,7 +270,10 @@ export class Agent {
* @param userMessage 用户消息
*/
async execute(userMessage: string): Promise<AgentResult> {
aiLogger.info('Agent', '开始执行', { userMessage: userMessage.slice(0, 100) })
aiLogger.info('Agent', '开始执行', {
userMessage: userMessage.slice(0, 100),
historyLength: this.historyMessages.length,
})
// 检查是否已中止
if (this.isAborted()) {
@@ -197,9 +281,10 @@ export class Agent {
return { content: '', toolsUsed: [], toolRounds: 0 }
}
// 初始化消息
// 初始化消息(包含历史记录)
this.messages = [
{ role: 'system', content: getSystemPrompt() },
{ role: 'system', content: buildSystemPrompt(this.chatType, this.promptConfig) },
...this.historyMessages, // 插入历史对话
{ role: 'user', content: userMessage },
]
this.toolsUsed = []
@@ -307,7 +392,10 @@ export class Agent {
* @param onChunk 流式回调
*/
async executeStream(userMessage: string, onChunk: (chunk: AgentStreamChunk) => void): Promise<AgentResult> {
aiLogger.info('Agent', '开始流式执行', { userMessage: userMessage.slice(0, 100) })
aiLogger.info('Agent', '开始流式执行', {
userMessage: userMessage.slice(0, 100),
historyLength: this.historyMessages.length,
})
// 检查是否已中止
if (this.isAborted()) {
@@ -316,9 +404,10 @@ export class Agent {
return { content: '', toolsUsed: [], toolRounds: 0 }
}
// 初始化消息
// 初始化消息(包含历史记录)
this.messages = [
{ role: 'system', content: getSystemPrompt() },
{ role: 'system', content: buildSystemPrompt(this.chatType, this.promptConfig) },
...this.historyMessages, // 插入历史对话
{ role: 'user', content: userMessage },
]
this.toolsUsed = []
@@ -579,8 +668,14 @@ export class Agent {
/**
* 创建 Agent 并执行对话(便捷函数)
*/
export async function runAgent(userMessage: string, context: ToolContext, config?: AgentConfig): Promise<AgentResult> {
const agent = new Agent(context, config)
export async function runAgent(
userMessage: string,
context: ToolContext,
config?: AgentConfig,
historyMessages?: ChatMessage[],
chatType?: 'group' | 'private'
): Promise<AgentResult> {
const agent = new Agent(context, config, historyMessages, chatType)
return agent.execute(userMessage)
}
@@ -591,8 +686,10 @@ export async function runAgentStream(
userMessage: string,
context: ToolContext,
onChunk: (chunk: AgentStreamChunk) => void,
config?: AgentConfig
config?: AgentConfig,
historyMessages?: ChatMessage[],
chatType?: 'group' | 'private'
): Promise<AgentResult> {
const agent = new Agent(context, config)
const agent = new Agent(context, config, historyMessages, chatType)
return agent.executeStream(userMessage, onChunk)
}
+40 -11
View File
@@ -3,7 +3,7 @@ import { ipcMain, BrowserWindow } from 'electron'
import * as aiConversations from '../ai/conversations'
import * as llm from '../ai/llm'
import { aiLogger } from '../ai/logger'
import { Agent, type AgentStreamChunk } from '../ai/agent'
import { Agent, type AgentStreamChunk, type PromptConfig } from '../ai/agent'
import type { ToolContext } from '../ai/tools/types'
import type { IpcContext } from './types'
@@ -424,19 +424,48 @@ export function registerAIHandlers({ win }: IpcContext): void {
/**
* 执行 Agent 对话(流式)
* Agent 会自动调用工具并返回最终结果
* @param historyMessages 对话历史(可选,用于上下文关联)
* @param chatType 聊天类型('group' | 'private'
* @param promptConfig 用户自定义提示词配置(可选)
*/
ipcMain.handle('agent:runStream', async (_, requestId: string, userMessage: string, context: ToolContext) => {
aiLogger.info('IPC', `收到 Agent 流式请求: ${requestId}`, {
userMessage: userMessage.slice(0, 100),
sessionId: context.sessionId,
})
ipcMain.handle(
'agent:runStream',
async (
_,
requestId: string,
userMessage: string,
context: ToolContext,
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
chatType?: 'group' | 'private',
promptConfig?: PromptConfig
) => {
aiLogger.info('IPC', `收到 Agent 流式请求: ${requestId}`, {
userMessage: userMessage.slice(0, 100),
sessionId: context.sessionId,
historyLength: historyMessages?.length ?? 0,
chatType: chatType ?? 'group',
hasPromptConfig: !!promptConfig,
})
try {
// 创建 AbortController 并存储
const abortController = new AbortController()
activeAgentRequests.set(requestId, abortController)
try {
// 创建 AbortController 并存储
const abortController = new AbortController()
activeAgentRequests.set(requestId, abortController)
const agent = new Agent(context, { abortSignal: abortController.signal })
// 转换历史消息格式
const formattedHistory =
historyMessages?.map((msg) => ({
role: msg.role as 'user' | 'assistant',
content: msg.content,
})) ?? []
const agent = new Agent(
context,
{ abortSignal: abortController.signal },
formattedHistory,
chatType ?? 'group',
promptConfig
)
// 异步执行,通过事件发送流式数据
;(async () => {
+11 -1
View File
@@ -269,11 +269,20 @@ interface ToolContext {
timeFilter?: { startTs: number; endTs: number }
}
// 用户自定义提示词配置
interface PromptConfig {
roleDefinition: string
responseRules: string
}
interface AgentApi {
runStream: (
userMessage: string,
context: ToolContext,
onChunk?: (chunk: AgentStreamChunk) => void
onChunk?: (chunk: AgentStreamChunk) => void,
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
chatType?: 'group' | 'private',
promptConfig?: PromptConfig
) => { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: string }> }
abort: (requestId: string) => Promise<{ success: boolean; error?: string }>
}
@@ -336,6 +345,7 @@ export {
AgentStreamChunk,
AgentResult,
ToolContext,
PromptConfig,
CacheDirectoryInfo,
CacheInfo,
}
+25 -4
View File
@@ -723,20 +723,41 @@ const llmApi = {
},
}
// 用户自定义提示词配置
interface PromptConfig {
roleDefinition: string
responseRules: string
}
// Agent API - AI Agent 功能(带 Function Calling
const agentApi = {
/**
* 执行 Agent 对话(流式)
* Agent 会自动调用工具获取数据并生成回答
* @param historyMessages 对话历史(可选,用于上下文关联)
* @param chatType 聊天类型('group' | 'private'
* @param promptConfig 用户自定义提示词配置(可选)
* @returns 返回 { requestId, promise }requestId 可用于中止请求
*/
runStream: (
userMessage: string,
context: ToolContext,
onChunk?: (chunk: AgentStreamChunk) => void
onChunk?: (chunk: AgentStreamChunk) => void,
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
chatType?: 'group' | 'private',
promptConfig?: PromptConfig
): { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: string }> } => {
const requestId = `agent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
console.log('[preload] Agent runStream 开始,requestId:', requestId)
console.log(
'[preload] Agent runStream 开始,requestId:',
requestId,
'historyLength:',
historyMessages?.length ?? 0,
'chatType:',
chatType ?? 'group',
'hasPromptConfig:',
!!promptConfig
)
const promise = new Promise<{ success: boolean; result?: AgentResult; error?: string }>((resolve) => {
// 监听流式 chunks
@@ -764,9 +785,9 @@ const agentApi = {
ipcRenderer.on('agent:streamChunk', chunkHandler)
ipcRenderer.on('agent:complete', completeHandler)
// 发起请求
// 发起请求(传递历史消息、聊天类型和提示词配置)
ipcRenderer
.invoke('agent:runStream', requestId, userMessage, context)
.invoke('agent:runStream', requestId, userMessage, context, historyMessages, chatType, promptConfig)
.then((result) => {
console.log('[preload] Agent invoke 返回:', result)
if (!result.success) {
+3
View File
@@ -13,6 +13,7 @@ declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
UAccordion: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Accordion.vue')['default']
UAlert: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Alert.vue')['default']
UApp: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/App.vue')['default']
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
@@ -24,9 +25,11 @@ declare module 'vue' {
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
UInputTags: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/InputTags.vue')['default']
UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
UProgress: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Progress.vue')['default']
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
UTextarea: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Textarea.vue')['default']
UTooltip: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Tooltip.vue')['default']
}
}
+2
View File
@@ -8,6 +8,7 @@ defineProps<{
sessionId: string
sessionName: string
timeFilter?: { startTs: number; endTs: number }
chatType?: 'group' | 'private'
}>()
// 子 Tab 配置
@@ -50,6 +51,7 @@ defineExpose({
:session-id="sessionId"
:session-name="sessionName"
:time-filter="timeFilter"
:chat-type="chatType"
/>
<!-- 实验室 - 暂未实现 -->
@@ -0,0 +1,437 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { AnalysisSession, MemberActivity, HourlyActivity, MessageType, WeekdayActivity } from '@/types/chat'
import { getMessageTypeName } from '@/types/chat'
import { DoughnutChart, BarChart } from '@/components/charts'
import type { DoughnutChartData, BarChartData } from '@/components/charts'
import { SectionCard, StatCard } from '@/components/UI'
const props = defineProps<{
session: AnalysisSession
memberActivity: MemberActivity[]
messageTypes: Array<{ type: MessageType; count: number }>
hourlyActivity: HourlyActivity[]
timeRange: { start: number; end: number } | null
selectedYear: number | null
filteredMessageCount: number
filteredMemberCount: number
timeFilter?: { startTs?: number; endTs?: number }
}>()
// 时间跨度
const durationDays = computed(() => {
if (props.selectedYear) {
const isLeapYear =
(props.selectedYear % 4 === 0 && props.selectedYear % 100 !== 0) || props.selectedYear % 400 === 0
return isLeapYear ? 366 : 365
}
if (!props.timeRange) return 0
return Math.ceil((props.timeRange.end - props.timeRange.start) / 86400)
})
// 显示的消息数
const displayMessageCount = computed(() => {
return props.selectedYear ? props.filteredMessageCount : props.session.messageCount
})
// 消息类型图表数据
const typeChartData = computed<DoughnutChartData>(() => {
return {
labels: props.messageTypes.map((t) => getMessageTypeName(t.type)),
values: props.messageTypes.map((t) => t.count),
}
})
// 双方消息对比数据
const memberComparisonData = computed(() => {
if (props.memberActivity.length !== 2) return null
const sorted = [...props.memberActivity].sort((a, b) => b.messageCount - a.messageCount)
const total = sorted[0].messageCount + sorted[1].messageCount
return {
member1: {
name: sorted[0].name,
count: sorted[0].messageCount,
percentage: total > 0 ? Math.round((sorted[0].messageCount / total) * 100) : 0,
},
member2: {
name: sorted[1].name,
count: sorted[1].messageCount,
percentage: total > 0 ? Math.round((sorted[1].messageCount / total) * 100) : 0,
},
total,
}
})
// 双方对比图表数据
const comparisonChartData = computed<DoughnutChartData>(() => {
if (!memberComparisonData.value) {
return { labels: [], values: [] }
}
return {
labels: [memberComparisonData.value.member1.name, memberComparisonData.value.member2.name],
values: [memberComparisonData.value.member1.count, memberComparisonData.value.member2.count],
}
})
// 最活跃时段
const peakHour = computed(() => {
if (!props.hourlyActivity.length) return null
const peak = props.hourlyActivity.reduce(
(max, h) => (h.messageCount > max.messageCount ? h : max),
props.hourlyActivity[0]
)
return peak
})
// 图片消息数量
const imageCount = computed(() => {
const imageType = props.messageTypes.find((t) => t.type === 1)
return imageType?.count || 0
})
// 日均消息数
const dailyAvgMessages = computed(() => {
if (durationDays.value === 0) return 0
return Math.round(displayMessageCount.value / durationDays.value)
})
// 星期活跃度数据
const weekdayActivity = ref<WeekdayActivity[]>([])
const isLoadingWeekday = ref(false)
// 星期名称映射(周一开始)
const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
// 加载星期活跃度数据
async function loadWeekdayActivity() {
if (!props.session.id) return
isLoadingWeekday.value = true
try {
weekdayActivity.value = await window.chatApi.getWeekdayActivity(props.session.id, props.timeFilter)
} catch (error) {
console.error('加载星期活跃度失败:', error)
} finally {
isLoadingWeekday.value = false
}
}
// 监听 session.id 和 timeFilter 变化
watch(
() => [props.session.id, props.timeFilter],
() => {
loadWeekdayActivity()
},
{ immediate: true, deep: true }
)
// 24小时分布图数据
const hourlyChartData = computed<BarChartData>(() => {
return {
labels: props.hourlyActivity.map((h) => `${h.hour}:00`),
values: props.hourlyActivity.map((h) => h.messageCount),
}
})
// 星期分布图数据
const weekdayChartData = computed<BarChartData>(() => {
return {
labels: weekdayActivity.value.map((w) => weekdayNames[w.weekday - 1]),
values: weekdayActivity.value.map((w) => w.messageCount),
}
})
// 最活跃星期
const peakWeekday = computed(() => {
if (!weekdayActivity.value.length) return null
return weekdayActivity.value.reduce(
(max, w) => (w.messageCount > max.messageCount ? w : max),
weekdayActivity.value[0]
)
})
// 时段占比计算
const totalMessages = computed(() => props.hourlyActivity.reduce((sum, h) => sum + h.messageCount, 0))
const lateNightRatio = computed(() => {
const lateNight = props.hourlyActivity
.filter((h) => h.hour >= 0 && h.hour < 6)
.reduce((sum, h) => sum + h.messageCount, 0)
return totalMessages.value > 0 ? Math.round((lateNight / totalMessages.value) * 100) : 0
})
const morningRatio = computed(() => {
const morning = props.hourlyActivity
.filter((h) => h.hour >= 6 && h.hour < 12)
.reduce((sum, h) => sum + h.messageCount, 0)
return totalMessages.value > 0 ? Math.round((morning / totalMessages.value) * 100) : 0
})
const afternoonRatio = computed(() => {
const afternoon = props.hourlyActivity
.filter((h) => h.hour >= 12 && h.hour < 18)
.reduce((sum, h) => sum + h.messageCount, 0)
return totalMessages.value > 0 ? Math.round((afternoon / totalMessages.value) * 100) : 0
})
const eveningRatio = computed(() => {
const evening = props.hourlyActivity
.filter((h) => h.hour >= 18 && h.hour < 24)
.reduce((sum, h) => sum + h.messageCount, 0)
return totalMessages.value > 0 ? Math.round((evening / totalMessages.value) * 100) : 0
})
const weekdayVsWeekend = computed(() => {
if (!weekdayActivity.value.length) return { weekday: 0, weekend: 0 }
const weekdaySum = weekdayActivity.value
.filter((w) => w.weekday >= 1 && w.weekday <= 5)
.reduce((sum, w) => sum + w.messageCount, 0)
const weekendSum = weekdayActivity.value
.filter((w) => w.weekday >= 6 && w.weekday <= 7)
.reduce((sum, w) => sum + w.messageCount, 0)
const total = weekdaySum + weekendSum
return {
weekday: total > 0 ? Math.round((weekdaySum / total) * 100) : 0,
weekend: total > 0 ? Math.round((weekendSum / total) * 100) : 0,
}
})
</script>
<template>
<div class="space-y-6 p-6">
<!-- 私聊身份卡 -->
<div class="relative overflow-hidden rounded-3xl bg-pink-500 p-8 text-white shadow-xl">
<div class="relative">
<div>
<div class="flex items-center gap-3">
<h2 class="text-3xl font-black tracking-tight">{{ session.name }}</h2>
<span class="rounded-full bg-white/20 px-3 py-1 text-xs font-medium backdrop-blur-md">
{{ session.platform.toUpperCase() }}
</span>
</div>
<p class="mt-2 text-lg text-white/90 font-medium">
私聊 ·
<span class="opacity-80">数据分析报告</span>
</p>
</div>
<div class="mt-8 grid grid-cols-3 gap-6">
<div class="rounded-2xl bg-white/10 px-6 py-4 backdrop-blur-md">
<p class="text-3xl font-black tracking-tight">{{ displayMessageCount.toLocaleString() }}</p>
<p class="mt-1 text-sm font-medium text-white/70">{{ selectedYear ? '筛选消息' : '消息总数' }}</p>
</div>
<div class="rounded-2xl bg-white/10 px-6 py-4 backdrop-blur-md">
<p class="text-3xl font-black tracking-tight">{{ durationDays }}</p>
<p class="mt-1 text-sm font-medium text-white/70">跨度天数</p>
</div>
<div class="rounded-2xl bg-white/10 px-6 py-4 backdrop-blur-md">
<p class="text-3xl font-black tracking-tight">{{ dailyAvgMessages }}</p>
<p class="mt-1 text-sm font-medium text-white/70">日均消息</p>
</div>
</div>
</div>
</div>
<!-- 双方消息对比 -->
<SectionCard v-if="memberComparisonData" title="消息占比" :show-divider="false">
<div class="p-5">
<div class="flex items-center gap-8">
<!-- 左侧成员 -->
<div class="flex-1 text-center">
<div
class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-pink-100 dark:bg-pink-900/30"
>
<span class="text-2xl font-bold text-pink-600 dark:text-pink-400">
{{ memberComparisonData.member1.name.charAt(0) }}
</span>
</div>
<p class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
{{ memberComparisonData.member1.name }}
</p>
<p class="text-2xl font-black text-pink-600 dark:text-pink-400">
{{ memberComparisonData.member1.percentage }}%
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ memberComparisonData.member1.count.toLocaleString() }}
</p>
</div>
<!-- 中间对比条 -->
<div class="flex-1">
<div class="relative h-8 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="absolute left-0 top-0 h-full rounded-l-full bg-pink-500 transition-all"
:style="{ width: `${memberComparisonData.member1.percentage}%` }"
/>
<div
class="absolute right-0 top-0 h-full rounded-r-full bg-blue-500 transition-all"
:style="{ width: `${memberComparisonData.member2.percentage}%` }"
/>
</div>
<div class="mt-2 flex justify-between text-xs text-gray-500">
<span>{{ memberComparisonData.member1.percentage }}%</span>
<span>{{ memberComparisonData.member2.percentage }}%</span>
</div>
</div>
<!-- 右侧成员 -->
<div class="flex-1 text-center">
<div
class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30"
>
<span class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{{ memberComparisonData.member2.name.charAt(0) }}
</span>
</div>
<p class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
{{ memberComparisonData.member2.name }}
</p>
<p class="text-2xl font-black text-blue-600 dark:text-blue-400">
{{ memberComparisonData.member2.percentage }}%
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ memberComparisonData.member2.count.toLocaleString() }}
</p>
</div>
</div>
</div>
</SectionCard>
<!-- 关键指标卡片 -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- 日均消息 -->
<StatCard label="日均消息" :value="`${dailyAvgMessages} 条`" icon="📊" icon-bg="blue">
<template #subtext>
<span class="text-sm text-gray-500"> {{ durationDays }} </span>
</template>
</StatCard>
<!-- 图片/表情 -->
<StatCard label="图片消息" :value="`${imageCount} 张`" icon="📸" icon-bg="pink">
<template #subtext>
<span class="text-sm text-gray-500">最活跃时段:</span>
<span class="font-semibold text-pink-500">{{ peakHour?.hour || 0 }}:00</span>
</template>
</StatCard>
<!-- 最活跃星期 -->
<StatCard
label="最活跃星期"
:value="peakWeekday ? weekdayNames[peakWeekday.weekday - 1] : '-'"
icon="📅"
icon-bg="amber"
>
<template #subtext>
<span class="text-sm text-gray-500">{{ peakWeekday?.messageCount ?? 0 }} 条消息</span>
</template>
</StatCard>
<!-- 周末活跃度 -->
<StatCard label="周末活跃度" :value="`${weekdayVsWeekend.weekend}%`" icon="🏖️" icon-bg="green">
<template #subtext>
<span class="text-sm text-gray-500">周末消息占比</span>
</template>
</StatCard>
</div>
<!-- 图表区域消息类型 & 双方占比 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 消息类型分布 -->
<SectionCard title="消息类型分布" :show-divider="false">
<div class="p-5">
<DoughnutChart :data="typeChartData" :height="256" />
</div>
</SectionCard>
<!-- 双方消息占比饼图 -->
<SectionCard v-if="memberComparisonData" title="双方消息占比" :show-divider="false">
<div class="p-5">
<DoughnutChart :data="comparisonChartData" :height="256" />
</div>
</SectionCard>
</div>
<!-- 24小时 & 星期分布 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 24小时分布 -->
<SectionCard title="24小时活跃分布" :show-divider="false">
<div class="p-5">
<BarChart
:data="hourlyChartData"
:height="256"
:x-label-filter="(_, index) => (index % 3 === 0 ? `${index}:00` : '')"
/>
<div class="mt-6 grid grid-cols-4 gap-2">
<div class="text-center">
<div class="text-xs text-gray-500 dark:text-gray-400">凌晨</div>
<div class="mt-1 text-base font-semibold text-gray-900 dark:text-white">{{ lateNightRatio }}%</div>
<div class="mx-auto mt-1 h-1 w-full max-w-12 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-pink-300 transition-all" :style="{ width: `${lateNightRatio}%` }" />
</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 dark:text-gray-400">上午</div>
<div class="mt-1 text-base font-semibold text-gray-900 dark:text-white">{{ morningRatio }}%</div>
<div class="mx-auto mt-1 h-1 w-full max-w-12 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-pink-400 transition-all" :style="{ width: `${morningRatio}%` }" />
</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 dark:text-gray-400">下午</div>
<div class="mt-1 text-base font-semibold text-gray-900 dark:text-white">{{ afternoonRatio }}%</div>
<div class="mx-auto mt-1 h-1 w-full max-w-12 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-pink-500 transition-all" :style="{ width: `${afternoonRatio}%` }" />
</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 dark:text-gray-400">晚上</div>
<div class="mt-1 text-base font-semibold text-gray-900 dark:text-white">{{ eveningRatio }}%</div>
<div class="mx-auto mt-1 h-1 w-full max-w-12 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-pink-600 transition-all" :style="{ width: `${eveningRatio}%` }" />
</div>
</div>
</div>
</div>
</SectionCard>
<!-- 星期分布 -->
<SectionCard title="星期活跃分布" :show-divider="false">
<div class="p-5">
<div v-if="isLoadingWeekday" class="flex h-64 items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
</div>
<BarChart v-else :data="weekdayChartData" :height="256" />
<div class="mt-6 grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-xs text-gray-500 dark:text-gray-400">工作日</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{{ weekdayVsWeekend.weekday }}%
</div>
<div class="mx-auto mt-2 h-1 w-full max-w-24 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full bg-pink-500 transition-all"
:style="{ width: `${weekdayVsWeekend.weekday}%` }"
/>
</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 dark:text-gray-400">周末</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{{ weekdayVsWeekend.weekend }}%
</div>
<div class="mx-auto mt-2 h-1 w-full max-w-24 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full bg-blue-500 transition-all"
:style="{ width: `${weekdayVsWeekend.weekend}%` }"
/>
</div>
</div>
</div>
</div>
</SectionCard>
</div>
</div>
</template>
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { DailyActivity, MemberActivity, MemberNameHistory } from '@/types/chat'
import dayjs from 'dayjs'
import { LineChart } from '@/components/charts'
import type { LineChartData } from '@/components/charts'
import { SectionCard, StatCard, EmptyState, LoadingState } from '@/components/UI'
import { formatPeriod } from '@/utils'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
dailyActivity: DailyActivity[]
memberActivity: MemberActivity[]
timeRange: { start: number; end: number } | null
timeFilter?: TimeFilter
}>()
// 检测是否跨年
const isMultiYear = computed(() => {
if (props.dailyActivity.length < 2) return false
const years = new Set(props.dailyActivity.map((d) => dayjs(d.date).year()))
return years.size > 1
})
// 每日趋势图数据(动态聚合)
const dailyChartData = computed<LineChartData>(() => {
const rawData = props.dailyActivity
const maxPoints = 50 // 最大展示点数
if (rawData.length <= maxPoints) {
const dateFormat = isMultiYear.value ? 'YYYY/MM/DD' : 'MM/DD'
return {
labels: rawData.map((d) => dayjs(d.date).format(dateFormat)),
values: rawData.map((d) => d.messageCount),
}
}
// 需要聚合
const groupSize = Math.ceil(rawData.length / maxPoints)
const aggregatedLabels: string[] = []
const aggregatedValues: number[] = []
for (let i = 0; i < rawData.length; i += groupSize) {
const chunk = rawData.slice(i, i + groupSize)
if (chunk.length === 0) continue
// 计算该组的平均日期作为标签
const midIndex = Math.floor(chunk.length / 2)
const midDate = chunk[midIndex].date
const dateFormat = isMultiYear.value ? 'YYYY/MM/DD' : 'MM/DD'
aggregatedLabels.push(dayjs(midDate).format(dateFormat))
// 计算该组的日均消息数
const totalMessages = chunk.reduce((sum, d) => sum + d.messageCount, 0)
const avgMessages = Math.round(totalMessages / chunk.length)
aggregatedValues.push(avgMessages)
}
return {
labels: aggregatedLabels,
values: aggregatedValues,
}
})
// 最活跃的一天
const peakDay = computed(() => {
if (!props.dailyActivity.length) return null
return props.dailyActivity.reduce((max, d) => (d.messageCount > max.messageCount ? d : max), props.dailyActivity[0])
})
// 平均每日消息数
const avgDailyMessages = computed(() => {
if (!props.dailyActivity.length) return 0
const total = props.dailyActivity.reduce((sum, d) => sum + d.messageCount, 0)
return Math.round(total / props.dailyActivity.length)
})
// 活跃天数
const activeDays = computed(() => {
return props.dailyActivity.filter((d) => d.messageCount > 0).length
})
// 总天数
const totalDays = computed(() => {
if (!props.timeRange) return 0
const start = dayjs.unix(props.timeRange.start)
const end = dayjs.unix(props.timeRange.end)
return end.diff(start, 'day') + 1
})
// 活跃率
const activeRate = computed(() => {
return totalDays.value > 0 ? Math.round((activeDays.value / totalDays.value) * 100) : 0
})
// ==================== 昵称变更记录 ====================
interface MemberWithHistory {
memberId: number
name: string
history: MemberNameHistory[]
}
const membersWithNicknameChanges = ref<MemberWithHistory[]>([])
const isLoadingHistory = ref(false)
async function loadMembersWithNicknameChanges() {
if (!props.sessionId || props.memberActivity.length === 0) return
isLoadingHistory.value = true
const membersWithChanges: MemberWithHistory[] = []
try {
const historyPromises = props.memberActivity.map((member) =>
window.chatApi.getMemberNameHistory(props.sessionId, member.memberId)
)
const allHistories = await Promise.all(historyPromises)
props.memberActivity.forEach((member, index) => {
const history = allHistories[index]
if (history.length > 1) {
membersWithChanges.push({
memberId: member.memberId,
name: member.name,
history,
})
}
})
membersWithNicknameChanges.value = membersWithChanges
} catch (error) {
console.error('加载昵称变更记录失败:', error)
} finally {
isLoadingHistory.value = false
}
}
watch(
() => [props.sessionId, props.memberActivity.length],
() => {
loadMembersWithNicknameChanges()
},
{ immediate: true }
)
</script>
<template>
<div class="space-y-6 p-6">
<!-- 标题 -->
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">时间轴分析</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">追踪私聊的活跃趋势变化</p>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard
label="最活跃日期"
:value="peakDay ? dayjs(peakDay.date).format('MM/DD') : '-'"
:subtext="`${peakDay?.messageCount ?? 0} 条消息`"
/>
<StatCard label="日均消息" :value="avgDailyMessages" subtext="条/天" />
<StatCard label="活跃天数" :value="activeDays" :subtext="`/ ${totalDays} 天`" />
<StatCard label="活跃率" :value="`${activeRate}%`" subtext="有消息的天数占比" />
</div>
<!-- 每日趋势 -->
<SectionCard title="每日消息趋势" :show-divider="false">
<div class="p-5">
<LineChart :data="dailyChartData" :height="288" />
</div>
</SectionCard>
<!-- 昵称变更记录 -->
<SectionCard
title="昵称变更记录"
:description="
isLoadingHistory
? '加载中...'
: membersWithNicknameChanges.length > 0
? `${membersWithNicknameChanges.length} 位成员曾修改过昵称`
: '暂无成员修改昵称'
"
>
<div
v-if="!isLoadingHistory && membersWithNicknameChanges.length > 0"
class="divide-y divide-gray-100 dark:divide-gray-800"
>
<div
v-for="member in membersWithNicknameChanges"
:key="member.memberId"
class="flex items-start gap-3 px-5 py-3"
>
<div class="w-32 shrink-0 pt-0.5 font-medium text-gray-900 dark:text-white">
{{ member.name }}
</div>
<div class="flex flex-1 flex-wrap items-center gap-2">
<template v-for="(item, index) in member.history" :key="index">
<div class="flex items-center gap-1.5 rounded-lg bg-gray-50 px-3 py-1.5 dark:bg-gray-800">
<span
class="text-sm"
:class="item.endTs === null ? 'font-semibold text-pink-600' : 'text-gray-700 dark:text-gray-300'"
>
{{ item.name }}
</span>
<UBadge v-if="item.endTs === null" color="primary" variant="soft" size="xs">当前</UBadge>
<span class="text-xs text-gray-400">({{ formatPeriod(item.startTs, item.endTs) }})</span>
</div>
<span v-if="index < member.history.length - 1" class="text-gray-300 dark:text-gray-600"></span>
</template>
</div>
</div>
</div>
<EmptyState v-else-if="!isLoadingHistory" text="双方均未修改过昵称" />
<LoadingState v-else text="正在加载昵称变更记录..." />
</SectionCard>
</div>
</template>
+87 -17
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import ConversationList from './ConversationList.vue'
import DataSourcePanel from './DataSourcePanel.vue'
import ChatMessage from './ChatMessage.vue'
@@ -12,6 +13,7 @@ const props = defineProps<{
sessionId: string
sessionName: string
timeFilter?: { startTs: number; endTs: number }
chatType?: 'group' | 'private'
}>()
// 使用 AI 对话 Composable
@@ -30,10 +32,43 @@ const {
loadMoreSourceMessages,
updateMaxMessages,
stopGeneration,
} = useAIChat(props.sessionId, props.timeFilter)
} = useAIChat(props.sessionId, props.timeFilter, props.chatType ?? 'group')
// Store
const chatStore = useChatStore()
const { groupPresets, privatePresets, aiPromptSettings } = storeToRefs(chatStore)
// 当前聊天类型
const currentChatType = computed(() => props.chatType ?? 'group')
// 当前类型对应的预设列表
const currentPresets = computed(() => (currentChatType.value === 'group' ? groupPresets.value : privatePresets.value))
// 当前激活的预设 ID
const currentActivePresetId = computed(() =>
currentChatType.value === 'group'
? aiPromptSettings.value.activeGroupPresetId
: aiPromptSettings.value.activePrivatePresetId
)
// 当前激活的预设
const currentActivePreset = computed(
() => currentPresets.value.find((p) => p.id === currentActivePresetId.value) || currentPresets.value[0]
)
// 预设下拉菜单状态
const isPresetPopoverOpen = ref(false)
// 设置激活预设
function setActivePreset(presetId: string) {
if (currentChatType.value === 'group') {
chatStore.setActiveGroupPreset(presetId)
} else {
chatStore.setActivePrivatePreset(presetId)
}
// 关闭下拉菜单
isPresetPopoverOpen.value = false
}
// UI 状态
const isSourcePanelCollapsed = ref(false)
@@ -242,7 +277,7 @@ watch(
class="mt-0.5 h-4 w-4 shrink-0"
:class="[
tool.status === 'running'
? 'animate-spin text-violet-500'
? 'animate-spin text-pink-500'
: tool.status === 'done'
? 'text-green-500'
: 'text-red-500',
@@ -261,7 +296,7 @@ watch(
<span
v-for="(kw, kwIdx) in tool.params.keywords as string[]"
:key="kwIdx"
class="ml-1 inline-flex rounded bg-violet-100 px-1.5 py-0.5 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300"
class="ml-1 inline-flex rounded bg-pink-100 px-1.5 py-0.5 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300"
>
{{ kw }}
</span>
@@ -341,7 +376,7 @@ watch(
<!-- AI 思考中指示器 -->
<div v-if="isAIThinking && !messages[messages.length - 1]?.content" class="flex items-start gap-3">
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-violet-500 to-purple-600"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-pink-500 to-pink-600"
>
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 text-white" />
</div>
@@ -353,7 +388,7 @@ watch(
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="[
currentToolStatus.status === 'running'
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300'
? 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300'
: currentToolStatus.status === 'done'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
@@ -373,9 +408,9 @@ watch(
{{ currentToolStatus.displayName }}
</span>
<span v-if="currentToolStatus.status === 'running'" class="flex gap-1">
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-violet-500 [animation-delay:0ms]" />
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-violet-500 [animation-delay:150ms]" />
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-violet-500 [animation-delay:300ms]" />
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-pink-500 [animation-delay:0ms]" />
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-pink-500 [animation-delay:150ms]" />
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-pink-500 [animation-delay:300ms]" />
</span>
<span
v-else-if="currentToolStatus.status === 'done'"
@@ -406,9 +441,9 @@ watch(
<div v-else class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">正在分析问题...</span>
<span class="flex gap-1">
<span class="h-2 w-2 animate-bounce rounded-full bg-violet-500 [animation-delay:0ms]" />
<span class="h-2 w-2 animate-bounce rounded-full bg-violet-500 [animation-delay:150ms]" />
<span class="h-2 w-2 animate-bounce rounded-full bg-violet-500 [animation-delay:300ms]" />
<span class="h-2 w-2 animate-bounce rounded-full bg-pink-500 [animation-delay:0ms]" />
<span class="h-2 w-2 animate-bounce rounded-full bg-pink-500 [animation-delay:150ms]" />
<span class="h-2 w-2 animate-bounce rounded-full bg-pink-500 [animation-delay:300ms]" />
</span>
</div>
</div>
@@ -428,13 +463,48 @@ watch(
<!-- 底部状态栏 -->
<div class="mt-2 flex items-center justify-between px-1">
<div class="flex items-center gap-2 text-xs text-gray-400">
<UIcon name="i-heroicons-sparkles" class="h-3.5 w-3.5" />
<span>探索 {{ sessionName }} 的聊天记录</span>
</div>
<!-- 左侧预设选择器 -->
<UPopover v-model:open="isPresetPopoverOpen" :ui="{ content: 'p-0' }">
<button
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<UIcon name="i-heroicons-chat-bubble-bottom-center-text" class="h-3.5 w-3.5" />
<span class="max-w-[120px] truncate">{{ currentActivePreset?.name || '默认预设' }}</span>
<UIcon name="i-heroicons-chevron-down" class="h-3 w-3" />
</button>
<template #content>
<div class="w-48 py-1">
<div class="px-3 py-1.5 text-xs font-medium text-gray-400 dark:text-gray-500">
{{ currentChatType === 'group' ? '群聊' : '私聊' }}提示词预设
</div>
<button
v-for="preset in currentPresets"
:key="preset.id"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
:class="[
preset.id === currentActivePresetId
? 'text-pink-600 dark:text-pink-400'
: 'text-gray-700 dark:text-gray-300',
]"
@click="setActivePreset(preset.id)"
>
<UIcon
:name="
preset.id === currentActivePresetId
? 'i-heroicons-check-circle-solid'
: 'i-heroicons-document-text'
"
class="h-4 w-4 shrink-0"
:class="[preset.id === currentActivePresetId ? 'text-pink-500' : 'text-gray-400']"
/>
<span class="truncate">{{ preset.name }}</span>
</button>
</div>
</template>
</UPopover>
<!-- 右侧配置状态指示 -->
<div class="flex items-center gap-3">
<!-- 配置状态指示 -->
<div
v-if="!isCheckingConfig"
class="flex items-center gap-1.5 text-xs transition-colors"
+2 -2
View File
@@ -43,7 +43,7 @@ const renderedContent = computed(() => {
</div>
<div
v-else
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-purple-600"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-pink-500 to-pink-600"
>
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 text-white" />
</div>
@@ -66,7 +66,7 @@ const renderedContent = computed(() => {
/>
<!-- 流式输出光标 -->
<span v-if="isStreaming" class="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-violet-500" />
<span v-if="isStreaming" class="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-pink-500" />
</div>
<!-- 时间戳 -->
+7
View File
@@ -2,6 +2,7 @@
import { ref, watch } from 'vue'
import AIConfigTab from './settings/AIConfigTab.vue'
import AIChatConfigTab from './settings/AIChatConfigTab.vue'
import AIPromptConfigTab from './settings/AIPromptConfigTab.vue'
import CacheManageTab from './settings/CacheManageTab.vue'
// Props
@@ -19,6 +20,7 @@ const emit = defineEmits<{
const tabs = [
{ id: 'settings', label: '基础设置', icon: 'i-heroicons-cog-6-tooth' },
{ id: 'ai-model', label: 'AI 模型', icon: 'i-heroicons-sparkles' },
{ id: 'ai-prompt', label: 'AI 提示词', icon: 'i-heroicons-document-text' },
{ id: 'ai-chat', label: 'AI 聊天', icon: 'i-heroicons-chat-bubble-left-right' },
{ id: 'help', label: '帮助', icon: 'i-heroicons-question-mark-circle' },
]
@@ -97,6 +99,11 @@ watch(
<AIConfigTab ref="aiConfigRef" @config-changed="handleAIConfigChanged" />
</div>
<!-- AI 提示词配置 Tab -->
<div v-show="activeTab === 'ai-prompt'" class="pr-1">
<AIPromptConfigTab @config-changed="handleAIConfigChanged" />
</div>
<!-- AI 聊天配置 Tab -->
<div v-show="activeTab === 'ai-chat'" class="pr-1">
<AIChatConfigTab @config-changed="handleAIConfigChanged" />
+21 -5
View File
@@ -115,6 +115,16 @@ function getContextMenuItems(session: AnalysisSession) {
],
]
}
// 根据会话类型获取路由名称
function getSessionRouteName(session: AnalysisSession): string {
return session.type === 'private' ? 'private-chat' : 'group-chat'
}
// 判断是否是私聊
function isPrivateChat(session: AnalysisSession): boolean {
return session.type === 'private'
}
</script>
<template>
@@ -208,19 +218,25 @@ function getContextMenuItems(session: AnalysisSession) {
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-200/60 dark:hover:bg-gray-800',
isCollapsed ? 'justify-center cursor-pointer' : 'cursor-pointer',
]"
@click="router.push({ name: 'group-chat', params: { id: session.id } })"
@click="router.push({ name: getSessionRouteName(session), params: { id: session.id } })"
>
<!-- Platform Icon / Text Avatar -->
<!-- Platform Icon / Text Avatar - 私聊和群聊使用不同样式 -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold"
:class="[
route.params.id === session.id
? 'bg-primary-600 text-white dark:bg-primary-500 dark:text-white'
: 'bg-gray-400 text-white dark:bg-gray-600 dark:text-white',
? isPrivateChat(session)
? 'bg-pink-600 text-white dark:bg-pink-500 dark:text-white'
: 'bg-primary-600 text-white dark:bg-primary-500 dark:text-white'
: isPrivateChat(session)
? 'bg-gray-400 text-white dark:bg-pink-600 dark:text-white'
: 'bg-gray-400 text-white dark:bg-gray-600 dark:text-white',
isCollapsed ? '' : 'mr-3',
]"
>
{{ session.name ? session.name.charAt(0) : '?' }}
<!-- 私聊显示用户图标群聊显示首字母 -->
<UIcon v-if="isPrivateChat(session)" name="i-heroicons-user" class="h-4 w-4" />
<template v-else>{{ session.name ? session.name.charAt(0) : '?' }}</template>
</div>
<!-- Session Info -->
+3 -11
View File
@@ -148,12 +148,13 @@ onMounted(() => {
<div
v-for="config in configs"
:key="config.id"
class="group flex items-center justify-between rounded-lg border p-3 transition-colors"
class="group flex cursor-pointer items-center justify-between rounded-lg border p-3 transition-colors"
:class="[
config.id === activeConfigId
? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/20'
: 'border-gray-200 bg-white hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:bg-gray-800',
]"
@click="setActive(config.id)"
>
<!-- 配置信息 -->
<div class="flex items-center gap-3">
@@ -188,16 +189,7 @@ onMounted(() => {
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<UButton
v-if="config.id !== activeConfigId"
size="xs"
color="primary"
variant="ghost"
@click="setActive(config.id)"
>
激活
</UButton>
<div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100" @click.stop>
<UButton size="xs" color="gray" variant="ghost" @click="openEditModal(config)">编辑</UButton>
<UButton size="xs" color="error" variant="ghost" @click="deleteConfig(config.id)">删除</UButton>
</div>
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import type { PromptPreset } from '@/types/chat'
import AIPromptEditModal from './AIPromptEditModal.vue'
// Store
const chatStore = useChatStore()
const { groupPresets, privatePresets, aiPromptSettings } = storeToRefs(chatStore)
// Emits
const emit = defineEmits<{
'config-changed': []
}>()
// 弹窗状态
const showEditModal = ref(false)
const editMode = ref<'add' | 'edit'>('add')
const editingPreset = ref<PromptPreset | null>(null)
const defaultChatType = ref<'group' | 'private'>('group')
// 方法
function openAddModal(chatType: 'group' | 'private') {
editMode.value = 'add'
editingPreset.value = null
defaultChatType.value = chatType
showEditModal.value = true
}
function openEditModal(preset: PromptPreset) {
editMode.value = 'edit'
editingPreset.value = preset
defaultChatType.value = preset.chatType
showEditModal.value = true
}
function handleModalSaved() {
emit('config-changed')
}
function setActivePreset(presetId: string, chatType: 'group' | 'private') {
if (chatType === 'group') {
chatStore.setActiveGroupPreset(presetId)
} else {
chatStore.setActivePrivatePreset(presetId)
}
emit('config-changed')
}
function duplicatePreset(presetId: string) {
chatStore.duplicatePromptPreset(presetId)
emit('config-changed')
}
function deletePreset(presetId: string) {
chatStore.removePromptPreset(presetId)
emit('config-changed')
}
function isActivePreset(presetId: string, chatType: 'group' | 'private'): boolean {
if (chatType === 'group') {
return aiPromptSettings.value.activeGroupPresetId === presetId
}
return aiPromptSettings.value.activePrivatePresetId === presetId
}
</script>
<template>
<div class="space-y-6">
<!-- 群聊预设组 -->
<div>
<div class="mb-3 flex items-center justify-between">
<h4 class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-4 w-4 text-violet-500" />
群聊预设
</h4>
<UButton size="xs" variant="ghost" color="gray" @click="openAddModal('group')">
<UIcon name="i-heroicons-plus" class="mr-1 h-3.5 w-3.5" />
添加
</UButton>
</div>
<div class="space-y-2">
<div
v-for="preset in groupPresets"
:key="preset.id"
class="group flex cursor-pointer items-center justify-between rounded-lg border p-3 transition-colors"
:class="[
isActivePreset(preset.id, 'group')
? 'border-violet-300 bg-violet-50 dark:border-violet-700 dark:bg-violet-900/20'
: 'border-gray-200 bg-white hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:bg-gray-800',
]"
@click="setActivePreset(preset.id, 'group')"
>
<!-- 预设信息 -->
<div class="flex items-center gap-3">
<div
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
:class="[
isActivePreset(preset.id, 'group')
? 'bg-violet-500 text-white'
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
]"
>
<UIcon :name="isActivePreset(preset.id, 'group') ? 'i-heroicons-check' : 'i-heroicons-document-text'" class="h-3.5 w-3.5" />
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ preset.name }}</span>
<UBadge v-if="preset.isBuiltIn" color="gray" variant="soft" size="xs">内置</UBadge>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100" @click.stop>
<UButton size="xs" color="gray" variant="ghost" @click="openEditModal(preset)">
{{ preset.isBuiltIn ? '查看' : '编辑' }}
</UButton>
<UButton size="xs" color="gray" variant="ghost" @click="duplicatePreset(preset.id)">复制</UButton>
<UButton v-if="!preset.isBuiltIn" size="xs" color="error" variant="ghost" @click="deletePreset(preset.id)">删除</UButton>
</div>
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="border-t border-gray-200 dark:border-gray-700"></div>
<!-- 私聊预设组 -->
<div>
<div class="mb-3 flex items-center justify-between">
<h4 class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-user" class="h-4 w-4 text-blue-500" />
私聊预设
</h4>
<UButton size="xs" variant="ghost" color="gray" @click="openAddModal('private')">
<UIcon name="i-heroicons-plus" class="mr-1 h-3.5 w-3.5" />
添加
</UButton>
</div>
<div class="space-y-2">
<div
v-for="preset in privatePresets"
:key="preset.id"
class="group flex cursor-pointer items-center justify-between rounded-lg border p-3 transition-colors"
:class="[
isActivePreset(preset.id, 'private')
? 'border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/20'
: 'border-gray-200 bg-white hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:bg-gray-800',
]"
@click="setActivePreset(preset.id, 'private')"
>
<!-- 预设信息 -->
<div class="flex items-center gap-3">
<div
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
:class="[
isActivePreset(preset.id, 'private')
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
]"
>
<UIcon :name="isActivePreset(preset.id, 'private') ? 'i-heroicons-check' : 'i-heroicons-document-text'" class="h-3.5 w-3.5" />
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ preset.name }}</span>
<UBadge v-if="preset.isBuiltIn" color="gray" variant="soft" size="xs">内置</UBadge>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100" @click.stop>
<UButton size="xs" color="gray" variant="ghost" @click="openEditModal(preset)">
{{ preset.isBuiltIn ? '查看' : '编辑' }}
</UButton>
<UButton size="xs" color="gray" variant="ghost" @click="duplicatePreset(preset.id)">复制</UButton>
<UButton v-if="!preset.isBuiltIn" size="xs" color="error" variant="ghost" @click="deletePreset(preset.id)">删除</UButton>
</div>
</div>
</div>
</div>
<!-- 提示信息 -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50">
<div class="flex items-start gap-2">
<UIcon name="i-heroicons-light-bulb" class="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
点击预设即可激活时间信息和工具说明由系统自动注入你只需编辑角色定义和回答要求
</p>
</div>
</div>
</div>
<!-- 编辑/添加弹窗 -->
<AIPromptEditModal
v-model:open="showEditModal"
:mode="editMode"
:preset="editingPreset"
:default-chat-type="defaultChatType"
@saved="handleModalSaved"
/>
</template>
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import type { PromptPreset } from '@/types/chat'
// Props
const props = defineProps<{
open: boolean
mode: 'add' | 'edit'
preset: PromptPreset | null
defaultChatType: 'group' | 'private'
}>()
// Emits
const emit = defineEmits<{
'update:open': [value: boolean]
saved: []
}>()
// Store
const chatStore = useChatStore()
// 表单数据
const formData = ref({
name: '',
chatType: 'group' as 'group' | 'private',
roleDefinition: '',
responseRules: '',
})
// 计算属性
const isBuiltIn = computed(() => props.preset?.isBuiltIn ?? false)
const isEditMode = computed(() => props.mode === 'edit')
const modalTitle = computed(() => {
if (isBuiltIn.value) return '查看预设'
return isEditMode.value ? '编辑预设' : '添加预设'
})
const canSave = computed(() => {
return formData.value.name.trim() && formData.value.roleDefinition.trim() && formData.value.responseRules.trim()
})
// 获取默认角色定义
function getDefaultRoleDefinition(chatType: 'group' | 'private'): string {
if (chatType === 'private') {
return `你是一个专业的私聊记录分析助手。
你的任务是帮助用户理解和分析他们的私聊记录数据。
注意:这是一个私聊对话,只有两个人参与。你的分析应该关注:
- 两人之间的对话互动
- 谁更主动、谁回复更多
- 对话的主题和内容变化
- 不要使用"群"这个词,使用"对话"或"聊天"`
}
return `你是一个专业的群聊记录分析助手。
你的任务是帮助用户理解和分析他们的群聊记录数据。`
}
// 获取默认回答要求
function getDefaultResponseRules(chatType: 'group' | 'private'): string {
if (chatType === 'private') {
return `1. 基于工具返回的数据回答,不要编造信息
2. 如果数据不足以回答问题,请说明
3. 回答要简洁明了,使用 Markdown 格式
4. 可以引用具体的发言作为证据
5. 关注两人之间的互动模式和对话特点`
}
return `1. 基于工具返回的数据回答,不要编造信息
2. 如果数据不足以回答问题,请说明
3. 回答要简洁明了,使用 Markdown 格式
4. 可以引用具体的发言作为证据
5. 对于统计数据,可以适当总结趋势和特点`
}
// 监听打开状态,初始化表单
watch(
() => props.open,
(newVal) => {
if (newVal) {
if (props.preset) {
// 编辑模式:加载现有预设
formData.value = {
name: props.preset.name,
chatType: props.preset.chatType,
roleDefinition: props.preset.roleDefinition,
responseRules: props.preset.responseRules,
}
} else {
// 添加模式:重置为默认(根据当前选中的聊天类型)
formData.value = {
name: '',
chatType: props.defaultChatType,
roleDefinition: getDefaultRoleDefinition(props.defaultChatType),
responseRules: getDefaultResponseRules(props.defaultChatType),
}
}
}
}
)
// 方法
function closeModal() {
emit('update:open', false)
}
function handleSave() {
if (!canSave.value || isBuiltIn.value) return
if (isEditMode.value && props.preset) {
// 更新现有预设
chatStore.updatePromptPreset(props.preset.id, {
name: formData.value.name.trim(),
chatType: formData.value.chatType,
roleDefinition: formData.value.roleDefinition.trim(),
responseRules: formData.value.responseRules.trim(),
})
} else {
// 添加新预设
chatStore.addPromptPreset({
name: formData.value.name.trim(),
chatType: formData.value.chatType,
roleDefinition: formData.value.roleDefinition.trim(),
responseRules: formData.value.responseRules.trim(),
})
}
emit('saved')
closeModal()
}
// 预览完整提示词(始终展示)
// 获取锁定部分的提示词(与 Agent 中的一致)
function getLockedPromptSection(chatType: string): string {
const now = new Date()
const currentDate = now.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
})
const isPrivate = chatType === 'private'
const chatTypeDesc = isPrivate ? '私聊记录' : '群聊记录'
const memberNote = isPrivate
? `成员查询策略:
- 私聊只有两个人,可以直接获取成员列表
- 当用户提到"对方"、"他/她"时,通过 get_group_members 获取另一方信息`
: `成员查询策略:
- 当用户提到特定群成员(如"张三说过什么"、"小明的发言"等)时,应先调用 get_group_members 获取成员列表
- 群成员有三种名称:accountNameQQ原始昵称)、groupNickname(群昵称)、aliases(用户自定义别名)
- 通过 get_group_members 的 search 参数可以模糊搜索这三种名称
- 找到成员后,使用其 id 字段作为 search_messages 的 sender_id 参数来获取该成员的发言`
return `当前日期是 ${currentDate}
你可以使用以下工具来获取${chatTypeDesc}数据:
1. search_messages - 根据关键词搜索聊天记录,可指定 year/month 筛选时间段,也可指定 sender_id 筛选特定成员的发言
2. get_recent_messages - 获取指定时间段的聊天消息,可指定 year 和 month
3. get_member_stats - 获取成员活跃度统计
4. get_time_stats - 获取时间分布统计
5. get_group_members - 获取成员列表,包括 id、QQ号、账号名称、昵称、别名和消息统计
6. get_member_name_history - 获取成员的昵称变更历史,需要先通过 get_group_members 获取成员 ID
7. get_conversation_between - 获取两个成员之间的对话记录,需要先通过 get_group_members 获取两人的成员 ID
${memberNote}
时间处理要求:
- 如果用户提到"X月"但没有指定年份,默认使用当前年份(${now.getFullYear()}年)
- 如果当前月份还没到用户提到的月份,则使用去年
- 例如:现在是${now.getFullYear()}${now.getMonth() + 1}月,用户问"10月的聊天"应该查询${now.getMonth() + 1 >= 10 ? now.getFullYear() : now.getFullYear() - 1}年10月
根据用户的问题,选择合适的工具获取数据,然后基于数据给出回答。`
}
const previewContent = computed(() => {
const chatType = formData.value.chatType
// 获取锁定的系统部分
const lockedSection = getLockedPromptSection(chatType)
// 组合完整提示词(与 Agent 中的 buildSystemPrompt 保持一致)
return `${formData.value.roleDefinition}
${lockedSection}
回答要求:
${formData.value.responseRules}`
})
</script>
<template>
<UModal :open="open" @update:open="emit('update:open', $event)" :ui="{ content: 'md:w-full max-w-2xl' }">
<template #content>
<div class="p-6">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ modalTitle }}</h2>
<UButton icon="i-heroicons-x-mark" variant="ghost" size="sm" @click="closeModal" />
</div>
<!-- 内置预设提示 -->
<UAlert v-if="isBuiltIn" color="info" variant="outline" icon="i-heroicons-information-circle" class="mb-4">
<template #title>
<span class="text-sm">内置预设不可编辑你可以复制后修改</span>
</template>
</UAlert>
<!-- 表单 -->
<div class="max-h-[500px] space-y-4 overflow-y-auto pr-1">
<!-- 预设名称 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">预设名称</label>
<UInput v-model="formData.name" placeholder="为预设起个名字" :disabled="isBuiltIn" />
</div>
<!-- 适用类型只读显示 -->
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<UIcon :name="formData.chatType === 'group' ? 'i-heroicons-chat-bubble-left-right' : 'i-heroicons-user'" class="h-4 w-4" />
<span>适用于{{ formData.chatType === 'group' ? '群聊' : '私聊' }}</span>
</div>
<!-- 角色定义 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">角色定义</label>
<UTextarea
v-model="formData.roleDefinition"
:rows="5"
placeholder="定义 AI 助手的角色和任务..."
:disabled="isBuiltIn"
class="font-mono text-sm"
/>
</div>
<!-- 回答要求 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
回答要求
<span class="font-normal text-gray-500">指导 AI 如何回答</span>
</label>
<UTextarea
v-model="formData.responseRules"
:rows="5"
placeholder="定义 AI 回答的格式和要求..."
:disabled="isBuiltIn"
class="font-mono text-sm"
/>
</div>
<!-- 完整提示词预览 -->
<div>
<label class="mb-1.5 flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<UIcon name="i-heroicons-eye" class="h-4 w-4 text-violet-500" />
完整提示词预览
</label>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<pre class="max-h-48 overflow-y-auto whitespace-pre-wrap text-xs text-gray-700 dark:text-gray-300">{{ previewContent }}</pre>
</div>
</div>
</div>
<!-- Footer -->
<div class="mt-6 flex justify-end gap-2">
<UButton variant="ghost" @click="closeModal">取消</UButton>
<UButton v-if="!isBuiltIn" color="primary" :disabled="!canSave" @click="handleSave">
{{ isEditMode ? '保存修改' : '添加预设' }}
</UButton>
</div>
</div>
</template>
</UModal>
</template>
+2
View File
@@ -1,4 +1,6 @@
export { default as AIConfigTab } from './AIConfigTab.vue'
export { default as AIChatConfigTab } from './AIChatConfigTab.vue'
export { default as AIConfigEditModal } from './AIConfigEditModal.vue'
export { default as AIPromptConfigTab } from './AIPromptConfigTab.vue'
export { default as AIPromptEditModal } from './AIPromptEditModal.vue'
export { default as CacheManageTab } from './CacheManageTab.vue'
+42 -6
View File
@@ -3,7 +3,9 @@
* 封装 AI 对话的核心逻辑(基于 Agent + Function Calling
*/
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
// 工具调用记录
export interface ToolCallRecord {
@@ -56,7 +58,24 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
get_time_stats: '获取时间分布',
}
export function useAIChat(sessionId: string, timeFilter?: { startTs: number; endTs: number }) {
export function useAIChat(
sessionId: string,
timeFilter?: { startTs: number; endTs: number },
chatType: 'group' | 'private' = 'group'
) {
// 获取 chat store 中的提示词配置
const chatStore = useChatStore()
const { activeGroupPreset, activePrivatePreset } = storeToRefs(chatStore)
// 获取当前聊天类型对应的提示词配置
const currentPromptConfig = computed(() => {
const preset = chatType === 'group' ? activeGroupPreset.value : activePrivatePreset.value
return {
roleDefinition: preset.roleDefinition,
responseRules: preset.responseRules,
}
})
// 状态
const messages = ref<ChatMessage[]>([])
const sourceMessages = ref<SourceMessage[]>([])
@@ -151,10 +170,23 @@ export function useAIChat(sessionId: string, timeFilter?: { startTs: number; end
timeFilter: timeFilter ? { startTs: timeFilter.startTs, endTs: timeFilter.endTs } : undefined,
}
console.log('[AI] 调用 Agent API...', context)
// 收集历史消息(排除当前用户消息和 AI 占位消息)
const historyMessages = messages.value
.slice(0, -2) // 排除刚添加的用户消息和 AI 占位消息
.filter((msg) => msg.role === 'user' || msg.role === 'assistant')
.filter((msg) => msg.content && msg.content.trim() !== '') // 排除空消息
.map((msg) => ({
role: msg.role as 'user' | 'assistant',
content: msg.content,
}))
// 获取 requestId 和 promise
const { requestId: agentReqId, promise: agentPromise } = window.agentApi.runStream(content, context, (chunk) => {
console.log('[AI] 调用 Agent API...', context, 'historyLength:', historyMessages.length, 'chatType:', chatType, 'promptConfig:', currentPromptConfig.value)
// 获取 requestId 和 promise(传递历史消息、聊天类型和提示词配置)
const { requestId: agentReqId, promise: agentPromise } = window.agentApi.runStream(
content,
context,
(chunk) => {
// 如果已中止或请求 ID 不匹配,忽略后续 chunks
if (isAborted || thisRequestId !== currentRequestId) {
console.log('[AI] 已中止或请求已过期,忽略 chunk', {
@@ -256,7 +288,11 @@ export function useAIChat(sessionId: string, timeFilter?: { startTs: number; end
}
break
}
})
},
historyMessages,
chatType,
currentPromptConfig.value
)
// 存储 Agent 请求 ID(用于中止)
currentAgentRequestId = agentReqId
+1
View File
@@ -354,6 +354,7 @@ onMounted(() => {
:session-id="currentSessionId!"
:session-name="session.name"
:time-filter="timeFilter"
chat-type="group"
/>
</Transition>
</div>
+11 -2
View File
@@ -47,6 +47,15 @@ const features = [
const router = useRouter()
// 根据会话类型导航到对应页面
async function navigateToSession(sessionId: string) {
const session = await window.chatApi.getSession(sessionId)
if (session) {
const routeName = session.type === 'private' ? 'private-chat' : 'group-chat'
router.push({ name: routeName, params: { id: sessionId } })
}
}
// 处理文件选择(点击选择)
async function handleClickImport() {
importError.value = null
@@ -54,7 +63,7 @@ async function handleClickImport() {
if (!result.success && result.error && result.error !== '未选择文件') {
importError.value = result.error
} else if (result.success && chatStore.currentSessionId) {
router.push({ name: 'group-chat', params: { id: chatStore.currentSessionId } })
await navigateToSession(chatStore.currentSessionId)
}
}
@@ -70,7 +79,7 @@ async function handleFileDrop({ paths }: { files: File[]; paths: string[] }) {
if (!result.success && result.error) {
importError.value = result.error
} else if (result.success && chatStore.currentSessionId) {
router.push({ name: 'group-chat', params: { id: chatStore.currentSessionId } })
await navigateToSession(chatStore.currentSessionId)
}
}
+332
View File
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import type { AnalysisSession, MemberActivity, HourlyActivity, DailyActivity, MessageType } from '@/types/chat'
import { formatDateRange } from '@/utils'
import UITabs from '@/components/UI/Tabs.vue'
import PrivateOverviewTab from '@/components/analysis/PrivateOverviewTab.vue'
import PrivateTimelineTab from '@/components/analysis/PrivateTimelineTab.vue'
import AITab from '@/components/analysis/AITab.vue'
const route = useRoute()
const router = useRouter()
const chatStore = useChatStore()
const { currentSessionId } = storeToRefs(chatStore)
// 数据状态
const isLoading = ref(true)
const session = ref<AnalysisSession | null>(null)
const memberActivity = ref<MemberActivity[]>([])
const hourlyActivity = ref<HourlyActivity[]>([])
const dailyActivity = ref<DailyActivity[]>([])
const messageTypes = ref<Array<{ type: MessageType; count: number }>>([])
const timeRange = ref<{ start: number; end: number } | null>(null)
// 年份筛选
const availableYears = ref<number[]>([])
const selectedYear = ref<number>(0) // 0 表示全部
const isInitialLoad = ref(true) // 用于跳过初始加载时的 watch 触发,并控制首屏加载状态
// Tab 配置 - 私聊有总览、趋势和 AI
const tabs = [
{ id: 'overview', label: '总览', icon: 'i-heroicons-chart-pie' },
{ id: 'timeline', label: '趋势', icon: 'i-heroicons-chart-bar' },
{ id: 'ai', label: 'AI实验室', icon: 'i-heroicons-sparkles' },
]
const activeTab = ref((route.query.tab as string) || 'overview')
// 计算时间过滤参数
const timeFilter = computed(() => {
if (selectedYear.value === 0) {
return undefined
}
// 计算年份的开始和结束时间戳
const startDate = new Date(selectedYear.value, 0, 1, 0, 0, 0)
const endDate = new Date(selectedYear.value, 11, 31, 23, 59, 59)
return {
startTs: Math.floor(startDate.getTime() / 1000),
endTs: Math.floor(endDate.getTime() / 1000),
}
})
// 年份选项
const yearOptions = computed(() => {
const options = [{ label: '全部时间', value: 0 }]
for (const year of availableYears.value) {
options.push({ label: `${year}`, value: year })
}
return options
})
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
return memberActivity.value.reduce((sum, m) => sum + m.messageCount, 0)
})
// 当前筛选后的活跃成员数
const filteredMemberCount = computed(() => {
return memberActivity.value.filter((m) => m.messageCount > 0).length
})
// 格式化时间范围显示
const dateRangeText = computed(() => {
if (selectedYear.value) {
return `${selectedYear.value}`
}
if (!timeRange.value) return ''
return formatDateRange(timeRange.value.start, timeRange.value.end)
})
// Sync route param to store
function syncSession() {
const id = route.params.id as string
if (id) {
chatStore.selectSession(id)
// If selection failed (e.g. invalid ID), redirect to home
if (chatStore.currentSessionId !== id) {
router.replace('/')
}
}
}
// 加载基础数据(不受年份筛选影响)
async function loadBaseData() {
if (!currentSessionId.value) return
try {
const [sessionData, years, range] = await Promise.all([
window.chatApi.getSession(currentSessionId.value),
window.chatApi.getAvailableYears(currentSessionId.value),
window.chatApi.getTimeRange(currentSessionId.value),
])
session.value = sessionData
availableYears.value = years
timeRange.value = range
// 初始化年份选择
const queryYear = Number(route.query.year)
if (queryYear === 0 || (queryYear && years.includes(queryYear))) {
selectedYear.value = queryYear
} else if (years.length > 0) {
selectedYear.value = years[0]
} else {
selectedYear.value = 0
}
} catch (error) {
console.error('加载基础数据失败:', error)
}
}
// 加载分析数据(受年份筛选影响)
async function loadAnalysisData() {
if (!currentSessionId.value) return
isLoading.value = true
try {
const filter = timeFilter.value
const [members, hourly, daily, types] = await Promise.all([
window.chatApi.getMemberActivity(currentSessionId.value, filter),
window.chatApi.getHourlyActivity(currentSessionId.value, filter),
window.chatApi.getDailyActivity(currentSessionId.value, filter),
window.chatApi.getMessageTypeDistribution(currentSessionId.value, filter),
])
memberActivity.value = members
hourlyActivity.value = hourly
dailyActivity.value = daily
messageTypes.value = types
} catch (error) {
console.error('加载分析数据失败:', error)
} finally {
isLoading.value = false
}
}
// 加载所有数据
async function loadData() {
isInitialLoad.value = true
await loadBaseData()
await loadAnalysisData()
isInitialLoad.value = false
}
// 监听路由参数变化
watch(
() => route.params.id,
() => {
if (!route.query.tab) {
activeTab.value = 'overview'
} else {
activeTab.value = route.query.tab as string
}
syncSession()
}
)
// 监听会话变化
watch(
currentSessionId,
() => {
loadData()
},
{ immediate: true }
)
// 监听年份筛选变化
watch(selectedYear, () => {
if (isInitialLoad.value) return
loadAnalysisData()
})
// 同步状态到 URL
watch([activeTab, selectedYear], ([newTab, newYear]) => {
if (isInitialLoad.value) return
router.replace({
query: {
...route.query,
tab: newTab,
year: newYear,
},
})
})
onMounted(() => {
syncSession()
})
</script>
<template>
<div class="flex h-full flex-col bg-gray-50 dark:bg-gray-950">
<!-- Loading State -->
<div v-if="isInitialLoad" class="flex h-full items-center justify-center">
<div class="flex flex-col items-center justify-center text-center">
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
<p class="mt-2 text-sm text-gray-500">加载分析数据...</p>
</div>
</div>
<!-- Content -->
<template v-else-if="session">
<!-- Header -->
<div class="border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between">
<!-- Session Info -->
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-pink-400 to-pink-600"
>
<UIcon name="i-heroicons-user" class="h-5 w-5 text-white" />
</div>
<div>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ session.name }}
</h1>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ dateRangeText }} {{ selectedYear ? filteredMessageCount : session.messageCount }} 条消息
</p>
</div>
</div>
</div>
<!-- Tabs -->
<div class="mt-4 flex items-center justify-between gap-4">
<div class="flex flex-shrink-0 items-center gap-1 overflow-x-auto scrollbar-hide">
<button
v-for="tab in tabs"
:key="tab.id"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-all"
:class="[
activeTab === tab.id
? 'bg-pink-500 text-white dark:bg-pink-900/30 dark:text-pink-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800',
]"
@click="activeTab = tab.id"
>
<UIcon :name="tab.icon" class="h-4 w-4" />
<span class="whitespace-nowrap">{{ tab.label }}</span>
</button>
</div>
<!-- 年份选择器靠右 -->
<UITabs v-model="selectedYear" :items="yearOptions" size="sm" class="min-w-0 flex-shrink" />
</div>
</div>
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<div
v-if="isLoading"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-gray-950/50"
>
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
</div>
<div class="h-full">
<Transition name="tab-slide" mode="out-in">
<PrivateOverviewTab
v-if="activeTab === 'overview'"
:key="'overview-' + selectedYear"
:session="session"
:member-activity="memberActivity"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:time-range="timeRange"
:selected-year="selectedYear"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
<PrivateTimelineTab
v-else-if="activeTab === 'timeline'"
:key="'timeline-' + selectedYear"
:session-id="currentSessionId!"
:daily-activity="dailyActivity"
:member-activity="memberActivity"
:time-range="timeRange"
:time-filter="timeFilter"
/>
<AITab
v-else-if="activeTab === 'ai'"
:key="'ai-' + selectedYear"
:session-id="currentSessionId!"
:session-name="session.name"
:time-filter="timeFilter"
chat-type="private"
/>
</Transition>
</div>
</div>
</template>
<!-- Empty State -->
<div v-else class="flex h-full items-center justify-center">
<p class="text-gray-500">无法加载会话数据</p>
</div>
</div>
</template>
<style scoped>
.tab-slide-enter-active,
.tab-slide-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.tab-slide-enter-from {
opacity: 0;
transform: translateY(10px);
}
.tab-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
+5
View File
@@ -12,6 +12,11 @@ export const router = createRouter({
name: 'group-chat',
component: () => import('@/pages/group-chat.vue'),
},
{
path: '/private-chat/:id',
name: 'private-chat',
component: () => import('@/pages/private-chat.vue'),
},
{
path: '/tools',
name: 'tools',
+172 -3
View File
@@ -1,6 +1,56 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { AnalysisSession, ImportProgress, KeywordTemplate } from '@/types/chat'
import type { AnalysisSession, ImportProgress, KeywordTemplate, PromptPreset, AIPromptSettings } from '@/types/chat'
// ==================== 内置提示词预设 ====================
/** 默认群聊预设ID */
export const DEFAULT_GROUP_PRESET_ID = 'builtin-group-default'
/** 默认私聊预设ID */
export const DEFAULT_PRIVATE_PRESET_ID = 'builtin-private-default'
/** 内置群聊预设 */
const BUILTIN_GROUP_PRESET: PromptPreset = {
id: DEFAULT_GROUP_PRESET_ID,
name: '默认群聊分析',
chatType: 'group',
roleDefinition: `你是一个专业的群聊记录分析助手。
你的任务是帮助用户理解和分析他们的群聊记录数据。`,
responseRules: `1. 基于工具返回的数据回答,不要编造信息
2. 如果数据不足以回答问题,请说明
3. 回答要简洁明了,使用 Markdown 格式
4. 可以引用具体的发言作为证据
5. 对于统计数据,可以适当总结趋势和特点`,
isBuiltIn: true,
createdAt: Date.now(),
updatedAt: Date.now(),
}
/** 内置私聊预设 */
const BUILTIN_PRIVATE_PRESET: PromptPreset = {
id: DEFAULT_PRIVATE_PRESET_ID,
name: '默认私聊分析',
chatType: 'private',
roleDefinition: `你是一个专业的私聊记录分析助手。
你的任务是帮助用户理解和分析他们的私聊记录数据。
注意:这是一个私聊对话,只有两个人参与。你的分析应该关注:
- 两人之间的对话互动
- 谁更主动、谁回复更多
- 对话的主题和内容变化
- 不要使用"群"这个词,使用"对话"或"聊天"`,
responseRules: `1. 基于工具返回的数据回答,不要编造信息
2. 如果数据不足以回答问题,请说明
3. 回答要简洁明了,使用 Markdown 格式
4. 可以引用具体的发言作为证据
5. 关注两人之间的互动模式和对话特点`,
isBuiltIn: true,
createdAt: Date.now(),
updatedAt: Date.now(),
}
/** 所有内置预设 */
export const BUILTIN_PRESETS: PromptPreset[] = [BUILTIN_GROUP_PRESET, BUILTIN_PRIVATE_PRESET]
export const useChatStore = defineStore(
'chat',
@@ -276,6 +326,111 @@ export const useChatStore = defineStore(
// ==================== 已删除的预设模板 ====================
const deletedPresetTemplateIds = ref<string[]>([])
// ==================== AI 提示词预设管理 ====================
const customPromptPresets = ref<PromptPreset[]>([])
const aiPromptSettings = ref<AIPromptSettings>({
activeGroupPresetId: DEFAULT_GROUP_PRESET_ID,
activePrivatePresetId: DEFAULT_PRIVATE_PRESET_ID,
})
/** 获取所有预设(内置 + 自定义) */
const allPromptPresets = computed(() => [...BUILTIN_PRESETS, ...customPromptPresets.value])
/** 获取群聊可用的预设 */
const groupPresets = computed(() => allPromptPresets.value.filter((p) => p.chatType === 'group'))
/** 获取私聊可用的预设 */
const privatePresets = computed(() => allPromptPresets.value.filter((p) => p.chatType === 'private'))
/** 获取当前群聊激活的预设 */
const activeGroupPreset = computed(() => {
const preset = allPromptPresets.value.find((p) => p.id === aiPromptSettings.value.activeGroupPresetId)
return preset || BUILTIN_PRESETS.find((p) => p.id === DEFAULT_GROUP_PRESET_ID)!
})
/** 获取当前私聊激活的预设 */
const activePrivatePreset = computed(() => {
const preset = allPromptPresets.value.find((p) => p.id === aiPromptSettings.value.activePrivatePresetId)
return preset || BUILTIN_PRESETS.find((p) => p.id === DEFAULT_PRIVATE_PRESET_ID)!
})
/** 添加自定义预设 */
function addPromptPreset(preset: { name: string; chatType: PromptPreset['chatType']; roleDefinition: string; responseRules: string }) {
const newPreset: PromptPreset = {
...preset,
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
isBuiltIn: false,
createdAt: Date.now(),
updatedAt: Date.now(),
}
customPromptPresets.value.push(newPreset)
return newPreset.id
}
/** 更新预设 */
function updatePromptPreset(presetId: string, updates: { name?: string; chatType?: PromptPreset['chatType']; roleDefinition?: string; responseRules?: string }) {
const index = customPromptPresets.value.findIndex((p) => p.id === presetId)
if (index !== -1) {
customPromptPresets.value[index] = {
...customPromptPresets.value[index],
...updates,
updatedAt: Date.now(),
}
}
}
/** 删除自定义预设 */
function removePromptPreset(presetId: string) {
const index = customPromptPresets.value.findIndex((p) => p.id === presetId)
if (index !== -1) {
customPromptPresets.value.splice(index, 1)
// 如果删除的是激活的预设,切换到默认
if (aiPromptSettings.value.activeGroupPresetId === presetId) {
aiPromptSettings.value.activeGroupPresetId = DEFAULT_GROUP_PRESET_ID
}
if (aiPromptSettings.value.activePrivatePresetId === presetId) {
aiPromptSettings.value.activePrivatePresetId = DEFAULT_PRIVATE_PRESET_ID
}
}
}
/** 复制预设 */
function duplicatePromptPreset(presetId: string) {
const source = allPromptPresets.value.find((p) => p.id === presetId)
if (source) {
return addPromptPreset({
name: `${source.name} (副本)`,
chatType: source.chatType,
roleDefinition: source.roleDefinition,
responseRules: source.responseRules,
})
}
return null
}
/** 设置群聊激活的预设 */
function setActiveGroupPreset(presetId: string) {
const preset = allPromptPresets.value.find((p) => p.id === presetId)
if (preset && preset.chatType === 'group') {
aiPromptSettings.value.activeGroupPresetId = presetId
notifyAIConfigChanged()
}
}
/** 设置私聊激活的预设 */
function setActivePrivatePreset(presetId: string) {
const preset = allPromptPresets.value.find((p) => p.id === presetId)
if (preset && preset.chatType === 'private') {
aiPromptSettings.value.activePrivatePresetId = presetId
notifyAIConfigChanged()
}
}
/** 获取指定聊天类型的激活预设 */
function getActivePresetForChatType(chatType: 'group' | 'private'): PromptPreset {
return chatType === 'group' ? activeGroupPreset.value : activePrivatePreset.value
}
function addDeletedPresetTemplateId(id: string) {
if (!deletedPresetTemplateIds.value.includes(id)) {
deletedPresetTemplateIds.value.push(id)
@@ -295,8 +450,15 @@ export const useChatStore = defineStore(
aiGlobalSettings,
customKeywordTemplates,
deletedPresetTemplateIds,
customPromptPresets,
aiPromptSettings,
// Computed
currentSession,
allPromptPresets,
groupPresets,
privatePresets,
activeGroupPreset,
activePrivatePreset,
// Actions
loadSessions,
importFile,
@@ -312,6 +474,13 @@ export const useChatStore = defineStore(
updateCustomKeywordTemplate,
removeCustomKeywordTemplate,
addDeletedPresetTemplateId,
addPromptPreset,
updatePromptPreset,
removePromptPreset,
duplicatePromptPreset,
setActiveGroupPreset,
setActivePrivatePreset,
getActivePresetForChatType,
}
},
{
@@ -322,8 +491,8 @@ export const useChatStore = defineStore(
storage: sessionStorage,
},
{
// 自定义模板AI 全局设置:localStorage(持久保存)
pick: ['customKeywordTemplates', 'deletedPresetTemplateIds', 'aiGlobalSettings'],
// 自定义模板AI 全局设置和提示词预设localStorage(持久保存)
pick: ['customKeywordTemplates', 'deletedPresetTemplateIds', 'aiGlobalSettings', 'customPromptPresets', 'aiPromptSettings'],
storage: localStorage,
},
],
+29
View File
@@ -637,6 +637,35 @@ export interface KeywordTemplate {
keywords: string[]
}
// ==================== AI 提示词预设 ====================
/**
* 提示词预设适用的聊天类型
*/
export type PromptPresetChatType = 'group' | 'private'
/**
* AI 提示词预设
*/
export interface PromptPreset {
id: string
name: string // 预设名称
chatType: PromptPresetChatType // 适用类型
roleDefinition: string // 角色定义(可编辑)
responseRules: string // 回答要求(可编辑)
isBuiltIn: boolean // 是否内置(内置不可删除)
createdAt: number
updatedAt: number
}
/**
* AI 提示词配置(激活的预设)
*/
export interface AIPromptSettings {
activeGroupPresetId: string // 群聊激活的预设ID
activePrivatePresetId: string // 私聊激活的预设ID
}
// ==================== 斗图分析类型 ====================
/**