mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-13 01:30:57 +08:00
feat: 支持私聊
This commit is contained in:
+122
-25
@@ -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 获取成员列表
|
||||
- 群成员有三种名称:accountName(QQ原始昵称)、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 获取成员列表
|
||||
- 群成员有三种名称:accountName(QQ原始昵称)、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
@@ -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 () => {
|
||||
|
||||
Vendored
+11
-1
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+3
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 时间戳 -->
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 获取成员列表
|
||||
- 群成员有三种名称:accountName(QQ原始昵称)、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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -354,6 +354,7 @@ onMounted(() => {
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
:time-filter="timeFilter"
|
||||
chat-type="group"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
+11
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ==================== 斗图分析类型 ====================
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user