diff --git a/electron/main/ai/conversations.ts b/electron/main/ai/conversations.ts index a367615b..8eac5e09 100644 --- a/electron/main/ai/conversations.ts +++ b/electron/main/ai/conversations.ts @@ -69,6 +69,7 @@ function getAiDb(): Database.Database { timestamp INTEGER NOT NULL, data_keywords TEXT, data_message_count INTEGER, + content_blocks TEXT, FOREIGN KEY(conversation_id) REFERENCES ai_conversation(id) ON DELETE CASCADE ); @@ -103,6 +104,21 @@ export interface AIConversation { updatedAt: number } +/** + * 内容块类型(用于 AI 消息的混合渲染) + */ +export type ContentBlock = + | { type: 'text'; text: string } + | { + type: 'tool' + tool: { + name: string + displayName: string + status: 'running' | 'done' | 'error' + params?: Record + } + } + /** * AI 消息类型 */ @@ -114,6 +130,8 @@ export interface AIMessage { timestamp: number dataKeywords?: string[] dataMessageCount?: number + /** AI 消息的内容块数组(按时序排列的文本和工具调用) */ + contentBlocks?: ContentBlock[] } // ==================== 对话管理 ==================== @@ -211,15 +229,16 @@ export function addMessage( role: 'user' | 'assistant', content: string, dataKeywords?: string[], - dataMessageCount?: number + dataMessageCount?: number, + contentBlocks?: ContentBlock[] ): AIMessage { const db = getAiDb() const now = Math.floor(Date.now() / 1000) const id = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` db.prepare(` - INSERT INTO ai_message (id, conversation_id, role, content, timestamp, data_keywords, data_message_count) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO ai_message (id, conversation_id, role, content, timestamp, data_keywords, data_message_count, content_blocks) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( id, conversationId, @@ -227,7 +246,8 @@ export function addMessage( content, now, dataKeywords ? JSON.stringify(dataKeywords) : null, - dataMessageCount ?? null + dataMessageCount ?? null, + contentBlocks ? JSON.stringify(contentBlocks) : null ) // 更新对话的 updated_at @@ -241,6 +261,7 @@ export function addMessage( timestamp: now, dataKeywords, dataMessageCount, + contentBlocks, } } @@ -258,7 +279,8 @@ export function getMessages(conversationId: string): AIMessage[] { content, timestamp, data_keywords as dataKeywords, - data_message_count as dataMessageCount + data_message_count as dataMessageCount, + content_blocks as contentBlocks FROM ai_message WHERE conversation_id = ? ORDER BY timestamp ASC @@ -270,6 +292,7 @@ export function getMessages(conversationId: string): AIMessage[] { timestamp: number dataKeywords: string | null dataMessageCount: number | null + contentBlocks: string | null }> return rows.map((row) => ({ @@ -280,6 +303,7 @@ export function getMessages(conversationId: string): AIMessage[] { timestamp: row.timestamp, dataKeywords: row.dataKeywords ? JSON.parse(row.dataKeywords) : undefined, dataMessageCount: row.dataMessageCount ?? undefined, + contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined, })) } diff --git a/electron/main/ipc/ai.ts b/electron/main/ipc/ai.ts index 44d64c5e..4ebb2334 100644 --- a/electron/main/ipc/ai.ts +++ b/electron/main/ipc/ai.ts @@ -95,10 +95,11 @@ export function registerAIHandlers({ win }: IpcContext): void { role: 'user' | 'assistant', content: string, dataKeywords?: string[], - dataMessageCount?: number + dataMessageCount?: number, + contentBlocks?: aiConversations.ContentBlock[] ) => { try { - return aiConversations.addMessage(conversationId, role, content, dataKeywords, dataMessageCount) + return aiConversations.addMessage(conversationId, role, content, dataKeywords, dataMessageCount, contentBlocks) } catch (error) { console.error('添加 AI 消息失败:', error) throw error diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 0565a05c..0f53ce2f 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -104,6 +104,19 @@ interface AIConversation { updatedAt: number } +// 内容块类型(用于 AI 消息的混合渲染) +type AIContentBlock = + | { type: 'text'; text: string } + | { + type: 'tool' + tool: { + name: string + displayName: string + status: 'running' | 'done' | 'error' + params?: Record + } + } + interface AIMessage { id: string conversationId: string @@ -112,6 +125,7 @@ interface AIMessage { timestamp: number dataKeywords?: string[] dataMessageCount?: number + contentBlocks?: AIContentBlock[] } interface AiApi { @@ -133,7 +147,8 @@ interface AiApi { role: 'user' | 'assistant', content: string, dataKeywords?: string[], - dataMessageCount?: number + dataMessageCount?: number, + contentBlocks?: AIContentBlock[] ) => Promise getMessages: (conversationId: string) => Promise deleteMessage: (messageId: string) => Promise diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 8e46875b..5d8f9b8f 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -381,6 +381,19 @@ interface AIConversation { updatedAt: number } +// 内容块类型(用于 AI 消息的混合渲染) +type ContentBlock = + | { type: 'text'; text: string } + | { + type: 'tool' + tool: { + name: string + displayName: string + status: 'running' | 'done' | 'error' + params?: Record + } + } + interface AIMessage { id: string conversationId: string @@ -389,6 +402,7 @@ interface AIMessage { timestamp: number dataKeywords?: string[] dataMessageCount?: number + contentBlocks?: ContentBlock[] } const aiApi = { @@ -455,9 +469,10 @@ const aiApi = { role: 'user' | 'assistant', content: string, dataKeywords?: string[], - dataMessageCount?: number + dataMessageCount?: number, + contentBlocks?: ContentBlock[] ): Promise => { - return ipcRenderer.invoke('ai:addMessage', conversationId, role, content, dataKeywords, dataMessageCount) + return ipcRenderer.invoke('ai:addMessage', conversationId, role, content, dataKeywords, dataMessageCount, contentBlocks) }, /** diff --git a/src/components/analysis/ai/ChatExplorer.vue b/src/components/analysis/ai/ChatExplorer.vue index 1b1d8fad..98a17dfc 100644 --- a/src/components/analysis/ai/ChatExplorer.vue +++ b/src/components/analysis/ai/ChatExplorer.vue @@ -243,134 +243,15 @@ watch(
diff --git a/src/components/analysis/ai/ChatMessage.vue b/src/components/analysis/ai/ChatMessage.vue index 531908e0..9049655c 100644 --- a/src/components/analysis/ai/ChatMessage.vue +++ b/src/components/analysis/ai/ChatMessage.vue @@ -3,6 +3,7 @@ import { computed } from 'vue' import dayjs from 'dayjs' import MarkdownIt from 'markdown-it' import userAvatar from '@/assets/images/momo.png' +import type { ContentBlock } from '@/composables/useAIChat' // Props const props = defineProps<{ @@ -10,6 +11,8 @@ const props = defineProps<{ content: string timestamp: number isStreaming?: boolean + /** AI 消息的混合内容块(按时序排列的文本和工具调用) */ + contentBlocks?: ContentBlock[] }>() // 格式化时间 @@ -28,11 +31,69 @@ const md = new MarkdownIt({ typographer: true, // 启用排版优化 }) -// 渲染后的 HTML +// 渲染 Markdown 文本 +function renderMarkdown(text: string): string { + if (!text) return '' + return md.render(text) +} + +// 渲染后的 HTML(用于用户消息或纯文本 AI 消息) const renderedContent = computed(() => { if (!props.content) return '' return md.render(props.content) }) + +// 是否使用 contentBlocks 渲染(AI 消息且有 contentBlocks) +const useBlocksRendering = computed(() => { + return props.role === 'assistant' && props.contentBlocks && props.contentBlocks.length > 0 +}) + +// 格式化工具参数显示 +function formatToolParams(tool: ContentBlock extends { type: 'tool'; tool: infer T } ? T : never): string { + if (!tool.params) return '' + + const name = tool.name + const params = tool.params + + if (name === 'search_messages' && params.keywords) { + const keywords = params.keywords as string[] + let result = `关键词: ${keywords.join(', ')}` + if (params.year) { + result += ` | 时间: ${params.year}年${params.month ? `${params.month}月` : ''}` + } + return result + } + + if (name === 'get_recent_messages') { + let result = `获取 ${params.limit || 100} 条消息` + if (params.year) { + result += ` | ${params.year}年${params.month ? `${params.month}月` : ''}` + } + return result + } + + if (name === 'get_member_stats') { + return `前 ${params.top_n || 10} 名成员` + } + + if (name === 'get_time_stats') { + const typeMap: Record = { + hourly: '按小时', + weekday: '按星期', + daily: '按日期', + } + return typeMap[params.type as string] || String(params.type) + } + + if (name === 'get_group_members') { + if (params.search) { + return `搜索: ${params.search}` + } + return '获取成员列表' + } + + return '' +}