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/conversationExporter.ts b/src/main/presenter/threadPresenter/conversationExporter.ts new file mode 100644 index 000000000..be4d89bd2 --- /dev/null +++ b/src/main/presenter/threadPresenter/conversationExporter.ts @@ -0,0 +1,572 @@ +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' + +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(`Unsupported export format: ${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 ?? 'unknown'})`) + } + 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 !== undefined) { + 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[] = [] + const { html, styles, templates } = conversationExportTemplates + + const styleLines = styles.map((styleRule) => ` ${styleRule}`).join('\n') + lines.push( + ...renderTemplate(html.documentStart, { + title: escapeHtml(conversation.title), + styleLines + }) + ) + + const metaRows: string[] = [] + metaRows.push( + ...renderTemplate(templates.metaRow, { + label: '导出时间', + value: escapeHtml(new Date().toLocaleString()) + }) + ) + metaRows.push( + ...renderTemplate(templates.metaRow, { + label: '会话 ID', + value: escapeHtml(conversation.id) + }) + ) + metaRows.push( + ...renderTemplate(templates.metaRow, { + label: '消息数量', + value: escapeHtml(String(messages.length)) + }) + ) + if (conversation.settings.modelId) { + metaRows.push( + ...renderTemplate(templates.metaRow, { + label: '模型', + value: escapeHtml(conversation.settings.modelId) + }) + ) + } + if (conversation.settings.providerId) { + metaRows.push( + ...renderTemplate(templates.metaRow, { + label: '服务商', + value: escapeHtml(conversation.settings.providerId) + }) + ) + } + + 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) + + const attachmentItems = + userContent.files?.map((file) => + renderTemplate(templates.attachmentItem, { + name: escapeHtml(file.name ?? ''), + mime: escapeHtml(file.mimeType ?? 'unknown') + }) + ) ?? [] + + const linkItems = + userContent.links?.map((link) => { + const safeHref = sanitizeHref(link) + return renderTemplate(templates.linkItem, { + href: escapeHtml(safeHref), + 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[] + const blockLines: string[] = [] + + for (const block of assistantBlocks) { + switch (block.type) { + case 'content': + if (block.content) { + blockLines.push( + ...renderTemplate(templates.assistantContent, { + content: formatInlineHtml(block.content) + }) + ) + } + break + case 'reasoning_content': + if (block.content) { + 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) { + 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) { + let paramsContent = block.tool_call.params + try { + const parsed = JSON.parse(block.tool_call.params) + paramsContent = JSON.stringify(parsed, null, 2) + } catch { + // keep original params text if JSON.parse fails + } + toolParams = renderTemplate(templates.assistantToolParams, { + value: escapeHtml(paramsContent) + }).join('\n') + } + + 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': + blockLines.push( + ...renderTemplate(templates.assistantSearch, { + caption: + block.extra?.total !== undefined + ? renderTemplate(templates.assistantSearchCaption, { + total: escapeHtml(String(block.extra.total)) + }).join('\n') + : '' + }) + ) + break + case 'image': + blockLines.push(...renderTemplate(templates.assistantImage)) + break + case 'error': + if (block.content) { + blockLines.push( + ...renderTemplate(templates.assistantError, { + content: escapeHtml(block.content) + }) + ) + } + break + } + } + + lines.push( + ...renderTemplate(templates.assistantMessage, { + timestamp: messageTime, + assistantBlocks: blockLines.join('\n') + }) + ) + } + + if (index < messages.length - 1) { + lines.push(...renderTemplate(templates.divider)) + } + } + + 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 token = `{{${key}}}` + if (rendered.includes(token)) { + rendered = rendered.split(token).join(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 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+.-]*:/i.test(trimmed)) { + return trimmed + } + + return '#' +} + +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 !== undefined) { + 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/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..451d0088c 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -12,9 +12,8 @@ import { ILlmProviderPresenter, MCPToolResponse, ChatMessage, - ChatMessageContent, LLMAgentEventData -} from '../../../shared/presenter' +} from '@shared/presenter' import { presenter } from '@/presenter' import { MessageManager } from './messageManager' import { eventBus, SendTarget } from '@/eventbus' @@ -25,53 +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' +import { + buildUserMessageContext, + formatUserMessageContent, + getNormalizedUserMessageText +} from './messageContent' +import { preparePromptContent, buildContinueToolCallContext } from './promptBuilder' +import { + buildConversationExportContent, + generateExportFilename, + ConversationExportFormat +} from './conversationExporter' +import type { GeneratingMessageState } from './types' +import { finalizeAssistantMessageBlocks } from '@shared/chat/messageBlocks' +import { approximateTokenSize } from 'tokenx' +import { DEFAULT_SETTINGS } from './const' -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 +export interface CreateConversationOptions { + forceNewAndActivate?: boolean } export class ThreadPresenter implements IThreadPresenter { @@ -81,11 +57,11 @@ export class ThreadPresenter implements IThreadPresenter { private configPresenter: IConfigPresenter private searchManager: SearchManager private generatingMessages: Map = new Map() + private activeConversationIds: Map = new Map() + private fetchThreadLength = 300 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, @@ -100,8 +76,9 @@ export class ThreadPresenter implements IThreadPresenter { // 监听Tab关闭事件,清理绑定关系 eventBus.on(TAB_EVENTS.CLOSED, (tabId: number) => { - if (this.activeConversationIds.has(tabId)) { - this.activeConversationIds.delete(tabId) + const activeConversationId = this.getActiveConversationIdSync(tabId) + if (activeConversationId) { + this.clearActiveConversation(tabId, { notify: true }) console.log(`ThreadPresenter: Cleaned up conversation binding for closed tab ${tabId}.`) } }) @@ -113,6 +90,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 @@ -121,45 +103,33 @@ export class ThreadPresenter implements IThreadPresenter { 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 + 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 } - 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 handleLLMAgentError(msg: LLMAgentEventData) { 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) } + this.searchingMessages.delete(eventId) eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, msg) } @@ -167,11 +137,12 @@ export class ThreadPresenter implements IThreadPresenter { const { eventId, userStop } = msg const state = this.generatingMessages.get(eventId) if (state) { - console.log( - `[ThreadPresenter] Handling LLM agent end for message: ${eventId}, userStop: ${userStop}` - ) + if (state.adaptiveBuffer) { + await this.flushAdaptiveBuffer(eventId) + } + + this.cleanupContentBuffer(state) - // 检查是否有未处理的权限请求 const hasPendingPermissions = state.message.content.some( (block) => block.type === 'action' && @@ -180,11 +151,6 @@ export class ThreadPresenter implements IThreadPresenter { ) 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') && @@ -194,48 +160,366 @@ export class ThreadPresenter implements IThreadPresenter { } }) await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + this.searchingMessages.delete(eventId) return } - console.log(`[ThreadPresenter] Finalizing message ${eventId} - no pending permissions`) - - // 正常完成流程 - await this.finalizeMessage(state, eventId, userStop || false) + await this.finalizeMessage(state, eventId, Boolean(userStop)) } + this.searchingMessages.delete(eventId) eventBus.sendToRenderer(STREAM_EVENTS.END, SendTarget.ALL_WINDOWS, msg) } - // 清理所有缓冲相关资源 - private cleanupContentBuffer(state: GeneratingMessageState): void { - if (state.flushTimeout) { - clearTimeout(state.flushTimeout) - state.flushTimeout = undefined + 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) { + return } - if (state.throttleTimeout) { - clearTimeout(state.throttleTimeout) - state.throttleTimeout = undefined + + if (state.firstTokenTime === null && (content || reasoning_content)) { + state.firstTokenTime = currentTime + await this.messageManager.updateMessageMetadata(eventId, { + firstTokenTime: currentTime - state.startTime + }) } - state.adaptiveBuffer = undefined - state.lastRendererUpdateTime = undefined + 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 || '' + } + this.searchingMessages.delete(eventId) + state.isSearching = false + } 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 || '' + } + this.searchingMessages.delete(eventId) + state.isSearching = false + } 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' + } + this.searchingMessages.delete(eventId) + state.isSearching = false + } + } + + 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 { - // 将所有块设为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 @@ -251,7 +535,6 @@ export class ThreadPresenter implements IThreadPresenter { } } - // 检查是否有内容块 const hasContentBlock = state.message.content.some( (block) => block.type === 'content' || @@ -260,7 +543,6 @@ export class ThreadPresenter implements IThreadPresenter { block.type === 'image' ) - // 如果没有内容块,添加错误信息 if (!hasContentBlock && !userStop) { state.message.content.push({ type: 'error', @@ -272,12 +554,12 @@ 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 - // 如果有reasoning_content,记录结束时间 const metadata: Partial = { totalTokens, inputTokens: state.promptTokens, @@ -293,24 +575,14 @@ export class ThreadPresenter implements IThreadPresenter { metadata.reasoningEndTime = state.lastReasoningTime - state.startTime } - // 刷新剩余缓冲内容 - if (state.adaptiveBuffer) { - await this.flushAdaptiveBuffer(eventId) - } - - // 清理缓冲相关资源 - this.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) + this.searchingMessages.delete(eventId) - // 处理标题更新和会话更新 await this.handleConversationUpdates(state) - // 广播消息生成完成事件 const finalMessage = await this.messageManager.getMessage(eventId) if (finalMessage) { eventBus.sendToMain(CONVERSATION_EVENTS.MESSAGE_GENERATED, { @@ -320,40 +592,43 @@ export class ThreadPresenter implements IThreadPresenter { } } - // 处理会话更新和标题生成 private async handleConversationUpdates(state: GeneratingMessageState): Promise { - const conversation = await this.sqlitePresenter.getConversation(state.conversationId) - let titleUpdated = false + const conversation = await this.getConversation(state.conversationId) if (conversation.is_new === 1) { try { - this.summaryTitles(undefined, state.conversationId).then((title) => { - if (title) { - this.renameConversation(state.conversationId, title).then(() => { - titleUpdated = true - }) - } + 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 }) - } 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.broadcastThreadListUpdate() - } + await this.sqlitePresenter.updateConversation(state.conversationId, { + updatedAt: Date.now() + }) + await this.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 @@ -361,100 +636,58 @@ export class ThreadPresenter implements IThreadPresenter { 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 + 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 } - // 优化的自适应内容处理 - 核心逻辑 (当前未使用) - // private async addToAdaptiveBuffer(eventId: string, content: string): Promise { - // // 方法保留以备将来使用 - // } - - // 分块大内容 - 使用更小的分块避免UI阻塞 - private splitLargeContent(content: string): string[] { - const chunks: string[] = [] - let maxChunkSize = 4096 // 默认4KB + private async processBufferedContent( + state: GeneratingMessageState, + eventId: string, + content: string, + currentTime: number + ): Promise { + const buffer = state.adaptiveBuffer - // 对于图片base64内容,使用非常小的分块 - if (content.includes('data:image/')) { - maxChunkSize = 512 // 图片内容使用512字节分块 + if (buffer?.isLargeContent) { + await this.processLargeContentAsynchronously(state, eventId, content, currentTime) + return } - // 对于超长内容,进一步减小分块 - if (content.length > 50000) { - maxChunkSize = Math.min(maxChunkSize, 256) - } + await this.processNormalContent(state, eventId, content, currentTime) + } - 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( + state: GeneratingMessageState, 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 @@ -462,7 +695,6 @@ export class ThreadPresenter implements IThreadPresenter { `[ThreadPresenter] Processing ${totalChunks} chunks asynchronously for ${content.length} bytes` ) - // 初始化或获取内容块 const lastBlock = state.message.content[state.message.content.length - 1] let contentBlock: any @@ -479,20 +711,16 @@ export class ThreadPresenter implements IThreadPresenter { 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, @@ -506,7 +734,6 @@ export class ThreadPresenter implements IThreadPresenter { eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, eventData) - // 每批次之间的延迟,让出event loop if (batchEnd < chunks.length) { await new Promise((resolve) => setImmediate(resolve)) } @@ -516,20 +743,16 @@ export class ThreadPresenter implements IThreadPresenter { } 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 state = this.generatingMessages.get(eventId) - if (!state) return - const lastBlock = state.message.content[state.message.content.length - 1] if (lastBlock && lastBlock.type === 'content') { @@ -544,397 +767,28 @@ export class ThreadPresenter implements IThreadPresenter { }) } - // 只更新数据库,不额外发送到渲染器(避免重复发送) 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' - } - } - } - - // 统一的数据库和渲染器更新 (当前未使用) - // private async updateMessageAndRenderer(eventId: string, content: string, currentTime: number, chunkInfo?: any): Promise { - // // 方法保留以备将来使用 - // } - - 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) { - // 使用保护逻辑 - 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 - await this.messageManager.updateMessageMetadata(eventId, { - firstTokenTime: currentTime - state.startTime - }) - } - if (totalUsage) { - state.totalUsage = totalUsage - state.promptTokens = totalUsage.prompt_tokens - } - - // 处理工具调用达到最大次数的情况 - if (maximum_tool_calls_reached) { - finalizeLastBlock() // 使用保护逻辑 - 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 - } - } - - finalizeLastBlock() - 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') { - // 创建新的工具调用块 - finalizeLastBlock() // 使用保护逻辑 - 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' - ) + private splitLargeContent(content: string): string[] { + const chunks: string[] = [] + let maxChunkSize = 4096 - 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) { - // 处理图像数据 - finalizeLastBlock() // 使用保护逻辑 - state.message.content.push({ - type: 'image', - content: 'image', - status: 'success', - timestamp: currentTime, - image_data: image_data - }) - } else if (content) { - // 简化的直接内容处理 - await this.processContentDirectly(state.message.id, content, currentTime) - } + if (content.includes('data:image/')) { + maxChunkSize = 512 + } - // 处理推理内容 - if (reasoning_content) { - if (lastBlock && lastBlock.type === 'reasoning_content') { - lastBlock.content += reasoning_content - if (lastBlock.reasoning_time) { - lastBlock.reasoning_time.end = currentTime - } - } else { - finalizeLastBlock() // 使用保护逻辑 - state.message.content.push({ - type: 'reasoning_content', - content: reasoning_content, - status: 'loading', - reasoning_time: { - start: currentTime, - end: currentTime - }, - timestamp: currentTime - }) - } - } + if (content.length > 50000) { + maxChunkSize = Math.min(maxChunkSize, 256) + } - // 更新消息内容 - await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) + for (let i = 0; i < content.length; i += maxChunkSize) { + chunks.push(content.slice(i, i + maxChunkSize)) } - eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, msg) - } - setSearchAssistantModel(model: MODEL_META, providerId: string) { - this.searchAssistantModel = model - this.searchAssistantProviderId = providerId + return chunks } + async getSearchEngines(): Promise { return this.searchManager.getEngines() } @@ -968,141 +822,246 @@ export class ThreadPresenter implements IThreadPresenter { } } - async renameConversation(conversationId: string, title: string): Promise { - await this.sqlitePresenter.renameConversation(conversationId, title) - await this.broadcastThreadListUpdate() // 必须广播 + getActiveConversationIdSync(tabId: number): string | null { + return this.activeConversationIds.get(tabId) || null + } - const conversation = await this.getConversation(conversationId) + getTabsByConversation(conversationId: string): number[] { + return Array.from(this.activeConversationIds.entries()) + .filter(([, id]) => id === conversationId) + .map(([tabId]) => tabId) + } - // 新增:找到与此 conversationId 关联的 tabId - let tabId: number | undefined - for (const [key, value] of this.activeConversationIds.entries()) { - if (value === conversationId) { - tabId = key - break + 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 + }) } } + } - // 新增:发出事件通知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 - }) + 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' } + } - return conversation + 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: { forceNewAndActivate?: boolean } = {} // 新增参数,允许强制创建新会话 + options: CreateConversationOptions = {} ): Promise { - console.log('createConversation', title, settings) + let latestConversation: CONVERSATION | null = null - const latestConversation = await this.getLatestConversation() + try { + latestConversation = await this.getLatestConversation() - // 只有在非强制模式下,才执行空会话的单例检查 - if (!options.forceNewAndActivate) { - if (latestConversation) { - const { list: messages } = await this.getMessages(latestConversation.id, 1, 1) + 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 - } - 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 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 + + 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 (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, { + if (tabId !== undefined) { + const windowId = presenter.tabPresenter.getTabWindowId(tabId) + eventBus.sendToRenderer(TAB_EVENTS.TITLE_UPDATED, SendTarget.ALL_WINDOWS, { + tabId, conversationId, - tabId + title: conversation.title, + windowId }) - } else { - // 默认模式:保持原有的、防止重复打开的激活逻辑 - await this.setActiveConversation(conversationId, tabId) } - await this.broadcastThreadListUpdate() // 必须广播 - return conversationId + return conversation } 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() // 必须广播 - } - - async getConversation(conversationId: string): Promise { - return await this.sqlitePresenter.getConversation(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() // 必须广播 + await this.broadcastThreadListUpdate() } async updateConversationTitle(conversationId: string, title: string): Promise { await this.sqlitePresenter.updateConversation(conversationId, { title }) - await this.broadcastThreadListUpdate() // 必须广播 + await this.broadcastThreadListUpdate() } async updateConversationSettings( @@ -1111,29 +1070,30 @@ export class ThreadPresenter implements IThreadPresenter { ): 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 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 (modelChanged) { 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.broadcastThreadListUpdate() } async getConversationList( @@ -1144,80 +1104,63 @@ export class ThreadPresenter implements IThreadPresenter { } 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 } } - async setActiveConversation(conversationId: string, tabId: number): Promise { - // 【核心修正】由主进程负责全部决策(防重和自动切换逻辑) - const existingTabId = await this.findTabForConversation(conversationId) + async broadcastThreadListUpdate(): Promise { + const result = await this.sqlitePresenter.getConversationList(1, this.fetchThreadLength) - // 如果会话已在其他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 + const pinnedConversations: CONVERSATION[] = [] + const normalConversations: CONVERSATION[] = [] + + result.list.forEach((conv) => { + if (conv.is_pinned === 1) { + pinnedConversations.push(conv) } else { - await presenter.tabPresenter.switchTab(existingTabId) - // 注意:这里不应该再为 requesting tab (即 tabId) 设置 activeConversationId - // 也不需要发送ACTIVATED事件,因为tab-session的绑定关系没有改变。 - // switchTab 自身会处理UI的激活。 - return + 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) } - // 如果会话未在其他Tab打开,或者是请求激活当前Tab已绑定的会话,则正常执行绑定 - const conversation = await this.getConversation(conversationId) - if (conversation) { - // 检查当前Tab是否已经绑定了这个会话,避免不必要的事件广播 - if (this.activeConversationIds.get(tabId) === conversationId) { - return // 状态未改变,无需操作 + 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) + }) - this.activeConversationIds.set(tabId, conversationId) - // 广播事件,通知所有渲染进程UI更新 - eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { - conversationId, - tabId - }) - } else { - throw new Error(`Conversation ${conversationId} not found`) - } + 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 { - const conversationId = this.activeConversationIds.get(tabId) - if (!conversationId) { - return null - } - return this.getConversation(conversationId) + private async getLatestConversation(): Promise { + const result = await this.getConversationList(1, 1) + return result.list[0] || null } async getMessages( @@ -1230,83 +1173,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 = this.formatUserMessageContent( - msgContent.content - ) - } - return newMsg - } else { - return msg - } - }) - } - - 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('') + return this.messageManager.getContextMessages(conversationId, messageCount) } async clearContext(conversationId: string): Promise { @@ -1434,25 +1305,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( @@ -1581,7 +1434,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 +1662,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 +1831,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 +2018,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) } } @@ -2176,525 +2032,29 @@ export class ThreadPresenter implements IThreadPresenter { // 处理用户消息内容 private async processUserMessageContent(userMessage: UserMessage): Promise<{ - userContent: string - urlResults: SearchResult[] - imageFiles: MessageFile[] // 图片文件列表 - }> { - // 处理文本内容 - const userContent = ` - ${ - userMessage.content.content - ? this.formatUserMessageContent(userMessage.content.content) - : userMessage.content.text - } - ${getFileContext(userMessage.content.files)} - ` - - // 从用户消息中提取并丰富URL内容 - const urlResults = await ContentEnricher.extractAndEnrichUrls(userMessage.content.text) - - // 提取图片文件 - - const imageFiles = - userMessage.content.files?.filter((file) => { - // 根据文件类型、MIME类型或扩展名过滤图片文件 - const isImage = - file.mimeType.startsWith('data:image') || - /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(file.name || '') - return isImage - }) || [] - - 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[] - 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 - ) - - // 合并连续的相同角色消息 - const mergedMessages = this.mergeConsecutiveMessages(formattedMessages) - - // 计算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) - - return { finalContent: mergedMessages, promptTokens } - } - - // 选择上下文消息 - private 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 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)合并 + userContent: string + urlResults: SearchResult[] + imageFiles: MessageFile[] // 图片文件列表 + }> { + // 处理文本内容 + const userContent = buildUserMessageContext(userMessage.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 - } - } + // 从用户消息中提取并丰富URL内容 + const normalizedText = getNormalizedUserMessageText(userMessage.content) + const urlResults = await ContentEnricher.extractAndEnrichUrls(normalizedText) - 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) - } - } + const imageFiles = + userMessage.content.files?.filter((file) => { + // 根据文件类型、MIME类型或扩展名过滤图片文件 + const isImage = + file.mimeType.startsWith('data:image') || + /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(file.name || '') + return isImage + }) || [] - return mergedResult + return { userContent, urlResults, imageFiles } } // 更新生成状态 @@ -2836,12 +2196,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.getActiveConversationIdSync(tabId) } getGeneratingMessageState(messageId: string): GeneratingMessageState | null { @@ -2914,8 +2269,8 @@ 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.getActiveConversationIdSync(tabId) : null + const targetConversationId = conversationId ?? activeId ?? undefined if (!targetConversationId) { throw new Error('找不到当前对话') } @@ -2948,16 +2303,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 +2342,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.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.getTabsByConversation(conversationId) + if (tabs.length > 0) { + await this.stopConversationGeneration(conversationId) } } @@ -3299,58 +2649,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 +2657,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 +2695,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 +2706,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 +3052,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 +3178,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..62ab741cb --- /dev/null +++ b/src/main/presenter/threadPresenter/messageContent.ts @@ -0,0 +1,164 @@ +import type { + MessageFile, + UserMessageContent, + UserMessageCodeBlock, + UserMessageMentionBlock, + 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}` +} + +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 + | 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 (isRecord(promptData) && Array.isArray(promptData.messages)) { + const messageTexts = promptData.messages + .map(extractPromptMessageText) + .filter((text) => text) + 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} ${escapeTagContent(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 ?? 0} + ${ + file.mimeType && !file.mimeType.startsWith('image') + ? truncateFileContent(String(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/messageManager.ts b/src/main/presenter/threadPresenter/messageManager.ts index b34aad858..74340c01e 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,77 @@ 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 userContent = msg.content as UserMessageContent + if (userContent?.content) { + return { + ...msg, + content: { + ...userContent, + text: formatUserMessageContent(userContent.content) + } + } + } + return msg + }) + } + return messages } + async getMessageHistory(messageId: string, limit: number = 100): Promise { + if (limit <= 0) { + return [] + } + + const message = await this.getMessage(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 orderedMessages.slice(Math.max(0, targetIndex - limit + 1), targetIndex + 1) + } + async getLastUserMessage(conversationId: string): Promise { const sqliteMessage = await this.sqlitePresenter.getLastUserMessage(conversationId) if (!sqliteMessage) { diff --git a/src/main/presenter/threadPresenter/promptBuilder.ts b/src/main/presenter/threadPresenter/promptBuilder.ts new file mode 100644 index 000000000..084324d22 --- /dev/null +++ b/src/main/presenter/threadPresenter/promptBuilder.ts @@ -0,0 +1,528 @@ +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 type { MCPToolDefinition } 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) + + 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 + ) + + 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 + 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 + } + } + + 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: 'user', + content: `Permission granted to call ${pendingToolCall.name}. Proceed with execution.` + }) + } 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 ? getNormalizedUserMessageText(msgContent) : JSON.stringify(msg.content) + ) + + if (currentLength + msgTokens <= remainingContextLength) { + 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.debug('Artifacts are provided by MCP; this is a backward-compatibility placeholder') + } + + 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 normalizedText = getNormalizedUserMessageText(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: normalizedText } + ] + }) + } else { + resultMessages.push({ + role: 'user', + content: normalizedText + }) + } + } 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 normalizedText = getNormalizedUserMessageText(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: normalizedText } + ] + }) + } else { + resultMessages.push({ + role: 'user', + content: normalizedText + }) + } + } 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/templates/conversationExportTemplates.ts b/src/main/presenter/threadPresenter/templates/conversationExportTemplates.ts new file mode 100644 index 000000000..98b799ff3 --- /dev/null +++ b/src/main/presenter/threadPresenter/templates/conversationExportTemplates.ts @@ -0,0 +1,170 @@ +export type ExportTemplates = { + html: { + documentStart: string[] + documentEnd: string[] + } + styles: string[] + templates: Record +} + +export const conversationExportTemplates: ExportTemplates = { + html: { + documentStart: [ + '', + '', + '', + ' ', + ' ', + ' {{title}}', + ' ', + '', + '', + '
' + ], + documentEnd: ['
', '', ''] + }, + styles: [ + ':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; }', + '.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; }', + '.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; }', + '.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: [ + ' ' + ], + 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: '
    ' + } +} 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 +} 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' + } +} 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(