From dbe76406350c3f33a936ed119e6b6cc14e37d9f1 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Tue, 28 Oct 2025 20:46:15 +0800 Subject: [PATCH 01/13] refactor(thread): extract conversation lifecycle --- .../threadPresenter/contentBufferManager.ts | 261 +++ .../threadPresenter/conversationExporter.ts | 511 ++++ .../conversationLifecycleManager.ts | 379 +++ .../presenter/threadPresenter/fileContext.ts | 22 - src/main/presenter/threadPresenter/index.ts | 2050 ++--------------- .../threadPresenter/messageContent.ts | 104 + .../threadPresenter/promptBuilder.ts | 506 ++++ src/main/presenter/threadPresenter/types.ts | 34 + 8 files changed, 1928 insertions(+), 1939 deletions(-) create mode 100644 src/main/presenter/threadPresenter/contentBufferManager.ts create mode 100644 src/main/presenter/threadPresenter/conversationExporter.ts create mode 100644 src/main/presenter/threadPresenter/conversationLifecycleManager.ts delete mode 100644 src/main/presenter/threadPresenter/fileContext.ts create mode 100644 src/main/presenter/threadPresenter/messageContent.ts create mode 100644 src/main/presenter/threadPresenter/promptBuilder.ts create mode 100644 src/main/presenter/threadPresenter/types.ts diff --git a/src/main/presenter/threadPresenter/contentBufferManager.ts b/src/main/presenter/threadPresenter/contentBufferManager.ts new file mode 100644 index 000000000..45d284068 --- /dev/null +++ b/src/main/presenter/threadPresenter/contentBufferManager.ts @@ -0,0 +1,261 @@ +import { eventBus, SendTarget } from '@/eventbus' +import { STREAM_EVENTS } from '@/events' + +import type { MessageManager } from './messageManager' +import type { GeneratingMessageState } from './types' + +interface ContentBufferDependencies { + messageManager: MessageManager + generatingMessages: Map +} + +export class ContentBufferManager { + private messageManager: MessageManager + private generatingMessages: Map + + constructor({ messageManager, generatingMessages }: ContentBufferDependencies) { + this.messageManager = messageManager + this.generatingMessages = generatingMessages + } + + cleanupContentBuffer(state: GeneratingMessageState): void { + if (state.flushTimeout) { + clearTimeout(state.flushTimeout) + state.flushTimeout = undefined + } + if (state.throttleTimeout) { + clearTimeout(state.throttleTimeout) + state.throttleTimeout = undefined + } + state.adaptiveBuffer = undefined + state.lastRendererUpdateTime = undefined + } + + async flushAdaptiveBuffer(eventId: string): Promise { + const state = this.generatingMessages.get(eventId) + if (!state?.adaptiveBuffer) return + + const buffer = state.adaptiveBuffer + const now = Date.now() + + if (state.flushTimeout) { + clearTimeout(state.flushTimeout) + state.flushTimeout = undefined + } + + if (buffer.content && buffer.sentPosition < buffer.content.length) { + const newContent = buffer.content.slice(buffer.sentPosition) + if (newContent) { + await this.processBufferedContent(state, eventId, newContent, now) + buffer.sentPosition = buffer.content.length + } + } + + state.adaptiveBuffer = undefined + } + + finalizeLastBlock(state: GeneratingMessageState): void { + const lastBlock = + state.message.content.length > 0 + ? state.message.content[state.message.content.length - 1] + : undefined + + if (!lastBlock) { + return + } + + if ( + lastBlock.type === 'action' && + lastBlock.action_type === 'tool_call_permission' && + lastBlock.status === 'pending' + ) { + lastBlock.status = 'granted' + return + } + + if (!(lastBlock.type === 'tool_call' && lastBlock.status === 'loading')) { + lastBlock.status = 'success' + } + } + + async processContentDirectly( + eventId: string, + content: string, + currentTime: number + ): Promise { + const state = this.generatingMessages.get(eventId) + if (!state) return + + if (this.shouldSplitContent(content)) { + await this.processLargeContentInChunks(state, eventId, content, currentTime) + } else { + await this.processNormalContent(state, eventId, content, currentTime) + } + } + + private async processBufferedContent( + state: GeneratingMessageState, + eventId: string, + content: string, + currentTime: number + ): Promise { + const buffer = state.adaptiveBuffer + + if (buffer?.isLargeContent) { + await this.processLargeContentAsynchronously(state, eventId, content, currentTime) + return + } + + await this.processNormalContent(state, eventId, content, currentTime) + } + + private async processLargeContentAsynchronously( + state: GeneratingMessageState, + eventId: string, + content: string, + currentTime: number + ): Promise { + const buffer = state.adaptiveBuffer + if (!buffer) return + + buffer.isProcessing = true + + try { + const chunks = this.splitLargeContent(content) + const totalChunks = chunks.length + + console.log( + `[ThreadPresenter] Processing ${totalChunks} chunks asynchronously for ${content.length} bytes` + ) + + const lastBlock = state.message.content[state.message.content.length - 1] + let contentBlock: any + + if (lastBlock && lastBlock.type === 'content') { + contentBlock = lastBlock + } else { + this.finalizeLastBlock(state) + contentBlock = { + type: 'content', + content: '', + status: 'loading', + timestamp: currentTime + } + state.message.content.push(contentBlock) + } + + const batchSize = 5 + for (let batchStart = 0; batchStart < chunks.length; batchStart += batchSize) { + const batchEnd = Math.min(batchStart + batchSize, chunks.length) + const batch = chunks.slice(batchStart, batchEnd) + + const batchContent = batch.join('') + contentBlock.content += batchContent + + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + + const eventData: any = { + eventId, + content: batchContent, + chunkInfo: { + current: batchEnd, + total: totalChunks, + isLargeContent: true, + batchSize: batch.length + } + } + + eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, eventData) + + if (batchEnd < chunks.length) { + await new Promise((resolve) => setImmediate(resolve)) + } + } + + console.log(`[ThreadPresenter] Completed processing ${totalChunks} chunks`) + } catch (error) { + console.error('[ThreadPresenter] Error in processLargeContentAsynchronously:', error) + } finally { + buffer.isProcessing = false + } + } + + private async processNormalContent( + state: GeneratingMessageState, + eventId: string, + content: string, + currentTime: number + ): Promise { + const lastBlock = state.message.content[state.message.content.length - 1] + + if (lastBlock && lastBlock.type === 'content') { + lastBlock.content += content + } else { + this.finalizeLastBlock(state) + state.message.content.push({ + type: 'content', + content: content, + status: 'loading', + timestamp: currentTime + }) + } + + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + } + + private async processLargeContentInChunks( + state: GeneratingMessageState, + eventId: string, + content: string, + currentTime: number + ): Promise { + console.log(`[ThreadPresenter] Processing large content in chunks: ${content.length} bytes`) + + const lastBlock = state.message.content[state.message.content.length - 1] + let contentBlock: any + + if (lastBlock && lastBlock.type === 'content') { + contentBlock = lastBlock + } else { + this.finalizeLastBlock(state) + contentBlock = { + type: 'content', + content: '', + status: 'loading', + timestamp: currentTime + } + state.message.content.push(contentBlock) + } + + contentBlock.content += content + + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + } + + private splitLargeContent(content: string): string[] { + const chunks: string[] = [] + let maxChunkSize = 4096 + + if (content.includes('data:image/')) { + maxChunkSize = 512 + } + + if (content.length > 50000) { + maxChunkSize = Math.min(maxChunkSize, 256) + } + + for (let i = 0; i < content.length; i += maxChunkSize) { + chunks.push(content.slice(i, i + maxChunkSize)) + } + + return chunks + } + + private shouldSplitContent(content: string): boolean { + const sizeThreshold = 8192 + const hasBase64Image = content.includes('data:image/') && content.includes('base64,') + const hasLargeBase64 = hasBase64Image && content.length > 5120 + + return content.length > sizeThreshold || hasLargeBase64 + } +} diff --git a/src/main/presenter/threadPresenter/conversationExporter.ts b/src/main/presenter/threadPresenter/conversationExporter.ts new file mode 100644 index 000000000..89a94e378 --- /dev/null +++ b/src/main/presenter/threadPresenter/conversationExporter.ts @@ -0,0 +1,511 @@ +import { AssistantMessageBlock, Message, UserMessageContent } from '@shared/chat' +import { CONVERSATION } from '../../../shared/presenter' +import { getNormalizedUserMessageText } from './messageContent' + +export type ConversationExportFormat = 'markdown' | 'html' | 'txt' + +export function generateExportFilename( + format: ConversationExportFormat, + timestamp: Date = new Date() +): string { + const extension = format === 'markdown' ? 'md' : format + const formattedTimestamp = timestamp + .toISOString() + .replace(/[:.]/g, '-') + .replace('T', '_') + .substring(0, 19) + + return `export_deepchat_${formattedTimestamp}.${extension}` +} + +export function buildConversationExportContent( + conversation: CONVERSATION, + messages: Message[], + format: ConversationExportFormat +): string { + switch (format) { + case 'markdown': + return exportToMarkdown(conversation, messages) + case 'html': + return exportToHtml(conversation, messages) + case 'txt': + return exportToText(conversation, messages) + default: + throw new Error(`不支持的导出格式: ${format}`) + } +} + +function exportToMarkdown(conversation: CONVERSATION, messages: Message[]): string { + const lines: string[] = [] + + lines.push(`# ${conversation.title}`) + lines.push('') + lines.push(`**Export Time:** ${new Date().toLocaleString()}`) + lines.push(`**Conversation ID:** ${conversation.id}`) + lines.push(`**Message Count:** ${messages.length}`) + if (conversation.settings.modelId) { + lines.push(`**Model:** ${conversation.settings.modelId}`) + } + if (conversation.settings.providerId) { + lines.push(`**Provider:** ${conversation.settings.providerId}`) + } + lines.push('') + lines.push('---') + lines.push('') + + for (const message of messages) { + const messageTime = new Date(message.timestamp).toLocaleString() + + if (message.role === 'user') { + lines.push(`## 👤 用户 (${messageTime})`) + lines.push('') + + const userContent = message.content as UserMessageContent + const messageText = getNormalizedUserMessageText(userContent) + + lines.push(messageText) + lines.push('') + + if (userContent.files && userContent.files.length > 0) { + lines.push('**附件:**') + for (const file of userContent.files) { + lines.push(`- ${file.name} (${file.mimeType})`) + } + lines.push('') + } + + if (userContent.links && userContent.links.length > 0) { + lines.push('**链接:**') + for (const link of userContent.links) { + lines.push(`- ${link}`) + } + lines.push('') + } + } else if (message.role === 'assistant') { + lines.push(`## 🤖 助手 (${messageTime})`) + lines.push('') + + const assistantBlocks = message.content as AssistantMessageBlock[] + + for (const block of assistantBlocks) { + switch (block.type) { + case 'content': + if (block.content) { + lines.push(block.content) + lines.push('') + } + break + case 'reasoning_content': + if (block.content) { + lines.push('### 🤔 思考过程') + lines.push('') + lines.push('```') + lines.push(block.content) + lines.push('```') + lines.push('') + } + break + case 'tool_call': + if (block.tool_call) { + lines.push(`### 🔧 工具调用: ${block.tool_call.name ?? ''}`) + lines.push('') + if (block.tool_call.params) { + lines.push('**参数:**') + lines.push('```json') + try { + const params = JSON.parse(block.tool_call.params) + lines.push(JSON.stringify(params, null, 2)) + } catch { + lines.push(block.tool_call.params) + } + lines.push('```') + lines.push('') + } + if (block.tool_call.response) { + lines.push('**响应:**') + lines.push('```') + lines.push(block.tool_call.response) + lines.push('```') + lines.push('') + } + } + break + case 'search': + lines.push('### 🔍 网络搜索') + if (block.extra?.total) { + lines.push(`找到 ${block.extra.total} 个搜索结果`) + } + lines.push('') + break + case 'image': + lines.push('### 🖼️ 图片') + lines.push('*[图片内容]*') + lines.push('') + break + case 'error': + if (block.content) { + lines.push('### ❌ 错误') + lines.push('') + lines.push(`\`${block.content}\``) + lines.push('') + } + break + case 'artifact-thinking': + if (block.content) { + lines.push('### 💭 创作思考') + lines.push('') + lines.push('```') + lines.push(block.content) + lines.push('```') + lines.push('') + } + break + } + } + } + + lines.push('---') + lines.push('') + } + + return lines.join('\n') +} + +function exportToHtml(conversation: CONVERSATION, messages: Message[]): string { + const lines: string[] = [] + + lines.push('') + lines.push('') + lines.push('') + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeHtml(conversation.title)}`) + lines.push(' ') + lines.push('') + lines.push('') + lines.push('
') + lines.push(`

${escapeHtml(conversation.title)}

`) + lines.push(`

Export Time: ${new Date().toLocaleString()}

`) + lines.push(`

Conversation ID: ${conversation.id}

`) + lines.push(`

Message Count: ${messages.length}

`) + if (conversation.settings.modelId) { + lines.push(`

Model: ${conversation.settings.modelId}

`) + } + if (conversation.settings.providerId) { + lines.push(`

Provider: ${conversation.settings.providerId}

`) + } + lines.push('
') + + for (const message of messages) { + const messageTime = new Date(message.timestamp).toLocaleString() + + if (message.role === 'user') { + const userContent = message.content as UserMessageContent + const messageText = getNormalizedUserMessageText(userContent) + + lines.push('
') + lines.push('
👤 用户
') + lines.push(`
${messageTime}
`) + lines.push('
') + lines.push(` ${escapeHtml(messageText).replace(/\n/g, '
')}`) + lines.push('
') + + if (userContent.files && userContent.files.length > 0) { + lines.push('
') + lines.push('
附件
') + lines.push('
    ') + for (const file of userContent.files) { + lines.push( + `
  • ${escapeHtml(file.name || '')} (${escapeHtml(file.mimeType)})
  • ` + ) + } + lines.push('
') + lines.push('
') + } + + if (userContent.links && userContent.links.length > 0) { + lines.push('
') + lines.push('
链接
') + lines.push('
    ') + for (const link of userContent.links) { + lines.push(`
  • ${escapeHtml(link)}
  • `) + } + lines.push('
') + lines.push('
') + } + + lines.push('
') + } else if (message.role === 'assistant') { + const assistantBlocks = message.content as AssistantMessageBlock[] + + lines.push('
') + lines.push('
🤖 助手
') + lines.push(`
${messageTime}
`) + + for (const block of assistantBlocks) { + switch (block.type) { + case 'content': + if (block.content) { + lines.push('
') + lines.push(` ${escapeHtml(block.content).replace(/\n/g, '
')}`) + lines.push('
') + } + break + case 'reasoning_content': + if (block.content) { + lines.push('
') + lines.push(' 🤔 思考过程') + lines.push(`
${escapeHtml(block.content)}
`) + lines.push('
') + } + break + case 'tool_call': + if (block.tool_call) { + lines.push('
') + lines.push( + ` 🔧 工具调用: ${escapeHtml(block.tool_call.name ?? '')}` + ) + if (block.tool_call.params) { + lines.push('
参数
') + lines.push('
') + try { + const params = JSON.parse(block.tool_call.params) + lines.push(escapeHtml(JSON.stringify(params, null, 2))) + } catch { + lines.push(escapeHtml(block.tool_call.params)) + } + lines.push('
') + } + if (block.tool_call.response) { + lines.push('
响应
') + lines.push('
') + lines.push(escapeHtml(block.tool_call.response)) + lines.push('
') + } + lines.push('
') + } + break + case 'search': + lines.push('
') + lines.push(' 🔍 网络搜索') + if (block.extra?.total) { + lines.push(`
找到 ${block.extra.total} 个搜索结果
`) + } + lines.push('
') + break + case 'image': + lines.push('
🖼️ 图片
') + lines.push('
*[图片内容]*
') + break + case 'error': + if (block.content) { + lines.push('
') + lines.push(` ❌ ${escapeHtml(block.content)}`) + lines.push('
') + } + break + case 'artifact-thinking': + if (block.content) { + lines.push('
') + lines.push(' 💭 创作思考:') + lines.push(`
${escapeHtml(block.content)}
`) + lines.push('
') + } + break + } + } + + lines.push('
') + } + + lines.push('
') + } + + lines.push('') + lines.push('') + + return lines.join('\n') +} + +function exportToText(conversation: CONVERSATION, messages: Message[]): string { + const lines: string[] = [] + + lines.push(`${conversation.title}`) + lines.push(''.padEnd(conversation.title.length, '=')) + lines.push('') + lines.push(`导出时间: ${new Date().toLocaleString()}`) + lines.push(`会话ID: ${conversation.id}`) + lines.push(`消息数量: ${messages.length}`) + if (conversation.settings.modelId) { + lines.push(`模型: ${conversation.settings.modelId}`) + } + if (conversation.settings.providerId) { + lines.push(`提供商: ${conversation.settings.providerId}`) + } + lines.push('') + lines.push(''.padEnd(80, '-')) + lines.push('') + + for (const message of messages) { + const messageTime = new Date(message.timestamp).toLocaleString() + + if (message.role === 'user') { + lines.push(`[用户] ${messageTime}`) + lines.push('') + + const userContent = message.content as UserMessageContent + const messageText = getNormalizedUserMessageText(userContent) + + lines.push(messageText) + lines.push('') + + if (userContent.files && userContent.files.length > 0) { + lines.push('附件:') + for (const file of userContent.files) { + lines.push(`- ${file.name} (${file.mimeType})`) + } + lines.push('') + } + + if (userContent.links && userContent.links.length > 0) { + lines.push('链接:') + for (const link of userContent.links) { + lines.push(`- ${link}`) + } + lines.push('') + } + } else if (message.role === 'assistant') { + lines.push(`[助手] ${messageTime}`) + lines.push('') + + const assistantBlocks = message.content as AssistantMessageBlock[] + + for (const block of assistantBlocks) { + switch (block.type) { + case 'content': + if (block.content) { + lines.push(block.content) + lines.push('') + } + break + case 'reasoning_content': + if (block.content) { + lines.push('[思考过程]') + lines.push(block.content) + lines.push('') + } + break + case 'tool_call': + if (block.tool_call) { + lines.push(`[工具调用] ${block.tool_call.name ?? ''}`) + if (block.tool_call.params) { + lines.push('参数:') + lines.push(block.tool_call.params) + } + if (block.tool_call.response) { + lines.push('响应:') + lines.push(block.tool_call.response) + } + lines.push('') + } + break + case 'search': + lines.push('[网络搜索]') + if (block.extra?.total) { + lines.push(`找到 ${block.extra.total} 个搜索结果`) + } + lines.push('') + break + case 'image': + lines.push('[图片内容]') + lines.push('') + break + case 'error': + if (block.content) { + lines.push(`[错误] ${block.content}`) + lines.push('') + } + break + case 'artifact-thinking': + if (block.content) { + lines.push('[创作思考]') + lines.push(block.content) + lines.push('') + } + break + } + } + } + + lines.push(''.padEnd(80, '-')) + lines.push('') + } + + return lines.join('\n') +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts new file mode 100644 index 000000000..db9fe0d48 --- /dev/null +++ b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts @@ -0,0 +1,379 @@ +import { + CONVERSATION, + CONVERSATION_SETTINGS, + ISQLitePresenter, + IConfigPresenter, + IMessageManager +} from '../../../shared/presenter' +import { eventBus, SendTarget } from '@/eventbus' +import { CONVERSATION_EVENTS, TAB_EVENTS } from '@/events' +import { presenter } from '@/presenter' +import { DEFAULT_SETTINGS } from './const' + +export interface CreateConversationOptions { + forceNewAndActivate?: boolean +} + +interface ConversationLifecycleDependencies { + sqlitePresenter: ISQLitePresenter + configPresenter: IConfigPresenter + messageManager: IMessageManager +} + +export class ConversationLifecycleManager { + private sqlitePresenter: ISQLitePresenter + private configPresenter: IConfigPresenter + private messageManager: IMessageManager + private activeConversationIds: Map = new Map() + private fetchThreadLength = 300 + + constructor({ + sqlitePresenter, + configPresenter, + messageManager + }: ConversationLifecycleDependencies) { + this.sqlitePresenter = sqlitePresenter + this.configPresenter = configPresenter + this.messageManager = messageManager + } + + getActiveConversationId(tabId: number): string | null { + return this.activeConversationIds.get(tabId) || null + } + + getTabsByConversation(conversationId: string): number[] { + return Array.from(this.activeConversationIds.entries()) + .filter(([, id]) => id === conversationId) + .map(([tabId]) => tabId) + } + + clearActiveConversation(tabId: number, options: { notify?: boolean } = {}): void { + if (!this.activeConversationIds.has(tabId)) { + return + } + this.activeConversationIds.delete(tabId) + if (options.notify) { + eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { tabId }) + } + } + + clearConversationBindings(conversationId: string): void { + for (const [tabId, activeId] of this.activeConversationIds.entries()) { + if (activeId === conversationId) { + this.activeConversationIds.delete(tabId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { + tabId + }) + } + } + } + + async findTabForConversation(conversationId: string): Promise { + for (const [tabId, activeId] of this.activeConversationIds.entries()) { + if (activeId === conversationId) { + const tabView = await presenter.tabPresenter.getTab(tabId) + if (tabView && !tabView.webContents.isDestroyed()) { + return tabId + } + } + } + return null + } + + private async getTabWindowType(tabId: number): Promise<'floating' | 'main' | 'unknown'> { + try { + const tabView = await presenter.tabPresenter.getTab(tabId) + if (!tabView) { + return 'unknown' + } + const windowId = presenter.tabPresenter['tabWindowMap'].get(tabId) + return windowId ? 'main' : 'floating' + } catch (error) { + console.error('Error determining tab window type:', error) + return 'unknown' + } + } + + async setActiveConversation(conversationId: string, tabId: number): Promise { + const existingTabId = await this.findTabForConversation(conversationId) + + if (existingTabId !== null && existingTabId !== tabId) { + console.log( + `Conversation ${conversationId} is already open in tab ${existingTabId}. Switching to it.` + ) + const currentTabType = await this.getTabWindowType(tabId) + const existingTabType = await this.getTabWindowType(existingTabId) + + if (currentTabType !== existingTabType) { + this.activeConversationIds.delete(existingTabId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { + tabId: existingTabId + }) + this.activeConversationIds.set(tabId, conversationId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { + conversationId, + tabId + }) + return + } + + await presenter.tabPresenter.switchTab(existingTabId) + return + } + + const conversation = await this.getConversation(conversationId) + if (!conversation) { + throw new Error(`Conversation ${conversationId} not found`) + } + + if (this.activeConversationIds.get(tabId) === conversationId) { + return + } + + this.activeConversationIds.set(tabId, conversationId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { + conversationId, + tabId + }) + } + + async getActiveConversation(tabId: number): Promise { + const conversationId = this.activeConversationIds.get(tabId) + if (!conversationId) { + return null + } + return this.getConversation(conversationId) + } + + async getConversation(conversationId: string): Promise { + return await this.sqlitePresenter.getConversation(conversationId) + } + + async createConversation( + title: string, + settings: Partial, + tabId: number, + options: CreateConversationOptions = {} + ): Promise { + const latestConversation = await this.getLatestConversation() + + if (!options.forceNewAndActivate) { + if (latestConversation) { + const { list: messages } = await this.messageManager.getMessageThread( + latestConversation.id, + 1, + 1 + ) + if (messages.length === 0) { + await this.setActiveConversation(latestConversation.id, tabId) + return latestConversation.id + } + } + } + + let defaultSettings = DEFAULT_SETTINGS + if (latestConversation?.settings) { + defaultSettings = { ...latestConversation.settings } + defaultSettings.systemPrompt = '' + defaultSettings.reasoningEffort = undefined + defaultSettings.enableSearch = undefined + defaultSettings.forcedSearch = undefined + defaultSettings.searchStrategy = undefined + } + + Object.keys(settings).forEach((key) => { + if (settings[key] === undefined || settings[key] === null || settings[key] === '') { + delete settings[key] + } + }) + + const mergedSettings = { ...defaultSettings, ...settings } + + const defaultModelsSettings = this.configPresenter.getModelConfig( + mergedSettings.modelId, + mergedSettings.providerId + ) + + if (defaultModelsSettings) { + mergedSettings.maxTokens = defaultModelsSettings.maxTokens + mergedSettings.contextLength = defaultModelsSettings.contextLength + mergedSettings.temperature = defaultModelsSettings.temperature ?? 0.7 + if (settings.thinkingBudget === undefined) { + mergedSettings.thinkingBudget = defaultModelsSettings.thinkingBudget + } + } + + if (settings.artifacts) { + mergedSettings.artifacts = settings.artifacts + } + + if (settings.maxTokens) { + mergedSettings.maxTokens = settings.maxTokens + } + + if (settings.temperature !== undefined && settings.temperature !== null) { + mergedSettings.temperature = settings.temperature + } + + if (settings.contextLength) { + mergedSettings.contextLength = settings.contextLength + } + + if (settings.systemPrompt) { + mergedSettings.systemPrompt = settings.systemPrompt + } + + const conversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) + + if (options.forceNewAndActivate) { + this.activeConversationIds.set(tabId, conversationId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { + conversationId, + tabId + }) + } else { + await this.setActiveConversation(conversationId, tabId) + } + + await this.broadcastThreadListUpdate() + return conversationId + } + + async renameConversation(conversationId: string, title: string): Promise { + await this.sqlitePresenter.renameConversation(conversationId, title) + await this.broadcastThreadListUpdate() + + const conversation = await this.getConversation(conversationId) + + let tabId: number | undefined + for (const [key, value] of this.activeConversationIds.entries()) { + if (value === conversationId) { + tabId = key + break + } + } + + if (tabId !== undefined) { + const windowId = presenter.tabPresenter['tabWindowMap'].get(tabId) + eventBus.sendToRenderer(TAB_EVENTS.TITLE_UPDATED, SendTarget.ALL_WINDOWS, { + tabId, + conversationId, + title: conversation.title, + windowId + }) + } + + return conversation + } + + async deleteConversation(conversationId: string): Promise { + await this.sqlitePresenter.deleteConversation(conversationId) + this.clearConversationBindings(conversationId) + await this.broadcastThreadListUpdate() + } + + async toggleConversationPinned(conversationId: string, pinned: boolean): Promise { + await this.sqlitePresenter.updateConversation(conversationId, { is_pinned: pinned ? 1 : 0 }) + await this.broadcastThreadListUpdate() + } + + async updateConversationTitle(conversationId: string, title: string): Promise { + await this.sqlitePresenter.updateConversation(conversationId, { title }) + await this.broadcastThreadListUpdate() + } + + async updateConversationSettings( + conversationId: string, + settings: Partial + ): Promise { + const conversation = await this.getConversation(conversationId) + const mergedSettings = { ...conversation.settings } + + for (const key in settings) { + if (settings[key] !== undefined) { + mergedSettings[key] = settings[key] + } + } + + if (settings.modelId && settings.modelId !== conversation.settings.modelId) { + const modelConfig = this.configPresenter.getModelConfig( + mergedSettings.modelId, + mergedSettings.providerId + ) + if (modelConfig) { + mergedSettings.maxTokens = modelConfig.maxTokens + mergedSettings.contextLength = modelConfig.contextLength + } + } + + await this.sqlitePresenter.updateConversation(conversationId, { settings: mergedSettings }) + await this.broadcastThreadListUpdate() + } + + async getConversationList( + page: number, + pageSize: number + ): Promise<{ total: number; list: CONVERSATION[] }> { + return await this.sqlitePresenter.getConversationList(page, pageSize) + } + + async loadMoreThreads(): Promise<{ hasMore: boolean; total: number }> { + const total = await this.sqlitePresenter.getConversationCount() + const hasMore = this.fetchThreadLength < total + + if (hasMore) { + this.fetchThreadLength = Math.min(this.fetchThreadLength + 300, total) + await this.broadcastThreadListUpdate() + } + + return { hasMore: this.fetchThreadLength < total, total } + } + + async broadcastThreadListUpdate(): Promise { + const result = await this.sqlitePresenter.getConversationList(1, this.fetchThreadLength) + + const pinnedConversations: CONVERSATION[] = [] + const normalConversations: CONVERSATION[] = [] + + result.list.forEach((conv) => { + if (conv.is_pinned === 1) { + pinnedConversations.push(conv) + } else { + normalConversations.push(conv) + } + }) + + pinnedConversations.sort((a, b) => b.updatedAt - a.updatedAt) + normalConversations.sort((a, b) => b.updatedAt - a.updatedAt) + + const groupedThreads: Map = new Map() + + if (pinnedConversations.length > 0) { + groupedThreads.set('Pinned', pinnedConversations) + } + + normalConversations.forEach((conv) => { + const date = new Date(conv.updatedAt).toISOString().split('T')[0] + if (!groupedThreads.has(date)) { + groupedThreads.set(date, []) + } + groupedThreads.get(date)!.push(conv) + }) + + const finalGroupedList = Array.from(groupedThreads.entries()).map(([dt, dtThreads]) => ({ + dt, + dtThreads + })) + + eventBus.sendToRenderer( + CONVERSATION_EVENTS.LIST_UPDATED, + SendTarget.ALL_WINDOWS, + finalGroupedList + ) + } + + private async getLatestConversation(): Promise { + const result = await this.getConversationList(1, 1) + return result.list[0] || null + } +} diff --git a/src/main/presenter/threadPresenter/fileContext.ts b/src/main/presenter/threadPresenter/fileContext.ts deleted file mode 100644 index 5983c3d06..000000000 --- a/src/main/presenter/threadPresenter/fileContext.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { MessageFile } from '@shared/chat' - -export const getFileContext = (files: MessageFile[]) => { - return files.length > 0 - ? ` - - - ${files - .map( - (file) => ` - ${file.name} - ${file.mimeType} - ${file.metadata.fileSize} - ${file.path} - ${!file.mimeType.startsWith('image') ? file.content : ''} - ` - ) - .join('\n')} - - ` - : '' -} diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts index 428c69d97..f08c136b1 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -12,7 +12,6 @@ import { ILlmProviderPresenter, MCPToolResponse, ChatMessage, - ChatMessageContent, LLMAgentEventData } from '../../../shared/presenter' import { presenter } from '@/presenter' @@ -25,54 +24,30 @@ import { SearchEngineTemplate, UserMessage, MessageFile, - UserMessageContent, - UserMessageTextBlock, - UserMessageMentionBlock, - UserMessageCodeBlock + UserMessageContent } from '@shared/chat' -import { ModelType } from '@shared/model' import { approximateTokenSize } from 'tokenx' -import { generateSearchPrompt, SearchManager } from './searchManager' -import { getFileContext } from './fileContext' +import { SearchManager } from './searchManager' import { ContentEnricher } from './contentEnricher' import { CONVERSATION_EVENTS, STREAM_EVENTS, TAB_EVENTS } from '@/events' -import { DEFAULT_SETTINGS } from './const' import { nanoid } from 'nanoid' - -interface GeneratingMessageState { - message: AssistantMessage - conversationId: string - startTime: number - firstTokenTime: number | null - promptTokens: number - reasoningStartTime: number | null - reasoningEndTime: number | null - lastReasoningTime: number | null - isSearching?: boolean - isCancelled?: boolean - totalUsage?: { - prompt_tokens: number - completion_tokens: number - total_tokens: number - context_length: number - } - // 统一的自适应内容处理 - adaptiveBuffer?: { - content: string - lastUpdateTime: number - updateCount: number - totalSize: number - isLargeContent: boolean - chunks?: string[] - currentChunkIndex?: number - // 精确追踪已发送内容的位置 - sentPosition: number // 已发送到渲染器的内容位置 - isProcessing?: boolean - } - flushTimeout?: NodeJS.Timeout - throttleTimeout?: NodeJS.Timeout - lastRendererUpdateTime?: number -} +import { ContentBufferManager } from './contentBufferManager' +import { + buildUserMessageContext, + formatUserMessageContent, + getNormalizedUserMessageText +} from './messageContent' +import { preparePromptContent, buildContinueToolCallContext } from './promptBuilder' +import { + buildConversationExportContent, + generateExportFilename, + ConversationExportFormat +} from './conversationExporter' +import { + ConversationLifecycleManager, + CreateConversationOptions +} from './conversationLifecycleManager' +import type { GeneratingMessageState } from './types' export class ThreadPresenter implements IThreadPresenter { private sqlitePresenter: ISQLitePresenter @@ -81,11 +56,11 @@ export class ThreadPresenter implements IThreadPresenter { private configPresenter: IConfigPresenter private searchManager: SearchManager private generatingMessages: Map = new Map() + private contentBufferManager: ContentBufferManager + private conversationLifecycle: ConversationLifecycleManager public searchAssistantModel: MODEL_META | null = null public searchAssistantProviderId: string | null = null private searchingMessages: Set = new Set() - private activeConversationIds: Map = new Map() - private fetchThreadLength: number = 300 constructor( sqlitePresenter: ISQLitePresenter, @@ -97,16 +72,26 @@ export class ThreadPresenter implements IThreadPresenter { this.llmProviderPresenter = llmProviderPresenter this.searchManager = new SearchManager() this.configPresenter = configPresenter + this.contentBufferManager = new ContentBufferManager({ + messageManager: this.messageManager, + generatingMessages: this.generatingMessages + }) + this.conversationLifecycle = new ConversationLifecycleManager({ + sqlitePresenter, + configPresenter, + messageManager: this.messageManager + }) // 监听Tab关闭事件,清理绑定关系 eventBus.on(TAB_EVENTS.CLOSED, (tabId: number) => { - if (this.activeConversationIds.has(tabId)) { - this.activeConversationIds.delete(tabId) + const activeConversationId = this.conversationLifecycle.getActiveConversationId(tabId) + if (activeConversationId) { + this.conversationLifecycle.clearActiveConversation(tabId, { notify: true }) console.log(`ThreadPresenter: Cleaned up conversation binding for closed tab ${tabId}.`) } }) eventBus.on(TAB_EVENTS.RENDERER_TAB_READY, () => { - this.broadcastThreadListUpdate() + this.conversationLifecycle.broadcastThreadListUpdate() }) // 初始化时处理所有未完成的消息 @@ -119,30 +104,7 @@ export class ThreadPresenter implements IThreadPresenter { * @returns 如果找到,返回tabId,否则返回null */ async findTabForConversation(conversationId: string): Promise { - for (const [tabId, activeId] of this.activeConversationIds.entries()) { - if (activeId === conversationId) { - // 验证该tab是否还真实存在 - const tabView = await presenter.tabPresenter.getTab(tabId) - if (tabView && !tabView.webContents.isDestroyed()) { - return tabId - } - } - } - return null - } - - private async getTabWindowType(tabId: number): Promise<'floating' | 'main' | 'unknown'> { - try { - const tabView = await presenter.tabPresenter.getTab(tabId) - if (!tabView) { - return 'unknown' - } - const windowId = presenter.tabPresenter['tabWindowMap'].get(tabId) - return windowId ? 'main' : 'floating' - } catch (error) { - console.error('Error determining tab window type:', error) - return 'unknown' - } + return this.conversationLifecycle.findTabForConversation(conversationId) } async handleLLMAgentError(msg: LLMAgentEventData) { @@ -151,11 +113,11 @@ export class ThreadPresenter implements IThreadPresenter { if (state) { // 刷新剩余缓冲内容 if (state.adaptiveBuffer) { - await this.flushAdaptiveBuffer(eventId) + await this.contentBufferManager.flushAdaptiveBuffer(eventId) } // 清理缓冲相关资源 - this.cleanupContentBuffer(state) + this.contentBufferManager.cleanupContentBuffer(state) await this.messageManager.handleMessageError(eventId, String(error)) this.generatingMessages.delete(eventId) @@ -206,20 +168,6 @@ export class ThreadPresenter implements IThreadPresenter { eventBus.sendToRenderer(STREAM_EVENTS.END, SendTarget.ALL_WINDOWS, msg) } - // 清理所有缓冲相关资源 - private cleanupContentBuffer(state: GeneratingMessageState): void { - if (state.flushTimeout) { - clearTimeout(state.flushTimeout) - state.flushTimeout = undefined - } - if (state.throttleTimeout) { - clearTimeout(state.throttleTimeout) - state.throttleTimeout = undefined - } - state.adaptiveBuffer = undefined - state.lastRendererUpdateTime = undefined - } - // 完成消息的通用方法 private async finalizeMessage( state: GeneratingMessageState, @@ -295,11 +243,11 @@ export class ThreadPresenter implements IThreadPresenter { // 刷新剩余缓冲内容 if (state.adaptiveBuffer) { - await this.flushAdaptiveBuffer(eventId) + await this.contentBufferManager.flushAdaptiveBuffer(eventId) } // 清理缓冲相关资源 - this.cleanupContentBuffer(state) + this.contentBufferManager.cleanupContentBuffer(state) // 更新消息的usage信息 await this.messageManager.updateMessageMetadata(eventId, metadata) @@ -322,7 +270,7 @@ export class ThreadPresenter implements IThreadPresenter { // 处理会话更新和标题生成 private async handleConversationUpdates(state: GeneratingMessageState): Promise { - const conversation = await this.sqlitePresenter.getConversation(state.conversationId) + const conversation = await this.conversationLifecycle.getConversation(state.conversationId) let titleUpdated = false if (conversation.is_new === 1) { @@ -347,234 +295,10 @@ export class ThreadPresenter implements IThreadPresenter { .then(() => { console.log('updated conv time', state.conversationId) }) - await this.broadcastThreadListUpdate() - } - } - - // 释放缓冲的内容 - - // 统一的自适应内容刷新 - private async flushAdaptiveBuffer(eventId: string): Promise { - const state = this.generatingMessages.get(eventId) - if (!state?.adaptiveBuffer) return - - const buffer = state.adaptiveBuffer - const now = Date.now() - - // 清理超时 - if (state.flushTimeout) { - clearTimeout(state.flushTimeout) - state.flushTimeout = undefined - } - - // 处理缓冲的内容 - 只发送从 sentPosition 开始的新内容 - if (buffer.content && buffer.sentPosition < buffer.content.length) { - const newContent = buffer.content.slice(buffer.sentPosition) - if (newContent) { - await this.processBufferedContent(eventId, newContent, now) - // 更新已发送位置 - buffer.sentPosition = buffer.content.length - } - } - - // 清理缓冲 - state.adaptiveBuffer = undefined - } - - // 优化的自适应内容处理 - 核心逻辑 (当前未使用) - // private async addToAdaptiveBuffer(eventId: string, content: string): Promise { - // // 方法保留以备将来使用 - // } - - // 分块大内容 - 使用更小的分块避免UI阻塞 - private splitLargeContent(content: string): string[] { - const chunks: string[] = [] - let maxChunkSize = 4096 // 默认4KB - - // 对于图片base64内容,使用非常小的分块 - if (content.includes('data:image/')) { - maxChunkSize = 512 // 图片内容使用512字节分块 - } - - // 对于超长内容,进一步减小分块 - if (content.length > 50000) { - maxChunkSize = Math.min(maxChunkSize, 256) - } - - for (let i = 0; i < content.length; i += maxChunkSize) { - chunks.push(content.slice(i, i + maxChunkSize)) - } - - return chunks - } - - // 智能判断是否需要分块处理 - 优化阈值判断 - private shouldSplitContent(content: string): boolean { - const sizeThreshold = 8192 // 8KB - 适中的阈值 - const hasBase64Image = content.includes('data:image/') && content.includes('base64,') - const hasLargeBase64 = hasBase64Image && content.length > 5120 // 图片内容超过5KB才分块 - - return content.length > sizeThreshold || hasLargeBase64 - } - - // 处理缓冲的内容 - 优化异步处理 - private async processBufferedContent( - eventId: string, - content: string, - currentTime: number - ): Promise { - const state = this.generatingMessages.get(eventId) - if (!state) return - - const buffer = state.adaptiveBuffer - - // 如果是大内容,使用分块处理 - if (buffer?.isLargeContent) { - await this.processLargeContentAsynchronously(eventId, content, currentTime) - return - } - - // 正常内容处理 - await this.processNormalContent(eventId, content, currentTime) - } - - // 异步处理大内容 - 避免阻塞主进程 - private async processLargeContentAsynchronously( - eventId: string, - content: string, - currentTime: number - ): Promise { - const state = this.generatingMessages.get(eventId) - if (!state) return - - const buffer = state.adaptiveBuffer - if (!buffer) return - - // 设置处理状态 - buffer.isProcessing = true - - try { - // 动态分块 - 只处理传入的新增内容 - const chunks = this.splitLargeContent(content) - const totalChunks = chunks.length - - console.log( - `[ThreadPresenter] Processing ${totalChunks} chunks asynchronously for ${content.length} bytes` - ) - - // 初始化或获取内容块 - const lastBlock = state.message.content[state.message.content.length - 1] - let contentBlock: any - - if (lastBlock && lastBlock.type === 'content') { - contentBlock = lastBlock - } else { - this.finalizeLastBlock(state) - contentBlock = { - type: 'content', - content: '', - status: 'loading', - timestamp: currentTime - } - state.message.content.push(contentBlock) - } - - // 批量处理分块,每次处琅5个 - const batchSize = 5 - for (let batchStart = 0; batchStart < chunks.length; batchStart += batchSize) { - const batchEnd = Math.min(batchStart + batchSize, chunks.length) - const batch = chunks.slice(batchStart, batchEnd) - - // 合并当前批次的内容 - const batchContent = batch.join('') - contentBlock.content += batchContent - - // 更新数据库 - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - - // 发送渲染器事件 - const eventData: any = { - eventId, - content: batchContent, - chunkInfo: { - current: batchEnd, - total: totalChunks, - isLargeContent: true, - batchSize: batch.length - } - } - - eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, eventData) - - // 每批次之间的延迟,让出event loop - if (batchEnd < chunks.length) { - await new Promise((resolve) => setImmediate(resolve)) - } - } - - console.log(`[ThreadPresenter] Completed processing ${totalChunks} chunks`) - } catch (error) { - console.error('[ThreadPresenter] Error in processLargeContentAsynchronously:', error) - } finally { - // 清理处理状态 - buffer.isProcessing = false - } - } - - // 处理普通内容 - private async processNormalContent( - eventId: string, - content: string, - currentTime: number - ): Promise { - const state = this.generatingMessages.get(eventId) - if (!state) return - - const lastBlock = state.message.content[state.message.content.length - 1] - - if (lastBlock && lastBlock.type === 'content') { - lastBlock.content += content - } else { - this.finalizeLastBlock(state) - state.message.content.push({ - type: 'content', - content: content, - status: 'loading', - timestamp: currentTime - }) - } - - // 只更新数据库,不额外发送到渲染器(避免重复发送) - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - } - - // 完成最后一个块的状态 - private finalizeLastBlock(state: GeneratingMessageState): void { - const lastBlock = - state.message.content.length > 0 - ? state.message.content[state.message.content.length - 1] - : undefined - - if (lastBlock) { - if ( - lastBlock.type === 'action' && - lastBlock.action_type === 'tool_call_permission' && - lastBlock.status === 'pending' - ) { - lastBlock.status = 'granted' - return - } - if (!(lastBlock.type === 'tool_call' && lastBlock.status === 'loading')) { - lastBlock.status = 'success' - } + await this.conversationLifecycle.broadcastThreadListUpdate() } } - // 统一的数据库和渲染器更新 (当前未使用) - // private async updateMessageAndRenderer(eventId: string, content: string, currentTime: number, chunkInfo?: any): Promise { - // // 方法保留以备将来使用 - // } - async handleLLMAgentResponse(msg: LLMAgentEventData) { const currentTime = Date.now() const { @@ -900,7 +624,11 @@ export class ThreadPresenter implements IThreadPresenter { }) } else if (content) { // 简化的直接内容处理 - await this.processContentDirectly(state.message.id, content, currentTime) + await this.contentBufferManager.processContentDirectly( + state.message.id, + content, + currentTime + ) } // 处理推理内容 @@ -969,255 +697,58 @@ export class ThreadPresenter implements IThreadPresenter { } async renameConversation(conversationId: string, title: string): Promise { - await this.sqlitePresenter.renameConversation(conversationId, title) - await this.broadcastThreadListUpdate() // 必须广播 - - const conversation = await this.getConversation(conversationId) - - // 新增:找到与此 conversationId 关联的 tabId - let tabId: number | undefined - for (const [key, value] of this.activeConversationIds.entries()) { - if (value === conversationId) { - tabId = key - break - } - } - - // 新增:发出事件通知UI更新标题 - if (tabId !== undefined) { - const windowId = presenter.tabPresenter['tabWindowMap'].get(tabId) - eventBus.sendToRenderer(TAB_EVENTS.TITLE_UPDATED, SendTarget.ALL_WINDOWS, { - tabId, - conversationId, - title: conversation.title, - windowId // 附带 windowId - }) - } - - return conversation + return this.conversationLifecycle.renameConversation(conversationId, title) } async createConversation( title: string, settings: Partial = {}, tabId: number, - options: { forceNewAndActivate?: boolean } = {} // 新增参数,允许强制创建新会话 + options: CreateConversationOptions = {} // 新增参数,允许强制创建新会话 ): Promise { console.log('createConversation', title, settings) - - const latestConversation = await this.getLatestConversation() - - // 只有在非强制模式下,才执行空会话的单例检查 - if (!options.forceNewAndActivate) { - if (latestConversation) { - const { list: messages } = await this.getMessages(latestConversation.id, 1, 1) - if (messages.length === 0) { - await this.setActiveConversation(latestConversation.id, tabId) - return latestConversation.id - } - } - } - - let defaultSettings = DEFAULT_SETTINGS - if (latestConversation?.settings) { - defaultSettings = { ...latestConversation.settings } - defaultSettings.systemPrompt = '' - defaultSettings.reasoningEffort = undefined - defaultSettings.enableSearch = undefined - defaultSettings.forcedSearch = undefined - defaultSettings.searchStrategy = undefined - } - Object.keys(settings).forEach((key) => { - if (settings[key] === undefined || settings[key] === null || settings[key] === '') { - delete settings[key] - } - }) - const mergedSettings = { ...defaultSettings, ...settings } - const defaultModelsSettings = this.configPresenter.getModelConfig( - mergedSettings.modelId, - mergedSettings.providerId - ) - if (defaultModelsSettings) { - mergedSettings.maxTokens = defaultModelsSettings.maxTokens - mergedSettings.contextLength = defaultModelsSettings.contextLength - mergedSettings.temperature = defaultModelsSettings.temperature ?? 0.7 - if (settings.thinkingBudget === undefined) { - mergedSettings.thinkingBudget = defaultModelsSettings.thinkingBudget - } - } - if (settings.artifacts) { - mergedSettings.artifacts = settings.artifacts - } - if (settings.maxTokens) { - mergedSettings.maxTokens = settings.maxTokens - } - if (settings.temperature !== undefined && settings.temperature !== null) { - mergedSettings.temperature = settings.temperature - } - if (settings.contextLength) { - mergedSettings.contextLength = settings.contextLength - } - if (settings.systemPrompt) { - mergedSettings.systemPrompt = settings.systemPrompt - } - const conversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) - - // 根据 forceNewAndActivate 标志决定激活行为 - if (options.forceNewAndActivate) { - // 强制模式:直接为当前 tabId 激活新会话,不进行任何检查 - this.activeConversationIds.set(tabId, conversationId) - eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { - conversationId, - tabId - }) - } else { - // 默认模式:保持原有的、防止重复打开的激活逻辑 - await this.setActiveConversation(conversationId, tabId) - } - - await this.broadcastThreadListUpdate() // 必须广播 - return conversationId + return this.conversationLifecycle.createConversation(title, settings, tabId, options) } async deleteConversation(conversationId: string): Promise { - await this.sqlitePresenter.deleteConversation(conversationId) - - // 作为兜底,确保所有与此会话相关的绑定都被移除 - for (const [tabId, activeId] of this.activeConversationIds.entries()) { - if (activeId === conversationId) { - this.activeConversationIds.delete(tabId) - } - } - - await this.broadcastThreadListUpdate() // 必须广播 + await this.conversationLifecycle.deleteConversation(conversationId) } async getConversation(conversationId: string): Promise { - return await this.sqlitePresenter.getConversation(conversationId) + return this.conversationLifecycle.getConversation(conversationId) } async toggleConversationPinned(conversationId: string, pinned: boolean): Promise { - await this.sqlitePresenter.updateConversation(conversationId, { is_pinned: pinned ? 1 : 0 }) - await this.broadcastThreadListUpdate() // 必须广播 + await this.conversationLifecycle.toggleConversationPinned(conversationId, pinned) } async updateConversationTitle(conversationId: string, title: string): Promise { - await this.sqlitePresenter.updateConversation(conversationId, { title }) - await this.broadcastThreadListUpdate() // 必须广播 + await this.conversationLifecycle.updateConversationTitle(conversationId, title) } async updateConversationSettings( conversationId: string, settings: Partial ): Promise { - const conversation = await this.getConversation(conversationId) - const mergedSettings = { ...conversation.settings } - for (const key in settings) { - if (settings[key] !== undefined) { - mergedSettings[key] = settings[key] - } - } - console.log('updateConversationSettings', mergedSettings) - // 检查是否有 modelId 的变化 - if (settings.modelId && settings.modelId !== conversation.settings.modelId) { - // 获取模型配置 - const modelConfig = this.configPresenter.getModelConfig( - mergedSettings.modelId, - mergedSettings.providerId - ) - console.log('check model default config', modelConfig) - if (modelConfig) { - // 如果当前设置小于推荐值,则使用推荐值 - mergedSettings.maxTokens = modelConfig.maxTokens - mergedSettings.contextLength = modelConfig.contextLength - } - } - - await this.sqlitePresenter.updateConversation(conversationId, { settings: mergedSettings }) - await this.broadcastThreadListUpdate() // 必须广播 + await this.conversationLifecycle.updateConversationSettings(conversationId, settings) } async getConversationList( page: number, pageSize: number ): Promise<{ total: number; list: CONVERSATION[] }> { - return await this.sqlitePresenter.getConversationList(page, pageSize) + return this.conversationLifecycle.getConversationList(page, pageSize) } async loadMoreThreads(): Promise<{ hasMore: boolean; total: number }> { - // 获取会话总数 - const total = await this.sqlitePresenter.getConversationCount() - - // 检查是否还有更多会话可以加载 - const hasMore = this.fetchThreadLength < total - - if (hasMore) { - // 增加 fetchThreadLength,每次增加 500 - this.fetchThreadLength = Math.min(this.fetchThreadLength + 300, total) - - // 广播更新的会话列表 - await this.broadcastThreadListUpdate() - } - - return { hasMore: this.fetchThreadLength < total, total } + return this.conversationLifecycle.loadMoreThreads() } async setActiveConversation(conversationId: string, tabId: number): Promise { - // 【核心修正】由主进程负责全部决策(防重和自动切换逻辑) - const existingTabId = await this.findTabForConversation(conversationId) - - // 如果会话已在其他Tab打开,并且不是当前Tab,则切换到那个Tab - if (existingTabId !== null && existingTabId !== tabId) { - console.log( - `Conversation ${conversationId} is already open in tab ${existingTabId}. Switching to it.` - ) - // 命令TabPresenter切换到已存在的Tab - const currentTabType = await this.getTabWindowType(tabId) - const existingTabType = await this.getTabWindowType(existingTabId) - if (currentTabType !== existingTabType) { - this.activeConversationIds.delete(existingTabId) - eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { - tabId: existingTabId - }) - this.activeConversationIds.set(tabId, conversationId) - eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { - conversationId, - tabId - }) - return - } else { - await presenter.tabPresenter.switchTab(existingTabId) - // 注意:这里不应该再为 requesting tab (即 tabId) 设置 activeConversationId - // 也不需要发送ACTIVATED事件,因为tab-session的绑定关系没有改变。 - // switchTab 自身会处理UI的激活。 - return - } - } - - // 如果会话未在其他Tab打开,或者是请求激活当前Tab已绑定的会话,则正常执行绑定 - const conversation = await this.getConversation(conversationId) - if (conversation) { - // 检查当前Tab是否已经绑定了这个会话,避免不必要的事件广播 - if (this.activeConversationIds.get(tabId) === conversationId) { - return // 状态未改变,无需操作 - } - - this.activeConversationIds.set(tabId, conversationId) - // 广播事件,通知所有渲染进程UI更新 - eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { - conversationId, - tabId - }) - } else { - throw new Error(`Conversation ${conversationId} not found`) - } + await this.conversationLifecycle.setActiveConversation(conversationId, tabId) } async getActiveConversation(tabId: number): Promise { - const conversationId = this.activeConversationIds.get(tabId) - if (!conversationId) { - return null - } - return this.getConversation(conversationId) + return this.conversationLifecycle.getActiveConversation(tabId) } async getMessages( @@ -1247,7 +778,7 @@ export class ThreadPresenter implements IThreadPresenter { const newMsg = { ...msg } const msgContent = newMsg.content as UserMessageContent if (msgContent.content) { - ;(newMsg.content as UserMessageContent).text = this.formatUserMessageContent( + ;(newMsg.content as UserMessageContent).text = formatUserMessageContent( msgContent.content ) } @@ -1258,57 +789,6 @@ export class ThreadPresenter implements IThreadPresenter { }) } - private formatUserMessageContent( - msgContentBlock: (UserMessageTextBlock | UserMessageMentionBlock | UserMessageCodeBlock)[] - ) { - return msgContentBlock - .map((block) => { - if (block.type === 'mention') { - if (block.category === 'resources') { - return `@${block.content}` - } else if (block.category === 'tools') { - return `@${block.id}` - } else if (block.category === 'files') { - return `@${block.id}` - } else if (block.category === 'prompts') { - try { - // 尝试解析prompt内容 - const promptData = JSON.parse(block.content) - // 如果包含messages数组,尝试提取其中的文本内容 - if (promptData && Array.isArray(promptData.messages)) { - const messageTexts = promptData.messages - .map((msg) => { - if (typeof msg.content === 'string') { - return msg.content - } else if (msg.content && msg.content.type === 'text') { - return msg.content.text - } else { - // 对于其他类型的内容(如图片等),返回空字符串或特定标记 - return `[${msg.content?.type || 'content'}]` - } - }) - .filter(Boolean) - .join('\n') - return `@${block.id} ${messageTexts || block.content}` - } - } catch (e) { - // 如果解析失败,直接返回原始内容 - console.log('解析prompt内容失败:', e) - } - // 默认返回原内容 - return `@${block.id} ${block.content}` - } - return `@${block.id}` - } else if (block.type === 'text') { - return block.content - } else if (block.type === 'code') { - return `\`\`\`${block.content}\`\`\`` - } - return '' - }) - .join('') - } - async clearContext(conversationId: string): Promise { await this.sqlitePresenter.runTransaction(async () => { const conversation = await this.getConversation(conversationId) @@ -1565,7 +1045,7 @@ export class ThreadPresenter implements IThreadPresenter { provider: engineName } } - this.finalizeLastBlock(state) + this.contentBufferManager.finalizeLastBlock(state) state.message.content.push(searchBlock) await this.messageManager.editMessage(messageId, JSON.stringify(state.message.content)) // 标记消息为搜索状态 @@ -1581,7 +1061,8 @@ export class ThreadPresenter implements IThreadPresenter { .map((msg) => { if (msg.role === 'user') { const content = msg.content as UserMessageContent - return `user: ${content.text}${getFileContext(content.files)}` + const userContext = buildUserMessageContext(content) + return `user: ${userContext}` } else if (msg.role === 'assistant') { let finalContent = 'assistant: ' const content = msg.content as AssistantMessageBlock[] @@ -1808,17 +1289,18 @@ export class ThreadPresenter implements IThreadPresenter { this.throwIfCancelled(state.message.id) // 4. 准备提示内容 - const { finalContent, promptTokens } = await this.preparePromptContent( + const { finalContent, promptTokens } = await preparePromptContent({ conversation, userContent, contextMessages, searchResults, urlResults, userMessage, - vision, - vision ? imageFiles : [], - modelConfig.functionCall - ) + vision: Boolean(vision), + imageFiles: vision ? imageFiles : [], + supportsFunctionCall: modelConfig.functionCall, + modelType: modelConfig.type + }) // 检查是否已被取消 this.throwIfCancelled(state.message.id) @@ -1976,17 +1458,18 @@ export class ThreadPresenter implements IThreadPresenter { } = conversation.settings const modelConfig = this.configPresenter.getModelConfig(modelId, providerId) - const { finalContent, promptTokens } = await this.preparePromptContent( + const { finalContent, promptTokens } = await preparePromptContent({ conversation, - 'continue', + userContent: 'continue', contextMessages, - null, // 不进行搜索 - [], // 没有 URL 结果 + searchResults: null, // 不进行搜索 + urlResults: [], // 没有 URL 结果 userMessage, - false, - [], // 没有图片文件 - modelConfig.functionCall - ) + vision: false, + imageFiles: [], // 没有图片文件 + supportsFunctionCall: modelConfig.functionCall, + modelType: modelConfig.type + }) // 8. 更新生成状态 await this.updateGenerationState(state, promptTokens) @@ -2162,7 +1645,7 @@ export class ThreadPresenter implements IThreadPresenter { if (userMessage.role === 'user') { const msgContent = userMessage.content as UserMessageContent if (msgContent.content && !msgContent.text) { - msgContent.text = this.formatUserMessageContent(msgContent.content) + msgContent.text = formatUserMessageContent(msgContent.content) } } @@ -2181,17 +1664,11 @@ export class ThreadPresenter implements IThreadPresenter { imageFiles: MessageFile[] // 图片文件列表 }> { // 处理文本内容 - const userContent = ` - ${ - userMessage.content.content - ? this.formatUserMessageContent(userMessage.content.content) - : userMessage.content.text - } - ${getFileContext(userMessage.content.files)} - ` + const userContent = buildUserMessageContext(userMessage.content) // 从用户消息中提取并丰富URL内容 - const urlResults = await ContentEnricher.extractAndEnrichUrls(userMessage.content.text) + const normalizedText = getNormalizedUserMessageText(userMessage.content) + const urlResults = await ContentEnricher.extractAndEnrichUrls(normalizedText) // 提取图片文件 @@ -2207,535 +1684,45 @@ export class ThreadPresenter implements IThreadPresenter { return { userContent, urlResults, imageFiles } } - // 准备提示内容 - private async preparePromptContent( - conversation: CONVERSATION, - userContent: string, - contextMessages: Message[], - searchResults: SearchResult[] | null, - urlResults: SearchResult[], - userMessage: Message, - vision: boolean, - imageFiles: MessageFile[], - supportsFunctionCall: boolean, - modelType?: ModelType - ): Promise<{ - finalContent: ChatMessage[] + // 更新生成状态 + private async updateGenerationState( + state: GeneratingMessageState, promptTokens: number - }> { - const { systemPrompt, contextLength, artifacts, enabledMcpTools } = conversation.settings - - // 判断是否为图片生成模型 - const isImageGeneration = modelType === ModelType.ImageGeneration - - // 图片生成模型不使用搜索、系统提示词和MCP工具 - const searchPrompt = - !isImageGeneration && searchResults ? generateSearchPrompt(userContent, searchResults) : '' - const enrichedUserMessage = - !isImageGeneration && urlResults.length > 0 - ? '\n\n' + ContentEnricher.enrichUserMessageWithUrlContent(userContent, urlResults) - : '' - - // 处理系统提示词,添加当前时间信息 - const finalSystemPrompt = this.enhanceSystemPromptWithDateTime(systemPrompt, isImageGeneration) - - // 计算token数量(使用处理后的系统提示词) - const searchPromptTokens = searchPrompt ? approximateTokenSize(searchPrompt ?? '') : 0 - const systemPromptTokens = - !isImageGeneration && finalSystemPrompt ? approximateTokenSize(finalSystemPrompt ?? '') : 0 - const userMessageTokens = approximateTokenSize(userContent + enrichedUserMessage) - // 图片生成模型不使用MCP工具 - const mcpTools = !isImageGeneration - ? await presenter.mcpPresenter.getAllToolDefinitions(enabledMcpTools) - : [] - const mcpToolsTokens = mcpTools.reduce( - (acc, tool) => acc + approximateTokenSize(JSON.stringify(tool)), - 0 - ) - // 计算剩余可用的上下文长度 - const reservedTokens = - searchPromptTokens + systemPromptTokens + userMessageTokens + mcpToolsTokens - const remainingContextLength = contextLength - reservedTokens - - // 选择合适的上下文消息 - const selectedContextMessages = this.selectContextMessages( - contextMessages, - userMessage, - remainingContextLength - ) - - // 格式化消息 - const formattedMessages = this.formatMessagesForCompletion( - selectedContextMessages, - isImageGeneration ? '' : finalSystemPrompt, // 图片生成模型不使用系统提示词 - artifacts, - searchPrompt, - userContent, - enrichedUserMessage, - imageFiles, - vision, - supportsFunctionCall - ) + ): Promise { + // 更新生成状态 + this.generatingMessages.set(state.message.id, { + ...state, + startTime: Date.now(), + firstTokenTime: null, + promptTokens + }) - // 合并连续的相同角色消息 - const mergedMessages = this.mergeConsecutiveMessages(formattedMessages) + // 更新消息的usage信息 + await this.messageManager.updateMessageMetadata(state.message.id, { + totalTokens: promptTokens, + generationTime: 0, + firstTokenTime: 0, + tokensPerSecond: 0 + }) + } - // 计算prompt tokens - let promptTokens = 0 - for (const msg of mergedMessages) { - if (typeof msg.content === 'string') { - promptTokens += approximateTokenSize(msg.content) - } else { - promptTokens += - approximateTokenSize(msg.content?.map((item) => item.text).join('') || '') + - imageFiles.reduce((acc, file) => acc + file.token, 0) - } - } - // console.log('preparePromptContent', mergedMessages, promptTokens) + async editMessage(messageId: string, content: string): Promise { + return await this.messageManager.editMessage(messageId, content) + } - return { finalContent: mergedMessages, promptTokens } + async deleteMessage(messageId: string): Promise { + await this.messageManager.deleteMessage(messageId) } - // 选择上下文消息 - private selectContextMessages( - contextMessages: Message[], - userMessage: Message, - remainingContextLength: number - ): Message[] { - if (remainingContextLength <= 0) { - return [] + async retryMessage(messageId: string): Promise { + const message = await this.messageManager.getMessage(messageId) + if (message.role !== 'assistant') { + throw new Error('只能重试助手消息') } - const messages = contextMessages.filter((msg) => msg.id !== userMessage?.id).reverse() - - let currentLength = 0 - const selectedMessages: Message[] = [] - - for (const msg of messages) { - if (msg.status !== 'sent') { - continue - } - const msgContent = msg.role === 'user' ? (msg.content as UserMessageContent) : null - const msgText = msgContent - ? msgContent.text || - (msgContent.content ? this.formatUserMessageContent(msgContent.content) : '') - : '' - - const msgTokens = approximateTokenSize( - msg.role === 'user' - ? `${msgText}${getFileContext(msgContent?.files || [])}` - : JSON.stringify(msg.content) - ) - - if (currentLength + msgTokens <= remainingContextLength) { - // 如果是用户消息且有 content 但没有 text,添加 text - if (msg.role === 'user') { - const userMsgContent = msg.content as UserMessageContent - if (userMsgContent.content && !userMsgContent.text) { - userMsgContent.text = this.formatUserMessageContent(userMsgContent.content) - } - } - - selectedMessages.unshift(msg) - currentLength += msgTokens - } else { - break - } - } - while (selectedMessages.length > 0 && selectedMessages[0].role !== 'user') { - selectedMessages.shift() - } - return selectedMessages - } - - // 格式化消息用于完成 - private formatMessagesForCompletion( - contextMessages: Message[], - systemPrompt: string, - artifacts: number, - searchPrompt: string, - userContent: string, - enrichedUserMessage: string, - imageFiles: MessageFile[], - vision: boolean, - supportsFunctionCall: boolean - ): ChatMessage[] { - const formattedMessages: ChatMessage[] = [] - - // 添加上下文消息 - formattedMessages.push( - ...this.addContextMessages(contextMessages, vision, supportsFunctionCall) - ) - - // 添加系统提示 - if (systemPrompt) { - // formattedMessages.push(...this.addSystemPrompt(formattedMessages, systemPrompt, artifacts)) - formattedMessages.unshift({ - role: 'system', - content: systemPrompt - }) - // console.log('-------------> system prompt \n', systemPrompt, artifacts, formattedMessages) - } - - // 添加当前用户消息 - let finalContent = searchPrompt || userContent - - if (enrichedUserMessage) { - finalContent += enrichedUserMessage - } - - if (artifacts === 1) { - // formattedMessages.push({ - // role: 'user', - // content: ARTIFACTS_PROMPT - // }) - console.log('artifacts目前由mcp提供,此处为兼容性保留') - } - // 没有 vision 就不用塞进去了 - if (vision && imageFiles.length > 0) { - formattedMessages.push(this.addImageFiles(finalContent, imageFiles)) - } else { - formattedMessages.push({ - role: 'user', - content: finalContent.trim() - }) - } - - return formattedMessages - } - - private addImageFiles(finalContent: string, imageFiles: MessageFile[]): ChatMessage { - return { - role: 'user', - content: [ - ...imageFiles.map((file) => ({ - type: 'image_url' as const, - image_url: { url: file.content, detail: 'auto' as const } - })), - { type: 'text' as const, text: finalContent.trim() } - ] - } - } - - // 添加上下文消息 - private addContextMessages( - contextMessages: Message[], - vision: boolean, - supportsFunctionCall: boolean - ): ChatMessage[] { - const resultMessages = [] as ChatMessage[] - - // 对于原生fc模型,支持正确的tool_call response history插入 - if (supportsFunctionCall) { - contextMessages.forEach((msg) => { - if (msg.role === 'user') { - // 处理用户消息 - const msgContent = msg.content as UserMessageContent - const msgText = msgContent.content - ? this.formatUserMessageContent(msgContent.content) - : msgContent.text - const userContent = `${msgText}${getFileContext(msgContent.files)}` - resultMessages.push({ - role: 'user', - content: userContent - }) - } else if (msg.role === 'assistant') { - // 处理助手消息 - let afterSearch = false - const assistantBlocks = msg.content as AssistantMessageBlock[] - for (const subMsg of assistantBlocks) { - if ( - subMsg.type === 'tool_call' && - subMsg?.tool_call?.id?.trim() && - subMsg?.tool_call?.name?.trim() && - subMsg?.tool_call?.params?.trim() && - subMsg?.tool_call?.response?.trim() - ) { - resultMessages.push({ - role: 'assistant', - tool_calls: [ - { - id: subMsg.tool_call.id, - type: 'function', - function: { - name: subMsg.tool_call.name, - arguments: subMsg.tool_call.params - } - } - ] - }) - resultMessages.push({ - role: 'tool', - tool_call_id: subMsg.tool_call.id, - content: subMsg.tool_call.response - }) - } else if (subMsg.type === 'search') { - // 删除强制搜索结果中遗留的[x]引文标记 - afterSearch = true - } else if (subMsg.type === 'content') { - // 删除强制搜索结果中遗留的[x]引文标记 - let content = subMsg.content ?? '' - if (afterSearch) content = content.replace(/\[\d+\]/g, '') - resultMessages.push({ - role: 'assistant', - content: content - }) - afterSearch = false - } - } - } - }) - return resultMessages - } else { - // 对于非原生fc模型,支持规范化prompt实现 - contextMessages.forEach((msg) => { - if (msg.role === 'user') { - // 处理用户消息 - const msgContent = msg.content as UserMessageContent - const msgText = msgContent.content - ? this.formatUserMessageContent(msgContent.content) - : msgContent.text - const userContent = `${msgText}${getFileContext(msgContent.files)}` - resultMessages.push({ - role: 'user', - content: userContent - }) - } else if (msg.role === 'assistant') { - // 处理助手消息 - const assistantBlocks = msg.content as AssistantMessageBlock[] - // 提取文本内容块,同时将工具调用的响应内容提取出来 - let afterSearch = false - const textContent = assistantBlocks - .filter( - (block) => - block.type === 'content' || block.type === 'search' || block.type === 'tool_call' - ) - .map((block) => { - if (block.type === 'search') { - // 删除强制搜索结果中遗留的[x]引文标记 - afterSearch = true - return '' - } else if (block.type === 'content') { - // 删除强制搜索结果中遗留的[x]引文标记 - let content = block.content ?? '' - if (afterSearch) content = content.replace(/\[\d+\]/g, '') - afterSearch = false - return content - } else if ( - block.type === 'tool_call' && - block.tool_call?.response && - block.tool_call?.params - ) { - let parsedParams - let parsedResponse - - try { - parsedParams = JSON.parse(block.tool_call.params) - } catch { - parsedParams = block.tool_call.params // 保留原字符串 - } - - try { - parsedResponse = JSON.parse(block.tool_call.response) - } catch { - parsedResponse = block.tool_call.response // 保留原字符串 - } - - return ( - '' + - JSON.stringify({ - function_call_record: { - name: block.tool_call.name, - arguments: parsedParams, - response: parsedResponse - } - }) + - '' - ) - } else { - return '' // 若 tool_call 或 response、params 是 undefined 返回。只是便于调试而已,可以为空。 - } - }) - .join('\n') - - // 查找图像块 - const imageBlocks = assistantBlocks.filter( - (block) => block.type === 'image' && block.image_data - ) - - // 如果没有任何内容,则跳过此消息 - if (!textContent && imageBlocks.length === 0) { - return - } - - // 如果有图像,则使用复合内容格式 - if (vision && imageBlocks.length > 0) { - const content: ChatMessageContent[] = [] - - // 添加图像内容 - imageBlocks.forEach((block) => { - if (block.image_data) { - content.push({ - type: 'image_url', - image_url: { - url: block.image_data.data, - detail: 'auto' - } - }) - } - }) - - // 添加文本内容 - if (textContent) { - content.push({ - type: 'text', - text: textContent - }) - } - - resultMessages.push({ - role: 'assistant', - content: content - }) - } else { - // 仅有文本内容 - resultMessages.push({ - role: 'assistant', - content: textContent - }) - } - } - }) - - return resultMessages - } - } - - // 合并连续的相同角色的content,但注意assistant下content不能跟tool_calls合并 - private mergeConsecutiveMessages(messages: ChatMessage[]): ChatMessage[] { - if (!messages || messages.length === 0) { - return [] - } - - const mergedResult: ChatMessage[] = [] - // 为第一条消息创建一个深拷贝并添加到结果数组 - mergedResult.push(JSON.parse(JSON.stringify(messages[0]))) - - for (let i = 1; i < messages.length; i++) { - // 为当前消息创建一个深拷贝 - const currentMessage = JSON.parse(JSON.stringify(messages[i])) as ChatMessage - const lastPushedMessage = mergedResult[mergedResult.length - 1] - - let allowMessagePropertiesMerge = false // 标志是否允许消息属性(如content)合并 - - // 步骤 1: 判断消息本身是否允许合并(基于role和tool_calls) - if (lastPushedMessage.role === currentMessage.role) { - if (currentMessage.role === 'assistant') { - // Assistant消息: 仅当两条消息都【不】包含tool_calls时,才允许合并 - if (!lastPushedMessage.tool_calls && !currentMessage.tool_calls) { - allowMessagePropertiesMerge = true - } - } else { - // 其他角色 (user, system): 如果role相同,则允许合并 - allowMessagePropertiesMerge = true - } - } - - if (allowMessagePropertiesMerge) { - // 步骤 2: 如果消息允许合并,尝试合并其 content 字段 - const LMC = lastPushedMessage.content // 上一条已推送消息的内容 - const CMC = currentMessage.content // 当前待处理消息的内容 - - let newCombinedContent: string | ChatMessageContent[] | undefined = undefined - let contentTypesCompatibleForMerging = false - - if (LMC === undefined && CMC === undefined) { - newCombinedContent = undefined - contentTypesCompatibleForMerging = true - } else if (typeof LMC === 'string' && (typeof CMC === 'string' || CMC === undefined)) { - // LMC是string, CMC是string或undefined - const sLMC = LMC || '' - const sCMC = CMC || '' - if (sLMC && sCMC) newCombinedContent = `${sLMC}\n${sCMC}` - else newCombinedContent = sLMC || sCMC // 保留有内容的一方 - if (newCombinedContent === '') newCombinedContent = undefined // 空字符串视为undefined - contentTypesCompatibleForMerging = true - } else if (Array.isArray(LMC) && (Array.isArray(CMC) || CMC === undefined)) { - // LMC是数组, CMC是数组或undefined - const arrLMC = LMC - const arrCMC = CMC || [] // 如果CMC是undefined, 视为空数组进行合并 - newCombinedContent = [...arrLMC, ...arrCMC] - if (newCombinedContent.length === 0) newCombinedContent = undefined // 空数组视为undefined - contentTypesCompatibleForMerging = true - } else if (LMC === undefined && CMC !== undefined) { - // LMC是undefined, CMC有值 (string或array) - newCombinedContent = CMC - contentTypesCompatibleForMerging = true - } else if (LMC !== undefined && CMC === undefined) { - // LMC有值, CMC是undefined -> content保持LMC的值,无需改变 - newCombinedContent = LMC - contentTypesCompatibleForMerging = true // 视为成功合并(当前消息内容被"吸收") - } - // 如果LMC和CMC的类型不兼容 (例如一个是string, 另一个是array), - // contentTypesCompatibleForMerging 将保持 false - - if (contentTypesCompatibleForMerging) { - lastPushedMessage.content = newCombinedContent - // currentMessage 被成功合并,不需单独push - } else { - // 角色和tool_calls条件允许合并,但内容类型不兼容 - // 因此,不合并消息,将 currentMessage 作为新消息加入 - mergedResult.push(currentMessage) - } - } else { - // 角色不同,或者 assistant 消息因 tool_calls 而不允许合并 - // 将 currentMessage 作为新消息加入 - mergedResult.push(currentMessage) - } - } - - return mergedResult - } - - // 更新生成状态 - private async updateGenerationState( - state: GeneratingMessageState, - promptTokens: number - ): Promise { - // 更新生成状态 - this.generatingMessages.set(state.message.id, { - ...state, - startTime: Date.now(), - firstTokenTime: null, - promptTokens - }) - - // 更新消息的usage信息 - await this.messageManager.updateMessageMetadata(state.message.id, { - totalTokens: promptTokens, - generationTime: 0, - firstTokenTime: 0, - tokensPerSecond: 0 - }) - } - - async editMessage(messageId: string, content: string): Promise { - return await this.messageManager.editMessage(messageId, content) - } - - async deleteMessage(messageId: string): Promise { - await this.messageManager.deleteMessage(messageId) - } - - async retryMessage(messageId: string): Promise { - const message = await this.messageManager.getMessage(messageId) - if (message.role !== 'assistant') { - throw new Error('只能重试助手消息') - } - - const userMessage = await this.messageManager.getMessage(message.parentId || '') - if (!userMessage) { - throw new Error('找不到对应的用户消息') + const userMessage = await this.messageManager.getMessage(message.parentId || '') + if (!userMessage) { + throw new Error('找不到对应的用户消息') } const conversation = await this.getConversation(message.conversationId) const { providerId, modelId } = conversation.settings @@ -2836,12 +1823,7 @@ export class ThreadPresenter implements IThreadPresenter { } async getActiveConversationId(tabId: number): Promise { - return this.activeConversationIds.get(tabId) || null - } - - private async getLatestConversation(): Promise { - const result = await this.getConversationList(1, 1) - return result.list[0] || null + return this.conversationLifecycle.getActiveConversationId(tabId) } getGeneratingMessageState(messageId: string): GeneratingMessageState | null { @@ -2862,11 +1844,11 @@ export class ThreadPresenter implements IThreadPresenter { // 刷新剩余缓冲内容 if (state.adaptiveBuffer) { - await this.flushAdaptiveBuffer(messageId) + await this.contentBufferManager.flushAdaptiveBuffer(messageId) } // 清理缓冲相关资源 - this.cleanupContentBuffer(state) + this.contentBufferManager.cleanupContentBuffer(state) // 标记消息不再处于搜索状态 if (state.isSearching) { @@ -2914,8 +1896,9 @@ export class ThreadPresenter implements IThreadPresenter { } async summaryTitles(tabId?: number, conversationId?: string): Promise { - const targetConversationId = - conversationId ?? (tabId !== undefined ? this.activeConversationIds.get(tabId) : undefined) + const activeId = + tabId !== undefined ? this.conversationLifecycle.getActiveConversationId(tabId) : null + const targetConversationId = conversationId ?? activeId ?? undefined if (!targetConversationId) { throw new Error('找不到当前对话') } @@ -2948,16 +1931,14 @@ export class ThreadPresenter implements IThreadPresenter { const messagesWithLength = variantAwareMessages .map((msg) => { if (msg.role === 'user') { + const userContent = msg.content as UserMessageContent + const serializedContent = buildUserMessageContext(userContent) return { message: msg, - length: `${(msg.content as UserMessageContent).text}${getFileContext( - (msg.content as UserMessageContent).files - )}`.length, + length: serializedContent.length, formattedMessage: { role: 'user' as const, - content: `${(msg.content as UserMessageContent).text}${getFileContext( - (msg.content as UserMessageContent).files - )}` + content: serializedContent } } } else { @@ -2989,18 +1970,15 @@ export class ThreadPresenter implements IThreadPresenter { } async clearActiveThread(tabId: number): Promise { - this.activeConversationIds.delete(tabId) - eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { tabId }) + this.conversationLifecycle.clearActiveConversation(tabId, { notify: true }) } async clearAllMessages(conversationId: string): Promise { await this.messageManager.clearAllMessages(conversationId) // 检查所有 tab 中的活跃会话 - for (const [, activeId] of this.activeConversationIds.entries()) { - if (activeId === conversationId) { - // 停止所有正在生成的消息 - await this.stopConversationGeneration(conversationId) - } + const tabs = this.conversationLifecycle.getTabsByConversation(conversationId) + if (tabs.length > 0) { + await this.stopConversationGeneration(conversationId) } } @@ -3166,7 +2144,7 @@ export class ThreadPresenter implements IThreadPresenter { } // 7. 在所有数据库操作完成后,调用广播方法 - await this.broadcastThreadListUpdate() + await this.conversationLifecycle.broadcastThreadListUpdate() // 8. 触发会话创建事件 return newConversationId @@ -3299,58 +2277,6 @@ export class ThreadPresenter implements IThreadPresenter { } } - private async broadcastThreadListUpdate(): Promise { - // 1. 获取所有会话 (假设9999足够大) - const result = await this.sqlitePresenter.getConversationList(1, this.fetchThreadLength) - - // 2. 分离置顶和非置顶会话 - const pinnedConversations: CONVERSATION[] = [] - const normalConversations: CONVERSATION[] = [] - - result.list.forEach((conv) => { - if (conv.is_pinned === 1) { - pinnedConversations.push(conv) - } else { - normalConversations.push(conv) - } - }) - - // 3. 对置顶会话按更新时间排序 - pinnedConversations.sort((a, b) => b.updatedAt - a.updatedAt) - - // 4. 对普通会话按更新时间排序 - normalConversations.sort((a, b) => b.updatedAt - a.updatedAt) - - // 5. 按日期分组 - const groupedThreads: Map = new Map() - - // 先添加置顶分组(如果有置顶会话) - if (pinnedConversations.length > 0) { - groupedThreads.set('Pinned', pinnedConversations) - } - - // 再添加普通会话的日期分组 - normalConversations.forEach((conv) => { - const date = new Date(conv.updatedAt).toISOString().split('T')[0] - if (!groupedThreads.has(date)) { - groupedThreads.set(date, []) - } - groupedThreads.get(date)!.push(conv) - }) - - const finalGroupedList = Array.from(groupedThreads.entries()).map(([dt, dtThreads]) => ({ - dt, - dtThreads - })) - - // 6. 广播这个格式化好的完整列表 - eventBus.sendToRenderer( - CONVERSATION_EVENTS.LIST_UPDATED, - SendTarget.ALL_WINDOWS, - finalGroupedList - ) - } - /** * 导出会话内容 * @param conversationId 会话ID @@ -3359,7 +2285,7 @@ export class ThreadPresenter implements IThreadPresenter { */ async exportConversation( conversationId: string, - format: 'markdown' | 'html' | 'txt' = 'markdown' + format: ConversationExportFormat = 'markdown' ): Promise<{ filename: string content: string @@ -3397,30 +2323,9 @@ export class ThreadPresenter implements IThreadPresenter { return msg }) - // 生成文件名 - 使用简化的时间戳格式 - const timestamp = new Date() - .toISOString() - .replace(/[:.]/g, '-') - .replace('T', '_') - .substring(0, 19) - const extension = format === 'markdown' ? 'md' : format - const filename = `export_deepchat_${timestamp}.${extension}` - - // 生成内容(在主进程中直接处理,避免Worker的复杂性) - let content: string - switch (format) { - case 'markdown': - content = this.exportToMarkdown(conversation, variantAwareMessages) - break - case 'html': - content = this.exportToHtml(conversation, variantAwareMessages) - break - case 'txt': - content = this.exportToText(conversation, variantAwareMessages) - break - default: - throw new Error(`不支持的导出格式: ${format}`) - } + // 生成文件名 + const filename = generateExportFilename(format) + const content = buildConversationExportContent(conversation, variantAwareMessages, format) return { filename, content } } catch (error) { @@ -3429,531 +2334,6 @@ export class ThreadPresenter implements IThreadPresenter { } } - /** - * 导出为 Markdown 格式 - */ - private exportToMarkdown(conversation: CONVERSATION, messages: Message[]): string { - const lines: string[] = [] - - // 标题和元信息 - lines.push(`# ${conversation.title}`) - lines.push('') - lines.push(`**Export Time:** ${new Date().toLocaleString()}`) - lines.push(`**Conversation ID:** ${conversation.id}`) - lines.push(`**Message Count:** ${messages.length}`) - if (conversation.settings.modelId) { - lines.push(`**Model:** ${conversation.settings.modelId}`) - } - if (conversation.settings.providerId) { - lines.push(`**Provider:** ${conversation.settings.providerId}`) - } - lines.push('') - lines.push('---') - lines.push('') - - // 处理每条消息 - for (const message of messages) { - const messageTime = new Date(message.timestamp).toLocaleString() - - if (message.role === 'user') { - lines.push(`## 👤 用户 (${messageTime})`) - lines.push('') - - const userContent = message.content as UserMessageContent - const messageText = userContent.content - ? this.formatUserMessageContent(userContent.content) - : userContent.text - - lines.push(messageText) - - // 处理文件附件 - if (userContent.files && userContent.files.length > 0) { - lines.push('') - lines.push('**附件:**') - for (const file of userContent.files) { - lines.push(`- ${file.name} (${file.mimeType})`) - } - } - - // 处理链接 - if (userContent.links && userContent.links.length > 0) { - lines.push('') - lines.push('**链接:**') - for (const link of userContent.links) { - lines.push(`- ${link}`) - } - } - } else if (message.role === 'assistant') { - lines.push(`## 🤖 助手 (${messageTime})`) - lines.push('') - - const assistantBlocks = message.content as AssistantMessageBlock[] - - for (const block of assistantBlocks) { - switch (block.type) { - case 'content': - if (block.content) { - lines.push(block.content) - lines.push('') - } - break - - case 'reasoning_content': - if (block.content) { - lines.push('### 🤔 思考过程') - lines.push('') - lines.push('```') - lines.push(block.content) - lines.push('```') - lines.push('') - } - break - - case 'tool_call': - if (block.tool_call) { - lines.push(`### 🔧 工具调用: ${block.tool_call.name}`) - lines.push('') - if (block.tool_call.params) { - lines.push('**参数:**') - lines.push('```json') - try { - const params = JSON.parse(block.tool_call.params) - lines.push(JSON.stringify(params, null, 2)) - } catch { - lines.push(block.tool_call.params) - } - lines.push('```') - lines.push('') - } - if (block.tool_call.response) { - lines.push('**响应:**') - lines.push('```') - lines.push(block.tool_call.response) - lines.push('```') - lines.push('') - } - } - break - - case 'search': - lines.push('### 🔍 网络搜索') - if (block.extra?.total) { - lines.push(`找到 ${block.extra.total} 个搜索结果`) - } - lines.push('') - break - - case 'image': - lines.push('### 🖼️ 图片') - lines.push('*[图片内容]*') - lines.push('') - break - - case 'error': - if (block.content) { - lines.push(`### ❌ 错误`) - lines.push('') - lines.push(`\`${block.content}\``) - lines.push('') - } - break - - case 'artifact-thinking': - if (block.content) { - lines.push('### 💭 创作思考') - lines.push('') - lines.push('```') - lines.push(block.content) - lines.push('```') - lines.push('') - } - break - } - } - } - - lines.push('---') - lines.push('') - } - - return lines.join('\n') - } - - /** - * 导出为 HTML 格式 - */ - private exportToHtml(conversation: CONVERSATION, messages: Message[]): string { - const lines: string[] = [] - - // HTML 头部 - lines.push('') - lines.push('') - lines.push('') - lines.push(' ') - lines.push(' ') - lines.push(` ${this.escapeHtml(conversation.title)}`) - lines.push(' ') - lines.push('') - lines.push('') - - // 标题和元信息 - lines.push('
') - lines.push(`

${this.escapeHtml(conversation.title)}

`) - lines.push(`

导出时间: ${new Date().toLocaleString()}

`) - lines.push(`

会话ID: ${conversation.id}

`) - lines.push(`

消息数量: ${messages.length}

`) - if (conversation.settings.modelId) { - lines.push( - `

模型: ${this.escapeHtml(conversation.settings.modelId)}

` - ) - } - if (conversation.settings.providerId) { - lines.push( - `

提供商: ${this.escapeHtml(conversation.settings.providerId)}

` - ) - } - lines.push('
') - - // 处理每条消息 - for (const message of messages) { - const messageTime = new Date(message.timestamp).toLocaleString() - - if (message.role === 'user') { - lines.push(`
`) - lines.push( - `
👤 用户 (${messageTime})
` - ) - - const userContent = message.content as UserMessageContent - const messageText = userContent.content - ? this.formatUserMessageContent(userContent.content) - : userContent.text - - lines.push(`
${this.escapeHtml(messageText).replace(/\n/g, '
')}
`) - - // 处理文件附件 - if (userContent.files && userContent.files.length > 0) { - lines.push('
') - lines.push(' 附件:') - lines.push('
    ') - for (const file of userContent.files) { - lines.push( - `
  • ${this.escapeHtml(file.name)} (${this.escapeHtml(file.mimeType)})
  • ` - ) - } - lines.push('
') - lines.push('
') - } - - // 处理链接 - if (userContent.links && userContent.links.length > 0) { - lines.push('
') - lines.push(' 链接:') - lines.push(' ') - lines.push('
') - } - - lines.push('
') - } else if (message.role === 'assistant') { - lines.push(`
`) - lines.push( - `
🤖 助手 (${messageTime})
` - ) - - const assistantBlocks = message.content as AssistantMessageBlock[] - - for (const block of assistantBlocks) { - switch (block.type) { - case 'content': - if (block.content) { - lines.push( - `
${this.escapeHtml(block.content).replace(/\n/g, '
')}
` - ) - } - break - - case 'reasoning_content': - if (block.content) { - lines.push('
') - lines.push(' 🤔 思考过程:') - lines.push(`
${this.escapeHtml(block.content)}
`) - lines.push('
') - } - break - - case 'tool_call': - if (block.tool_call) { - lines.push('
') - lines.push( - ` 🔧 工具调用: ${this.escapeHtml(block.tool_call.name || '')}` - ) - if (block.tool_call.params) { - lines.push('
参数:
') - lines.push( - `
${this.escapeHtml(block.tool_call.params)}
` - ) - } - if (block.tool_call.response) { - lines.push('
响应:
') - lines.push( - `
${this.escapeHtml(block.tool_call.response)}
` - ) - } - lines.push('
') - } - break - - case 'search': - lines.push('
') - lines.push(' 🔍 网络搜索') - if (block.extra?.total) { - lines.push(`

找到 ${block.extra.total} 个搜索结果

`) - } - lines.push('
') - break - - case 'image': - lines.push('
') - lines.push(' 🖼️ 图片') - lines.push('

[图片内容]

') - lines.push('
') - break - - case 'error': - if (block.content) { - lines.push('
') - lines.push(' ❌ 错误') - lines.push(`

${this.escapeHtml(block.content)}

`) - lines.push('
') - } - break - - case 'artifact-thinking': - if (block.content) { - lines.push('
') - lines.push(' 💭 创作思考:') - lines.push(`
${this.escapeHtml(block.content)}
`) - lines.push('
') - } - break - } - } - - lines.push('
') - } - } - - // HTML 尾部 - lines.push('') - lines.push('') - - return lines.join('\n') - } - - /** - * 导出为纯文本格式 - */ - private exportToText(conversation: CONVERSATION, messages: Message[]): string { - const lines: string[] = [] - - // 标题和元信息 - lines.push(`${conversation.title}`) - lines.push(''.padEnd(conversation.title.length, '=')) - lines.push('') - lines.push(`导出时间: ${new Date().toLocaleString()}`) - lines.push(`会话ID: ${conversation.id}`) - lines.push(`消息数量: ${messages.length}`) - if (conversation.settings.modelId) { - lines.push(`模型: ${conversation.settings.modelId}`) - } - if (conversation.settings.providerId) { - lines.push(`提供商: ${conversation.settings.providerId}`) - } - lines.push('') - lines.push(''.padEnd(80, '-')) - lines.push('') - - // 处理每条消息 - for (const message of messages) { - const messageTime = new Date(message.timestamp).toLocaleString() - - if (message.role === 'user') { - lines.push(`[用户] ${messageTime}`) - lines.push('') - - const userContent = message.content as UserMessageContent - const messageText = userContent.content - ? this.formatUserMessageContent(userContent.content) - : userContent.text - - lines.push(messageText) - - // 处理文件附件 - if (userContent.files && userContent.files.length > 0) { - lines.push('') - lines.push('附件:') - for (const file of userContent.files) { - lines.push(`- ${file.name} (${file.mimeType})`) - } - } - - // 处理链接 - if (userContent.links && userContent.links.length > 0) { - lines.push('') - lines.push('链接:') - for (const link of userContent.links) { - lines.push(`- ${link}`) - } - } - } else if (message.role === 'assistant') { - lines.push(`[助手] ${messageTime}`) - lines.push('') - - const assistantBlocks = message.content as AssistantMessageBlock[] - - for (const block of assistantBlocks) { - switch (block.type) { - case 'content': - if (block.content) { - lines.push(block.content) - lines.push('') - } - break - - case 'reasoning_content': - if (block.content) { - lines.push('[思考过程]') - lines.push(block.content) - lines.push('') - } - break - - case 'tool_call': - if (block.tool_call) { - lines.push(`[工具调用] ${block.tool_call.name}`) - if (block.tool_call.params) { - lines.push('参数:') - lines.push(block.tool_call.params) - } - if (block.tool_call.response) { - lines.push('响应:') - lines.push(block.tool_call.response) - } - lines.push('') - } - break - - case 'search': - lines.push('[网络搜索]') - if (block.extra?.total) { - lines.push(`找到 ${block.extra.total} 个搜索结果`) - } - lines.push('') - break - - case 'image': - lines.push('[图片内容]') - lines.push('') - break - - case 'error': - if (block.content) { - lines.push(`[错误] ${block.content}`) - lines.push('') - } - break - - case 'artifact-thinking': - if (block.content) { - lines.push('[创作思考]') - lines.push(block.content) - lines.push('') - } - break - } - } - } - - lines.push(''.padEnd(80, '-')) - lines.push('') - } - - return lines.join('\n') - } - - /** - * HTML 转义辅助函数 - */ - private escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - // 权限响应处理方法 - 重新设计为基于消息数据的流程 async handlePermissionResponse( messageId: string, @@ -4300,13 +2680,13 @@ export class ThreadPresenter implements IThreadPresenter { ) // 构建专门的继续执行上下文 - const finalContent = await this.buildContinueToolCallContext( + const finalContent = await buildContinueToolCallContext({ conversation, contextMessages, userMessage, pendingToolCall, modelConfig - ) + }) console.log(`[ThreadPresenter] Built continue context for tool: ${pendingToolCall.name}`) @@ -4426,168 +2806,4 @@ export class ThreadPresenter implements IThreadPresenter { return { id, name, params } } - - // 构建继续工具调用执行的上下文 - private async buildContinueToolCallContext( - conversation: any, - contextMessages: any[], - userMessage: any, - pendingToolCall: { id: string; name: string; params: string }, - modelConfig: any - ): Promise { - const { systemPrompt } = conversation.settings - const formattedMessages: ChatMessage[] = [] - - // 1. 添加系统提示(包含当前时间信息) - if (systemPrompt) { - const finalSystemPrompt = this.enhanceSystemPromptWithDateTime(systemPrompt) - formattedMessages.push({ - role: 'system', - content: finalSystemPrompt - }) - } - - // 2. 添加上下文消息 - const contextChatMessages = this.addContextMessages( - contextMessages, - false, - modelConfig.functionCall - ) - formattedMessages.push(...contextChatMessages) - - // 3. 添加当前用户消息 - const userContent = userMessage.content - const msgText = userContent.content - ? this.formatUserMessageContent(userContent.content) - : userContent.text - const finalUserContent = `${msgText}${getFileContext(userContent.files || [])}` - - formattedMessages.push({ - role: 'user', - content: finalUserContent - }) - - // 4. 添加助手消息,说明需要执行工具调用 - if (modelConfig.functionCall) { - // 对于原生支持函数调用的模型,添加tool_calls - formattedMessages.push({ - role: 'assistant', - tool_calls: [ - { - id: pendingToolCall.id, - type: 'function', - function: { - name: pendingToolCall.name, - arguments: pendingToolCall.params - } - } - ] - }) - - // 添加一个虚拟的工具响应,说明权限已经授予 - formattedMessages.push({ - role: 'tool', - tool_call_id: pendingToolCall.id, - content: `Permission granted. Please proceed with executing the ${pendingToolCall.name} function.` - }) - } else { - // 对于非原生支持的模型,使用文本提示 - formattedMessages.push({ - role: 'assistant', - content: `I need to call the ${pendingToolCall.name} function with the following parameters: ${pendingToolCall.params}` - }) - - formattedMessages.push({ - role: 'user', - content: `Permission has been granted for the ${pendingToolCall.name} function. Please proceed with the execution.` - }) - } - - return formattedMessages - } - - /** - * 为系统提示词添加当前时间信息 - * @param systemPrompt 原始系统提示词 - * @param isImageGeneration 是否为图片生成模型 - * @returns 处理后的系统提示词 - */ - private enhanceSystemPromptWithDateTime( - systemPrompt: string, - isImageGeneration: boolean = false - ): string { - // 如果是图片生成模型或者系统提示词为空,则直接返回原值 - if (isImageGeneration || !systemPrompt || !systemPrompt.trim()) { - return systemPrompt - } - - // 生成当前时间字符串,包含完整的时区信息 - const currentDateTime = new Date().toLocaleString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZoneName: 'short', - hour12: false - }) - - return `${systemPrompt}\nToday's date and time is ${currentDateTime}` - } - - /** - * 直接处理内容的方法 - */ - private async processContentDirectly( - eventId: string, - content: string, - currentTime: number - ): Promise { - const state = this.generatingMessages.get(eventId) - if (!state) return - - // 检查是否需要分块处理 - if (this.shouldSplitContent(content)) { - await this.processLargeContentInChunks(eventId, content, currentTime) - } else { - await this.processNormalContent(eventId, content, currentTime) - } - } - - /** - * 分块处理大内容 - */ - private async processLargeContentInChunks( - eventId: string, - content: string, - currentTime: number - ): Promise { - const state = this.generatingMessages.get(eventId) - if (!state) return - - console.log(`[ThreadPresenter] Processing large content in chunks: ${content.length} bytes`) - - const lastBlock = state.message.content[state.message.content.length - 1] - let contentBlock: any - - if (lastBlock && lastBlock.type === 'content') { - contentBlock = lastBlock - } else { - this.finalizeLastBlock(state) - contentBlock = { - type: 'content', - content: '', - status: 'loading', - timestamp: currentTime - } - state.message.content.push(contentBlock) - } - - // 直接添加内容,不做复杂分块 - contentBlock.content += content - - // 只更新数据库,不额外发送到渲染器(避免重复发送) - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - } } diff --git a/src/main/presenter/threadPresenter/messageContent.ts b/src/main/presenter/threadPresenter/messageContent.ts new file mode 100644 index 000000000..ba7f9820d --- /dev/null +++ b/src/main/presenter/threadPresenter/messageContent.ts @@ -0,0 +1,104 @@ +import type { + MessageFile, + UserMessageContent, + UserMessageCodeBlock, + UserMessageMentionBlock, + UserMessageTextBlock +} from '@shared/chat' + +export type UserMessageRichBlock = + | UserMessageTextBlock + | UserMessageMentionBlock + | UserMessageCodeBlock + +export function formatUserMessageContent(msgContentBlock: UserMessageRichBlock[]): string { + if (!Array.isArray(msgContentBlock)) { + return '' + } + + return msgContentBlock + .map((block) => { + if (block.type === 'mention') { + if (block.category === 'resources') { + return `@${block.content}` + } else if (block.category === 'tools') { + return `@${block.id}` + } else if (block.category === 'files') { + return `@${block.id}` + } else if (block.category === 'prompts') { + try { + const promptData = JSON.parse(block.content) + if (promptData && Array.isArray(promptData.messages)) { + const messageTexts = promptData.messages + .map((msg: any) => { + if (typeof msg.content === 'string') { + return msg.content + } else if (msg.content && msg.content.type === 'text') { + return msg.content.text + } + return `[${msg.content?.type || 'content'}]` + }) + .filter(Boolean) + .join('\n') + return `@${block.id} ${messageTexts || block.content}` + } + } catch (e) { + console.log('解析prompt内容失败:', e) + } + return `@${block.id} ${block.content}` + } + return `@${block.id}` + } else if (block.type === 'text') { + return block.content + } else if (block.type === 'code') { + return `\`\`\`${block.content}\`\`\`` + } + return '' + }) + .join('') +} + +export function getFileContext(files?: MessageFile[]): string { + if (!files || files.length === 0) { + return '' + } + + return ` + + ${files + .map( + (file) => ` + ${file.name} + ${file.mimeType} + ${file.metadata.fileSize} + ${file.path} + ${!file.mimeType.startsWith('image') ? file.content : ''} + ` + ) + .join('\n')} + + ` +} + +export function getNormalizedUserMessageText(content: UserMessageContent | undefined): string { + if (!content) { + return '' + } + + if (content.content && Array.isArray(content.content) && content.content.length > 0) { + return formatUserMessageContent(content.content) + } + + return content.text || '' +} + +export function buildUserMessageContext(content: UserMessageContent | undefined): string { + if (!content) { + return '' + } + + const messageText = getNormalizedUserMessageText(content) + const fileContext = getFileContext(content.files) + + return `${messageText}${fileContext}` +} diff --git a/src/main/presenter/threadPresenter/promptBuilder.ts b/src/main/presenter/threadPresenter/promptBuilder.ts new file mode 100644 index 000000000..fb19d0098 --- /dev/null +++ b/src/main/presenter/threadPresenter/promptBuilder.ts @@ -0,0 +1,506 @@ +import { approximateTokenSize } from 'tokenx' +import { presenter } from '@/presenter' +import { AssistantMessageBlock, Message, MessageFile, UserMessageContent } from '@shared/chat' +import { ModelType } from '@shared/model' +import { + CONVERSATION, + ModelConfig, + SearchResult, + ChatMessage, + ChatMessageContent +} from '../../../shared/presenter' +import { ContentEnricher } from './contentEnricher' +import { buildUserMessageContext, getNormalizedUserMessageText } from './messageContent' +import { generateSearchPrompt } from './searchManager' + +type PendingToolCall = { id: string; name: string; params: string } +type VisionUserMessageContent = UserMessageContent & { images?: string[] } + +export interface PreparePromptContentParams { + conversation: CONVERSATION + userContent: string + contextMessages: Message[] + searchResults: SearchResult[] | null + urlResults: SearchResult[] + userMessage: Message + vision: boolean + imageFiles: MessageFile[] + supportsFunctionCall: boolean + modelType?: ModelType +} + +export interface ContinueToolCallContextParams { + conversation: CONVERSATION + contextMessages: Message[] + userMessage: Message + pendingToolCall: PendingToolCall + modelConfig: ModelConfig +} + +export async function preparePromptContent({ + conversation, + userContent, + contextMessages, + searchResults, + urlResults, + userMessage, + vision, + imageFiles, + supportsFunctionCall, + modelType +}: PreparePromptContentParams): Promise<{ + finalContent: ChatMessage[] + promptTokens: number +}> { + const { systemPrompt, contextLength, artifacts, enabledMcpTools } = conversation.settings + + const isImageGeneration = modelType === ModelType.ImageGeneration + const searchPrompt = + !isImageGeneration && searchResults ? generateSearchPrompt(userContent, searchResults) : '' + + const enrichedUserMessage = + !isImageGeneration && urlResults.length > 0 + ? '\n\n' + ContentEnricher.enrichUserMessageWithUrlContent(userContent, urlResults) + : '' + + const finalSystemPrompt = enhanceSystemPromptWithDateTime(systemPrompt, isImageGeneration) + + const searchPromptTokens = searchPrompt ? approximateTokenSize(searchPrompt) : 0 + const systemPromptTokens = + !isImageGeneration && finalSystemPrompt ? approximateTokenSize(finalSystemPrompt) : 0 + const userMessageTokens = approximateTokenSize(userContent + enrichedUserMessage) + + const mcpTools = !isImageGeneration + ? await presenter.mcpPresenter.getAllToolDefinitions(enabledMcpTools) + : [] + const mcpToolsTokens = mcpTools.reduce( + (acc, tool) => acc + approximateTokenSize(JSON.stringify(tool)), + 0 + ) + + const reservedTokens = + searchPromptTokens + systemPromptTokens + userMessageTokens + mcpToolsTokens + const remainingContextLength = contextLength - reservedTokens + + const selectedContextMessages = selectContextMessages( + contextMessages, + userMessage, + remainingContextLength + ) + + const formattedMessages = formatMessagesForCompletion( + selectedContextMessages, + isImageGeneration ? '' : finalSystemPrompt, + artifacts, + searchPrompt, + userContent, + enrichedUserMessage, + imageFiles, + vision, + supportsFunctionCall + ) + + const mergedMessages = mergeConsecutiveMessages(formattedMessages) + + let promptTokens = 0 + for (const msg of mergedMessages) { + if (typeof msg.content === 'string') { + promptTokens += approximateTokenSize(msg.content) + } else { + const textContent = msg.content?.map((item) => item.text).join('') || '' + promptTokens += + approximateTokenSize(textContent) + imageFiles.reduce((acc, file) => acc + file.token, 0) + } + } + + return { finalContent: mergedMessages, promptTokens } +} + +export async function buildContinueToolCallContext({ + conversation, + contextMessages, + userMessage, + pendingToolCall, + modelConfig +}: ContinueToolCallContextParams): Promise { + const { systemPrompt } = conversation.settings + const formattedMessages: ChatMessage[] = [] + + if (systemPrompt) { + const finalSystemPrompt = enhanceSystemPromptWithDateTime(systemPrompt) + formattedMessages.push({ + role: 'system', + content: finalSystemPrompt + }) + } + + const contextChatMessages = addContextMessages(contextMessages, false, modelConfig.functionCall) + formattedMessages.push(...contextChatMessages) + + const userContent = userMessage.content as UserMessageContent + const finalUserContent = buildUserMessageContext(userContent) + + formattedMessages.push({ + role: 'user', + content: finalUserContent + }) + + if (modelConfig.functionCall) { + formattedMessages.push({ + role: 'assistant', + tool_calls: [ + { + id: pendingToolCall.id, + type: 'function', + function: { + name: pendingToolCall.name, + arguments: pendingToolCall.params + } + } + ] + }) + + formattedMessages.push({ + role: 'tool', + tool_call_id: pendingToolCall.id, + content: `Permission granted. Please proceed with executing the ${pendingToolCall.name} function.` + }) + } else { + formattedMessages.push({ + role: 'assistant', + content: `I need to call the ${pendingToolCall.name} function with the following parameters: ${pendingToolCall.params}` + }) + + formattedMessages.push({ + role: 'user', + content: `Permission has been granted for the ${pendingToolCall.name} function. Please proceed with the execution.` + }) + } + + return formattedMessages +} + +function selectContextMessages( + contextMessages: Message[], + userMessage: Message, + remainingContextLength: number +): Message[] { + if (remainingContextLength <= 0) { + return [] + } + + const messages = contextMessages.filter((msg) => msg.id !== userMessage?.id).reverse() + + let currentLength = 0 + const selectedMessages: Message[] = [] + + for (const msg of messages) { + if (msg.status !== 'sent') { + continue + } + + const msgContent = msg.role === 'user' ? (msg.content as UserMessageContent) : null + const msgTokens = approximateTokenSize( + msgContent ? buildUserMessageContext(msgContent) : JSON.stringify(msg.content) + ) + + if (currentLength + msgTokens <= remainingContextLength) { + if (msg.role === 'user') { + const userMsgContent = msg.content as UserMessageContent + if (userMsgContent.content && !userMsgContent.text) { + userMsgContent.text = getNormalizedUserMessageText(userMsgContent) + } + } + + selectedMessages.unshift(msg) + currentLength += msgTokens + } else { + break + } + } + + while (selectedMessages.length > 0 && selectedMessages[0].role !== 'user') { + selectedMessages.shift() + } + + return selectedMessages +} + +function formatMessagesForCompletion( + contextMessages: Message[], + systemPrompt: string, + artifacts: number, + searchPrompt: string, + userContent: string, + enrichedUserMessage: string, + imageFiles: MessageFile[], + vision: boolean, + supportsFunctionCall: boolean +): ChatMessage[] { + const formattedMessages: ChatMessage[] = [] + + formattedMessages.push(...addContextMessages(contextMessages, vision, supportsFunctionCall)) + + if (systemPrompt) { + formattedMessages.unshift({ + role: 'system', + content: systemPrompt + }) + } + + let finalContent = searchPrompt || userContent + if (enrichedUserMessage) { + finalContent += enrichedUserMessage + } + + if (artifacts === 1) { + console.log('artifacts目前由mcp提供,此处为兼容性保留') + } + + if (vision && imageFiles.length > 0) { + formattedMessages.push(addImageFiles(finalContent, imageFiles)) + } else { + formattedMessages.push({ + role: 'user', + content: finalContent.trim() + }) + } + + return formattedMessages +} + +function addImageFiles(finalContent: string, imageFiles: MessageFile[]): ChatMessage { + return { + role: 'user', + content: [ + ...imageFiles.map((file) => ({ + type: 'image_url' as const, + image_url: { url: file.content, detail: 'auto' as const } + })), + { type: 'text' as const, text: finalContent.trim() } + ] + } +} + +function addContextMessages( + contextMessages: Message[], + vision: boolean, + supportsFunctionCall: boolean +): ChatMessage[] { + const resultMessages: ChatMessage[] = [] + + if (supportsFunctionCall) { + contextMessages.forEach((msg) => { + if (msg.role === 'user') { + const msgContent = msg.content as VisionUserMessageContent + const userContext = buildUserMessageContext(msgContent) + if (vision && msgContent.images && msgContent.images.length > 0) { + resultMessages.push({ + role: 'user', + content: [ + ...msgContent.images.map((image) => ({ + type: 'image_url' as const, + image_url: { url: image, detail: 'auto' as const } + })), + { type: 'text' as const, text: userContext } + ] + }) + } else { + resultMessages.push({ + role: 'user', + content: userContext + }) + } + } else if (msg.role === 'assistant') { + const content = msg.content as AssistantMessageBlock[] + const messageContent: ChatMessageContent[] = [] + const toolCalls: ChatMessage['tool_calls'] = [] + + content.forEach((block) => { + if (block.type === 'tool_call' && block.tool_call) { + toolCalls.push({ + id: block.tool_call.id, + type: 'function', + function: { + name: block.tool_call.name, + arguments: block.tool_call.params || '' + } + }) + if (block.tool_call.response) { + messageContent.push({ type: 'text', text: block.tool_call.response }) + } + } else if (block.type === 'content' && block.content) { + messageContent.push({ type: 'text', text: block.content }) + } + }) + + if (toolCalls.length > 0) { + resultMessages.push({ + role: 'assistant', + content: messageContent.length > 0 ? messageContent : undefined, + tool_calls: toolCalls + }) + } else if (messageContent.length > 0) { + resultMessages.push({ + role: 'assistant', + content: messageContent + }) + } + } else { + resultMessages.push({ + role: msg.role, + content: JSON.stringify(msg.content) + }) + } + }) + + return resultMessages + } + + contextMessages.forEach((msg) => { + if (msg.role === 'user') { + const msgContent = msg.content as VisionUserMessageContent + const userContext = buildUserMessageContext(msgContent) + if (vision && msgContent.images && msgContent.images.length > 0) { + resultMessages.push({ + role: 'user', + content: [ + ...msgContent.images.map((image) => ({ + type: 'image_url' as const, + image_url: { url: image, detail: 'auto' as const } + })), + { type: 'text' as const, text: userContext } + ] + }) + } else { + resultMessages.push({ + role: 'user', + content: userContext + }) + } + } else if (msg.role === 'assistant') { + const content = msg.content as AssistantMessageBlock[] + const textContent = content + .filter((block) => block.type === 'content' && block.content) + .map((block) => block.content) + .join('\n') + + if (textContent) { + resultMessages.push({ + role: 'assistant', + content: textContent + }) + } + } else { + resultMessages.push({ + role: msg.role, + content: JSON.stringify(msg.content) + }) + } + }) + + return resultMessages +} + +function mergeConsecutiveMessages(messages: ChatMessage[]): ChatMessage[] { + if (!messages || messages.length === 0) { + return [] + } + + const mergedResult: ChatMessage[] = [] + mergedResult.push(JSON.parse(JSON.stringify(messages[0]))) + + for (let i = 1; i < messages.length; i++) { + const currentMessage = JSON.parse(JSON.stringify(messages[i])) as ChatMessage + const lastPushedMessage = mergedResult[mergedResult.length - 1] + + let allowMessagePropertiesMerge = false + + if (lastPushedMessage.role === currentMessage.role) { + if (currentMessage.role === 'assistant') { + if (!lastPushedMessage.tool_calls && !currentMessage.tool_calls) { + allowMessagePropertiesMerge = true + } + } else { + allowMessagePropertiesMerge = true + } + } + + if (allowMessagePropertiesMerge) { + const lastContent = lastPushedMessage.content + const currentContent = currentMessage.content + + let newCombinedContent: string | ChatMessageContent[] | undefined = undefined + let contentTypesCompatible = false + + if (lastContent === undefined && currentContent === undefined) { + newCombinedContent = undefined + contentTypesCompatible = true + } else if ( + typeof lastContent === 'string' && + (typeof currentContent === 'string' || currentContent === undefined) + ) { + const previous = lastContent || '' + const current = currentContent || '' + if (previous && current) { + newCombinedContent = `${previous}\n${current}` + } else { + newCombinedContent = previous || current + } + if (newCombinedContent === '') { + newCombinedContent = undefined + } + contentTypesCompatible = true + } else if ( + Array.isArray(lastContent) && + (Array.isArray(currentContent) || currentContent === undefined) + ) { + const prevArray = lastContent + const currArray = currentContent || [] + newCombinedContent = [...prevArray, ...currArray] + if (newCombinedContent.length === 0) { + newCombinedContent = undefined + } + contentTypesCompatible = true + } else if (lastContent === undefined && currentContent !== undefined) { + newCombinedContent = currentContent + contentTypesCompatible = true + } else if (lastContent !== undefined && currentContent === undefined) { + newCombinedContent = lastContent + contentTypesCompatible = true + } + + if (contentTypesCompatible) { + lastPushedMessage.content = newCombinedContent + } else { + mergedResult.push(currentMessage) + } + } else { + mergedResult.push(currentMessage) + } + } + + return mergedResult +} + +function enhanceSystemPromptWithDateTime( + systemPrompt: string, + isImageGeneration: boolean = false +): string { + if (isImageGeneration || !systemPrompt || !systemPrompt.trim()) { + return systemPrompt + } + + const currentDateTime = new Date().toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + hour12: false + }) + + return `${systemPrompt}\nToday's date and time is ${currentDateTime}` +} diff --git a/src/main/presenter/threadPresenter/types.ts b/src/main/presenter/threadPresenter/types.ts new file mode 100644 index 000000000..a99400243 --- /dev/null +++ b/src/main/presenter/threadPresenter/types.ts @@ -0,0 +1,34 @@ +import type { AssistantMessage } from '@shared/chat' + +export interface GeneratingMessageState { + message: AssistantMessage + conversationId: string + startTime: number + firstTokenTime: number | null + promptTokens: number + reasoningStartTime: number | null + reasoningEndTime: number | null + lastReasoningTime: number | null + isSearching?: boolean + isCancelled?: boolean + totalUsage?: { + prompt_tokens: number + completion_tokens: number + total_tokens: number + context_length: number + } + adaptiveBuffer?: { + content: string + lastUpdateTime: number + updateCount: number + totalSize: number + isLargeContent: boolean + chunks?: string[] + currentChunkIndex?: number + sentPosition: number + isProcessing?: boolean + } + flushTimeout?: NodeJS.Timeout + throttleTimeout?: NodeJS.Timeout + lastRendererUpdateTime?: number +} From 69cb54cb8149f6dcd31c2ceb21f05d2f4cd64841 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Tue, 28 Oct 2025 21:11:24 +0800 Subject: [PATCH 02/13] refactor(thread): centralize block finalization --- .../threadPresenter/contentBufferManager.ts | 24 ++------------ src/main/presenter/threadPresenter/index.ts | 32 +++---------------- src/renderer/src/stores/chat.ts | 23 ++++--------- src/shared/chat/messageBlocks.ts | 25 +++++++++++++++ 4 files changed, 39 insertions(+), 65 deletions(-) create mode 100644 src/shared/chat/messageBlocks.ts diff --git a/src/main/presenter/threadPresenter/contentBufferManager.ts b/src/main/presenter/threadPresenter/contentBufferManager.ts index 45d284068..0d0b1a570 100644 --- a/src/main/presenter/threadPresenter/contentBufferManager.ts +++ b/src/main/presenter/threadPresenter/contentBufferManager.ts @@ -1,6 +1,8 @@ import { eventBus, SendTarget } from '@/eventbus' import { STREAM_EVENTS } from '@/events' +import { finalizeAssistantMessageBlocks } from '@shared/chat/messageBlocks' + import type { MessageManager } from './messageManager' import type { GeneratingMessageState } from './types' @@ -55,27 +57,7 @@ export class ContentBufferManager { } finalizeLastBlock(state: GeneratingMessageState): void { - const lastBlock = - state.message.content.length > 0 - ? state.message.content[state.message.content.length - 1] - : undefined - - if (!lastBlock) { - return - } - - if ( - lastBlock.type === 'action' && - lastBlock.action_type === 'tool_call_permission' && - lastBlock.status === 'pending' - ) { - lastBlock.status = 'granted' - return - } - - if (!(lastBlock.type === 'tool_call' && lastBlock.status === 'loading')) { - lastBlock.status = 'success' - } + finalizeAssistantMessageBlocks(state.message.content) } async processContentDirectly( diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts index f08c136b1..e304fc2a5 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -320,28 +320,6 @@ export class ThreadPresenter implements IThreadPresenter { } = msg const state = this.generatingMessages.get(eventId) if (state) { - // 使用保护逻辑 - const finalizeLastBlock = () => { - const lastBlock = - state.message.content.length > 0 - ? state.message.content[state.message.content.length - 1] - : undefined - if (lastBlock) { - if ( - lastBlock.type === 'action' && - lastBlock.action_type === 'tool_call_permission' && - lastBlock.status === 'pending' - ) { - lastBlock.status = 'granted' - return - } - // 只有当上一个块不是一个正在等待结果的工具调用时,才将其标记为成功 - if (!(lastBlock.type === 'tool_call' && lastBlock.status === 'loading')) { - lastBlock.status = 'success' - } - } - } - // 记录第一个token的时间 if (state.firstTokenTime === null && (content || reasoning_content)) { state.firstTokenTime = currentTime @@ -356,7 +334,7 @@ export class ThreadPresenter implements IThreadPresenter { // 处理工具调用达到最大次数的情况 if (maximum_tool_calls_reached) { - finalizeLastBlock() // 使用保护逻辑 + this.contentBufferManager.finalizeLastBlock(state) // 使用保护逻辑 state.message.content.push({ type: 'action', content: 'common.error.maximumToolCallsReached', @@ -465,7 +443,7 @@ export class ThreadPresenter implements IThreadPresenter { } } - finalizeLastBlock() + this.contentBufferManager.finalizeLastBlock(state) state.message.content.push(searchBlock) for (const result of searchResults) { @@ -496,7 +474,7 @@ export class ThreadPresenter implements IThreadPresenter { if (tool_call) { if (tool_call === 'start') { // 创建新的工具调用块 - finalizeLastBlock() // 使用保护逻辑 + this.contentBufferManager.finalizeLastBlock(state) // 使用保护逻辑 state.message.content.push({ type: 'tool_call', content: '', @@ -614,7 +592,7 @@ export class ThreadPresenter implements IThreadPresenter { } } else if (image_data) { // 处理图像数据 - finalizeLastBlock() // 使用保护逻辑 + this.contentBufferManager.finalizeLastBlock(state) // 使用保护逻辑 state.message.content.push({ type: 'image', content: 'image', @@ -639,7 +617,7 @@ export class ThreadPresenter implements IThreadPresenter { lastBlock.reasoning_time.end = currentTime } } else { - finalizeLastBlock() // 使用保护逻辑 + this.contentBufferManager.finalizeLastBlock(state) // 使用保护逻辑 state.message.content.push({ type: 'reasoning_content', content: reasoning_content, diff --git a/src/renderer/src/stores/chat.ts b/src/renderer/src/stores/chat.ts index 7927d8f95..1f10d6e8d 100644 --- a/src/renderer/src/stores/chat.ts +++ b/src/renderer/src/stores/chat.ts @@ -7,6 +7,7 @@ import type { UserMessage, Message } from '@shared/chat' +import { finalizeAssistantMessageBlocks } from '@shared/chat/messageBlocks' import type { CONVERSATION, CONVERSATION_SETTINGS } from '@shared/presenter' import { usePresenter } from '@/composables/usePresenter' import { CONVERSATION_EVENTS, DEEPLINK_EVENTS, MEETING_EVENTS } from '@/events' @@ -428,21 +429,9 @@ export const useChatStore = defineStore('chat', () => { if (cached) { const curMsg = cached.message as AssistantMessage if (curMsg.content) { - // 提取一个可复用的保护逻辑 - const finalizeLastBlock = () => { - const lastBlock = - curMsg.content.length > 0 ? curMsg.content[curMsg.content.length - 1] : undefined - if (lastBlock) { - // 只有当上一个块不是一个正在等待结果的工具调用时,才将其标记为成功 - if (!(lastBlock.type === 'tool_call' && lastBlock.status === 'loading')) { - lastBlock.status = 'success' - } - } - } - // 处理工具调用达到最大次数的情况 if (msg.maximum_tool_calls_reached) { - finalizeLastBlock() // 使用保护逻辑 + finalizeAssistantMessageBlocks(curMsg.content) // 使用保护逻辑 curMsg.content.push({ type: 'action', content: 'common.error.maximumToolCallsReached', @@ -464,7 +453,7 @@ export const useChatStore = defineStore('chat', () => { } else if (msg.tool_call) { if (msg.tool_call === 'start') { // 工具调用开始解析参数 - 创建新的工具调用块 - finalizeLastBlock() // 使用保护逻辑 + finalizeAssistantMessageBlocks(curMsg.content) // 使用保护逻辑 // 工具调用音效,与实际数据流同步 playToolcallSound() @@ -519,7 +508,7 @@ export const useChatStore = defineStore('chat', () => { } } else { // 如果没有找到现有的工具调用块,创建一个新的(兼容旧逻辑) - finalizeLastBlock() // 使用保护逻辑 + finalizeAssistantMessageBlocks(curMsg.content) // 使用保护逻辑 curMsg.content.push({ type: 'tool_call', @@ -563,7 +552,7 @@ export const useChatStore = defineStore('chat', () => { } // 处理图像数据 else if (msg.image_data) { - finalizeLastBlock() // 使用保护逻辑 + finalizeAssistantMessageBlocks(curMsg.content) // 使用保护逻辑 curMsg.content.push({ type: 'image', content: 'image', @@ -577,7 +566,7 @@ export const useChatStore = defineStore('chat', () => { } // 处理速率限制 else if (msg.rate_limit) { - finalizeLastBlock() // 使用保护逻辑 + finalizeAssistantMessageBlocks(curMsg.content) // 使用保护逻辑 curMsg.content.push({ type: 'action', content: 'chat.messages.rateLimitWaiting', diff --git a/src/shared/chat/messageBlocks.ts b/src/shared/chat/messageBlocks.ts new file mode 100644 index 000000000..b0a56ee59 --- /dev/null +++ b/src/shared/chat/messageBlocks.ts @@ -0,0 +1,25 @@ +import type { AssistantMessageBlock } from '@shared/chat' + +export function finalizeAssistantMessageBlocks(blocks: AssistantMessageBlock[] | undefined): void { + if (!blocks?.length) { + return + } + + const lastBlock = blocks[blocks.length - 1] + + if (!lastBlock) { + return + } + + if (lastBlock.type === 'action' && lastBlock.action_type === 'tool_call_permission') { + return + } + + if (lastBlock.type === 'tool_call' && lastBlock.status === 'loading') { + return + } + + if (lastBlock.status === 'loading') { + lastBlock.status = 'success' + } +} From d95dbdcc509c2865612fb6da0f7dd096f4c45485 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Tue, 28 Oct 2025 21:37:31 +0800 Subject: [PATCH 03/13] fix(thread-presenter): harden prompt and buffer workflows --- .../threadPresenter/contentBufferManager.ts | 39 ++++++++++----- .../threadPresenter/conversationExporter.ts | 6 +-- .../conversationLifecycleManager.ts | 6 +-- .../threadPresenter/messageContent.ts | 11 +++-- .../threadPresenter/promptBuilder.ts | 48 ++++++++++++------- 5 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/main/presenter/threadPresenter/contentBufferManager.ts b/src/main/presenter/threadPresenter/contentBufferManager.ts index 0d0b1a570..7ac409a83 100644 --- a/src/main/presenter/threadPresenter/contentBufferManager.ts +++ b/src/main/presenter/threadPresenter/contentBufferManager.ts @@ -45,15 +45,23 @@ export class ContentBufferManager { state.flushTimeout = undefined } - if (buffer.content && buffer.sentPosition < buffer.content.length) { - const newContent = buffer.content.slice(buffer.sentPosition) - if (newContent) { - await this.processBufferedContent(state, eventId, newContent, now) - buffer.sentPosition = buffer.content.length + try { + if (buffer.content && buffer.sentPosition < buffer.content.length) { + const newContent = buffer.content.slice(buffer.sentPosition) + if (newContent) { + await this.processBufferedContent(state, eventId, newContent, now) + buffer.sentPosition = buffer.content.length + } } + } catch (error) { + console.error('[ContentBuffer] ERROR flushing adaptive buffer', { + eventId, + err: error + }) + throw error + } finally { + state.adaptiveBuffer = undefined } - - state.adaptiveBuffer = undefined } finalizeLastBlock(state: GeneratingMessageState): void { @@ -68,10 +76,19 @@ export class ContentBufferManager { const state = this.generatingMessages.get(eventId) if (!state) return - if (this.shouldSplitContent(content)) { - await this.processLargeContentInChunks(state, eventId, content, currentTime) - } else { - await this.processNormalContent(state, eventId, content, currentTime) + try { + if (this.shouldSplitContent(content)) { + await this.processLargeContentInChunks(state, eventId, content, currentTime) + } else { + await this.processNormalContent(state, eventId, content, currentTime) + } + } catch (error) { + console.error('[ContentBuffer] ERROR processing content', { + eventId, + size: content.length, + err: error + }) + throw error } } diff --git a/src/main/presenter/threadPresenter/conversationExporter.ts b/src/main/presenter/threadPresenter/conversationExporter.ts index 89a94e378..5bb0782b4 100644 --- a/src/main/presenter/threadPresenter/conversationExporter.ts +++ b/src/main/presenter/threadPresenter/conversationExporter.ts @@ -272,9 +272,9 @@ function exportToHtml(conversation: CONVERSATION, messages: Message[]): string { lines.push('
附件
') lines.push('
    ') for (const file of userContent.files) { - lines.push( - `
  • ${escapeHtml(file.name || '')} (${escapeHtml(file.mimeType)})
  • ` - ) + const name = escapeHtml(file.name ?? '') + const mime = file.mimeType ? escapeHtml(file.mimeType) : 'unknown' + lines.push(`
  • ${name} (${mime})
  • `) } lines.push('
') lines.push(' ') diff --git a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts index db9fe0d48..c077f30eb 100644 --- a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts +++ b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts @@ -203,11 +203,11 @@ export class ConversationLifecycleManager { } } - if (settings.artifacts) { + if (settings.artifacts !== undefined) { mergedSettings.artifacts = settings.artifacts } - if (settings.maxTokens) { + if (settings.maxTokens !== undefined) { mergedSettings.maxTokens = settings.maxTokens } @@ -215,7 +215,7 @@ export class ConversationLifecycleManager { mergedSettings.temperature = settings.temperature } - if (settings.contextLength) { + if (settings.contextLength !== undefined) { mergedSettings.contextLength = settings.contextLength } diff --git a/src/main/presenter/threadPresenter/messageContent.ts b/src/main/presenter/threadPresenter/messageContent.ts index ba7f9820d..63dbbfe55 100644 --- a/src/main/presenter/threadPresenter/messageContent.ts +++ b/src/main/presenter/threadPresenter/messageContent.ts @@ -68,11 +68,12 @@ export function getFileContext(files?: MessageFile[]): string { ${files .map( (file) => ` - ${file.name} - ${file.mimeType} - ${file.metadata.fileSize} - ${file.path} - ${!file.mimeType.startsWith('image') ? file.content : ''} + ${file.name ?? ''} + ${file.mimeType ?? ''} + ${file.metadata?.fileSize ?? 0} + ${ + file.mimeType && !file.mimeType.startsWith('image') ? (file.content ?? '') : '' + } ` ) .join('\n')} diff --git a/src/main/presenter/threadPresenter/promptBuilder.ts b/src/main/presenter/threadPresenter/promptBuilder.ts index fb19d0098..e00a33d61 100644 --- a/src/main/presenter/threadPresenter/promptBuilder.ts +++ b/src/main/presenter/threadPresenter/promptBuilder.ts @@ -10,7 +10,7 @@ import { ChatMessageContent } from '../../../shared/presenter' import { ContentEnricher } from './contentEnricher' -import { buildUserMessageContext, getNormalizedUserMessageText } from './messageContent' +import { buildUserMessageContext } from './messageContent' import { generateSearchPrompt } from './searchManager' type PendingToolCall = { id: string; name: string; params: string } @@ -103,13 +103,33 @@ export async function preparePromptContent({ const mergedMessages = mergeConsecutiveMessages(formattedMessages) let promptTokens = 0 - for (const msg of mergedMessages) { - if (typeof msg.content === 'string') { - promptTokens += approximateTokenSize(msg.content) - } else { - const textContent = msg.content?.map((item) => item.text).join('') || '' - promptTokens += - approximateTokenSize(textContent) + imageFiles.reduce((acc, file) => acc + file.token, 0) + const imageTokenCost = imageFiles.reduce((acc, file) => acc + (file.token ?? 0), 0) + for (let i = 0; i < mergedMessages.length; i++) { + const msg = mergedMessages[i] + + if (typeof msg.content === 'string' || msg.content === undefined) { + promptTokens += approximateTokenSize(msg.content || '') + continue + } + + const textContent = + msg.content?.reduce((acc, item) => { + if (item.type === 'text' && typeof item.text === 'string') { + return acc + item.text + } + return acc + }, '') ?? '' + + promptTokens += approximateTokenSize(textContent) + + const isFinalUserWithImages = + i === mergedMessages.length - 1 && + msg.role === 'user' && + Array.isArray(msg.content) && + msg.content.some((block) => block.type === 'image_url') + + if (isFinalUserWithImages && imageTokenCost > 0) { + promptTokens += imageTokenCost } } @@ -161,9 +181,8 @@ export async function buildContinueToolCallContext({ }) formattedMessages.push({ - role: 'tool', - tool_call_id: pendingToolCall.id, - content: `Permission granted. Please proceed with executing the ${pendingToolCall.name} function.` + role: 'user', + content: `Permission granted to call ${pendingToolCall.name}. Proceed with execution.` }) } else { formattedMessages.push({ @@ -205,13 +224,6 @@ function selectContextMessages( ) if (currentLength + msgTokens <= remainingContextLength) { - if (msg.role === 'user') { - const userMsgContent = msg.content as UserMessageContent - if (userMsgContent.content && !userMsgContent.text) { - userMsgContent.text = getNormalizedUserMessageText(userMsgContent) - } - } - selectedMessages.unshift(msg) currentLength += msgTokens } else { From e69701b7d714bc13fac4583ff428990bac3520dd Mon Sep 17 00:00:00 2001 From: duskzhen Date: Tue, 28 Oct 2025 22:38:01 +0800 Subject: [PATCH 04/13] fix(thread-presenter): address review feedback --- .../conversationLifecycleManager.ts | 127 ++++++++++-------- .../threadPresenter/messageContent.ts | 60 +++++++-- .../threadPresenter/promptBuilder.ts | 6 +- 3 files changed, 122 insertions(+), 71 deletions(-) diff --git a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts index c077f30eb..4fb107d1e 100644 --- a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts +++ b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts @@ -155,10 +155,12 @@ export class ConversationLifecycleManager { tabId: number, options: CreateConversationOptions = {} ): Promise { - const latestConversation = await this.getLatestConversation() + let latestConversation: CONVERSATION | null = null - if (!options.forceNewAndActivate) { - if (latestConversation) { + try { + latestConversation = await this.getLatestConversation() + + if (!options.forceNewAndActivate && latestConversation) { const { list: messages } = await this.messageManager.getMessageThread( latestConversation.id, 1, @@ -169,74 +171,87 @@ export class ConversationLifecycleManager { return latestConversation.id } } - } - - let defaultSettings = DEFAULT_SETTINGS - if (latestConversation?.settings) { - defaultSettings = { ...latestConversation.settings } - defaultSettings.systemPrompt = '' - defaultSettings.reasoningEffort = undefined - defaultSettings.enableSearch = undefined - defaultSettings.forcedSearch = undefined - defaultSettings.searchStrategy = undefined - } - Object.keys(settings).forEach((key) => { - if (settings[key] === undefined || settings[key] === null || settings[key] === '') { - delete settings[key] + let defaultSettings = DEFAULT_SETTINGS + if (latestConversation?.settings) { + defaultSettings = { ...latestConversation.settings } + defaultSettings.systemPrompt = '' + defaultSettings.reasoningEffort = undefined + defaultSettings.enableSearch = undefined + defaultSettings.forcedSearch = undefined + defaultSettings.searchStrategy = undefined } - }) - const mergedSettings = { ...defaultSettings, ...settings } + const sanitizedSettings: Partial = { ...settings } + Object.keys(sanitizedSettings).forEach((key) => { + const typedKey = key as keyof CONVERSATION_SETTINGS + const value = sanitizedSettings[typedKey] + if (value === undefined || value === null || value === '') { + delete sanitizedSettings[typedKey] + } + }) - const defaultModelsSettings = this.configPresenter.getModelConfig( - mergedSettings.modelId, - mergedSettings.providerId - ) + const mergedSettings = { ...defaultSettings, ...sanitizedSettings } + + const defaultModelsSettings = this.configPresenter.getModelConfig( + mergedSettings.modelId, + mergedSettings.providerId + ) - if (defaultModelsSettings) { - mergedSettings.maxTokens = defaultModelsSettings.maxTokens - mergedSettings.contextLength = defaultModelsSettings.contextLength - mergedSettings.temperature = defaultModelsSettings.temperature ?? 0.7 - if (settings.thinkingBudget === undefined) { - mergedSettings.thinkingBudget = defaultModelsSettings.thinkingBudget + if (defaultModelsSettings) { + mergedSettings.maxTokens = defaultModelsSettings.maxTokens + mergedSettings.contextLength = defaultModelsSettings.contextLength + mergedSettings.temperature = defaultModelsSettings.temperature ?? 0.7 + if (sanitizedSettings.thinkingBudget === undefined) { + mergedSettings.thinkingBudget = defaultModelsSettings.thinkingBudget + } } - } - if (settings.artifacts !== undefined) { - mergedSettings.artifacts = settings.artifacts - } + if (sanitizedSettings.artifacts !== undefined) { + mergedSettings.artifacts = sanitizedSettings.artifacts + } - if (settings.maxTokens !== undefined) { - mergedSettings.maxTokens = settings.maxTokens - } + if (sanitizedSettings.maxTokens !== undefined) { + mergedSettings.maxTokens = sanitizedSettings.maxTokens + } - if (settings.temperature !== undefined && settings.temperature !== null) { - mergedSettings.temperature = settings.temperature - } + if (sanitizedSettings.temperature !== undefined && sanitizedSettings.temperature !== null) { + mergedSettings.temperature = sanitizedSettings.temperature + } - if (settings.contextLength !== undefined) { - mergedSettings.contextLength = settings.contextLength - } + if (sanitizedSettings.contextLength !== undefined) { + mergedSettings.contextLength = sanitizedSettings.contextLength + } - if (settings.systemPrompt) { - mergedSettings.systemPrompt = settings.systemPrompt - } + if (sanitizedSettings.systemPrompt) { + mergedSettings.systemPrompt = sanitizedSettings.systemPrompt + } - const conversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) + const conversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) - if (options.forceNewAndActivate) { - this.activeConversationIds.set(tabId, conversationId) - eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { - conversationId, - tabId + if (options.forceNewAndActivate) { + this.activeConversationIds.set(tabId, conversationId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { + conversationId, + tabId + }) + } else { + await this.setActiveConversation(conversationId, tabId) + } + + await this.broadcastThreadListUpdate() + return conversationId + } catch (error) { + console.error('ThreadPresenter: Failed to create conversation', { + title, + tabId, + options, + latestConversationId: latestConversation?.id, + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined }) - } else { - await this.setActiveConversation(conversationId, tabId) + throw error } - - await this.broadcastThreadListUpdate() - return conversationId } async renameConversation(conversationId: string, title: string): Promise { diff --git a/src/main/presenter/threadPresenter/messageContent.ts b/src/main/presenter/threadPresenter/messageContent.ts index 63dbbfe55..b36093f25 100644 --- a/src/main/presenter/threadPresenter/messageContent.ts +++ b/src/main/presenter/threadPresenter/messageContent.ts @@ -6,6 +6,47 @@ import type { UserMessageTextBlock } from '@shared/chat' +const FILE_CONTENT_MAX_CHARS = 8000 +const FILE_CONTENT_TRUNCATION_SUFFIX = '…(truncated)' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isTextBlock(content: unknown): content is { type: 'text'; text: string } { + return isRecord(content) && content.type === 'text' && typeof content.text === 'string' +} + +function extractPromptMessageText(message: unknown): string { + if (!isRecord(message)) { + return '' + } + + const content = message.content + + if (typeof content === 'string') { + return content + } + + if (isTextBlock(content)) { + return content.text + } + + if (isRecord(content) && typeof content.type === 'string') { + return `[${content.type}]` + } + + return '[content]' +} + +function truncateFileContent(content: string): string { + if (content.length <= FILE_CONTENT_MAX_CHARS) { + return content + } + + return `${content.slice(0, FILE_CONTENT_MAX_CHARS)}${FILE_CONTENT_TRUNCATION_SUFFIX}` +} + export type UserMessageRichBlock = | UserMessageTextBlock | UserMessageMentionBlock @@ -28,22 +69,15 @@ export function formatUserMessageContent(msgContentBlock: UserMessageRichBlock[] } else if (block.category === 'prompts') { try { const promptData = JSON.parse(block.content) - if (promptData && Array.isArray(promptData.messages)) { + if (isRecord(promptData) && Array.isArray(promptData.messages)) { const messageTexts = promptData.messages - .map((msg: any) => { - if (typeof msg.content === 'string') { - return msg.content - } else if (msg.content && msg.content.type === 'text') { - return msg.content.text - } - return `[${msg.content?.type || 'content'}]` - }) - .filter(Boolean) + .map(extractPromptMessageText) + .filter((text) => text) .join('\n') return `@${block.id} ${messageTexts || block.content}` } } catch (e) { - console.log('解析prompt内容失败:', e) + console.warn('Failed to parse prompt content:', e) } return `@${block.id} ${block.content}` } @@ -72,7 +106,9 @@ export function getFileContext(files?: MessageFile[]): string { ${file.mimeType ?? ''} ${file.metadata?.fileSize ?? 0} ${ - file.mimeType && !file.mimeType.startsWith('image') ? (file.content ?? '') : '' + file.mimeType && !file.mimeType.startsWith('image') + ? truncateFileContent(String(file.content ?? '')) + : '' } ` ) diff --git a/src/main/presenter/threadPresenter/promptBuilder.ts b/src/main/presenter/threadPresenter/promptBuilder.ts index e00a33d61..c7fb69835 100644 --- a/src/main/presenter/threadPresenter/promptBuilder.ts +++ b/src/main/presenter/threadPresenter/promptBuilder.ts @@ -10,7 +10,7 @@ import { ChatMessageContent } from '../../../shared/presenter' import { ContentEnricher } from './contentEnricher' -import { buildUserMessageContext } from './messageContent' +import { buildUserMessageContext, getNormalizedUserMessageText } from './messageContent' import { generateSearchPrompt } from './searchManager' type PendingToolCall = { id: string; name: string; params: string } @@ -220,7 +220,7 @@ function selectContextMessages( const msgContent = msg.role === 'user' ? (msg.content as UserMessageContent) : null const msgTokens = approximateTokenSize( - msgContent ? buildUserMessageContext(msgContent) : JSON.stringify(msg.content) + msgContent ? getNormalizedUserMessageText(msgContent) : JSON.stringify(msg.content) ) if (currentLength + msgTokens <= remainingContextLength) { @@ -266,7 +266,7 @@ function formatMessagesForCompletion( } if (artifacts === 1) { - console.log('artifacts目前由mcp提供,此处为兼容性保留') + console.debug('Artifacts are provided by MCP; this is a backward-compatibility placeholder') } if (vision && imageFiles.length > 0) { From c1a385bb1d54f59cc428b7a5c839f4d40de44df0 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Tue, 28 Oct 2025 23:21:37 +0800 Subject: [PATCH 05/13] fix(threadPresenter): address review feedback --- .../conversationLifecycleManager.ts | 41 ++++++++----------- .../threadPresenter/messageContent.ts | 29 +++++++++++-- .../threadPresenter/promptBuilder.ts | 28 +++++++++---- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts index 4fb107d1e..35c1e21b0 100644 --- a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts +++ b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts @@ -191,40 +191,35 @@ export class ConversationLifecycleManager { } }) - const mergedSettings = { ...defaultSettings, ...sanitizedSettings } + const mergedSettings = { ...defaultSettings } + + const previewSettings = { ...mergedSettings, ...sanitizedSettings } const defaultModelsSettings = this.configPresenter.getModelConfig( - mergedSettings.modelId, - mergedSettings.providerId + previewSettings.modelId, + previewSettings.providerId ) if (defaultModelsSettings) { - mergedSettings.maxTokens = defaultModelsSettings.maxTokens - mergedSettings.contextLength = defaultModelsSettings.contextLength + if (defaultModelsSettings.maxTokens !== undefined) { + mergedSettings.maxTokens = defaultModelsSettings.maxTokens + } + if (defaultModelsSettings.contextLength !== undefined) { + mergedSettings.contextLength = defaultModelsSettings.contextLength + } mergedSettings.temperature = defaultModelsSettings.temperature ?? 0.7 - if (sanitizedSettings.thinkingBudget === undefined) { + if ( + sanitizedSettings.thinkingBudget === undefined && + defaultModelsSettings.thinkingBudget !== undefined + ) { mergedSettings.thinkingBudget = defaultModelsSettings.thinkingBudget } } - if (sanitizedSettings.artifacts !== undefined) { - mergedSettings.artifacts = sanitizedSettings.artifacts - } - - if (sanitizedSettings.maxTokens !== undefined) { - mergedSettings.maxTokens = sanitizedSettings.maxTokens - } - - if (sanitizedSettings.temperature !== undefined && sanitizedSettings.temperature !== null) { - mergedSettings.temperature = sanitizedSettings.temperature - } - - if (sanitizedSettings.contextLength !== undefined) { - mergedSettings.contextLength = sanitizedSettings.contextLength - } + Object.assign(mergedSettings, sanitizedSettings) - if (sanitizedSettings.systemPrompt) { - mergedSettings.systemPrompt = sanitizedSettings.systemPrompt + if (mergedSettings.temperature === undefined || mergedSettings.temperature === null) { + mergedSettings.temperature = defaultModelsSettings?.temperature ?? 0.7 } const conversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) diff --git a/src/main/presenter/threadPresenter/messageContent.ts b/src/main/presenter/threadPresenter/messageContent.ts index b36093f25..62ab741cb 100644 --- a/src/main/presenter/threadPresenter/messageContent.ts +++ b/src/main/presenter/threadPresenter/messageContent.ts @@ -47,6 +47,27 @@ function truncateFileContent(content: string): string { return `${content.slice(0, FILE_CONTENT_MAX_CHARS)}${FILE_CONTENT_TRUNCATION_SUFFIX}` } +function escapeTagContent(value: string): string { + return String(value).replace(/[&<>\u0000-\u001F]/g, (ch) => { + switch (ch) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '\n': + return ' ' + case '\r': + return ' ' + case '\t': + return ' ' + default: + return '' + } + }) +} + export type UserMessageRichBlock = | UserMessageTextBlock | UserMessageMentionBlock @@ -73,13 +94,15 @@ export function formatUserMessageContent(msgContentBlock: UserMessageRichBlock[] const messageTexts = promptData.messages .map(extractPromptMessageText) .filter((text) => text) - .join('\n') - return `@${block.id} ${messageTexts || block.content}` + const escapedContent = messageTexts.length + ? messageTexts.map(escapeTagContent).join('\n') + : escapeTagContent(block.content ?? '') + return `@${block.id} ${escapedContent}` } } catch (e) { console.warn('Failed to parse prompt content:', e) } - return `@${block.id} ${block.content}` + return `@${block.id} ${escapeTagContent(block.content ?? '')}` } return `@${block.id}` } else if (block.type === 'text') { diff --git a/src/main/presenter/threadPresenter/promptBuilder.ts b/src/main/presenter/threadPresenter/promptBuilder.ts index c7fb69835..084324d22 100644 --- a/src/main/presenter/threadPresenter/promptBuilder.ts +++ b/src/main/presenter/threadPresenter/promptBuilder.ts @@ -9,6 +9,7 @@ import { ChatMessage, ChatMessageContent } from '../../../shared/presenter' +import type { MCPToolDefinition } from '../../../shared/presenter' import { ContentEnricher } from './contentEnricher' import { buildUserMessageContext, getNormalizedUserMessageText } from './messageContent' import { generateSearchPrompt } from './searchManager' @@ -70,9 +71,18 @@ export async function preparePromptContent({ !isImageGeneration && finalSystemPrompt ? approximateTokenSize(finalSystemPrompt) : 0 const userMessageTokens = approximateTokenSize(userContent + enrichedUserMessage) - const mcpTools = !isImageGeneration - ? await presenter.mcpPresenter.getAllToolDefinitions(enabledMcpTools) - : [] + let mcpTools: MCPToolDefinition[] = [] + if (!isImageGeneration) { + try { + const toolDefinitions = await presenter.mcpPresenter.getAllToolDefinitions(enabledMcpTools) + if (Array.isArray(toolDefinitions)) { + mcpTools = toolDefinitions + } + } catch (error) { + console.warn('ThreadPresenter: Failed to load MCP tool definitions', error) + mcpTools = [] + } + } const mcpToolsTokens = mcpTools.reduce( (acc, tool) => acc + approximateTokenSize(JSON.stringify(tool)), 0 @@ -305,7 +315,7 @@ function addContextMessages( contextMessages.forEach((msg) => { if (msg.role === 'user') { const msgContent = msg.content as VisionUserMessageContent - const userContext = buildUserMessageContext(msgContent) + const normalizedText = getNormalizedUserMessageText(msgContent) if (vision && msgContent.images && msgContent.images.length > 0) { resultMessages.push({ role: 'user', @@ -314,13 +324,13 @@ function addContextMessages( type: 'image_url' as const, image_url: { url: image, detail: 'auto' as const } })), - { type: 'text' as const, text: userContext } + { type: 'text' as const, text: normalizedText } ] }) } else { resultMessages.push({ role: 'user', - content: userContext + content: normalizedText }) } } else if (msg.role === 'assistant') { @@ -372,7 +382,7 @@ function addContextMessages( contextMessages.forEach((msg) => { if (msg.role === 'user') { const msgContent = msg.content as VisionUserMessageContent - const userContext = buildUserMessageContext(msgContent) + const normalizedText = getNormalizedUserMessageText(msgContent) if (vision && msgContent.images && msgContent.images.length > 0) { resultMessages.push({ role: 'user', @@ -381,13 +391,13 @@ function addContextMessages( type: 'image_url' as const, image_url: { url: image, detail: 'auto' as const } })), - { type: 'text' as const, text: userContext } + { type: 'text' as const, text: normalizedText } ] }) } else { resultMessages.push({ role: 'user', - content: userContext + content: normalizedText }) } } else if (msg.role === 'assistant') { From b35e4145452562e5c44665ec3d318ade10058b01 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Tue, 28 Oct 2025 23:40:31 +0800 Subject: [PATCH 06/13] fix(thread): expose tab window accessor --- src/main/presenter/tabPresenter.ts | 4 ++++ .../presenter/threadPresenter/conversationLifecycleManager.ts | 4 ++-- src/shared/types/presenters/legacy.presenters.d.ts | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/presenter/tabPresenter.ts b/src/main/presenter/tabPresenter.ts index ebfcd57e9..af9bc15ef 100644 --- a/src/main/presenter/tabPresenter.ts +++ b/src/main/presenter/tabPresenter.ts @@ -484,6 +484,10 @@ export class TabPresenter implements ITabPresenter { return tabId ? this.tabWindowMap.get(tabId) : undefined } + getTabWindowId(tabId: number): number | undefined { + return this.tabWindowMap.get(tabId) + } + /** * 通知渲染进程更新标签列表 */ diff --git a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts index 35c1e21b0..357256f7f 100644 --- a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts +++ b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts @@ -86,7 +86,7 @@ export class ConversationLifecycleManager { if (!tabView) { return 'unknown' } - const windowId = presenter.tabPresenter['tabWindowMap'].get(tabId) + const windowId = presenter.tabPresenter.getTabWindowId(tabId) return windowId ? 'main' : 'floating' } catch (error) { console.error('Error determining tab window type:', error) @@ -264,7 +264,7 @@ export class ConversationLifecycleManager { } if (tabId !== undefined) { - const windowId = presenter.tabPresenter['tabWindowMap'].get(tabId) + const windowId = presenter.tabPresenter.getTabWindowId(tabId) eventBus.sendToRenderer(TAB_EVENTS.TITLE_UPDATED, SendTarget.ALL_WINDOWS, { tabId, conversationId, diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index 556bb6146..e9410be96 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -227,6 +227,7 @@ export interface ITabPresenter { getActiveTabId(windowId: number): Promise getTabIdByWebContentsId(webContentsId: number): number | undefined getWindowIdByWebContentsId(webContentsId: number): number | undefined + getTabWindowId(tabId: number): number | undefined reorderTabs(windowId: number, tabIds: number[]): Promise moveTabToNewWindow(tabId: number, screenX?: number, screenY?: number): Promise captureTabArea( From d9d69f91aa0eacbc618ae36f42269d57fe18c469 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Wed, 29 Oct 2025 06:52:12 +0800 Subject: [PATCH 07/13] refactor(thread): split llm manager --- src/main/presenter/threadPresenter/index.ts | 597 +----------------- .../threadPresenter/llmGenerationManager.ts | 531 ++++++++++++++++ .../threadPresenter/messageManager.ts | 45 +- 3 files changed, 599 insertions(+), 574 deletions(-) create mode 100644 src/main/presenter/threadPresenter/llmGenerationManager.ts diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts index e304fc2a5..2ff7486e2 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -26,10 +26,9 @@ import { MessageFile, UserMessageContent } from '@shared/chat' -import { approximateTokenSize } from 'tokenx' import { SearchManager } from './searchManager' import { ContentEnricher } from './contentEnricher' -import { CONVERSATION_EVENTS, STREAM_EVENTS, TAB_EVENTS } from '@/events' +import { STREAM_EVENTS, TAB_EVENTS } from '@/events' import { nanoid } from 'nanoid' import { ContentBufferManager } from './contentBufferManager' import { @@ -48,6 +47,7 @@ import { CreateConversationOptions } from './conversationLifecycleManager' import type { GeneratingMessageState } from './types' +import { LLMGenerationManager } from './llmGenerationManager' export class ThreadPresenter implements IThreadPresenter { private sqlitePresenter: ISQLitePresenter @@ -58,6 +58,7 @@ export class ThreadPresenter implements IThreadPresenter { private generatingMessages: Map = new Map() private contentBufferManager: ContentBufferManager private conversationLifecycle: ConversationLifecycleManager + private llmGenerationManager: LLMGenerationManager public searchAssistantModel: MODEL_META | null = null public searchAssistantProviderId: string | null = null private searchingMessages: Set = new Set() @@ -81,6 +82,22 @@ export class ThreadPresenter implements IThreadPresenter { configPresenter, messageManager: this.messageManager }) + this.llmGenerationManager = new LLMGenerationManager({ + sqlitePresenter, + messageManager: this.messageManager, + contentBufferManager: this.contentBufferManager, + conversationLifecycle: this.conversationLifecycle, + generatingMessages: this.generatingMessages, + searchingMessages: this.searchingMessages, + summarizeConversationTitle: async (conversationId: string) => { + try { + return await this.summaryTitles(undefined, conversationId) + } catch (error) { + console.error('Failed to summarize title in main process:', error) + return undefined + } + } + }) // 监听Tab关闭事件,清理绑定关系 eventBus.on(TAB_EVENTS.CLOSED, (tabId: number) => { @@ -98,6 +115,11 @@ export class ThreadPresenter implements IThreadPresenter { this.messageManager.initializeUnfinishedMessages() } + setSearchAssistantModel(model: MODEL_META, providerId: string): void { + this.searchAssistantModel = model + this.searchAssistantProviderId = providerId + } + /** * 新增:查找指定会话ID所在的Tab ID * @param conversationId 会话ID @@ -108,539 +130,17 @@ export class ThreadPresenter implements IThreadPresenter { } async handleLLMAgentError(msg: LLMAgentEventData) { - const { eventId, error } = msg - const state = this.generatingMessages.get(eventId) - if (state) { - // 刷新剩余缓冲内容 - if (state.adaptiveBuffer) { - await this.contentBufferManager.flushAdaptiveBuffer(eventId) - } - - // 清理缓冲相关资源 - this.contentBufferManager.cleanupContentBuffer(state) - - await this.messageManager.handleMessageError(eventId, String(error)) - this.generatingMessages.delete(eventId) - } - eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, msg) + await this.llmGenerationManager.handleLLMAgentError(msg) } async handleLLMAgentEnd(msg: LLMAgentEventData) { - const { eventId, userStop } = msg - const state = this.generatingMessages.get(eventId) - if (state) { - console.log( - `[ThreadPresenter] Handling LLM agent end for message: ${eventId}, userStop: ${userStop}` - ) - - // 检查是否有未处理的权限请求 - const hasPendingPermissions = state.message.content.some( - (block) => - block.type === 'action' && - block.action_type === 'tool_call_permission' && - block.status === 'pending' - ) - - if (hasPendingPermissions) { - console.log( - `[ThreadPresenter] Message ${eventId} has pending permissions, keeping in generating state` - ) - // 保持消息在generating状态,等待权限响应 - // 但是要更新非权限块为success状态 - state.message.content.forEach((block) => { - if ( - !(block.type === 'action' && block.action_type === 'tool_call_permission') && - block.status === 'loading' - ) { - block.status = 'success' - } - }) - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - return - } - - console.log(`[ThreadPresenter] Finalizing message ${eventId} - no pending permissions`) - - // 正常完成流程 - await this.finalizeMessage(state, eventId, userStop || false) - } - - eventBus.sendToRenderer(STREAM_EVENTS.END, SendTarget.ALL_WINDOWS, msg) - } - - // 完成消息的通用方法 - private async finalizeMessage( - state: GeneratingMessageState, - eventId: string, - userStop: boolean - ): Promise { - // 将所有块设为success状态,但保留权限块的状态 - state.message.content.forEach((block) => { - if (block.type === 'action' && block.action_type === 'tool_call_permission') { - // 权限块保持其当前状态(granted/denied/error) - return - } - block.status = 'success' - }) - - // 计算completion tokens - let completionTokens = 0 - if (state.totalUsage) { - completionTokens = state.totalUsage.completion_tokens - } else { - for (const block of state.message.content) { - if ( - block.type === 'content' || - block.type === 'reasoning_content' || - block.type === 'tool_call' - ) { - completionTokens += approximateTokenSize(block.content) - } - } - } - - // 检查是否有内容块 - const hasContentBlock = state.message.content.some( - (block) => - block.type === 'content' || - block.type === 'reasoning_content' || - block.type === 'tool_call' || - block.type === 'image' - ) - - // 如果没有内容块,添加错误信息 - if (!hasContentBlock && !userStop) { - state.message.content.push({ - type: 'error', - content: 'common.error.noModelResponse', - status: 'error', - timestamp: Date.now() - }) - } - - const totalTokens = state.promptTokens + completionTokens - const generationTime = Date.now() - (state.firstTokenTime ?? state.startTime) - const tokensPerSecond = completionTokens / (generationTime / 1000) - const contextUsage = state?.totalUsage?.context_length - ? (totalTokens / state.totalUsage.context_length) * 100 - : 0 - - // 如果有reasoning_content,记录结束时间 - const metadata: Partial = { - totalTokens, - inputTokens: state.promptTokens, - outputTokens: completionTokens, - generationTime, - firstTokenTime: state.firstTokenTime ? state.firstTokenTime - state.startTime : 0, - tokensPerSecond, - contextUsage - } - - if (state.reasoningStartTime !== null && state.lastReasoningTime !== null) { - metadata.reasoningStartTime = state.reasoningStartTime - state.startTime - metadata.reasoningEndTime = state.lastReasoningTime - state.startTime - } - - // 刷新剩余缓冲内容 - if (state.adaptiveBuffer) { - await this.contentBufferManager.flushAdaptiveBuffer(eventId) - } - - // 清理缓冲相关资源 - this.contentBufferManager.cleanupContentBuffer(state) - - // 更新消息的usage信息 - await this.messageManager.updateMessageMetadata(eventId, metadata) - await this.messageManager.updateMessageStatus(eventId, 'sent') - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - this.generatingMessages.delete(eventId) - - // 处理标题更新和会话更新 - await this.handleConversationUpdates(state) - - // 广播消息生成完成事件 - const finalMessage = await this.messageManager.getMessage(eventId) - if (finalMessage) { - eventBus.sendToMain(CONVERSATION_EVENTS.MESSAGE_GENERATED, { - conversationId: finalMessage.conversationId, - message: finalMessage - }) - } - } - - // 处理会话更新和标题生成 - private async handleConversationUpdates(state: GeneratingMessageState): Promise { - const conversation = await this.conversationLifecycle.getConversation(state.conversationId) - let titleUpdated = false - - if (conversation.is_new === 1) { - try { - this.summaryTitles(undefined, state.conversationId).then((title) => { - if (title) { - this.renameConversation(state.conversationId, title).then(() => { - titleUpdated = true - }) - } - }) - } catch (e) { - console.error('Failed to summarize title in main process:', e) - } - } - - if (!titleUpdated) { - this.sqlitePresenter - .updateConversation(state.conversationId, { - updatedAt: Date.now() - }) - .then(() => { - console.log('updated conv time', state.conversationId) - }) - await this.conversationLifecycle.broadcastThreadListUpdate() - } + await this.llmGenerationManager.handleLLMAgentEnd(msg) } async handleLLMAgentResponse(msg: LLMAgentEventData) { - const currentTime = Date.now() - const { - eventId, - content, - reasoning_content, - tool_call_id, - tool_call_name, - tool_call_params, - tool_call_response, - maximum_tool_calls_reached, - tool_call_server_name, - tool_call_server_icons, - tool_call_server_description, - tool_call_response_raw, - tool_call, - totalUsage, - image_data - } = msg - const state = this.generatingMessages.get(eventId) - if (state) { - // 记录第一个token的时间 - if (state.firstTokenTime === null && (content || reasoning_content)) { - state.firstTokenTime = currentTime - await this.messageManager.updateMessageMetadata(eventId, { - firstTokenTime: currentTime - state.startTime - }) - } - if (totalUsage) { - state.totalUsage = totalUsage - state.promptTokens = totalUsage.prompt_tokens - } - - // 处理工具调用达到最大次数的情况 - if (maximum_tool_calls_reached) { - this.contentBufferManager.finalizeLastBlock(state) // 使用保护逻辑 - state.message.content.push({ - type: 'action', - content: 'common.error.maximumToolCallsReached', - status: 'success', - timestamp: currentTime, - action_type: 'maximum_tool_calls_reached', - tool_call: { - id: tool_call_id, - name: tool_call_name, - params: tool_call_params, - server_name: tool_call_server_name, - server_icons: tool_call_server_icons, - server_description: tool_call_server_description - }, - extra: { - needContinue: true - } - }) - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - return - } - - // 处理reasoning_content的时间戳 - if (reasoning_content) { - if (state.reasoningStartTime === null) { - state.reasoningStartTime = currentTime - await this.messageManager.updateMessageMetadata(eventId, { - reasoningStartTime: currentTime - state.startTime - }) - } - state.lastReasoningTime = currentTime - } - - const lastBlock = state.message.content[state.message.content.length - 1] - - // 检查tool_call_response_raw中是否包含搜索结果 - if (tool_call_response_raw && tool_call === 'end') { - try { - // 检查返回的内容中是否有deepchat-webpage类型的资源 - // 确保content是数组才调用some方法 - const hasSearchResults = - Array.isArray(tool_call_response_raw.content) && - tool_call_response_raw.content.some( - (item: { type: string; resource?: { mimeType: string } }) => - item?.type === 'resource' && - item?.resource?.mimeType === 'application/deepchat-webpage' - ) - - if (hasSearchResults && Array.isArray(tool_call_response_raw.content)) { - // 解析搜索结果 - const searchResults = tool_call_response_raw.content - .filter( - (item: { - type: string - resource?: { mimeType: string; text: string; uri?: string } - }) => - item.type === 'resource' && - item.resource?.mimeType === 'application/deepchat-webpage' - ) - .map((item: { resource: { text: string; uri?: string } }) => { - try { - const blobContent = JSON.parse(item.resource.text) as { - title?: string - url?: string - content?: string - icon?: string - } - return { - title: blobContent.title || '', - url: blobContent.url || item.resource.uri || '', - content: blobContent.content || '', - description: blobContent.content || '', - icon: blobContent.icon || '' - } - } catch (e) { - console.error('解析搜索结果失败:', e) - return null - } - }) - .filter(Boolean) - - if (searchResults.length > 0) { - const searchId = nanoid() - const pages = searchResults - .filter((item) => item && (item.icon || item.favicon)) - .slice(0, 6) - .map((item) => ({ - url: item?.url ?? '', - icon: item?.icon || item?.favicon || '' - })) - - const searchBlock: AssistantMessageBlock = { - id: searchId, - type: 'search', - content: '', - status: 'success', - timestamp: currentTime, - extra: { - total: searchResults.length, - searchId, - pages, - label: tool_call_name || 'web_search', - name: tool_call_name || 'web_search', - engine: tool_call_server_name || undefined, - provider: tool_call_server_name || undefined - } - } - - this.contentBufferManager.finalizeLastBlock(state) - state.message.content.push(searchBlock) - - for (const result of searchResults) { - await this.sqlitePresenter.addMessageAttachment( - eventId, - 'search_result', - JSON.stringify({ - title: result?.title || '', - url: result?.url || '', - content: result?.content || '', - description: result?.description || '', - icon: result?.icon || result?.favicon || '', - rank: typeof result?.rank === 'number' ? result.rank : undefined, - searchId - }) - ) - } - - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - } - } - } catch (error) { - console.error('处理搜索结果时出错:', error) - } - } - - // 处理工具调用 - if (tool_call) { - if (tool_call === 'start') { - // 创建新的工具调用块 - this.contentBufferManager.finalizeLastBlock(state) // 使用保护逻辑 - state.message.content.push({ - type: 'tool_call', - content: '', - status: 'loading', - timestamp: currentTime, - tool_call: { - id: tool_call_id, - name: tool_call_name, - params: tool_call_params || '', - server_name: tool_call_server_name, - server_icons: tool_call_server_icons, - server_description: tool_call_server_description - } - }) - } else if (tool_call === 'update') { - // 更新工具调用参数 - const toolCallBlock = state.message.content.find( - (block) => - block.type === 'tool_call' && - block.tool_call?.id === tool_call_id && - block.status === 'loading' - ) - - if (toolCallBlock && toolCallBlock.type === 'tool_call' && toolCallBlock.tool_call) { - toolCallBlock.tool_call.params = tool_call_params || '' - } - } else if (tool_call === 'running') { - // 工具调用正在执行 - const toolCallBlock = state.message.content.find( - (block) => - block.type === 'tool_call' && - block.tool_call?.id === tool_call_id && - block.status === 'loading' - ) - - if (toolCallBlock && toolCallBlock.type === 'tool_call') { - // 保持 loading 状态,但更新工具信息 - if (toolCallBlock.tool_call) { - toolCallBlock.tool_call.params = tool_call_params || '' - toolCallBlock.tool_call.server_name = tool_call_server_name - toolCallBlock.tool_call.server_icons = tool_call_server_icons - toolCallBlock.tool_call.server_description = tool_call_server_description - } - } - } else if (tool_call === 'permission-required') { - // 处理权限请求:创建权限请求块 - // 注意:不调用finalizeLastBlock,因为工具调用还没有完成,在等待权限 - - // 从 msg 中获取权限请求信息 - const { permission_request } = msg - - state.message.content.push({ - type: 'action', - action_type: 'tool_call_permission', - content: - typeof tool_call_response === 'string' - ? tool_call_response - : 'Permission required for this operation', - status: 'pending', - timestamp: currentTime, - tool_call: { - id: tool_call_id, - name: tool_call_name, - params: tool_call_params || '', - server_name: tool_call_server_name, - server_icons: tool_call_server_icons, - server_description: tool_call_server_description - }, - extra: { - permissionType: permission_request?.permissionType || 'write', - serverName: permission_request?.serverName || tool_call_server_name || '', - toolName: permission_request?.toolName || tool_call_name || '', - needsUserAction: true, - permissionRequest: JSON.stringify( - permission_request || { - toolName: tool_call_name || '', - serverName: tool_call_server_name || '', - permissionType: 'write' as const, - description: 'Permission required for this operation' - } - ) - } - }) - } else if (tool_call === 'end' || tool_call === 'error') { - // 查找对应的工具调用块 - const toolCallBlock = state.message.content.find( - (block) => - block.type === 'tool_call' && - ((tool_call_id && block.tool_call?.id === tool_call_id) || - block.tool_call?.name === tool_call_name) && - block.status === 'loading' - ) - - if (toolCallBlock && toolCallBlock.type === 'tool_call') { - if (tool_call === 'error') { - toolCallBlock.status = 'error' - if (toolCallBlock.tool_call) { - if (typeof tool_call_response === 'string') { - toolCallBlock.tool_call.response = tool_call_response || '执行失败' - } else { - toolCallBlock.tool_call.response = JSON.stringify(tool_call_response) - } - } - } else { - toolCallBlock.status = 'success' - if (toolCallBlock.tool_call) { - if (typeof tool_call_response === 'string') { - toolCallBlock.tool_call.response = tool_call_response - } else { - toolCallBlock.tool_call.response = JSON.stringify(tool_call_response) - } - } - } - } - } - } else if (image_data) { - // 处理图像数据 - this.contentBufferManager.finalizeLastBlock(state) // 使用保护逻辑 - state.message.content.push({ - type: 'image', - content: 'image', - status: 'success', - timestamp: currentTime, - image_data: image_data - }) - } else if (content) { - // 简化的直接内容处理 - await this.contentBufferManager.processContentDirectly( - state.message.id, - content, - currentTime - ) - } - - // 处理推理内容 - if (reasoning_content) { - if (lastBlock && lastBlock.type === 'reasoning_content') { - lastBlock.content += reasoning_content - if (lastBlock.reasoning_time) { - lastBlock.reasoning_time.end = currentTime - } - } else { - this.contentBufferManager.finalizeLastBlock(state) // 使用保护逻辑 - state.message.content.push({ - type: 'reasoning_content', - content: reasoning_content, - status: 'loading', - reasoning_time: { - start: currentTime, - end: currentTime - }, - timestamp: currentTime - }) - } - } - - // 更新消息内容 - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - } - eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, msg) + await this.llmGenerationManager.handleLLMAgentResponse(msg) } - setSearchAssistantModel(model: MODEL_META, providerId: string) { - this.searchAssistantModel = model - this.searchAssistantProviderId = providerId - } async getSearchEngines(): Promise { return this.searchManager.getEngines() } @@ -739,32 +239,11 @@ export class ThreadPresenter implements IThreadPresenter { async getContextMessages(conversationId: string): Promise { const conversation = await this.getConversation(conversationId) - // 计算需要获取的消息数量(假设每条消息平均300字) let messageCount = Math.ceil(conversation.settings.contextLength / 300) if (messageCount < 2) { messageCount = 2 } - const messages = await this.messageManager.getContextMessages(conversationId, messageCount) - - // 确保消息列表以用户消息开始 - while (messages.length > 0 && messages[0].role !== 'user') { - messages.shift() - } - - return messages.map((msg) => { - if (msg.role === 'user') { - const newMsg = { ...msg } - const msgContent = newMsg.content as UserMessageContent - if (msgContent.content) { - ;(newMsg.content as UserMessageContent).text = formatUserMessageContent( - msgContent.content - ) - } - return newMsg - } else { - return msg - } - }) + return this.messageManager.getContextMessages(conversationId, messageCount) } async clearContext(conversationId: string): Promise { @@ -892,25 +371,7 @@ export class ThreadPresenter implements IThreadPresenter { * @returns 历史消息列表,按时间正序排列 */ private async getMessageHistory(messageId: string, limit: number = 100): Promise { - const message = await this.messageManager.getMessage(messageId) - if (!message) { - throw new Error('找不到指定的消息') - } - - const { list: messages } = await this.messageManager.getMessageThread( - message.conversationId, - 1, - limit * 2 - ) - - // 找到目标消息在列表中的位置 - const targetIndex = messages.findIndex((msg) => msg.id === messageId) - if (targetIndex === -1) { - return [message] - } - - // 返回目标消息之前的消息(包括目标消息) - return messages.slice(Math.max(0, targetIndex - limit + 1), targetIndex + 1) + return this.messageManager.getMessageHistory(messageId, limit) } private async rewriteUserSearchQuery( diff --git a/src/main/presenter/threadPresenter/llmGenerationManager.ts b/src/main/presenter/threadPresenter/llmGenerationManager.ts new file mode 100644 index 000000000..07e8596b8 --- /dev/null +++ b/src/main/presenter/threadPresenter/llmGenerationManager.ts @@ -0,0 +1,531 @@ +import { ISQLitePresenter, MESSAGE_METADATA, LLMAgentEventData } from '../../../shared/presenter' +import { MessageManager } from './messageManager' +import { ContentBufferManager } from './contentBufferManager' +import { ConversationLifecycleManager } from './conversationLifecycleManager' +import type { GeneratingMessageState } from './types' +import { eventBus, SendTarget } from '@/eventbus' +import { CONVERSATION_EVENTS, STREAM_EVENTS } from '@/events' +import { AssistantMessageBlock } from '@shared/chat' +import { approximateTokenSize } from 'tokenx' +import { nanoid } from 'nanoid' + +interface LLMGenerationManagerOptions { + sqlitePresenter: ISQLitePresenter + messageManager: MessageManager + contentBufferManager: ContentBufferManager + conversationLifecycle: ConversationLifecycleManager + generatingMessages: Map + searchingMessages: Set + summarizeConversationTitle: (conversationId: string) => Promise +} + +export class LLMGenerationManager { + private sqlitePresenter: ISQLitePresenter + private messageManager: MessageManager + private contentBufferManager: ContentBufferManager + private conversationLifecycle: ConversationLifecycleManager + private generatingMessages: Map + private searchingMessages: Set + private summarizeConversationTitle: (conversationId: string) => Promise + + constructor(options: LLMGenerationManagerOptions) { + this.sqlitePresenter = options.sqlitePresenter + this.messageManager = options.messageManager + this.contentBufferManager = options.contentBufferManager + this.conversationLifecycle = options.conversationLifecycle + this.generatingMessages = options.generatingMessages + this.searchingMessages = options.searchingMessages + this.summarizeConversationTitle = options.summarizeConversationTitle + } + + async handleLLMAgentError(msg: LLMAgentEventData): Promise { + const { eventId, error } = msg + const state = this.generatingMessages.get(eventId) + if (state) { + if (state.adaptiveBuffer) { + await this.contentBufferManager.flushAdaptiveBuffer(eventId) + } + + this.contentBufferManager.cleanupContentBuffer(state) + + await this.messageManager.handleMessageError(eventId, String(error)) + this.generatingMessages.delete(eventId) + } + eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, msg) + } + + async handleLLMAgentEnd(msg: LLMAgentEventData): Promise { + const { eventId, userStop } = msg + const state = this.generatingMessages.get(eventId) + if (state) { + if (state.adaptiveBuffer) { + await this.contentBufferManager.flushAdaptiveBuffer(eventId) + } + + this.contentBufferManager.cleanupContentBuffer(state) + + const hasPendingPermissions = state.message.content.some( + (block) => + block.type === 'action' && + block.action_type === 'tool_call_permission' && + block.status === 'pending' + ) + + if (hasPendingPermissions) { + state.message.content.forEach((block) => { + if ( + !(block.type === 'action' && block.action_type === 'tool_call_permission') && + block.status === 'loading' + ) { + block.status = 'success' + } + }) + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + return + } + + await this.finalizeMessage(state, eventId, Boolean(userStop)) + } + + eventBus.sendToRenderer(STREAM_EVENTS.END, SendTarget.ALL_WINDOWS, msg) + } + + async handleLLMAgentResponse(msg: LLMAgentEventData): Promise { + const currentTime = Date.now() + const { + eventId, + content, + reasoning_content, + tool_call_id, + tool_call_name, + tool_call_params, + tool_call_response, + maximum_tool_calls_reached, + tool_call_server_name, + tool_call_server_icons, + tool_call_server_description, + tool_call_response_raw, + tool_call, + totalUsage, + image_data + } = msg + const state = this.generatingMessages.get(eventId) + if (!state) { + return + } + + if (state.firstTokenTime === null && (content || reasoning_content)) { + state.firstTokenTime = currentTime + await this.messageManager.updateMessageMetadata(eventId, { + firstTokenTime: currentTime - state.startTime + }) + } + if (totalUsage) { + state.totalUsage = totalUsage + state.promptTokens = totalUsage.prompt_tokens + } + + if (maximum_tool_calls_reached) { + this.contentBufferManager.finalizeLastBlock(state) + state.message.content.push({ + type: 'action', + content: 'common.error.maximumToolCallsReached', + status: 'success', + timestamp: currentTime, + action_type: 'maximum_tool_calls_reached', + tool_call: { + id: tool_call_id, + name: tool_call_name, + params: tool_call_params, + server_name: tool_call_server_name, + server_icons: tool_call_server_icons, + server_description: tool_call_server_description + }, + extra: { + needContinue: true + } + }) + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + return + } + + if (reasoning_content) { + if (state.reasoningStartTime === null) { + state.reasoningStartTime = currentTime + await this.messageManager.updateMessageMetadata(eventId, { + reasoningStartTime: currentTime - state.startTime + }) + } + state.lastReasoningTime = currentTime + } + + const lastBlock = state.message.content[state.message.content.length - 1] + + if (tool_call_response_raw && tool_call === 'end') { + try { + const hasSearchResults = + Array.isArray(tool_call_response_raw.content) && + tool_call_response_raw.content.some( + (item: { type: string; resource?: { mimeType: string } }) => + item?.type === 'resource' && + item?.resource?.mimeType === 'application/deepchat-webpage' + ) + + if (hasSearchResults && Array.isArray(tool_call_response_raw.content)) { + const searchResults = tool_call_response_raw.content + .filter( + (item: { + type: string + resource?: { mimeType: string; text: string; uri?: string } + }) => + item.type === 'resource' && + item.resource?.mimeType === 'application/deepchat-webpage' + ) + .map((item: { resource: { text: string; uri?: string } }) => { + try { + const blobContent = JSON.parse(item.resource.text) as { + title?: string + url?: string + content?: string + icon?: string + } + return { + title: blobContent.title || '', + url: blobContent.url || item.resource.uri || '', + content: blobContent.content || '', + description: blobContent.content || '', + icon: blobContent.icon || '' + } + } catch (e) { + console.error('解析搜索结果失败:', e) + return null + } + }) + .filter(Boolean) + + if (searchResults.length > 0) { + const searchId = nanoid() + const pages = searchResults + .filter((item) => item && (item.icon || item.favicon)) + .slice(0, 6) + .map((item) => ({ + url: item?.url ?? '', + icon: item?.icon || item?.favicon || '' + })) + + const searchBlock: AssistantMessageBlock = { + id: searchId, + type: 'search', + content: '', + status: 'success', + timestamp: currentTime, + extra: { + total: searchResults.length, + searchId, + pages, + label: tool_call_name || 'web_search', + name: tool_call_name || 'web_search', + engine: tool_call_server_name || undefined, + provider: tool_call_server_name || undefined + } + } + + this.contentBufferManager.finalizeLastBlock(state) + state.message.content.push(searchBlock) + + for (const result of searchResults) { + await this.sqlitePresenter.addMessageAttachment( + eventId, + 'search_result', + JSON.stringify({ + title: result?.title || '', + url: result?.url || '', + content: result?.content || '', + description: result?.description || '', + icon: result?.icon || result?.favicon || '', + rank: typeof result?.rank === 'number' ? result.rank : undefined, + searchId + }) + ) + } + + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + } + } + } catch (error) { + console.error('处理搜索结果时出错:', error) + } + } + + if (tool_call) { + if (tool_call === 'start') { + this.contentBufferManager.finalizeLastBlock(state) + state.message.content.push({ + type: 'tool_call', + content: '', + status: 'loading', + timestamp: currentTime, + tool_call: { + id: tool_call_id, + name: tool_call_name, + params: tool_call_params || '', + server_name: tool_call_server_name, + server_icons: tool_call_server_icons, + server_description: tool_call_server_description + } + }) + } else if (tool_call === 'update') { + const toolCallBlock = state.message.content.find( + (block) => + block.type === 'tool_call' && + block.tool_call?.id === tool_call_id && + block.status === 'loading' + ) + + if (toolCallBlock && toolCallBlock.type === 'tool_call' && toolCallBlock.tool_call) { + toolCallBlock.tool_call.params = tool_call_params || '' + } + } else if (tool_call === 'running') { + const toolCallBlock = state.message.content.find( + (block) => + block.type === 'tool_call' && + block.tool_call?.id === tool_call_id && + block.status === 'loading' + ) + + if (toolCallBlock && toolCallBlock.type === 'tool_call') { + if (toolCallBlock.tool_call) { + toolCallBlock.tool_call.params = tool_call_params || '' + toolCallBlock.tool_call.server_name = tool_call_server_name + toolCallBlock.tool_call.server_icons = tool_call_server_icons + toolCallBlock.tool_call.server_description = tool_call_server_description + } + } + } else if (tool_call === 'permission-required') { + if (lastBlock && lastBlock.type === 'tool_call' && lastBlock.tool_call) { + lastBlock.status = 'success' + } + + this.contentBufferManager.finalizeLastBlock(state) + state.message.content.push({ + type: 'action', + content: tool_call_response || '', + status: 'pending', + timestamp: currentTime, + action_type: 'tool_call_permission', + tool_call: { + id: tool_call_id, + name: tool_call_name, + params: tool_call_params || '', + server_name: tool_call_server_name, + server_icons: tool_call_server_icons, + server_description: tool_call_server_description + } + }) + + this.searchingMessages.add(eventId) + state.isSearching = true + } else if (tool_call === 'permission-granted') { + if ( + lastBlock && + lastBlock.type === 'action' && + lastBlock.action_type === 'tool_call_permission' + ) { + lastBlock.status = 'success' + lastBlock.content = tool_call_response || '' + } + } else if (tool_call === 'permission-denied') { + if ( + lastBlock && + lastBlock.type === 'action' && + lastBlock.action_type === 'tool_call_permission' + ) { + lastBlock.status = 'error' + lastBlock.content = tool_call_response || '' + } + } else if (tool_call === 'continue') { + if ( + lastBlock && + lastBlock.type === 'action' && + lastBlock.action_type === 'tool_call_permission' + ) { + lastBlock.status = 'success' + } + } else if (tool_call === 'end') { + const toolCallBlock = state.message.content.find( + (block) => + block.type === 'tool_call' && + block.tool_call?.id === tool_call_id && + block.status === 'loading' + ) + + if (toolCallBlock && toolCallBlock.type === 'tool_call') { + toolCallBlock.status = 'success' + if (toolCallBlock.tool_call) { + toolCallBlock.tool_call.response = tool_call_response || '' + } + } + + if ( + lastBlock && + lastBlock.type === 'action' && + lastBlock.action_type === 'tool_call_permission' + ) { + lastBlock.status = 'success' + } + } + } + + if (image_data) { + const imageBlock: AssistantMessageBlock = { + type: 'image', + status: 'success', + timestamp: currentTime, + content: image_data + } + state.message.content.push(imageBlock) + } + + if (content) { + if (!lastBlock || lastBlock.type !== 'content' || lastBlock.status !== 'loading') { + this.contentBufferManager.finalizeLastBlock(state) + state.message.content.push({ + type: 'content', + content: content || '', + status: 'loading', + timestamp: currentTime + }) + } else if (lastBlock.type === 'content') { + lastBlock.content += content + } + } + + if (reasoning_content) { + if (!lastBlock || lastBlock.type !== 'reasoning_content') { + this.contentBufferManager.finalizeLastBlock(state) + state.message.content.push({ + type: 'reasoning_content', + content: reasoning_content || '', + status: 'loading', + timestamp: currentTime + }) + } else if (lastBlock.type === 'reasoning_content') { + lastBlock.content += reasoning_content + } + } + + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + } + + private async finalizeMessage( + state: GeneratingMessageState, + eventId: string, + userStop: boolean + ): Promise { + state.message.content.forEach((block) => { + if (block.type === 'action' && block.action_type === 'tool_call_permission') { + return + } + block.status = 'success' + }) + + let completionTokens = 0 + if (state.totalUsage) { + completionTokens = state.totalUsage.completion_tokens + } else { + for (const block of state.message.content) { + if ( + block.type === 'content' || + block.type === 'reasoning_content' || + block.type === 'tool_call' + ) { + completionTokens += approximateTokenSize(block.content) + } + } + } + + const hasContentBlock = state.message.content.some( + (block) => + block.type === 'content' || + block.type === 'reasoning_content' || + block.type === 'tool_call' || + block.type === 'image' + ) + + if (!hasContentBlock && !userStop) { + state.message.content.push({ + type: 'error', + content: 'common.error.noModelResponse', + status: 'error', + timestamp: Date.now() + }) + } + + const totalTokens = state.promptTokens + completionTokens + const generationTime = Date.now() - (state.firstTokenTime ?? state.startTime) + const tokensPerSecond = completionTokens / (generationTime / 1000) + const contextUsage = state?.totalUsage?.context_length + ? (totalTokens / state.totalUsage.context_length) * 100 + : 0 + + const metadata: Partial = { + totalTokens, + inputTokens: state.promptTokens, + outputTokens: completionTokens, + generationTime, + firstTokenTime: state.firstTokenTime ? state.firstTokenTime - state.startTime : 0, + tokensPerSecond, + contextUsage + } + + if (state.reasoningStartTime !== null && state.lastReasoningTime !== null) { + metadata.reasoningStartTime = state.reasoningStartTime - state.startTime + metadata.reasoningEndTime = state.lastReasoningTime - state.startTime + } + + await this.messageManager.updateMessageMetadata(eventId, metadata) + await this.messageManager.updateMessageStatus(eventId, 'sent') + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + this.generatingMessages.delete(eventId) + + await this.handleConversationUpdates(state) + + const finalMessage = await this.messageManager.getMessage(eventId) + if (finalMessage) { + eventBus.sendToMain(CONVERSATION_EVENTS.MESSAGE_GENERATED, { + conversationId: finalMessage.conversationId, + message: finalMessage + }) + } + } + + private async handleConversationUpdates(state: GeneratingMessageState): Promise { + const conversation = await this.conversationLifecycle.getConversation(state.conversationId) + let titleUpdated = false + + if (conversation.is_new === 1) { + try { + this.summarizeConversationTitle(state.conversationId).then((title) => { + if (title) { + this.conversationLifecycle.renameConversation(state.conversationId, title).then(() => { + titleUpdated = true + }) + } + }) + } catch (e) { + console.error('Failed to summarize title in main process:', e) + } + } + + if (!titleUpdated) { + await this.sqlitePresenter + .updateConversation(state.conversationId, { + updatedAt: Date.now() + }) + .then(() => { + console.log('updated conv time', state.conversationId) + }) + await this.conversationLifecycle.broadcastThreadListUpdate() + } + } +} diff --git a/src/main/presenter/threadPresenter/messageManager.ts b/src/main/presenter/threadPresenter/messageManager.ts index b34aad858..30211c641 100644 --- a/src/main/presenter/threadPresenter/messageManager.ts +++ b/src/main/presenter/threadPresenter/messageManager.ts @@ -14,6 +14,7 @@ import { UserMessageMentionBlock, UserMessageCodeBlock } from '@shared/chat' +import { formatUserMessageContent } from './messageContent' import { eventBus, SendTarget } from '@/eventbus' import { CONVERSATION_EVENTS } from '@/events' @@ -227,30 +228,62 @@ export class MessageManager implements IMessageManager { }) } - async getContextMessages(conversationId: string, messageCount: number): Promise { + async getContextMessages( + conversationId: string, + messageCount: number, + { ensureUserStart = true, normalizeUserText = true } = {} + ): Promise { const sqliteMessages = await this.sqlitePresenter.queryMessages(conversationId) - // 按创建时间和序号倒序排序 const messages = sqliteMessages .sort((a, b) => { - // 首先按创建时间倒序排序 const timeCompare = b.created_at - a.created_at if (timeCompare !== 0) return timeCompare - // 如果创建时间相同,按序号倒序排序 return b.order_seq - a.order_seq }) - .slice(0, messageCount) // 只取需要的消息数量 + .slice(0, messageCount) .sort((a, b) => { - // 再次按正序排序以保持对话顺序 const timeCompare = a.created_at - b.created_at if (timeCompare !== 0) return timeCompare return a.order_seq - b.order_seq }) .map((msg) => this.convertToMessage(msg)) + if (ensureUserStart) { + while (messages.length > 0 && messages[0].role !== 'user') { + messages.shift() + } + } + + if (normalizeUserText) { + return messages.map((msg) => { + if (msg.role !== 'user') { + return msg + } + const normalized = { ...msg } + const userContent = normalized.content as UserMessageContent + if (userContent?.content) { + ;(normalized.content as UserMessageContent).text = formatUserMessageContent( + userContent.content + ) + } + return normalized + }) + } + return messages } + async getMessageHistory(messageId: string, limit: number = 100): Promise { + const message = await this.getMessage(messageId) + const { list: messages } = await this.getMessageThread(message.conversationId, 1, limit * 2) + const targetIndex = messages.findIndex((msg) => msg.id === messageId) + if (targetIndex === -1) { + return [message] + } + return messages.slice(Math.max(0, targetIndex - limit + 1), targetIndex + 1) + } + async getLastUserMessage(conversationId: string): Promise { const sqliteMessage = await this.sqlitePresenter.getLastUserMessage(conversationId) if (!sqliteMessage) { From 955d5ef92840570828d4bec3034d6b68a8187a99 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Wed, 29 Oct 2025 07:05:21 +0800 Subject: [PATCH 08/13] refactor(thread): inline llm handling --- .../threadPresenter/contentBufferManager.ts | 260 ------- src/main/presenter/threadPresenter/index.ts | 702 +++++++++++++++++- .../threadPresenter/llmGenerationManager.ts | 531 ------------- 3 files changed, 671 insertions(+), 822 deletions(-) delete mode 100644 src/main/presenter/threadPresenter/contentBufferManager.ts delete mode 100644 src/main/presenter/threadPresenter/llmGenerationManager.ts diff --git a/src/main/presenter/threadPresenter/contentBufferManager.ts b/src/main/presenter/threadPresenter/contentBufferManager.ts deleted file mode 100644 index 7ac409a83..000000000 --- a/src/main/presenter/threadPresenter/contentBufferManager.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { eventBus, SendTarget } from '@/eventbus' -import { STREAM_EVENTS } from '@/events' - -import { finalizeAssistantMessageBlocks } from '@shared/chat/messageBlocks' - -import type { MessageManager } from './messageManager' -import type { GeneratingMessageState } from './types' - -interface ContentBufferDependencies { - messageManager: MessageManager - generatingMessages: Map -} - -export class ContentBufferManager { - private messageManager: MessageManager - private generatingMessages: Map - - constructor({ messageManager, generatingMessages }: ContentBufferDependencies) { - this.messageManager = messageManager - this.generatingMessages = generatingMessages - } - - cleanupContentBuffer(state: GeneratingMessageState): void { - if (state.flushTimeout) { - clearTimeout(state.flushTimeout) - state.flushTimeout = undefined - } - if (state.throttleTimeout) { - clearTimeout(state.throttleTimeout) - state.throttleTimeout = undefined - } - state.adaptiveBuffer = undefined - state.lastRendererUpdateTime = undefined - } - - async flushAdaptiveBuffer(eventId: string): Promise { - const state = this.generatingMessages.get(eventId) - if (!state?.adaptiveBuffer) return - - const buffer = state.adaptiveBuffer - const now = Date.now() - - if (state.flushTimeout) { - clearTimeout(state.flushTimeout) - state.flushTimeout = undefined - } - - try { - if (buffer.content && buffer.sentPosition < buffer.content.length) { - const newContent = buffer.content.slice(buffer.sentPosition) - if (newContent) { - await this.processBufferedContent(state, eventId, newContent, now) - buffer.sentPosition = buffer.content.length - } - } - } catch (error) { - console.error('[ContentBuffer] ERROR flushing adaptive buffer', { - eventId, - err: error - }) - throw error - } finally { - state.adaptiveBuffer = undefined - } - } - - finalizeLastBlock(state: GeneratingMessageState): void { - finalizeAssistantMessageBlocks(state.message.content) - } - - async processContentDirectly( - eventId: string, - content: string, - currentTime: number - ): Promise { - const state = this.generatingMessages.get(eventId) - if (!state) return - - try { - if (this.shouldSplitContent(content)) { - await this.processLargeContentInChunks(state, eventId, content, currentTime) - } else { - await this.processNormalContent(state, eventId, content, currentTime) - } - } catch (error) { - console.error('[ContentBuffer] ERROR processing content', { - eventId, - size: content.length, - err: error - }) - throw error - } - } - - private async processBufferedContent( - state: GeneratingMessageState, - eventId: string, - content: string, - currentTime: number - ): Promise { - const buffer = state.adaptiveBuffer - - if (buffer?.isLargeContent) { - await this.processLargeContentAsynchronously(state, eventId, content, currentTime) - return - } - - await this.processNormalContent(state, eventId, content, currentTime) - } - - private async processLargeContentAsynchronously( - state: GeneratingMessageState, - eventId: string, - content: string, - currentTime: number - ): Promise { - const buffer = state.adaptiveBuffer - if (!buffer) return - - buffer.isProcessing = true - - try { - const chunks = this.splitLargeContent(content) - const totalChunks = chunks.length - - console.log( - `[ThreadPresenter] Processing ${totalChunks} chunks asynchronously for ${content.length} bytes` - ) - - const lastBlock = state.message.content[state.message.content.length - 1] - let contentBlock: any - - if (lastBlock && lastBlock.type === 'content') { - contentBlock = lastBlock - } else { - this.finalizeLastBlock(state) - contentBlock = { - type: 'content', - content: '', - status: 'loading', - timestamp: currentTime - } - state.message.content.push(contentBlock) - } - - const batchSize = 5 - for (let batchStart = 0; batchStart < chunks.length; batchStart += batchSize) { - const batchEnd = Math.min(batchStart + batchSize, chunks.length) - const batch = chunks.slice(batchStart, batchEnd) - - const batchContent = batch.join('') - contentBlock.content += batchContent - - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - - const eventData: any = { - eventId, - content: batchContent, - chunkInfo: { - current: batchEnd, - total: totalChunks, - isLargeContent: true, - batchSize: batch.length - } - } - - eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, eventData) - - if (batchEnd < chunks.length) { - await new Promise((resolve) => setImmediate(resolve)) - } - } - - console.log(`[ThreadPresenter] Completed processing ${totalChunks} chunks`) - } catch (error) { - console.error('[ThreadPresenter] Error in processLargeContentAsynchronously:', error) - } finally { - buffer.isProcessing = false - } - } - - private async processNormalContent( - state: GeneratingMessageState, - eventId: string, - content: string, - currentTime: number - ): Promise { - const lastBlock = state.message.content[state.message.content.length - 1] - - if (lastBlock && lastBlock.type === 'content') { - lastBlock.content += content - } else { - this.finalizeLastBlock(state) - state.message.content.push({ - type: 'content', - content: content, - status: 'loading', - timestamp: currentTime - }) - } - - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - } - - private async processLargeContentInChunks( - state: GeneratingMessageState, - eventId: string, - content: string, - currentTime: number - ): Promise { - console.log(`[ThreadPresenter] Processing large content in chunks: ${content.length} bytes`) - - const lastBlock = state.message.content[state.message.content.length - 1] - let contentBlock: any - - if (lastBlock && lastBlock.type === 'content') { - contentBlock = lastBlock - } else { - this.finalizeLastBlock(state) - contentBlock = { - type: 'content', - content: '', - status: 'loading', - timestamp: currentTime - } - state.message.content.push(contentBlock) - } - - contentBlock.content += content - - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - } - - private splitLargeContent(content: string): string[] { - const chunks: string[] = [] - let maxChunkSize = 4096 - - if (content.includes('data:image/')) { - maxChunkSize = 512 - } - - if (content.length > 50000) { - maxChunkSize = Math.min(maxChunkSize, 256) - } - - for (let i = 0; i < content.length; i += maxChunkSize) { - chunks.push(content.slice(i, i + maxChunkSize)) - } - - return chunks - } - - private shouldSplitContent(content: string): boolean { - const sizeThreshold = 8192 - const hasBase64Image = content.includes('data:image/') && content.includes('base64,') - const hasLargeBase64 = hasBase64Image && content.length > 5120 - - return content.length > sizeThreshold || hasLargeBase64 - } -} diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts index 2ff7486e2..104eecda8 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -28,9 +28,8 @@ import { } from '@shared/chat' import { SearchManager } from './searchManager' import { ContentEnricher } from './contentEnricher' -import { STREAM_EVENTS, TAB_EVENTS } from '@/events' +import { CONVERSATION_EVENTS, STREAM_EVENTS, TAB_EVENTS } from '@/events' import { nanoid } from 'nanoid' -import { ContentBufferManager } from './contentBufferManager' import { buildUserMessageContext, formatUserMessageContent, @@ -47,7 +46,8 @@ import { CreateConversationOptions } from './conversationLifecycleManager' import type { GeneratingMessageState } from './types' -import { LLMGenerationManager } from './llmGenerationManager' +import { finalizeAssistantMessageBlocks } from '@shared/chat/messageBlocks' +import { approximateTokenSize } from 'tokenx' export class ThreadPresenter implements IThreadPresenter { private sqlitePresenter: ISQLitePresenter @@ -56,9 +56,7 @@ export class ThreadPresenter implements IThreadPresenter { private configPresenter: IConfigPresenter private searchManager: SearchManager private generatingMessages: Map = new Map() - private contentBufferManager: ContentBufferManager private conversationLifecycle: ConversationLifecycleManager - private llmGenerationManager: LLMGenerationManager public searchAssistantModel: MODEL_META | null = null public searchAssistantProviderId: string | null = null private searchingMessages: Set = new Set() @@ -73,31 +71,11 @@ export class ThreadPresenter implements IThreadPresenter { this.llmProviderPresenter = llmProviderPresenter this.searchManager = new SearchManager() this.configPresenter = configPresenter - this.contentBufferManager = new ContentBufferManager({ - messageManager: this.messageManager, - generatingMessages: this.generatingMessages - }) this.conversationLifecycle = new ConversationLifecycleManager({ sqlitePresenter, configPresenter, messageManager: this.messageManager }) - this.llmGenerationManager = new LLMGenerationManager({ - sqlitePresenter, - messageManager: this.messageManager, - contentBufferManager: this.contentBufferManager, - conversationLifecycle: this.conversationLifecycle, - generatingMessages: this.generatingMessages, - searchingMessages: this.searchingMessages, - summarizeConversationTitle: async (conversationId: string) => { - try { - return await this.summaryTitles(undefined, conversationId) - } catch (error) { - console.error('Failed to summarize title in main process:', error) - return undefined - } - } - }) // 监听Tab关闭事件,清理绑定关系 eventBus.on(TAB_EVENTS.CLOSED, (tabId: number) => { @@ -130,15 +108,677 @@ export class ThreadPresenter implements IThreadPresenter { } async handleLLMAgentError(msg: LLMAgentEventData) { - await this.llmGenerationManager.handleLLMAgentError(msg) + const { eventId, error } = msg + const state = this.generatingMessages.get(eventId) + if (state) { + if (state.adaptiveBuffer) { + await this.flushAdaptiveBuffer(eventId) + } + + this.cleanupContentBuffer(state) + + await this.messageManager.handleMessageError(eventId, String(error)) + this.generatingMessages.delete(eventId) + } + eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, msg) } async handleLLMAgentEnd(msg: LLMAgentEventData) { - await this.llmGenerationManager.handleLLMAgentEnd(msg) + const { eventId, userStop } = msg + const state = this.generatingMessages.get(eventId) + if (state) { + if (state.adaptiveBuffer) { + await this.flushAdaptiveBuffer(eventId) + } + + this.cleanupContentBuffer(state) + + const hasPendingPermissions = state.message.content.some( + (block) => + block.type === 'action' && + block.action_type === 'tool_call_permission' && + block.status === 'pending' + ) + + if (hasPendingPermissions) { + state.message.content.forEach((block) => { + if ( + !(block.type === 'action' && block.action_type === 'tool_call_permission') && + block.status === 'loading' + ) { + block.status = 'success' + } + }) + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + return + } + + await this.finalizeMessage(state, eventId, Boolean(userStop)) + } + + eventBus.sendToRenderer(STREAM_EVENTS.END, SendTarget.ALL_WINDOWS, msg) } async handleLLMAgentResponse(msg: LLMAgentEventData) { - await this.llmGenerationManager.handleLLMAgentResponse(msg) + const currentTime = Date.now() + const { + eventId, + content, + reasoning_content, + tool_call_id, + tool_call_name, + tool_call_params, + tool_call_response, + maximum_tool_calls_reached, + tool_call_server_name, + tool_call_server_icons, + tool_call_server_description, + tool_call_response_raw, + tool_call, + totalUsage, + image_data + } = msg + const state = this.generatingMessages.get(eventId) + if (!state) { + return + } + + if (state.firstTokenTime === null && (content || reasoning_content)) { + state.firstTokenTime = currentTime + await this.messageManager.updateMessageMetadata(eventId, { + firstTokenTime: currentTime - state.startTime + }) + } + if (totalUsage) { + state.totalUsage = totalUsage + state.promptTokens = totalUsage.prompt_tokens + } + + if (maximum_tool_calls_reached) { + this.finalizeLastBlock(state) + state.message.content.push({ + type: 'action', + content: 'common.error.maximumToolCallsReached', + status: 'success', + timestamp: currentTime, + action_type: 'maximum_tool_calls_reached', + tool_call: { + id: tool_call_id, + name: tool_call_name, + params: tool_call_params, + server_name: tool_call_server_name, + server_icons: tool_call_server_icons, + server_description: tool_call_server_description + }, + extra: { + needContinue: true + } + }) + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + return + } + + if (reasoning_content) { + if (state.reasoningStartTime === null) { + state.reasoningStartTime = currentTime + await this.messageManager.updateMessageMetadata(eventId, { + reasoningStartTime: currentTime - state.startTime + }) + } + state.lastReasoningTime = currentTime + } + + const lastBlock = state.message.content[state.message.content.length - 1] + + if (tool_call_response_raw && tool_call === 'end') { + try { + const hasSearchResults = + Array.isArray(tool_call_response_raw.content) && + tool_call_response_raw.content.some( + (item: { type: string; resource?: { mimeType: string } }) => + item?.type === 'resource' && + item?.resource?.mimeType === 'application/deepchat-webpage' + ) + + if (hasSearchResults && Array.isArray(tool_call_response_raw.content)) { + const searchResults = tool_call_response_raw.content + .filter( + (item: { + type: string + resource?: { mimeType: string; text: string; uri?: string } + }) => + item.type === 'resource' && + item.resource?.mimeType === 'application/deepchat-webpage' + ) + .map((item: { resource: { text: string; uri?: string } }) => { + try { + const blobContent = JSON.parse(item.resource.text) as { + title?: string + url?: string + content?: string + icon?: string + } + return { + title: blobContent.title || '', + url: blobContent.url || item.resource.uri || '', + content: blobContent.content || '', + description: blobContent.content || '', + icon: blobContent.icon || '' + } + } catch (e) { + console.error('解析搜索结果失败:', e) + return null + } + }) + .filter(Boolean) + + if (searchResults.length > 0) { + const searchId = nanoid() + const pages = searchResults + .filter((item) => item && (item.icon || item.favicon)) + .slice(0, 6) + .map((item) => ({ + url: item?.url ?? '', + icon: item?.icon || item?.favicon || '' + })) + + const searchBlock: AssistantMessageBlock = { + id: searchId, + type: 'search', + content: '', + status: 'success', + timestamp: currentTime, + extra: { + total: searchResults.length, + searchId, + pages, + label: tool_call_name || 'web_search', + name: tool_call_name || 'web_search', + engine: tool_call_server_name || undefined, + provider: tool_call_server_name || undefined + } + } + + this.finalizeLastBlock(state) + state.message.content.push(searchBlock) + + for (const result of searchResults) { + await this.sqlitePresenter.addMessageAttachment( + eventId, + 'search_result', + JSON.stringify({ + title: result?.title || '', + url: result?.url || '', + content: result?.content || '', + description: result?.description || '', + icon: result?.icon || result?.favicon || '', + rank: typeof result?.rank === 'number' ? result.rank : undefined, + searchId + }) + ) + } + + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + } + } + } catch (error) { + console.error('处理搜索结果时出错:', error) + } + } + + if (tool_call) { + if (tool_call === 'start') { + this.finalizeLastBlock(state) + state.message.content.push({ + type: 'tool_call', + content: '', + status: 'loading', + timestamp: currentTime, + tool_call: { + id: tool_call_id, + name: tool_call_name, + params: tool_call_params || '', + server_name: tool_call_server_name, + server_icons: tool_call_server_icons, + server_description: tool_call_server_description + } + }) + } else if (tool_call === 'update') { + const toolCallBlock = state.message.content.find( + (block) => + block.type === 'tool_call' && + block.tool_call?.id === tool_call_id && + block.status === 'loading' + ) + + if (toolCallBlock && toolCallBlock.type === 'tool_call' && toolCallBlock.tool_call) { + toolCallBlock.tool_call.params = tool_call_params || '' + } + } else if (tool_call === 'running') { + const toolCallBlock = state.message.content.find( + (block) => + block.type === 'tool_call' && + block.tool_call?.id === tool_call_id && + block.status === 'loading' + ) + + if (toolCallBlock && toolCallBlock.type === 'tool_call') { + if (toolCallBlock.tool_call) { + toolCallBlock.tool_call.params = tool_call_params || '' + toolCallBlock.tool_call.server_name = tool_call_server_name + toolCallBlock.tool_call.server_icons = tool_call_server_icons + toolCallBlock.tool_call.server_description = tool_call_server_description + } + } + } else if (tool_call === 'permission-required') { + if (lastBlock && lastBlock.type === 'tool_call' && lastBlock.tool_call) { + lastBlock.status = 'success' + } + + this.finalizeLastBlock(state) + state.message.content.push({ + type: 'action', + content: tool_call_response || '', + status: 'pending', + timestamp: currentTime, + action_type: 'tool_call_permission', + tool_call: { + id: tool_call_id, + name: tool_call_name, + params: tool_call_params || '', + server_name: tool_call_server_name, + server_icons: tool_call_server_icons, + server_description: tool_call_server_description + } + }) + + this.searchingMessages.add(eventId) + state.isSearching = true + } else if (tool_call === 'permission-granted') { + if ( + lastBlock && + lastBlock.type === 'action' && + lastBlock.action_type === 'tool_call_permission' + ) { + lastBlock.status = 'success' + lastBlock.content = tool_call_response || '' + } + } else if (tool_call === 'permission-denied') { + if ( + lastBlock && + lastBlock.type === 'action' && + lastBlock.action_type === 'tool_call_permission' + ) { + lastBlock.status = 'error' + lastBlock.content = tool_call_response || '' + } + } else if (tool_call === 'continue') { + if ( + lastBlock && + lastBlock.type === 'action' && + lastBlock.action_type === 'tool_call_permission' + ) { + lastBlock.status = 'success' + } + } else if (tool_call === 'end') { + const toolCallBlock = state.message.content.find( + (block) => + block.type === 'tool_call' && + block.tool_call?.id === tool_call_id && + block.status === 'loading' + ) + + if (toolCallBlock && toolCallBlock.type === 'tool_call') { + toolCallBlock.status = 'success' + if (toolCallBlock.tool_call) { + toolCallBlock.tool_call.response = tool_call_response || '' + } + } + + if ( + lastBlock && + lastBlock.type === 'action' && + lastBlock.action_type === 'tool_call_permission' + ) { + lastBlock.status = 'success' + } + } + } + + if (image_data) { + const imageBlock: AssistantMessageBlock = { + type: 'image', + status: 'success', + timestamp: currentTime, + content: image_data + } + state.message.content.push(imageBlock) + } + + if (content) { + if (!lastBlock || lastBlock.type !== 'content' || lastBlock.status !== 'loading') { + this.finalizeLastBlock(state) + state.message.content.push({ + type: 'content', + content: content || '', + status: 'loading', + timestamp: currentTime + }) + } else if (lastBlock.type === 'content') { + lastBlock.content += content + } + } + + if (reasoning_content) { + if (!lastBlock || lastBlock.type !== 'reasoning_content') { + this.finalizeLastBlock(state) + state.message.content.push({ + type: 'reasoning_content', + content: reasoning_content || '', + status: 'loading', + timestamp: currentTime + }) + } else if (lastBlock.type === 'reasoning_content') { + lastBlock.content += reasoning_content + } + } + + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + } + + private finalizeLastBlock(state: GeneratingMessageState): void { + finalizeAssistantMessageBlocks(state.message.content) + } + + private async finalizeMessage( + state: GeneratingMessageState, + eventId: string, + userStop: boolean + ): Promise { + state.message.content.forEach((block) => { + if (block.type === 'action' && block.action_type === 'tool_call_permission') { + return + } + block.status = 'success' + }) + + let completionTokens = 0 + if (state.totalUsage) { + completionTokens = state.totalUsage.completion_tokens + } else { + for (const block of state.message.content) { + if ( + block.type === 'content' || + block.type === 'reasoning_content' || + block.type === 'tool_call' + ) { + completionTokens += approximateTokenSize(block.content) + } + } + } + + const hasContentBlock = state.message.content.some( + (block) => + block.type === 'content' || + block.type === 'reasoning_content' || + block.type === 'tool_call' || + block.type === 'image' + ) + + if (!hasContentBlock && !userStop) { + state.message.content.push({ + type: 'error', + content: 'common.error.noModelResponse', + status: 'error', + timestamp: Date.now() + }) + } + + const totalTokens = state.promptTokens + completionTokens + const generationTime = Date.now() - (state.firstTokenTime ?? state.startTime) + const tokensPerSecond = completionTokens / (generationTime / 1000) + const contextUsage = state?.totalUsage?.context_length + ? (totalTokens / state.totalUsage.context_length) * 100 + : 0 + + const metadata: Partial = { + totalTokens, + inputTokens: state.promptTokens, + outputTokens: completionTokens, + generationTime, + firstTokenTime: state.firstTokenTime ? state.firstTokenTime - state.startTime : 0, + tokensPerSecond, + contextUsage + } + + if (state.reasoningStartTime !== null && state.lastReasoningTime !== null) { + metadata.reasoningStartTime = state.reasoningStartTime - state.startTime + metadata.reasoningEndTime = state.lastReasoningTime - state.startTime + } + + await this.messageManager.updateMessageMetadata(eventId, metadata) + await this.messageManager.updateMessageStatus(eventId, 'sent') + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + this.generatingMessages.delete(eventId) + + await this.handleConversationUpdates(state) + + const finalMessage = await this.messageManager.getMessage(eventId) + if (finalMessage) { + eventBus.sendToMain(CONVERSATION_EVENTS.MESSAGE_GENERATED, { + conversationId: finalMessage.conversationId, + message: finalMessage + }) + } + } + + private async handleConversationUpdates(state: GeneratingMessageState): Promise { + const conversation = await this.conversationLifecycle.getConversation(state.conversationId) + let titleUpdated = false + + if (conversation.is_new === 1) { + try { + this.summaryTitles(undefined, state.conversationId) + .then((title) => { + if (title) { + this.conversationLifecycle + .renameConversation(state.conversationId, title) + .then(() => { + titleUpdated = true + }) + } + }) + .catch((error) => { + console.error('Failed to summarize title in main process:', error) + }) + } catch (e) { + console.error('Failed to summarize title in main process:', e) + } + } + + if (!titleUpdated) { + await this.sqlitePresenter + .updateConversation(state.conversationId, { + updatedAt: Date.now() + }) + .then(() => { + console.log('updated conv time', state.conversationId) + }) + await this.conversationLifecycle.broadcastThreadListUpdate() + } + } + + private cleanupContentBuffer(state: GeneratingMessageState): void { + if (state.flushTimeout) { + clearTimeout(state.flushTimeout) + state.flushTimeout = undefined + } + if (state.throttleTimeout) { + clearTimeout(state.throttleTimeout) + state.throttleTimeout = undefined + } + state.adaptiveBuffer = undefined + state.lastRendererUpdateTime = undefined + } + + private async flushAdaptiveBuffer(eventId: string): Promise { + const state = this.generatingMessages.get(eventId) + if (!state?.adaptiveBuffer) return + + const buffer = state.adaptiveBuffer + const now = Date.now() + + if (state.flushTimeout) { + clearTimeout(state.flushTimeout) + state.flushTimeout = undefined + } + + try { + if (buffer.content && buffer.sentPosition < buffer.content.length) { + const newContent = buffer.content.slice(buffer.sentPosition) + if (newContent) { + await this.processBufferedContent(state, eventId, newContent, now) + buffer.sentPosition = buffer.content.length + } + } + } catch (error) { + console.error('[ContentBuffer] ERROR flushing adaptive buffer', { + eventId, + err: error + }) + throw error + } finally { + state.adaptiveBuffer = undefined + } + } + + private async processBufferedContent( + state: GeneratingMessageState, + eventId: string, + content: string, + currentTime: number + ): Promise { + const buffer = state.adaptiveBuffer + + if (buffer?.isLargeContent) { + await this.processLargeContentAsynchronously(state, eventId, content, currentTime) + return + } + + await this.processNormalContent(state, eventId, content, currentTime) + } + + private async processLargeContentAsynchronously( + state: GeneratingMessageState, + eventId: string, + content: string, + currentTime: number + ): Promise { + const buffer = state.adaptiveBuffer + if (!buffer) return + + buffer.isProcessing = true + + try { + const chunks = this.splitLargeContent(content) + const totalChunks = chunks.length + + console.log( + `[ThreadPresenter] Processing ${totalChunks} chunks asynchronously for ${content.length} bytes` + ) + + const lastBlock = state.message.content[state.message.content.length - 1] + let contentBlock: any + + if (lastBlock && lastBlock.type === 'content') { + contentBlock = lastBlock + } else { + this.finalizeLastBlock(state) + contentBlock = { + type: 'content', + content: '', + status: 'loading', + timestamp: currentTime + } + state.message.content.push(contentBlock) + } + + const batchSize = 5 + for (let batchStart = 0; batchStart < chunks.length; batchStart += batchSize) { + const batchEnd = Math.min(batchStart + batchSize, chunks.length) + const batch = chunks.slice(batchStart, batchEnd) + + const batchContent = batch.join('') + contentBlock.content += batchContent + + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + + const eventData: any = { + eventId, + content: batchContent, + chunkInfo: { + current: batchEnd, + total: totalChunks, + isLargeContent: true, + batchSize: batch.length + } + } + + eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, eventData) + + if (batchEnd < chunks.length) { + await new Promise((resolve) => setImmediate(resolve)) + } + } + + console.log(`[ThreadPresenter] Completed processing ${totalChunks} chunks`) + } catch (error) { + console.error('[ThreadPresenter] Error in processLargeContentAsynchronously:', error) + } finally { + buffer.isProcessing = false + } + } + + private async processNormalContent( + state: GeneratingMessageState, + eventId: string, + content: string, + currentTime: number + ): Promise { + const lastBlock = state.message.content[state.message.content.length - 1] + + if (lastBlock && lastBlock.type === 'content') { + lastBlock.content += content + } else { + this.finalizeLastBlock(state) + state.message.content.push({ + type: 'content', + content: content, + status: 'loading', + timestamp: currentTime + }) + } + + await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + } + + private splitLargeContent(content: string): string[] { + const chunks: string[] = [] + let maxChunkSize = 4096 + + if (content.includes('data:image/')) { + maxChunkSize = 512 + } + + if (content.length > 50000) { + maxChunkSize = Math.min(maxChunkSize, 256) + } + + for (let i = 0; i < content.length; i += maxChunkSize) { + chunks.push(content.slice(i, i + maxChunkSize)) + } + + return chunks } async getSearchEngines(): Promise { @@ -484,7 +1124,7 @@ export class ThreadPresenter implements IThreadPresenter { provider: engineName } } - this.contentBufferManager.finalizeLastBlock(state) + this.finalizeLastBlock(state) state.message.content.push(searchBlock) await this.messageManager.editMessage(messageId, JSON.stringify(state.message.content)) // 标记消息为搜索状态 @@ -1283,11 +1923,11 @@ export class ThreadPresenter implements IThreadPresenter { // 刷新剩余缓冲内容 if (state.adaptiveBuffer) { - await this.contentBufferManager.flushAdaptiveBuffer(messageId) + await this.flushAdaptiveBuffer(messageId) } // 清理缓冲相关资源 - this.contentBufferManager.cleanupContentBuffer(state) + this.cleanupContentBuffer(state) // 标记消息不再处于搜索状态 if (state.isSearching) { diff --git a/src/main/presenter/threadPresenter/llmGenerationManager.ts b/src/main/presenter/threadPresenter/llmGenerationManager.ts deleted file mode 100644 index 07e8596b8..000000000 --- a/src/main/presenter/threadPresenter/llmGenerationManager.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { ISQLitePresenter, MESSAGE_METADATA, LLMAgentEventData } from '../../../shared/presenter' -import { MessageManager } from './messageManager' -import { ContentBufferManager } from './contentBufferManager' -import { ConversationLifecycleManager } from './conversationLifecycleManager' -import type { GeneratingMessageState } from './types' -import { eventBus, SendTarget } from '@/eventbus' -import { CONVERSATION_EVENTS, STREAM_EVENTS } from '@/events' -import { AssistantMessageBlock } from '@shared/chat' -import { approximateTokenSize } from 'tokenx' -import { nanoid } from 'nanoid' - -interface LLMGenerationManagerOptions { - sqlitePresenter: ISQLitePresenter - messageManager: MessageManager - contentBufferManager: ContentBufferManager - conversationLifecycle: ConversationLifecycleManager - generatingMessages: Map - searchingMessages: Set - summarizeConversationTitle: (conversationId: string) => Promise -} - -export class LLMGenerationManager { - private sqlitePresenter: ISQLitePresenter - private messageManager: MessageManager - private contentBufferManager: ContentBufferManager - private conversationLifecycle: ConversationLifecycleManager - private generatingMessages: Map - private searchingMessages: Set - private summarizeConversationTitle: (conversationId: string) => Promise - - constructor(options: LLMGenerationManagerOptions) { - this.sqlitePresenter = options.sqlitePresenter - this.messageManager = options.messageManager - this.contentBufferManager = options.contentBufferManager - this.conversationLifecycle = options.conversationLifecycle - this.generatingMessages = options.generatingMessages - this.searchingMessages = options.searchingMessages - this.summarizeConversationTitle = options.summarizeConversationTitle - } - - async handleLLMAgentError(msg: LLMAgentEventData): Promise { - const { eventId, error } = msg - const state = this.generatingMessages.get(eventId) - if (state) { - if (state.adaptiveBuffer) { - await this.contentBufferManager.flushAdaptiveBuffer(eventId) - } - - this.contentBufferManager.cleanupContentBuffer(state) - - await this.messageManager.handleMessageError(eventId, String(error)) - this.generatingMessages.delete(eventId) - } - eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, msg) - } - - async handleLLMAgentEnd(msg: LLMAgentEventData): Promise { - const { eventId, userStop } = msg - const state = this.generatingMessages.get(eventId) - if (state) { - if (state.adaptiveBuffer) { - await this.contentBufferManager.flushAdaptiveBuffer(eventId) - } - - this.contentBufferManager.cleanupContentBuffer(state) - - const hasPendingPermissions = state.message.content.some( - (block) => - block.type === 'action' && - block.action_type === 'tool_call_permission' && - block.status === 'pending' - ) - - if (hasPendingPermissions) { - state.message.content.forEach((block) => { - if ( - !(block.type === 'action' && block.action_type === 'tool_call_permission') && - block.status === 'loading' - ) { - block.status = 'success' - } - }) - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - return - } - - await this.finalizeMessage(state, eventId, Boolean(userStop)) - } - - eventBus.sendToRenderer(STREAM_EVENTS.END, SendTarget.ALL_WINDOWS, msg) - } - - async handleLLMAgentResponse(msg: LLMAgentEventData): Promise { - const currentTime = Date.now() - const { - eventId, - content, - reasoning_content, - tool_call_id, - tool_call_name, - tool_call_params, - tool_call_response, - maximum_tool_calls_reached, - tool_call_server_name, - tool_call_server_icons, - tool_call_server_description, - tool_call_response_raw, - tool_call, - totalUsage, - image_data - } = msg - const state = this.generatingMessages.get(eventId) - if (!state) { - return - } - - if (state.firstTokenTime === null && (content || reasoning_content)) { - state.firstTokenTime = currentTime - await this.messageManager.updateMessageMetadata(eventId, { - firstTokenTime: currentTime - state.startTime - }) - } - if (totalUsage) { - state.totalUsage = totalUsage - state.promptTokens = totalUsage.prompt_tokens - } - - if (maximum_tool_calls_reached) { - this.contentBufferManager.finalizeLastBlock(state) - state.message.content.push({ - type: 'action', - content: 'common.error.maximumToolCallsReached', - status: 'success', - timestamp: currentTime, - action_type: 'maximum_tool_calls_reached', - tool_call: { - id: tool_call_id, - name: tool_call_name, - params: tool_call_params, - server_name: tool_call_server_name, - server_icons: tool_call_server_icons, - server_description: tool_call_server_description - }, - extra: { - needContinue: true - } - }) - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - return - } - - if (reasoning_content) { - if (state.reasoningStartTime === null) { - state.reasoningStartTime = currentTime - await this.messageManager.updateMessageMetadata(eventId, { - reasoningStartTime: currentTime - state.startTime - }) - } - state.lastReasoningTime = currentTime - } - - const lastBlock = state.message.content[state.message.content.length - 1] - - if (tool_call_response_raw && tool_call === 'end') { - try { - const hasSearchResults = - Array.isArray(tool_call_response_raw.content) && - tool_call_response_raw.content.some( - (item: { type: string; resource?: { mimeType: string } }) => - item?.type === 'resource' && - item?.resource?.mimeType === 'application/deepchat-webpage' - ) - - if (hasSearchResults && Array.isArray(tool_call_response_raw.content)) { - const searchResults = tool_call_response_raw.content - .filter( - (item: { - type: string - resource?: { mimeType: string; text: string; uri?: string } - }) => - item.type === 'resource' && - item.resource?.mimeType === 'application/deepchat-webpage' - ) - .map((item: { resource: { text: string; uri?: string } }) => { - try { - const blobContent = JSON.parse(item.resource.text) as { - title?: string - url?: string - content?: string - icon?: string - } - return { - title: blobContent.title || '', - url: blobContent.url || item.resource.uri || '', - content: blobContent.content || '', - description: blobContent.content || '', - icon: blobContent.icon || '' - } - } catch (e) { - console.error('解析搜索结果失败:', e) - return null - } - }) - .filter(Boolean) - - if (searchResults.length > 0) { - const searchId = nanoid() - const pages = searchResults - .filter((item) => item && (item.icon || item.favicon)) - .slice(0, 6) - .map((item) => ({ - url: item?.url ?? '', - icon: item?.icon || item?.favicon || '' - })) - - const searchBlock: AssistantMessageBlock = { - id: searchId, - type: 'search', - content: '', - status: 'success', - timestamp: currentTime, - extra: { - total: searchResults.length, - searchId, - pages, - label: tool_call_name || 'web_search', - name: tool_call_name || 'web_search', - engine: tool_call_server_name || undefined, - provider: tool_call_server_name || undefined - } - } - - this.contentBufferManager.finalizeLastBlock(state) - state.message.content.push(searchBlock) - - for (const result of searchResults) { - await this.sqlitePresenter.addMessageAttachment( - eventId, - 'search_result', - JSON.stringify({ - title: result?.title || '', - url: result?.url || '', - content: result?.content || '', - description: result?.description || '', - icon: result?.icon || result?.favicon || '', - rank: typeof result?.rank === 'number' ? result.rank : undefined, - searchId - }) - ) - } - - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - } - } - } catch (error) { - console.error('处理搜索结果时出错:', error) - } - } - - if (tool_call) { - if (tool_call === 'start') { - this.contentBufferManager.finalizeLastBlock(state) - state.message.content.push({ - type: 'tool_call', - content: '', - status: 'loading', - timestamp: currentTime, - tool_call: { - id: tool_call_id, - name: tool_call_name, - params: tool_call_params || '', - server_name: tool_call_server_name, - server_icons: tool_call_server_icons, - server_description: tool_call_server_description - } - }) - } else if (tool_call === 'update') { - const toolCallBlock = state.message.content.find( - (block) => - block.type === 'tool_call' && - block.tool_call?.id === tool_call_id && - block.status === 'loading' - ) - - if (toolCallBlock && toolCallBlock.type === 'tool_call' && toolCallBlock.tool_call) { - toolCallBlock.tool_call.params = tool_call_params || '' - } - } else if (tool_call === 'running') { - const toolCallBlock = state.message.content.find( - (block) => - block.type === 'tool_call' && - block.tool_call?.id === tool_call_id && - block.status === 'loading' - ) - - if (toolCallBlock && toolCallBlock.type === 'tool_call') { - if (toolCallBlock.tool_call) { - toolCallBlock.tool_call.params = tool_call_params || '' - toolCallBlock.tool_call.server_name = tool_call_server_name - toolCallBlock.tool_call.server_icons = tool_call_server_icons - toolCallBlock.tool_call.server_description = tool_call_server_description - } - } - } else if (tool_call === 'permission-required') { - if (lastBlock && lastBlock.type === 'tool_call' && lastBlock.tool_call) { - lastBlock.status = 'success' - } - - this.contentBufferManager.finalizeLastBlock(state) - state.message.content.push({ - type: 'action', - content: tool_call_response || '', - status: 'pending', - timestamp: currentTime, - action_type: 'tool_call_permission', - tool_call: { - id: tool_call_id, - name: tool_call_name, - params: tool_call_params || '', - server_name: tool_call_server_name, - server_icons: tool_call_server_icons, - server_description: tool_call_server_description - } - }) - - this.searchingMessages.add(eventId) - state.isSearching = true - } else if (tool_call === 'permission-granted') { - if ( - lastBlock && - lastBlock.type === 'action' && - lastBlock.action_type === 'tool_call_permission' - ) { - lastBlock.status = 'success' - lastBlock.content = tool_call_response || '' - } - } else if (tool_call === 'permission-denied') { - if ( - lastBlock && - lastBlock.type === 'action' && - lastBlock.action_type === 'tool_call_permission' - ) { - lastBlock.status = 'error' - lastBlock.content = tool_call_response || '' - } - } else if (tool_call === 'continue') { - if ( - lastBlock && - lastBlock.type === 'action' && - lastBlock.action_type === 'tool_call_permission' - ) { - lastBlock.status = 'success' - } - } else if (tool_call === 'end') { - const toolCallBlock = state.message.content.find( - (block) => - block.type === 'tool_call' && - block.tool_call?.id === tool_call_id && - block.status === 'loading' - ) - - if (toolCallBlock && toolCallBlock.type === 'tool_call') { - toolCallBlock.status = 'success' - if (toolCallBlock.tool_call) { - toolCallBlock.tool_call.response = tool_call_response || '' - } - } - - if ( - lastBlock && - lastBlock.type === 'action' && - lastBlock.action_type === 'tool_call_permission' - ) { - lastBlock.status = 'success' - } - } - } - - if (image_data) { - const imageBlock: AssistantMessageBlock = { - type: 'image', - status: 'success', - timestamp: currentTime, - content: image_data - } - state.message.content.push(imageBlock) - } - - if (content) { - if (!lastBlock || lastBlock.type !== 'content' || lastBlock.status !== 'loading') { - this.contentBufferManager.finalizeLastBlock(state) - state.message.content.push({ - type: 'content', - content: content || '', - status: 'loading', - timestamp: currentTime - }) - } else if (lastBlock.type === 'content') { - lastBlock.content += content - } - } - - if (reasoning_content) { - if (!lastBlock || lastBlock.type !== 'reasoning_content') { - this.contentBufferManager.finalizeLastBlock(state) - state.message.content.push({ - type: 'reasoning_content', - content: reasoning_content || '', - status: 'loading', - timestamp: currentTime - }) - } else if (lastBlock.type === 'reasoning_content') { - lastBlock.content += reasoning_content - } - } - - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - } - - private async finalizeMessage( - state: GeneratingMessageState, - eventId: string, - userStop: boolean - ): Promise { - state.message.content.forEach((block) => { - if (block.type === 'action' && block.action_type === 'tool_call_permission') { - return - } - block.status = 'success' - }) - - let completionTokens = 0 - if (state.totalUsage) { - completionTokens = state.totalUsage.completion_tokens - } else { - for (const block of state.message.content) { - if ( - block.type === 'content' || - block.type === 'reasoning_content' || - block.type === 'tool_call' - ) { - completionTokens += approximateTokenSize(block.content) - } - } - } - - const hasContentBlock = state.message.content.some( - (block) => - block.type === 'content' || - block.type === 'reasoning_content' || - block.type === 'tool_call' || - block.type === 'image' - ) - - if (!hasContentBlock && !userStop) { - state.message.content.push({ - type: 'error', - content: 'common.error.noModelResponse', - status: 'error', - timestamp: Date.now() - }) - } - - const totalTokens = state.promptTokens + completionTokens - const generationTime = Date.now() - (state.firstTokenTime ?? state.startTime) - const tokensPerSecond = completionTokens / (generationTime / 1000) - const contextUsage = state?.totalUsage?.context_length - ? (totalTokens / state.totalUsage.context_length) * 100 - : 0 - - const metadata: Partial = { - totalTokens, - inputTokens: state.promptTokens, - outputTokens: completionTokens, - generationTime, - firstTokenTime: state.firstTokenTime ? state.firstTokenTime - state.startTime : 0, - tokensPerSecond, - contextUsage - } - - if (state.reasoningStartTime !== null && state.lastReasoningTime !== null) { - metadata.reasoningStartTime = state.reasoningStartTime - state.startTime - metadata.reasoningEndTime = state.lastReasoningTime - state.startTime - } - - await this.messageManager.updateMessageMetadata(eventId, metadata) - await this.messageManager.updateMessageStatus(eventId, 'sent') - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) - this.generatingMessages.delete(eventId) - - await this.handleConversationUpdates(state) - - const finalMessage = await this.messageManager.getMessage(eventId) - if (finalMessage) { - eventBus.sendToMain(CONVERSATION_EVENTS.MESSAGE_GENERATED, { - conversationId: finalMessage.conversationId, - message: finalMessage - }) - } - } - - private async handleConversationUpdates(state: GeneratingMessageState): Promise { - const conversation = await this.conversationLifecycle.getConversation(state.conversationId) - let titleUpdated = false - - if (conversation.is_new === 1) { - try { - this.summarizeConversationTitle(state.conversationId).then((title) => { - if (title) { - this.conversationLifecycle.renameConversation(state.conversationId, title).then(() => { - titleUpdated = true - }) - } - }) - } catch (e) { - console.error('Failed to summarize title in main process:', e) - } - } - - if (!titleUpdated) { - await this.sqlitePresenter - .updateConversation(state.conversationId, { - updatedAt: Date.now() - }) - .then(() => { - console.log('updated conv time', state.conversationId) - }) - await this.conversationLifecycle.broadcastThreadListUpdate() - } - } -} From 5d5eeb3c2ebbe632839e5d3f40d87dd227d40e75 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Wed, 29 Oct 2025 09:01:32 +0800 Subject: [PATCH 09/13] refactor(thread): inline lifecycle management --- .../conversationLifecycleManager.ts | 389 ------------------ src/main/presenter/threadPresenter/index.ts | 378 +++++++++++++++-- 2 files changed, 333 insertions(+), 434 deletions(-) delete mode 100644 src/main/presenter/threadPresenter/conversationLifecycleManager.ts diff --git a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts b/src/main/presenter/threadPresenter/conversationLifecycleManager.ts deleted file mode 100644 index 357256f7f..000000000 --- a/src/main/presenter/threadPresenter/conversationLifecycleManager.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { - CONVERSATION, - CONVERSATION_SETTINGS, - ISQLitePresenter, - IConfigPresenter, - IMessageManager -} from '../../../shared/presenter' -import { eventBus, SendTarget } from '@/eventbus' -import { CONVERSATION_EVENTS, TAB_EVENTS } from '@/events' -import { presenter } from '@/presenter' -import { DEFAULT_SETTINGS } from './const' - -export interface CreateConversationOptions { - forceNewAndActivate?: boolean -} - -interface ConversationLifecycleDependencies { - sqlitePresenter: ISQLitePresenter - configPresenter: IConfigPresenter - messageManager: IMessageManager -} - -export class ConversationLifecycleManager { - private sqlitePresenter: ISQLitePresenter - private configPresenter: IConfigPresenter - private messageManager: IMessageManager - private activeConversationIds: Map = new Map() - private fetchThreadLength = 300 - - constructor({ - sqlitePresenter, - configPresenter, - messageManager - }: ConversationLifecycleDependencies) { - this.sqlitePresenter = sqlitePresenter - this.configPresenter = configPresenter - this.messageManager = messageManager - } - - getActiveConversationId(tabId: number): string | null { - return this.activeConversationIds.get(tabId) || null - } - - getTabsByConversation(conversationId: string): number[] { - return Array.from(this.activeConversationIds.entries()) - .filter(([, id]) => id === conversationId) - .map(([tabId]) => tabId) - } - - clearActiveConversation(tabId: number, options: { notify?: boolean } = {}): void { - if (!this.activeConversationIds.has(tabId)) { - return - } - this.activeConversationIds.delete(tabId) - if (options.notify) { - eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { tabId }) - } - } - - clearConversationBindings(conversationId: string): void { - for (const [tabId, activeId] of this.activeConversationIds.entries()) { - if (activeId === conversationId) { - this.activeConversationIds.delete(tabId) - eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { - tabId - }) - } - } - } - - async findTabForConversation(conversationId: string): Promise { - for (const [tabId, activeId] of this.activeConversationIds.entries()) { - if (activeId === conversationId) { - const tabView = await presenter.tabPresenter.getTab(tabId) - if (tabView && !tabView.webContents.isDestroyed()) { - return tabId - } - } - } - return null - } - - private async getTabWindowType(tabId: number): Promise<'floating' | 'main' | 'unknown'> { - try { - const tabView = await presenter.tabPresenter.getTab(tabId) - if (!tabView) { - return 'unknown' - } - const windowId = presenter.tabPresenter.getTabWindowId(tabId) - return windowId ? 'main' : 'floating' - } catch (error) { - console.error('Error determining tab window type:', error) - return 'unknown' - } - } - - async setActiveConversation(conversationId: string, tabId: number): Promise { - const existingTabId = await this.findTabForConversation(conversationId) - - if (existingTabId !== null && existingTabId !== tabId) { - console.log( - `Conversation ${conversationId} is already open in tab ${existingTabId}. Switching to it.` - ) - const currentTabType = await this.getTabWindowType(tabId) - const existingTabType = await this.getTabWindowType(existingTabId) - - if (currentTabType !== existingTabType) { - this.activeConversationIds.delete(existingTabId) - eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { - tabId: existingTabId - }) - this.activeConversationIds.set(tabId, conversationId) - eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { - conversationId, - tabId - }) - return - } - - await presenter.tabPresenter.switchTab(existingTabId) - return - } - - const conversation = await this.getConversation(conversationId) - if (!conversation) { - throw new Error(`Conversation ${conversationId} not found`) - } - - if (this.activeConversationIds.get(tabId) === conversationId) { - return - } - - this.activeConversationIds.set(tabId, conversationId) - eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { - conversationId, - tabId - }) - } - - async getActiveConversation(tabId: number): Promise { - const conversationId = this.activeConversationIds.get(tabId) - if (!conversationId) { - return null - } - return this.getConversation(conversationId) - } - - async getConversation(conversationId: string): Promise { - return await this.sqlitePresenter.getConversation(conversationId) - } - - async createConversation( - title: string, - settings: Partial, - tabId: number, - options: CreateConversationOptions = {} - ): Promise { - let latestConversation: CONVERSATION | null = null - - try { - latestConversation = await this.getLatestConversation() - - if (!options.forceNewAndActivate && latestConversation) { - const { list: messages } = await this.messageManager.getMessageThread( - latestConversation.id, - 1, - 1 - ) - if (messages.length === 0) { - await this.setActiveConversation(latestConversation.id, tabId) - return latestConversation.id - } - } - - let defaultSettings = DEFAULT_SETTINGS - if (latestConversation?.settings) { - defaultSettings = { ...latestConversation.settings } - defaultSettings.systemPrompt = '' - defaultSettings.reasoningEffort = undefined - defaultSettings.enableSearch = undefined - defaultSettings.forcedSearch = undefined - defaultSettings.searchStrategy = undefined - } - - const sanitizedSettings: Partial = { ...settings } - Object.keys(sanitizedSettings).forEach((key) => { - const typedKey = key as keyof CONVERSATION_SETTINGS - const value = sanitizedSettings[typedKey] - if (value === undefined || value === null || value === '') { - delete sanitizedSettings[typedKey] - } - }) - - const mergedSettings = { ...defaultSettings } - - const previewSettings = { ...mergedSettings, ...sanitizedSettings } - - const defaultModelsSettings = this.configPresenter.getModelConfig( - previewSettings.modelId, - previewSettings.providerId - ) - - if (defaultModelsSettings) { - if (defaultModelsSettings.maxTokens !== undefined) { - mergedSettings.maxTokens = defaultModelsSettings.maxTokens - } - if (defaultModelsSettings.contextLength !== undefined) { - mergedSettings.contextLength = defaultModelsSettings.contextLength - } - mergedSettings.temperature = defaultModelsSettings.temperature ?? 0.7 - if ( - sanitizedSettings.thinkingBudget === undefined && - defaultModelsSettings.thinkingBudget !== undefined - ) { - mergedSettings.thinkingBudget = defaultModelsSettings.thinkingBudget - } - } - - Object.assign(mergedSettings, sanitizedSettings) - - if (mergedSettings.temperature === undefined || mergedSettings.temperature === null) { - mergedSettings.temperature = defaultModelsSettings?.temperature ?? 0.7 - } - - const conversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) - - if (options.forceNewAndActivate) { - this.activeConversationIds.set(tabId, conversationId) - eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { - conversationId, - tabId - }) - } else { - await this.setActiveConversation(conversationId, tabId) - } - - await this.broadcastThreadListUpdate() - return conversationId - } catch (error) { - console.error('ThreadPresenter: Failed to create conversation', { - title, - tabId, - options, - latestConversationId: latestConversation?.id, - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined - }) - throw error - } - } - - async renameConversation(conversationId: string, title: string): Promise { - await this.sqlitePresenter.renameConversation(conversationId, title) - await this.broadcastThreadListUpdate() - - const conversation = await this.getConversation(conversationId) - - let tabId: number | undefined - for (const [key, value] of this.activeConversationIds.entries()) { - if (value === conversationId) { - tabId = key - break - } - } - - if (tabId !== undefined) { - const windowId = presenter.tabPresenter.getTabWindowId(tabId) - eventBus.sendToRenderer(TAB_EVENTS.TITLE_UPDATED, SendTarget.ALL_WINDOWS, { - tabId, - conversationId, - title: conversation.title, - windowId - }) - } - - return conversation - } - - async deleteConversation(conversationId: string): Promise { - await this.sqlitePresenter.deleteConversation(conversationId) - this.clearConversationBindings(conversationId) - await this.broadcastThreadListUpdate() - } - - async toggleConversationPinned(conversationId: string, pinned: boolean): Promise { - await this.sqlitePresenter.updateConversation(conversationId, { is_pinned: pinned ? 1 : 0 }) - await this.broadcastThreadListUpdate() - } - - async updateConversationTitle(conversationId: string, title: string): Promise { - await this.sqlitePresenter.updateConversation(conversationId, { title }) - await this.broadcastThreadListUpdate() - } - - async updateConversationSettings( - conversationId: string, - settings: Partial - ): Promise { - const conversation = await this.getConversation(conversationId) - const mergedSettings = { ...conversation.settings } - - for (const key in settings) { - if (settings[key] !== undefined) { - mergedSettings[key] = settings[key] - } - } - - if (settings.modelId && settings.modelId !== conversation.settings.modelId) { - const modelConfig = this.configPresenter.getModelConfig( - mergedSettings.modelId, - mergedSettings.providerId - ) - if (modelConfig) { - mergedSettings.maxTokens = modelConfig.maxTokens - mergedSettings.contextLength = modelConfig.contextLength - } - } - - await this.sqlitePresenter.updateConversation(conversationId, { settings: mergedSettings }) - await this.broadcastThreadListUpdate() - } - - async getConversationList( - page: number, - pageSize: number - ): Promise<{ total: number; list: CONVERSATION[] }> { - return await this.sqlitePresenter.getConversationList(page, pageSize) - } - - async loadMoreThreads(): Promise<{ hasMore: boolean; total: number }> { - const total = await this.sqlitePresenter.getConversationCount() - const hasMore = this.fetchThreadLength < total - - if (hasMore) { - this.fetchThreadLength = Math.min(this.fetchThreadLength + 300, total) - await this.broadcastThreadListUpdate() - } - - return { hasMore: this.fetchThreadLength < total, total } - } - - async broadcastThreadListUpdate(): Promise { - const result = await this.sqlitePresenter.getConversationList(1, this.fetchThreadLength) - - const pinnedConversations: CONVERSATION[] = [] - const normalConversations: CONVERSATION[] = [] - - result.list.forEach((conv) => { - if (conv.is_pinned === 1) { - pinnedConversations.push(conv) - } else { - normalConversations.push(conv) - } - }) - - pinnedConversations.sort((a, b) => b.updatedAt - a.updatedAt) - normalConversations.sort((a, b) => b.updatedAt - a.updatedAt) - - const groupedThreads: Map = new Map() - - if (pinnedConversations.length > 0) { - groupedThreads.set('Pinned', pinnedConversations) - } - - normalConversations.forEach((conv) => { - const date = new Date(conv.updatedAt).toISOString().split('T')[0] - if (!groupedThreads.has(date)) { - groupedThreads.set(date, []) - } - groupedThreads.get(date)!.push(conv) - }) - - const finalGroupedList = Array.from(groupedThreads.entries()).map(([dt, dtThreads]) => ({ - dt, - dtThreads - })) - - eventBus.sendToRenderer( - CONVERSATION_EVENTS.LIST_UPDATED, - SendTarget.ALL_WINDOWS, - finalGroupedList - ) - } - - private async getLatestConversation(): Promise { - const result = await this.getConversationList(1, 1) - return result.list[0] || null - } -} diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts index 104eecda8..83c987cbb 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -41,13 +41,14 @@ import { generateExportFilename, ConversationExportFormat } from './conversationExporter' -import { - ConversationLifecycleManager, - CreateConversationOptions -} from './conversationLifecycleManager' import type { GeneratingMessageState } from './types' import { finalizeAssistantMessageBlocks } from '@shared/chat/messageBlocks' import { approximateTokenSize } from 'tokenx' +import { DEFAULT_SETTINGS } from './const' + +export interface CreateConversationOptions { + forceNewAndActivate?: boolean +} export class ThreadPresenter implements IThreadPresenter { private sqlitePresenter: ISQLitePresenter @@ -56,7 +57,8 @@ export class ThreadPresenter implements IThreadPresenter { private configPresenter: IConfigPresenter private searchManager: SearchManager private generatingMessages: Map = new Map() - private conversationLifecycle: ConversationLifecycleManager + private activeConversationIds: Map = new Map() + private fetchThreadLength = 300 public searchAssistantModel: MODEL_META | null = null public searchAssistantProviderId: string | null = null private searchingMessages: Set = new Set() @@ -71,22 +73,17 @@ export class ThreadPresenter implements IThreadPresenter { this.llmProviderPresenter = llmProviderPresenter this.searchManager = new SearchManager() this.configPresenter = configPresenter - this.conversationLifecycle = new ConversationLifecycleManager({ - sqlitePresenter, - configPresenter, - messageManager: this.messageManager - }) // 监听Tab关闭事件,清理绑定关系 eventBus.on(TAB_EVENTS.CLOSED, (tabId: number) => { - const activeConversationId = this.conversationLifecycle.getActiveConversationId(tabId) + const activeConversationId = this.getActiveConversationIdSync(tabId) if (activeConversationId) { - this.conversationLifecycle.clearActiveConversation(tabId, { notify: true }) + this.clearActiveConversation(tabId, { notify: true }) console.log(`ThreadPresenter: Cleaned up conversation binding for closed tab ${tabId}.`) } }) eventBus.on(TAB_EVENTS.RENDERER_TAB_READY, () => { - this.conversationLifecycle.broadcastThreadListUpdate() + this.broadcastThreadListUpdate() }) // 初始化时处理所有未完成的消息 @@ -104,7 +101,19 @@ export class ThreadPresenter implements IThreadPresenter { * @returns 如果找到,返回tabId,否则返回null */ async findTabForConversation(conversationId: string): Promise { - return this.conversationLifecycle.findTabForConversation(conversationId) + for (const [tabId, activeId] of this.activeConversationIds.entries()) { + if (activeId === conversationId) { + try { + const tabView = await presenter.tabPresenter.getTab(tabId) + if (tabView && !tabView.webContents.isDestroyed()) { + return tabId + } + } catch (error) { + console.error('Error finding tab for conversation:', error) + } + } + } + return null } async handleLLMAgentError(msg: LLMAgentEventData) { @@ -573,7 +582,7 @@ export class ThreadPresenter implements IThreadPresenter { } private async handleConversationUpdates(state: GeneratingMessageState): Promise { - const conversation = await this.conversationLifecycle.getConversation(state.conversationId) + const conversation = await this.getConversation(state.conversationId) let titleUpdated = false if (conversation.is_new === 1) { @@ -581,11 +590,9 @@ export class ThreadPresenter implements IThreadPresenter { this.summaryTitles(undefined, state.conversationId) .then((title) => { if (title) { - this.conversationLifecycle - .renameConversation(state.conversationId, title) - .then(() => { - titleUpdated = true - }) + this.renameConversation(state.conversationId, title).then(() => { + titleUpdated = true + }) } }) .catch((error) => { @@ -604,7 +611,7 @@ export class ThreadPresenter implements IThreadPresenter { .then(() => { console.log('updated conv time', state.conversationId) }) - await this.conversationLifecycle.broadcastThreadListUpdate() + await this.broadcastThreadListUpdate() } } @@ -814,59 +821,341 @@ export class ThreadPresenter implements IThreadPresenter { } } - async renameConversation(conversationId: string, title: string): Promise { - return this.conversationLifecycle.renameConversation(conversationId, title) + getActiveConversationIdSync(tabId: number): string | null { + return this.activeConversationIds.get(tabId) || null + } + + getTabsByConversation(conversationId: string): number[] { + return Array.from(this.activeConversationIds.entries()) + .filter(([, id]) => id === conversationId) + .map(([tabId]) => tabId) + } + + clearActiveConversation(tabId: number, options: { notify?: boolean } = {}): void { + if (!this.activeConversationIds.has(tabId)) { + return + } + this.activeConversationIds.delete(tabId) + if (options.notify) { + eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { tabId }) + } + } + + clearConversationBindings(conversationId: string): void { + for (const [tabId, activeId] of this.activeConversationIds.entries()) { + if (activeId === conversationId) { + this.activeConversationIds.delete(tabId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { + tabId + }) + } + } + } + + private async getTabWindowType(tabId: number): Promise<'floating' | 'main' | 'unknown'> { + try { + const tabView = await presenter.tabPresenter.getTab(tabId) + if (!tabView) { + return 'unknown' + } + const windowId = presenter.tabPresenter.getTabWindowId(tabId) + return windowId ? 'main' : 'floating' + } catch (error) { + console.error('Error determining tab window type:', error) + return 'unknown' + } + } + + async setActiveConversation(conversationId: string, tabId: number): Promise { + const existingTabId = await this.findTabForConversation(conversationId) + + if (existingTabId !== null && existingTabId !== tabId) { + console.log( + `Conversation ${conversationId} is already open in tab ${existingTabId}. Switching to it.` + ) + const currentTabType = await this.getTabWindowType(tabId) + const existingTabType = await this.getTabWindowType(existingTabId) + + if (currentTabType !== existingTabType) { + this.activeConversationIds.delete(existingTabId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { + tabId: existingTabId + }) + this.activeConversationIds.set(tabId, conversationId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { + conversationId, + tabId + }) + return + } + + await presenter.tabPresenter.switchTab(existingTabId) + return + } + + const conversation = await this.getConversation(conversationId) + if (!conversation) { + throw new Error(`Conversation ${conversationId} not found`) + } + + if (this.activeConversationIds.get(tabId) === conversationId) { + return + } + + this.activeConversationIds.set(tabId, conversationId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { + conversationId, + tabId + }) + } + + async getActiveConversation(tabId: number): Promise { + const conversationId = this.activeConversationIds.get(tabId) + if (!conversationId) { + return null + } + return this.getConversation(conversationId) + } + + async getConversation(conversationId: string): Promise { + return await this.sqlitePresenter.getConversation(conversationId) } + async createConversation( title: string, settings: Partial = {}, tabId: number, - options: CreateConversationOptions = {} // 新增参数,允许强制创建新会话 + options: CreateConversationOptions = {} ): Promise { - console.log('createConversation', title, settings) - return this.conversationLifecycle.createConversation(title, settings, tabId, options) + let latestConversation: CONVERSATION | null = null + + try { + latestConversation = await this.getLatestConversation() + + if (!options.forceNewAndActivate && latestConversation) { + const { list: messages } = await this.messageManager.getMessageThread( + latestConversation.id, + 1, + 1 + ) + if (messages.length === 0) { + await this.setActiveConversation(latestConversation.id, tabId) + return latestConversation.id + } + } + + let defaultSettings = DEFAULT_SETTINGS + if (latestConversation?.settings) { + defaultSettings = { ...latestConversation.settings } + defaultSettings.systemPrompt = '' + defaultSettings.reasoningEffort = undefined + defaultSettings.enableSearch = undefined + defaultSettings.forcedSearch = undefined + defaultSettings.searchStrategy = undefined + } + + const sanitizedSettings: Partial = { ...settings } + Object.keys(sanitizedSettings).forEach((key) => { + const typedKey = key as keyof CONVERSATION_SETTINGS + const value = sanitizedSettings[typedKey] + if (value === undefined || value === null || value === '') { + delete sanitizedSettings[typedKey] + } + }) + + const mergedSettings = { ...defaultSettings } + const previewSettings = { ...mergedSettings, ...sanitizedSettings } + + const defaultModelsSettings = this.configPresenter.getModelConfig( + previewSettings.modelId, + previewSettings.providerId + ) + + if (defaultModelsSettings) { + if (defaultModelsSettings.maxTokens !== undefined) { + mergedSettings.maxTokens = defaultModelsSettings.maxTokens + } + if (defaultModelsSettings.contextLength !== undefined) { + mergedSettings.contextLength = defaultModelsSettings.contextLength + } + mergedSettings.temperature = defaultModelsSettings.temperature ?? 0.7 + if ( + sanitizedSettings.thinkingBudget === undefined && + defaultModelsSettings.thinkingBudget !== undefined + ) { + mergedSettings.thinkingBudget = defaultModelsSettings.thinkingBudget + } + } + + Object.assign(mergedSettings, sanitizedSettings) + + if (mergedSettings.temperature === undefined || mergedSettings.temperature === null) { + mergedSettings.temperature = defaultModelsSettings?.temperature ?? 0.7 + } + + const conversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) + + if (options.forceNewAndActivate) { + this.activeConversationIds.set(tabId, conversationId) + eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { + conversationId, + tabId + }) + } else { + await this.setActiveConversation(conversationId, tabId) + } + + await this.broadcastThreadListUpdate() + return conversationId + } catch (error) { + console.error('ThreadPresenter: Failed to create conversation', { + title, + tabId, + options, + latestConversationId: latestConversation?.id, + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined + }) + throw error + } } - async deleteConversation(conversationId: string): Promise { - await this.conversationLifecycle.deleteConversation(conversationId) + async renameConversation(conversationId: string, title: string): Promise { + await this.sqlitePresenter.renameConversation(conversationId, title) + await this.broadcastThreadListUpdate() + + const conversation = await this.getConversation(conversationId) + + let tabId: number | undefined + for (const [key, value] of this.activeConversationIds.entries()) { + if (value === conversationId) { + tabId = key + break + } + } + + if (tabId !== undefined) { + const windowId = presenter.tabPresenter.getTabWindowId(tabId) + eventBus.sendToRenderer(TAB_EVENTS.TITLE_UPDATED, SendTarget.ALL_WINDOWS, { + tabId, + conversationId, + title: conversation.title, + windowId + }) + } + + return conversation } - async getConversation(conversationId: string): Promise { - return this.conversationLifecycle.getConversation(conversationId) + async deleteConversation(conversationId: string): Promise { + await this.sqlitePresenter.deleteConversation(conversationId) + this.clearConversationBindings(conversationId) + await this.broadcastThreadListUpdate() } async toggleConversationPinned(conversationId: string, pinned: boolean): Promise { - await this.conversationLifecycle.toggleConversationPinned(conversationId, pinned) + await this.sqlitePresenter.updateConversation(conversationId, { is_pinned: pinned ? 1 : 0 }) + await this.broadcastThreadListUpdate() } async updateConversationTitle(conversationId: string, title: string): Promise { - await this.conversationLifecycle.updateConversationTitle(conversationId, title) + await this.sqlitePresenter.updateConversation(conversationId, { title }) + await this.broadcastThreadListUpdate() } async updateConversationSettings( conversationId: string, settings: Partial ): Promise { - await this.conversationLifecycle.updateConversationSettings(conversationId, settings) + const conversation = await this.getConversation(conversationId) + const mergedSettings = { ...conversation.settings } + + for (const key in settings) { + if (settings[key] !== undefined) { + mergedSettings[key] = settings[key] + } + } + + if (settings.modelId && settings.modelId !== conversation.settings.modelId) { + const modelConfig = this.configPresenter.getModelConfig( + mergedSettings.modelId, + mergedSettings.providerId + ) + if (modelConfig) { + mergedSettings.maxTokens = modelConfig.maxTokens + mergedSettings.contextLength = modelConfig.contextLength + } + } + + await this.sqlitePresenter.updateConversation(conversationId, { settings: mergedSettings }) + await this.broadcastThreadListUpdate() } async getConversationList( page: number, pageSize: number ): Promise<{ total: number; list: CONVERSATION[] }> { - return this.conversationLifecycle.getConversationList(page, pageSize) + return await this.sqlitePresenter.getConversationList(page, pageSize) } async loadMoreThreads(): Promise<{ hasMore: boolean; total: number }> { - return this.conversationLifecycle.loadMoreThreads() + const total = await this.sqlitePresenter.getConversationCount() + const hasMore = this.fetchThreadLength < total + + if (hasMore) { + this.fetchThreadLength = Math.min(this.fetchThreadLength + 300, total) + await this.broadcastThreadListUpdate() + } + + return { hasMore: this.fetchThreadLength < total, total } } - async setActiveConversation(conversationId: string, tabId: number): Promise { - await this.conversationLifecycle.setActiveConversation(conversationId, tabId) + async broadcastThreadListUpdate(): Promise { + const result = await this.sqlitePresenter.getConversationList(1, this.fetchThreadLength) + + const pinnedConversations: CONVERSATION[] = [] + const normalConversations: CONVERSATION[] = [] + + result.list.forEach((conv) => { + if (conv.is_pinned === 1) { + pinnedConversations.push(conv) + } else { + normalConversations.push(conv) + } + }) + + pinnedConversations.sort((a, b) => b.updatedAt - a.updatedAt) + normalConversations.sort((a, b) => b.updatedAt - a.updatedAt) + + const groupedThreads: Map = new Map() + + if (pinnedConversations.length > 0) { + groupedThreads.set('Pinned', pinnedConversations) + } + + normalConversations.forEach((conv) => { + const date = new Date(conv.updatedAt).toISOString().split('T')[0] + if (!groupedThreads.has(date)) { + groupedThreads.set(date, []) + } + groupedThreads.get(date)!.push(conv) + }) + + const finalGroupedList = Array.from(groupedThreads.entries()).map(([dt, dtThreads]) => ({ + dt, + dtThreads + })) + + eventBus.sendToRenderer( + CONVERSATION_EVENTS.LIST_UPDATED, + SendTarget.ALL_WINDOWS, + finalGroupedList + ) } - async getActiveConversation(tabId: number): Promise { - return this.conversationLifecycle.getActiveConversation(tabId) + private async getLatestConversation(): Promise { + const result = await this.getConversationList(1, 1) + return result.list[0] || null } async getMessages( @@ -1902,7 +2191,7 @@ export class ThreadPresenter implements IThreadPresenter { } async getActiveConversationId(tabId: number): Promise { - return this.conversationLifecycle.getActiveConversationId(tabId) + return this.getActiveConversationIdSync(tabId) } getGeneratingMessageState(messageId: string): GeneratingMessageState | null { @@ -1975,8 +2264,7 @@ export class ThreadPresenter implements IThreadPresenter { } async summaryTitles(tabId?: number, conversationId?: string): Promise { - const activeId = - tabId !== undefined ? this.conversationLifecycle.getActiveConversationId(tabId) : null + const activeId = tabId !== undefined ? this.getActiveConversationIdSync(tabId) : null const targetConversationId = conversationId ?? activeId ?? undefined if (!targetConversationId) { throw new Error('找不到当前对话') @@ -2049,13 +2337,13 @@ export class ThreadPresenter implements IThreadPresenter { } async clearActiveThread(tabId: number): Promise { - this.conversationLifecycle.clearActiveConversation(tabId, { notify: true }) + this.clearActiveConversation(tabId, { notify: true }) } async clearAllMessages(conversationId: string): Promise { await this.messageManager.clearAllMessages(conversationId) // 检查所有 tab 中的活跃会话 - const tabs = this.conversationLifecycle.getTabsByConversation(conversationId) + const tabs = this.getTabsByConversation(conversationId) if (tabs.length > 0) { await this.stopConversationGeneration(conversationId) } @@ -2223,7 +2511,7 @@ export class ThreadPresenter implements IThreadPresenter { } // 7. 在所有数据库操作完成后,调用广播方法 - await this.conversationLifecycle.broadcastThreadListUpdate() + await this.broadcastThreadListUpdate() // 8. 触发会话创建事件 return newConversationId From 705b2e18583e028614dd8335b1d7dd6f3415ace3 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Wed, 29 Oct 2025 09:47:07 +0800 Subject: [PATCH 10/13] fix(thread): address reviewer feedback --- src/main/presenter/threadPresenter/index.ts | 69 ++++++++++--------- .../threadPresenter/messageManager.ts | 18 ++++- 2 files changed, 52 insertions(+), 35 deletions(-) diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts index 83c987cbb..451d0088c 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -13,7 +13,7 @@ import { MCPToolResponse, ChatMessage, LLMAgentEventData -} from '../../../shared/presenter' +} from '@shared/presenter' import { presenter } from '@/presenter' import { MessageManager } from './messageManager' import { eventBus, SendTarget } from '@/eventbus' @@ -129,6 +129,7 @@ export class ThreadPresenter implements IThreadPresenter { await this.messageManager.handleMessageError(eventId, String(error)) this.generatingMessages.delete(eventId) } + this.searchingMessages.delete(eventId) eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, msg) } @@ -159,12 +160,14 @@ export class ThreadPresenter implements IThreadPresenter { } }) await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + this.searchingMessages.delete(eventId) return } await this.finalizeMessage(state, eventId, Boolean(userStop)) } + this.searchingMessages.delete(eventId) eventBus.sendToRenderer(STREAM_EVENTS.END, SendTarget.ALL_WINDOWS, msg) } @@ -412,6 +415,8 @@ export class ThreadPresenter implements IThreadPresenter { lastBlock.status = 'success' lastBlock.content = tool_call_response || '' } + this.searchingMessages.delete(eventId) + state.isSearching = false } else if (tool_call === 'permission-denied') { if ( lastBlock && @@ -421,6 +426,8 @@ export class ThreadPresenter implements IThreadPresenter { lastBlock.status = 'error' lastBlock.content = tool_call_response || '' } + this.searchingMessages.delete(eventId) + state.isSearching = false } else if (tool_call === 'continue') { if ( lastBlock && @@ -451,6 +458,8 @@ export class ThreadPresenter implements IThreadPresenter { ) { lastBlock.status = 'success' } + this.searchingMessages.delete(eventId) + state.isSearching = false } } @@ -545,7 +554,8 @@ export class ThreadPresenter implements IThreadPresenter { const totalTokens = state.promptTokens + completionTokens const generationTime = Date.now() - (state.firstTokenTime ?? state.startTime) - const tokensPerSecond = completionTokens / (generationTime / 1000) + const safeMs = Math.max(1, generationTime) + const tokensPerSecond = completionTokens / (safeMs / 1000) const contextUsage = state?.totalUsage?.context_length ? (totalTokens / state.totalUsage.context_length) * 100 : 0 @@ -569,6 +579,7 @@ export class ThreadPresenter implements IThreadPresenter { await this.messageManager.updateMessageStatus(eventId, 'sent') await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) this.generatingMessages.delete(eventId) + this.searchingMessages.delete(eventId) await this.handleConversationUpdates(state) @@ -583,36 +594,26 @@ export class ThreadPresenter implements IThreadPresenter { private async handleConversationUpdates(state: GeneratingMessageState): Promise { const conversation = await this.getConversation(state.conversationId) - let titleUpdated = false if (conversation.is_new === 1) { try { - this.summaryTitles(undefined, state.conversationId) - .then((title) => { - if (title) { - this.renameConversation(state.conversationId, title).then(() => { - titleUpdated = true - }) - } - }) - .catch((error) => { - console.error('Failed to summarize title in main process:', error) - }) - } catch (e) { - console.error('Failed to summarize title in main process:', e) + const title = await this.summaryTitles(undefined, state.conversationId) + if (title) { + await this.renameConversation(state.conversationId, title) + return + } + } catch (error) { + console.error('[ThreadPresenter] Failed to summarize title', { + conversationId: state.conversationId, + err: error + }) } } - if (!titleUpdated) { - await this.sqlitePresenter - .updateConversation(state.conversationId, { - updatedAt: Date.now() - }) - .then(() => { - console.log('updated conv time', state.conversationId) - }) - await this.broadcastThreadListUpdate() - } + await this.sqlitePresenter.updateConversation(state.conversationId, { + updatedAt: Date.now() + }) + await this.broadcastThreadListUpdate() } private cleanupContentBuffer(state: GeneratingMessageState): void { @@ -1070,13 +1071,17 @@ export class ThreadPresenter implements IThreadPresenter { const conversation = await this.getConversation(conversationId) const mergedSettings = { ...conversation.settings } - for (const key in settings) { - if (settings[key] !== undefined) { - mergedSettings[key] = settings[key] - } - } + const sanitizedOverrides = Object.fromEntries( + Object.entries(settings).filter(([, value]) => value !== undefined) + ) as Partial + Object.assign(mergedSettings, sanitizedOverrides) + + const modelChanged = + (settings.modelId !== undefined && settings.modelId !== conversation.settings.modelId) || + (settings.providerId !== undefined && + settings.providerId !== conversation.settings.providerId) - if (settings.modelId && settings.modelId !== conversation.settings.modelId) { + if (modelChanged) { const modelConfig = this.configPresenter.getModelConfig( mergedSettings.modelId, mergedSettings.providerId diff --git a/src/main/presenter/threadPresenter/messageManager.ts b/src/main/presenter/threadPresenter/messageManager.ts index 30211c641..dab882052 100644 --- a/src/main/presenter/threadPresenter/messageManager.ts +++ b/src/main/presenter/threadPresenter/messageManager.ts @@ -275,13 +275,25 @@ export class MessageManager implements IMessageManager { } async getMessageHistory(messageId: string, limit: number = 100): Promise { + if (limit <= 0) { + return [] + } + const message = await this.getMessage(messageId) - const { list: messages } = await this.getMessageThread(message.conversationId, 1, limit * 2) - const targetIndex = messages.findIndex((msg) => msg.id === messageId) + const sqliteMessages = await this.sqlitePresenter.queryMessages(message.conversationId) + const orderedMessages = sqliteMessages + .sort((a, b) => { + const timeDiff = a.created_at - b.created_at + return timeDiff !== 0 ? timeDiff : a.order_seq - b.order_seq + }) + .map((sqliteMessage) => this.convertToMessage(sqliteMessage)) + + const targetIndex = orderedMessages.findIndex((msg) => msg.id === messageId) if (targetIndex === -1) { return [message] } - return messages.slice(Math.max(0, targetIndex - limit + 1), targetIndex + 1) + + return orderedMessages.slice(Math.max(0, targetIndex - limit + 1), targetIndex + 1) } async getLastUserMessage(conversationId: string): Promise { From 43d201c31ec7ed4bf90d58c26080cd1602f72ee2 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 29 Oct 2025 10:12:15 +0800 Subject: [PATCH 11/13] refactor: extract html template --- .../threadPresenter/conversationExporter.ts | 342 ++++++++++-------- .../templates/conversationExportTemplates.ts | 169 +++++++++ 2 files changed, 357 insertions(+), 154 deletions(-) create mode 100644 src/main/presenter/threadPresenter/templates/conversationExportTemplates.ts diff --git a/src/main/presenter/threadPresenter/conversationExporter.ts b/src/main/presenter/threadPresenter/conversationExporter.ts index 5bb0782b4..2ae661838 100644 --- a/src/main/presenter/threadPresenter/conversationExporter.ts +++ b/src/main/presenter/threadPresenter/conversationExporter.ts @@ -1,6 +1,7 @@ import { AssistantMessageBlock, Message, UserMessageContent } from '@shared/chat' import { CONVERSATION } from '../../../shared/presenter' import { getNormalizedUserMessageText } from './messageContent' +import { conversationExportTemplates } from './templates/conversationExportTemplates' export type ConversationExportFormat = 'markdown' | 'html' | 'txt' @@ -173,217 +174,250 @@ function exportToMarkdown(conversation: CONVERSATION, messages: Message[]): stri function exportToHtml(conversation: CONVERSATION, messages: Message[]): string { const lines: string[] = [] + const { html, styles, templates } = conversationExportTemplates - lines.push('') - lines.push('') - lines.push('') - lines.push(' ') - lines.push(' ') - lines.push(` ${escapeHtml(conversation.title)}`) - lines.push(' ') - lines.push('') - lines.push('') - lines.push('
') - lines.push(`

${escapeHtml(conversation.title)}

`) - lines.push(`

Export Time: ${new Date().toLocaleString()}

`) - lines.push(`

Conversation ID: ${conversation.id}

`) - lines.push(`

Message Count: ${messages.length}

`) if (conversation.settings.modelId) { - lines.push(`

Model: ${conversation.settings.modelId}

`) + metaRows.push( + ...renderTemplate(templates.metaRow, { + label: '模型', + value: escapeHtml(conversation.settings.modelId) + }) + ) } if (conversation.settings.providerId) { - lines.push(`

Provider: ${conversation.settings.providerId}

`) + metaRows.push( + ...renderTemplate(templates.metaRow, { + label: '服务商', + value: escapeHtml(conversation.settings.providerId) + }) + ) } - lines.push('
') - for (const message of messages) { - const messageTime = new Date(message.timestamp).toLocaleString() + lines.push( + ...renderTemplate(templates.header, { + title: escapeHtml(conversation.title), + metaRows: metaRows.join('\n') + }) + ) + + for (let index = 0; index < messages.length; index++) { + const message = messages[index] + const messageTime = escapeHtml(new Date(message.timestamp).toLocaleString()) if (message.role === 'user') { const userContent = message.content as UserMessageContent const messageText = getNormalizedUserMessageText(userContent) - lines.push('
') - lines.push('
👤 用户
') - lines.push(`
${messageTime}
`) - lines.push('
') - lines.push(` ${escapeHtml(messageText).replace(/\n/g, '
')}`) - lines.push('
') - - if (userContent.files && userContent.files.length > 0) { - lines.push('
') - lines.push('
附件
') - lines.push('
    ') - for (const file of userContent.files) { - const name = escapeHtml(file.name ?? '') - const mime = file.mimeType ? escapeHtml(file.mimeType) : 'unknown' - lines.push(`
  • ${name} (${mime})
  • `) - } - lines.push('
') - lines.push('
') - } - - if (userContent.links && userContent.links.length > 0) { - lines.push('
') - lines.push('
链接
') - lines.push('
    ') - for (const link of userContent.links) { - lines.push(`
  • ${escapeHtml(link)}
  • `) - } - lines.push('
') - lines.push('
') - } - - lines.push('
') + const attachmentItems = + userContent.files?.map((file) => + renderTemplate(templates.attachmentItem, { + name: escapeHtml(file.name ?? ''), + mime: escapeHtml(file.mimeType ?? 'unknown') + }) + ) ?? [] + + const linkItems = + userContent.links?.map((link) => + renderTemplate(templates.linkItem, { + href: escapeHtml(link), + label: escapeHtml(link) + }) + ) ?? [] + + const attachmentsSection = + attachmentItems.length > 0 + ? renderTemplate(templates.attachmentsSection, { + items: attachmentItems.flat().join('\n') + }).join('\n') + : '' + + const linksSection = + linkItems.length > 0 + ? renderTemplate(templates.linksSection, { + items: linkItems.flat().join('\n') + }).join('\n') + : '' + + lines.push( + ...renderTemplate(templates.userMessage, { + timestamp: messageTime, + content: formatInlineHtml(messageText), + attachmentsSection, + linksSection + }) + ) } else if (message.role === 'assistant') { const assistantBlocks = message.content as AssistantMessageBlock[] - - lines.push('
') - lines.push('
🤖 助手
') - lines.push(`
${messageTime}
`) + const blockLines: string[] = [] for (const block of assistantBlocks) { switch (block.type) { case 'content': if (block.content) { - lines.push('
') - lines.push(` ${escapeHtml(block.content).replace(/\n/g, '
')}`) - lines.push('
') + blockLines.push( + ...renderTemplate(templates.assistantContent, { + content: formatInlineHtml(block.content) + }) + ) } break case 'reasoning_content': if (block.content) { - lines.push('
') - lines.push(' 🤔 思考过程') - lines.push(`
${escapeHtml(block.content)}
`) - lines.push('
') + blockLines.push( + ...renderTemplate(templates.assistantReasoning, { + content: escapeHtml(block.content) + }) + ) + } + break + case 'artifact-thinking': + if (block.content) { + blockLines.push( + ...renderTemplate(templates.assistantArtifact, { + content: escapeHtml(block.content) + }) + ) } break case 'tool_call': if (block.tool_call) { - lines.push('
') - lines.push( - ` 🔧 工具调用: ${escapeHtml(block.tool_call.name ?? '')}` - ) + const toolName = + block.tool_call.name && block.tool_call.name.length > 0 + ? renderTemplate(templates.assistantToolName, { + value: escapeHtml(block.tool_call.name) + }).join('\n') + : '' + + let toolParams = '' if (block.tool_call.params) { - lines.push('
参数
') - lines.push('
') + let paramsContent = block.tool_call.params try { - const params = JSON.parse(block.tool_call.params) - lines.push(escapeHtml(JSON.stringify(params, null, 2))) + const parsed = JSON.parse(block.tool_call.params) + paramsContent = JSON.stringify(parsed, null, 2) } catch { - lines.push(escapeHtml(block.tool_call.params)) + // keep original params text if JSON.parse fails } - lines.push('
') - } - if (block.tool_call.response) { - lines.push('
响应
') - lines.push('
') - lines.push(escapeHtml(block.tool_call.response)) - lines.push('
') + toolParams = renderTemplate(templates.assistantToolParams, { + value: escapeHtml(paramsContent) + }).join('\n') } - lines.push('
') + + const toolResponse = + block.tool_call.response && block.tool_call.response.length > 0 + ? renderTemplate(templates.assistantToolResponse, { + value: escapeHtml(block.tool_call.response) + }).join('\n') + : '' + + blockLines.push( + ...renderTemplate(templates.assistantToolCall, { + name: toolName, + params: toolParams, + response: toolResponse + }) + ) } break case 'search': - lines.push('
') - lines.push(' 🔍 网络搜索') - if (block.extra?.total) { - lines.push(`
找到 ${block.extra.total} 个搜索结果
`) - } - lines.push('
') + blockLines.push( + ...renderTemplate(templates.assistantSearch, { + caption: + block.extra?.total !== undefined + ? renderTemplate(templates.assistantSearchCaption, { + total: escapeHtml(String(block.extra.total)) + }).join('\n') + : '' + }) + ) break case 'image': - lines.push('
🖼️ 图片
') - lines.push('
*[图片内容]*
') + blockLines.push(...renderTemplate(templates.assistantImage)) break case 'error': if (block.content) { - lines.push('
') - lines.push(` ❌ ${escapeHtml(block.content)}`) - lines.push('
') - } - break - case 'artifact-thinking': - if (block.content) { - lines.push('
') - lines.push(' 💭 创作思考:') - lines.push(`
${escapeHtml(block.content)}
`) - lines.push('
') + blockLines.push( + ...renderTemplate(templates.assistantError, { + content: escapeHtml(block.content) + }) + ) } break } } - lines.push('
') + lines.push( + ...renderTemplate(templates.assistantMessage, { + timestamp: messageTime, + assistantBlocks: blockLines.join('\n') + }) + ) } - lines.push('
') + if (index < messages.length - 1) { + lines.push(...renderTemplate(templates.divider)) + } } - lines.push('') - lines.push('') + lines.push(...renderTemplate(html.documentEnd)) return lines.join('\n') } +type TemplateInput = string | string[] + +function renderTemplate( + template: TemplateInput, + replacements: Record = {} +): string[] { + const source = Array.isArray(template) ? template : [template] + const output: string[] = [] + + for (const line of source) { + let rendered = line + + for (const [key, value] of Object.entries(replacements)) { + const pattern = new RegExp(`\\{\\{${key}\\}\\}`, 'g') + rendered = rendered.replace(pattern, value) + } + + rendered = rendered.replace(/{{\w+}}/g, '') + output.push(...rendered.split('\n')) + } + + return output +} + +function formatInlineHtml(content: string): string { + return escapeHtml(content).replace(/\n/g, '
') +} + function exportToText(conversation: CONVERSATION, messages: Message[]): string { const lines: string[] = [] diff --git a/src/main/presenter/threadPresenter/templates/conversationExportTemplates.ts b/src/main/presenter/threadPresenter/templates/conversationExportTemplates.ts new file mode 100644 index 000000000..6e6ff1aa1 --- /dev/null +++ b/src/main/presenter/threadPresenter/templates/conversationExportTemplates.ts @@ -0,0 +1,169 @@ +export type ExportTemplates = { + html: { + documentStart: string[] + documentEnd: string[] + } + styles: string[] + templates: Record +} + +export const conversationExportTemplates: ExportTemplates = { + html: { + documentStart: [ + '', + '', + '', + ' ', + ' ', + ' {{title}}', + ' ', + '', + '', + '
' + ], + documentEnd: ['
', '', ''] + }, + styles: [ + ':root { color-scheme: only light; }', + '* { box-sizing: border-box; }', + 'body { margin: 0; padding: 40px 16px 48px; background: #f1f4f9; color: #1f2933; font-family: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.7; }', + '.page { max-width: 840px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }', + '.header { background: #ffffff; border-radius: 18px; padding: 28px 32px; border: 1px solid rgba(148, 163, 184, 0.24); box-shadow: 0 14px 35px rgba(15, 23, 42, 0.05); }', + '.header h1 { margin: 0 0 16px; font-size: 2.1rem; font-weight: 700; color: #0f172a; }', + '.meta { display: grid; gap: 6px; font-size: 0.92rem; color: #475569; }', + '.meta-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }', + '.meta-label { font-weight: 600; color: #1e293b; }', + '.message { background: #ffffff; border-radius: 16px; padding: 24px 28px; border: 1px solid rgba(203, 213, 225, 0.6); box-shadow: 0 6px 20px rgba(15, 23, 42, 0.04); }', + '.message.user-message { border-left: 4px solid #2563eb; }', + '.message.assistant-message { border-left: 4px solid #0ea5e9; }', + '.message-header { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; }', + '.message-avatar { font-size: 1.4rem; line-height: 1; }', + '.message-identity { font-weight: 600; font-size: 1.02rem; color: #0f172a; }', + '.message-meta { font-size: 0.85rem; color: #64748b; }', + '.message-content { font-size: 1rem; color: #1f2937; word-break: break-word; }', + '.section { margin-top: 18px; }', + '.section-title { font-weight: 600; font-size: 0.95rem; color: #0f172a; display: flex; gap: 6px; align-items: center; }', + '.section-label { font-size: 0.75rem; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; }', + '.section-caption { font-size: 0.85rem; color: #475569; margin-bottom: 4px; }', + '.attachments, .search-block, .tool-call, .error-block, .reasoning-block { border-radius: 12px; padding: 18px 20px; border: 1px solid rgba(203, 213, 225, 0.7); background: #f8fafc; }', + '.attachments { background: #fef3c7; border-color: #fcd34d; }', + '.attachments ul { margin: 8px 0 0; padding-left: 20px; }', + '.attachments li { margin: 4px 0; }', + '.tool-call { background: #ecfdf3; border-color: #c4f0d6; }', + '.reasoning-block { background: #e0e7ff; border-color: #c7d2fe; color: #1e293b; }', + '.error-block { background: #fee2e2; border-color: #fecaca; color: #b91c1c; }', + '.search-block { background: #eff6ff; border-color: #bfdbfe; color: #1e3a8a; }', + 'pre.code { background: #0f172a; color: #e2e8f0; border-radius: 10px; padding: 16px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.88rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; margin: 10px 0 0; }', + 'a { color: #1d4ed8; text-decoration: none; font-weight: 500; }', + 'a:hover { text-decoration: underline; }', + '.divider { height: 1px; background: linear-gradient(90deg, rgba(148, 163, 184, 0.4), rgba(148, 163, 184, 0)); margin: 12px auto 32px; max-width: 820px; }' + ], + templates: { + header: [ + '
', + '

{{title}}

', + '
', + '{{metaRows}}', + '
', + '
' + ], + metaRow: + '
{{label}}{{value}}
', + userMessage: [ + '
', + '
', + '
👤
', + '
', + '
用户
', + '
{{timestamp}}
', + '
', + '
', + '
{{content}}
', + '{{attachmentsSection}}', + '{{linksSection}}', + '
' + ], + assistantMessage: [ + '
', + '
', + '
🤖
', + '
', + '
助手
', + '
{{timestamp}}
', + '
', + '
', + '{{assistantBlocks}}', + '
' + ], + attachmentsSection: [ + '
', + '
📎 附件
', + '
    ', + '{{items}}', + '
', + '
' + ], + attachmentItem: '
  • {{name}} ({{mime}})
  • ', + linksSection: [ + '
    ', + '
    🔗 链接
    ', + '
      ', + '{{items}}', + '
    ', + '
    ' + ], + linkItem: + '
  • {{label}}
  • ', + assistantContent: ['
    {{content}}
    '], + assistantReasoning: [ + '
    ', + '
    🤔 思考过程
    ', + '
    {{content}}
    ', + '
    ' + ], + assistantArtifact: [ + '
    ', + '
    💭 创作思考
    ', + '
    {{content}}
    ', + '
    ' + ], + assistantToolCall: [ + '
    ', + '
    🔧 工具调用
    ', + '{{name}}', + '{{params}}', + '{{response}}', + '
    ' + ], + assistantToolName: '
    {{value}}
    ', + assistantToolParams: [ + ' ', + '
    {{value}}
    ' + ], + assistantToolResponse: [ + ' ', + '
    {{value}}
    ' + ], + assistantSearch: [ + '
    ', + '
    🔍 网络搜索
    ', + '{{caption}}', + '
    ' + ], + assistantSearchCaption: '
    找到 {{total}} 个搜索结果
    ', + assistantImage: [ + '
    ', + '
    🖼️ 图片
    ', + '
    *[图片内容]*
    ', + '
    ' + ], + assistantError: [ + '
    ', + ' ❌ {{content}}', + '
    ' + ], + divider: '
    ' + } +} From 60523e3c4616fc72a4ad1b5b517c0bd81f2940cb Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 29 Oct 2025 11:46:02 +0800 Subject: [PATCH 12/13] fix: review issue --- .../threadPresenter/conversationExporter.ts | 47 +++++++++++++++---- .../threadPresenter/messageManager.ts | 15 +++--- .../templates/conversationExportTemplates.ts | 5 +- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/main/presenter/threadPresenter/conversationExporter.ts b/src/main/presenter/threadPresenter/conversationExporter.ts index 2ae661838..349f2e265 100644 --- a/src/main/presenter/threadPresenter/conversationExporter.ts +++ b/src/main/presenter/threadPresenter/conversationExporter.ts @@ -32,7 +32,7 @@ export function buildConversationExportContent( case 'txt': return exportToText(conversation, messages) default: - throw new Error(`不支持的导出格式: ${format}`) + throw new Error(`Unsupported export format: ${format}`) } } @@ -70,7 +70,7 @@ function exportToMarkdown(conversation: CONVERSATION, messages: Message[]): stri if (userContent.files && userContent.files.length > 0) { lines.push('**附件:**') for (const file of userContent.files) { - lines.push(`- ${file.name} (${file.mimeType})`) + lines.push(`- ${file.name ?? ''} (${file.mimeType ?? 'unknown'})`) } lines.push('') } @@ -133,7 +133,7 @@ function exportToMarkdown(conversation: CONVERSATION, messages: Message[]): stri break case 'search': lines.push('### 🔍 网络搜索') - if (block.extra?.total) { + if (block.extra?.total !== undefined) { lines.push(`找到 ${block.extra.total} 个搜索结果`) } lines.push('') @@ -244,12 +244,13 @@ function exportToHtml(conversation: CONVERSATION, messages: Message[]): string { ) ?? [] const linkItems = - userContent.links?.map((link) => - renderTemplate(templates.linkItem, { - href: escapeHtml(link), + userContent.links?.map((link) => { + const safeHref = sanitizeHref(link) + return renderTemplate(templates.linkItem, { + href: escapeHtml(safeHref), label: escapeHtml(link) }) - ) ?? [] + }) ?? [] const attachmentsSection = attachmentItems.length > 0 @@ -403,8 +404,10 @@ function renderTemplate( let rendered = line for (const [key, value] of Object.entries(replacements)) { - const pattern = new RegExp(`\\{\\{${key}\\}\\}`, 'g') - rendered = rendered.replace(pattern, value) + const token = `{{${key}}}` + if (rendered.includes(token)) { + rendered = rendered.split(token).join(value) + } } rendered = rendered.replace(/{{\w+}}/g, '') @@ -418,6 +421,30 @@ function formatInlineHtml(content: string): string { return escapeHtml(content).replace(/\n/g, '
    ') } +function sanitizeHref(link: string): string { + const trimmed = link?.trim() + if (!trimmed) { + return '#' + } + + const lower = trimmed.toLowerCase() + if ( + lower.startsWith('http://') || + lower.startsWith('https://') || + lower.startsWith('mailto:') || + lower.startsWith('#') + ) { + return trimmed + } + + // Allow relative URLs (no scheme) + if (!/^[a-z][\w+.-]*:/.test(trimmed)) { + return trimmed + } + + return '#' +} + function exportToText(conversation: CONVERSATION, messages: Message[]): string { const lines: string[] = [] @@ -502,7 +529,7 @@ function exportToText(conversation: CONVERSATION, messages: Message[]): string { break case 'search': lines.push('[网络搜索]') - if (block.extra?.total) { + if (block.extra?.total !== undefined) { lines.push(`找到 ${block.extra.total} 个搜索结果`) } lines.push('') diff --git a/src/main/presenter/threadPresenter/messageManager.ts b/src/main/presenter/threadPresenter/messageManager.ts index dab882052..74340c01e 100644 --- a/src/main/presenter/threadPresenter/messageManager.ts +++ b/src/main/presenter/threadPresenter/messageManager.ts @@ -260,14 +260,17 @@ export class MessageManager implements IMessageManager { if (msg.role !== 'user') { return msg } - const normalized = { ...msg } - const userContent = normalized.content as UserMessageContent + const userContent = msg.content as UserMessageContent if (userContent?.content) { - ;(normalized.content as UserMessageContent).text = formatUserMessageContent( - userContent.content - ) + return { + ...msg, + content: { + ...userContent, + text: formatUserMessageContent(userContent.content) + } + } } - return normalized + return msg }) } diff --git a/src/main/presenter/threadPresenter/templates/conversationExportTemplates.ts b/src/main/presenter/threadPresenter/templates/conversationExportTemplates.ts index 6e6ff1aa1..98b799ff3 100644 --- a/src/main/presenter/threadPresenter/templates/conversationExportTemplates.ts +++ b/src/main/presenter/threadPresenter/templates/conversationExportTemplates.ts @@ -26,7 +26,7 @@ export const conversationExportTemplates: ExportTemplates = { documentEnd: [' ', '', ''] }, styles: [ - ':root { color-scheme: only light; }', + ':root { color-scheme: light; }', '* { box-sizing: border-box; }', 'body { margin: 0; padding: 40px 16px 48px; background: #f1f4f9; color: #1f2933; font-family: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.7; }', '.page { max-width: 840px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }', @@ -48,6 +48,7 @@ export const conversationExportTemplates: ExportTemplates = { '.section-label { font-size: 0.75rem; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; }', '.section-caption { font-size: 0.85rem; color: #475569; margin-bottom: 4px; }', '.attachments, .search-block, .tool-call, .error-block, .reasoning-block { border-radius: 12px; padding: 18px 20px; border: 1px solid rgba(203, 213, 225, 0.7); background: #f8fafc; }', + '.links { border-radius: 12px; padding: 18px 20px; border: 1px solid rgba(203, 213, 225, 0.7); background: #f8fafc; }', '.attachments { background: #fef3c7; border-color: #fcd34d; }', '.attachments ul { margin: 8px 0 0; padding-left: 20px; }', '.attachments li { margin: 4px 0; }', @@ -107,7 +108,7 @@ export const conversationExportTemplates: ExportTemplates = { ], attachmentItem: '
  • {{name}} ({{mime}})
  • ', linksSection: [ - '
    ', + '