From 1f99b5864add8f0802efba993d6d3fe77c321985 Mon Sep 17 00:00:00 2001 From: Tomobobo710 <64335998+Tomobobo710@users.noreply.github.com> Date: Sun, 8 Jun 2025 00:59:51 -0600 Subject: [PATCH 1/3] Initial proposal --- src/cobolt-backend/chat_history.ts | 33 +- src/cobolt-backend/memory.ts | 6 +- src/cobolt-backend/query_engine.ts | 272 ++----- src/main/menu.ts | 47 +- src/main/preload.ts | 10 +- .../ChatInterface/ChatInterface.css | 224 ++++-- .../ChatInterface/ChatInterface.tsx | 741 +++++++++++++----- .../components/MessageBlocks/ChatInput.tsx | 108 +++ .../components/MessageBlocks/MessageBlock.tsx | 127 +++ .../components/MessageBlocks/TextBlock.tsx | 40 + .../MessageBlocks/ThinkingBlock.tsx | 93 +++ .../MessageBlocks/ToolCallBlock.tsx | 142 ++++ .../components/MessageBlocks/index.ts | 5 + src/renderer/hooks/useMessages.ts | 5 +- src/renderer/preload.d.ts | 4 +- 15 files changed, 1418 insertions(+), 439 deletions(-) create mode 100644 src/renderer/components/MessageBlocks/ChatInput.tsx create mode 100644 src/renderer/components/MessageBlocks/MessageBlock.tsx create mode 100644 src/renderer/components/MessageBlocks/TextBlock.tsx create mode 100644 src/renderer/components/MessageBlocks/ThinkingBlock.tsx create mode 100644 src/renderer/components/MessageBlocks/ToolCallBlock.tsx create mode 100644 src/renderer/components/MessageBlocks/index.ts diff --git a/src/cobolt-backend/chat_history.ts b/src/cobolt-backend/chat_history.ts index 8be31da..00f94ac 100644 --- a/src/cobolt-backend/chat_history.ts +++ b/src/cobolt-backend/chat_history.ts @@ -56,15 +56,44 @@ class ChatHistory { /** * Convert chat history to format used by Ollama LLM - * @returns Array of Message objects in Ollama format + * Removes execution event metadata from AI context + * @returns Array of Message objects in Ollama format with clean content */ toOllamaMessages(): Message[] { return this.messages.map(message => ({ role: message.role, - content: message.content + content: this.cleanContentForAI(message.content) })); } + /** + * Clean content for AI context - removes ALL execution metadata + * This prevents AI from learning how to fake execution patterns + */ + private cleanContentForAI(content: string): string { + let cleaned = content; + + // AGGRESSIVE CLEANING: Remove ALL XML-like tags that start with these patterns + cleaned = cleaned.replace(/]*>.*?<\/execution_event>/gs, ''); + cleaned = cleaned.replace(/]*>/g, ''); + cleaned = cleaned.replace(/]*>.*?<\/tool_calls_update>/gs, ''); + cleaned = cleaned.replace(/]*>.*?<\/tool_calls_complete>/gs, ''); + cleaned = cleaned.replace(/]*>.*?<\/tool_calls>/gs, ''); + + // Remove standalone closing tags that might be left behind + cleaned = cleaned.replace(/<\/(?:execution_event|tool_calls_update|tool_calls_complete|tool_calls|tool_call_position)>/g, ''); + + // Remove any remaining XML-like execution metadata + cleaned = cleaned.replace(/<[^>]*(?:execution|tool_call|metadata|event)[^>]*>.*?<\/[^>]+>/gs, ''); + + // Clean up any excessive whitespace left behind + cleaned = cleaned.replace(/\n\s*\n\s*\n/g, '\n\n'); + cleaned = cleaned.replace(/^\s+|\s+$/gm, ''); // Trim each line + cleaned = cleaned.trim(); + + return cleaned; + } + /** * Clear the chat history */ diff --git a/src/cobolt-backend/memory.ts b/src/cobolt-backend/memory.ts index 9d2b8b1..d7e57bc 100644 --- a/src/cobolt-backend/memory.ts +++ b/src/cobolt-backend/memory.ts @@ -128,4 +128,8 @@ if (require.main === module) { } } -export { addToMemory, searchMemories, clearMemory, listMemories, updateMemoryEnabled }; \ No newline at end of file +function isMemoryEnabled(): boolean { + return memoryEnabled; +} + +export { addToMemory, searchMemories, clearMemory, listMemories, updateMemoryEnabled, isMemoryEnabled }; \ No newline at end of file diff --git a/src/cobolt-backend/query_engine.ts b/src/cobolt-backend/query_engine.ts index 39d9da8..312310c 100644 --- a/src/cobolt-backend/query_engine.ts +++ b/src/cobolt-backend/query_engine.ts @@ -1,137 +1,57 @@ import { RequestContext, TraceLogger } from './logger'; import { formatDateTime } from './datetime_parser'; -import { queryOllamaWithTools, simpleChatOllamaStream } from './ollama_client'; -import { createChatPrompt, createQueryWithToolsPrompt, createQueryWithToolResponsePrompt } from './prompt_templates'; +import { simpleChatOllamaStream } from './simple_ollama_stream'; +import { createChatPrompt } from './prompt_templates'; import { searchMemories } from './memory'; import { FunctionTool } from './ollama_tools'; -import { Message } from 'ollama'; -import { ChatHistory } from './chat_history'; import { McpClient } from './connectors/mcp_client'; import { CancellationToken, globalCancellationToken } from './utils/cancellation'; +import { ConductorGenerator } from './generators/conductor_generator'; +import { SequentialGenerator } from './generators/sequential_generator'; class QueryEngine { + private conductorGenerator: ConductorGenerator; + private sequentialGenerator: SequentialGenerator; + + constructor() { + this.conductorGenerator = new ConductorGenerator(); + this.sequentialGenerator = new SequentialGenerator(); + } + /** - * V1 RAT | RAG query pipeline - * @param requestContext - * @returns + * Clear executed tool tracking for a specific request or all requests */ - async processRagRatQuery( + public clearExecutedTools(requestId?: string): void { + this.sequentialGenerator.clearExecutedTools(requestId); + } + + /** + * Check if conductor mode is enabled + */ + private async isConductorModeEnabled(): Promise { + // Import here to avoid circular dependency + const { default: appMetadata } = await import('./data_models/app_metadata'); + return appMetadata.getConductorEnabled(); + } + + async processConductorQuery( requestContext: RequestContext, toolCalls: FunctionTool[], cancellationToken: CancellationToken = globalCancellationToken ): Promise> { - // Perform initial query with tool descriptions - const memories = await searchMemories(requestContext.question) - if (cancellationToken.isCancelled) { - return this.emptyCancelledStream(cancellationToken); - } - TraceLogger.trace(requestContext, 'processRagRatQuery-question', requestContext.question); - TraceLogger.trace(requestContext, 'processRagRatQuery-memories_retrieved', memories); - const toolUseSystemPrompt = createQueryWithToolsPrompt(formatDateTime(requestContext.currentDatetime).toString()); - const response = await queryOllamaWithTools(requestContext, toolUseSystemPrompt, toolCalls, memories); - if (cancellationToken.isCancelled) { - return this.emptyCancelledStream(cancellationToken); - } + // Get RAG memories + const memories = await searchMemories(requestContext.question); + TraceLogger.trace(requestContext, 'conductor_relevant_memories', memories); - // if there are no tool calls, - // return a simple chat response based on the original prompt - if (!response.message.tool_calls) { - TraceLogger.trace(requestContext, 'processRagRatQuery', 'no tool calls requested'); - const chatSystemPrompt = createChatPrompt(formatDateTime(requestContext.currentDatetime).toString()); - return this.wrappedStream( - simpleChatOllamaStream(requestContext, chatSystemPrompt, memories), - cancellationToken); - } - const toolMessages: Message[] = []; - const capturedToolCalls: Array<{name: string, arguments: string, result: string, isError?: boolean}> = []; - - // Handle tool calls - // All tool results are fed back to the AI for context - for (const toolCall of response.message.tool_calls) { - if (cancellationToken.isCancelled) { - TraceLogger.trace(requestContext, 'tool_execution_cancelled', - 'Tool execution cancelled by user request'); - break; - } - - const toolName = toolCall.function.name; - const tool = toolCalls.find((tool) => tool.toolDefinition.function.name === toolName); - if (!tool) { - continue - } - - if (tool.type === "mcp") { - const toolResponse = await tool.mcpFunction(requestContext, toolCall); - - // Capture tool call information for UI display - const toolCallInfo = { - name: toolName, - arguments: JSON.stringify(toolCall.function.arguments, null, 2), - result: '', - isError: false - }; - - if (toolResponse.isError) { - toolCallInfo.isError = true; - toolCallInfo.result = toolResponse.content?.map(c => c.type === 'text' ? c.text : JSON.stringify(c)).join('') || 'Tool call failed'; - // ALWAYS add tool message for AI feedback, even on error - toolMessages.push({ role: 'tool', content: createQueryWithToolResponsePrompt(toolName, `Error: ${toolCallInfo.result}`) }); - capturedToolCalls.push(toolCallInfo); - TraceLogger.trace(requestContext, `processRagRatQuery-${toolName}`, `tool call failed`); - } else if (!toolResponse.content || toolResponse.content.length === 0) { - toolCallInfo.result = 'Tool executed successfully (no content returned)'; - // ALWAYS add tool message for AI feedback - toolMessages.push({ role: 'tool', content: createQueryWithToolResponsePrompt(toolName, toolCallInfo.result) }); - capturedToolCalls.push(toolCallInfo); - TraceLogger.trace(requestContext, `processRagRatQuery-${toolName}`, `tool call completed with no content`); - } else { - // Process ALL content types, not just text - let resultText = ''; - for (const item of toolResponse.content) { - if (item.type === "text") { - resultText += item.text as string; - } else { - // Convert non-text content to string for AI feedback - resultText += JSON.stringify(item); - } - } - - toolCallInfo.result = resultText || 'Tool executed successfully'; - // ALWAYS add tool message for AI feedback - toolMessages.push({ role: 'tool', content: createQueryWithToolResponsePrompt(toolName, toolCallInfo.result) }); - capturedToolCalls.push(toolCallInfo); - TraceLogger.trace(requestContext, `processRagRatQuery-${toolName}`, `tool call completed successfully`); - } - } - } - - // If no tool messages were generated - // fall back to simple chat without tool context - if (toolMessages.length === 0) { - TraceLogger.trace(requestContext, 'processRagRatQuery', 'no tool messages generated (unexpected)'); - const chatSystemPrompt = createChatPrompt(formatDateTime(requestContext.currentDatetime).toString()); - // Include our tool calls metadata for transparency - const toolCallsMetadata = capturedToolCalls.length > 0 ? - `${JSON.stringify(capturedToolCalls)}` : ''; - return this.wrappedStreamWithToolCalls( - simpleChatOllamaStream(requestContext, chatSystemPrompt, memories), - cancellationToken, - toolCallsMetadata - ); - } - - // create tool prompts for the final query + // Use chat prompt const chatSystemPrompt = createChatPrompt(formatDateTime(requestContext.currentDatetime).toString()); - // Create tool calls metadata for frontend - const toolCallsMetadata = capturedToolCalls.length > 0 ? - `${JSON.stringify(capturedToolCalls)}` : ''; - - // Wrap the stream to include tool calls metadata at the beginning - return this.wrappedStreamWithToolCalls( - simpleChatOllamaStream(requestContext, chatSystemPrompt, memories, toolMessages), - cancellationToken, - toolCallsMetadata + return this.conductorGenerator.createConductorResponseGenerator( + requestContext, + chatSystemPrompt, + toolCalls, + memories, + cancellationToken ); } @@ -150,6 +70,35 @@ class QueryEngine { ); } + async processRagRatQuery( + requestContext: RequestContext, + toolCalls: FunctionTool[], + cancellationToken: CancellationToken = globalCancellationToken + ): Promise> { + // Check if conductor mode is enabled + const conductorEnabled = await this.isConductorModeEnabled(); + + if (conductorEnabled) { + // Use conductor flow instead of regular RAG + return this.processConductorQuery(requestContext, toolCalls, cancellationToken); + } + + // Regular RAG flow + const memories = await searchMemories(requestContext.question); + TraceLogger.trace(requestContext, 'chat_relevant_memories', memories); + + // Use chat prompt (not tool planning prompt) + const chatSystemPrompt = createChatPrompt(formatDateTime(requestContext.currentDatetime).toString()); + + return this.sequentialGenerator.createSequentialResponseGenerator( + requestContext, + chatSystemPrompt, + toolCalls, + memories, + cancellationToken + ); + } + /** * Wrap a stream generator with cancellation check */ @@ -160,55 +109,14 @@ class QueryEngine { try { for await (const chunk of stream) { if (cancellationToken.isCancelled) { - TraceLogger.trace({ requestId: "cancelled", currentDatetime: new Date(), question: "", chatHistory: new ChatHistory() }, - 'stream_cancelled', 'User cancelled the request'); - break; + return; } yield chunk; } - } finally { - // Ensure we don't leave the token in cancelled state - cancellationToken.reset(); - } - } - - /** - * Wrap a stream generator with cancellation check and tool calls metadata - */ - private async *wrappedStreamWithToolCalls( - stream: AsyncGenerator, - cancellationToken: CancellationToken, - toolCallsMetadata: string - ): AsyncGenerator { - try { - let isFirstChunk = true; - for await (const chunk of stream) { - if (cancellationToken.isCancelled) { - TraceLogger.trace({ requestId: "cancelled", currentDatetime: new Date(), question: "", chatHistory: new ChatHistory() }, - 'stream_cancelled', 'User cancelled the request'); - break; - } - - // Prepend tool calls metadata to the first chunk if we have tool calls - if (isFirstChunk && toolCallsMetadata) { - yield toolCallsMetadata + chunk; - isFirstChunk = false; - } else { - yield chunk; - } - } - } finally { - // Ensure we don't leave the token in cancelled state - cancellationToken.reset(); - } - } - - private async *emptyCancelledStream(cancellationToken: CancellationToken): AsyncGenerator { - // We still need to reset the token - try { - yield "Operation cancelled"; - } finally { - cancellationToken.reset(); + } catch (error) { + console.error('Error in wrapped stream:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + yield `\nError in stream: ${errorMessage}`; } } @@ -220,7 +128,7 @@ class QueryEngine { TraceLogger.trace(requestContext, 'user_chat_history', requestContext.chatHistory.toString()); TraceLogger.trace(requestContext, 'user_question', requestContext.question); TraceLogger.trace(requestContext, 'current_date', formatDateTime(requestContext.currentDatetime)); - + if (chatMode === 'CONTEXT_AWARE') { const toolCalls: FunctionTool[] = McpClient.toolCache; return this.processRagRatQuery(requestContext, toolCalls, cancellationToken); @@ -230,37 +138,5 @@ class QueryEngine { } } -const queryEngineInstance = new QueryEngine(); - -// TODO: replace this with actual tests -if (require.main === module) { - (async () => { - const chatMode = 'CONTEXT_AWARE'; - const requestContext: RequestContext = { - currentDatetime: new Date(), - chatHistory: new ChatHistory(), - question: 'Why is the sky blue?', - requestId: "test-request-id", - }; - const stream = await queryEngineInstance.query(requestContext, chatMode); - if (!stream) { - process.exit(1); - } - // eslint-disable-next-line no-restricted-syntax - let output = ""; - let isFirstToken = true; - for await (const chunk of stream) { - if (isFirstToken) { - output += chunk; - isFirstToken = false; - TraceLogger.trace(requestContext, 'response_to_user_ttft', output); - } else { - output += chunk; - } - } - TraceLogger.trace(requestContext, 'response_to_user_complete', output); - })(); -} - -export { QueryEngine, queryEngineInstance }; - +export const queryEngineInstance = new QueryEngine(); +export { QueryEngine }; \ No newline at end of file diff --git a/src/main/menu.ts b/src/main/menu.ts index ba0fb77..312de1b 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -40,15 +40,48 @@ export default class MenuBuilder { setupDevelopmentEnvironment(): void { this.mainWindow.webContents.on('context-menu', (_, props) => { const { x, y } = props; + const { selectionText, editFlags } = props; - Menu.buildFromTemplate([ - { - label: 'Inspect element', - click: () => { - this.mainWindow.webContents.inspectElement(x, y); - }, + const menuItems: MenuItemConstructorOptions[] = []; + + // Add standard editing options based on context + if (editFlags.canUndo) { + menuItems.push({ label: 'Undo', role: 'undo' }); + } + if (editFlags.canRedo) { + menuItems.push({ label: 'Redo', role: 'redo' }); + } + if (menuItems.length > 0) { + menuItems.push({ type: 'separator' }); + } + + if (editFlags.canCut) { + menuItems.push({ label: 'Cut', role: 'cut' }); + } + if (editFlags.canCopy || selectionText) { + menuItems.push({ label: 'Copy', role: 'copy' }); + } + if (editFlags.canPaste) { + menuItems.push({ label: 'Paste', role: 'paste' }); + } + if (editFlags.canSelectAll) { + menuItems.push({ label: 'Select All', role: 'selectAll' }); + } + + // Add separator before developer options if we have standard items + if (menuItems.length > 0) { + menuItems.push({ type: 'separator' }); + } + + // Add developer option + menuItems.push({ + label: 'Inspect element', + click: () => { + this.mainWindow.webContents.inspectElement(x, y); }, - ]).popup({ window: this.mainWindow }); + }); + + Menu.buildFromTemplate(menuItems).popup({ window: this.mainWindow }); }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index eb69ea7..d2daab4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -49,10 +49,16 @@ contextBridge.exposeInMainWorld('api', { // Message events onMessage: (callback: (message: string) => void) => { - ipcRenderer.on('message-response', (_, message) => callback(message)); + const handler = (_: any, message: string) => callback(message); + ipcRenderer.on('message-response', handler); + // Return cleanup function + return () => ipcRenderer.removeListener('message-response', handler); }, onMessageCancelled: (callback: (message: string) => void) => { - ipcRenderer.on('message-cancelled', (_, message) => callback(message)); + const handler = (_: any, message: string) => callback(message); + ipcRenderer.on('message-cancelled', handler); + // Return cleanup function + return () => ipcRenderer.removeListener('message-cancelled', handler); }, // Memory settings diff --git a/src/renderer/components/ChatInterface/ChatInterface.css b/src/renderer/components/ChatInterface/ChatInterface.css index 7ecc9b1..1f63caf 100644 --- a/src/renderer/components/ChatInterface/ChatInterface.css +++ b/src/renderer/components/ChatInterface/ChatInterface.css @@ -116,8 +116,13 @@ } } -.user-message { +.user-message-wrapper { align-self: flex-end; + display: flex; + justify-content: flex-end; +} + +.user-message { background: linear-gradient(135deg, rgba(124, 169, 126, 0.15) 0%, rgba(143, 185, 150, 0.15) 100%); color: #C5D8BC; border-radius: 1rem; @@ -260,9 +265,8 @@ .assistant-message::after { content: ''; position: absolute; - right: 0.5rem; - top: 50%; - transform: translateY(-50%); + right: 0.25rem; /* Closer to actual content edge */ + bottom: 0.75rem; /* Fixed distance from bottom */ width: 1rem; height: 1rem; border: 2px solid #7CA97E; @@ -270,9 +274,10 @@ border-top-color: transparent; animation: spin 1s linear infinite; opacity: 0; + z-index: 10; /* Ensure it's above content */ } -.assistant-message:last-child::after { +.assistant-message.loading::after { opacity: 1; } @@ -294,6 +299,7 @@ padding: 1rem var(--chat-padding-large) 2rem; background-color: #1E2329; width: 100%; + position: relative; box-sizing: border-box; } @@ -318,7 +324,7 @@ } .reset-button { - background: transparent; + background: rgba(143, 185, 150, 0.1); border: none; color: #8FB996; padding: 0.5rem; @@ -363,6 +369,69 @@ background-color: #ffffff; } +.close-chat-button { + position: absolute; + top: 47%; + right: 0rem; + transform: translateY(-50%); + background: rgba(244, 67, 54, 0.1); + border: none; + color: #f44336; + padding: 0.5rem; + font-size: 1rem; + border-radius: 50%; + min-width: 25px; + height: 28px; + display: flex; + align-items: center; +} + +.close-chat-button:hover { + background: rgba(244, 67, 54, 0.2); + color: #e53935; + transform: translateY(calc(-50% - 1px)); +} + +.close-chat-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.send-button { + background: rgba(143, 185, 150, 0.15); + border: none; + color: #8FB996; + padding: 0.5rem; + font-size: 1rem; + border-radius: 50%; + min-width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.send-button:hover { + background: rgba(143, 185, 150, 0.25); + color: #C5D8BC; + transform: translateY(-1px); +} + +.send-button.disabled { + background: rgba(143, 185, 150, 0.05); + color: rgba(143, 185, 150, 0.3); + cursor: not-allowed; + opacity: 0.5; +} + +.send-button.disabled:hover { + transform: none; + background: rgba(143, 185, 150, 0.05); + color: rgba(143, 185, 150, 0.3); +} + .message-input { flex: 1; padding: 6px 10px; @@ -458,9 +527,27 @@ background: #7CA97E; } +/* Thinking content scrollbar styling */ +.thinking-content::-webkit-scrollbar { + width: 6px; +} + +.thinking-content::-webkit-scrollbar-track { + background: #1E2329; +} + +.thinking-content::-webkit-scrollbar-thumb { + background: #2A2F35; + border-radius: 3px; +} + +.thinking-content::-webkit-scrollbar-thumb:hover { + background: #7CA97E; +} + .assistant-message-content { position: relative; - padding-bottom: 24px; /* give space for button */ + padding-bottom: 24px; } .message-actions { @@ -477,7 +564,7 @@ border: none; cursor: pointer; opacity: 0.6; - color: #e6e6e6; /* light gray/white tone */ + color: #e6e6e6; transition: opacity 0.2s ease-in-out; padding: 2px; } @@ -701,71 +788,86 @@ .thinking-header { /* Reset button styles */ - background: none; border: none; font: inherit; text-align: left; width: 100%; - /* Modern button styling */ - background: linear-gradient(135deg, rgba(124, 169, 126, 0.08) 0%, rgba(143, 185, 150, 0.06) 100%); + /* Visible button styling */ + background: rgba(143, 185, 150, 0.15); padding: 0.5rem 0.75rem; cursor: pointer; display: flex; align-items: center; justify-content: space-between; color: rgba(143, 185, 150, 0.85); - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + /* Removed transitions for simpler styling */ border-radius: 4px 4px 0 0; font-weight: 500; font-size: 0.875rem; letter-spacing: 0.025em; position: relative; - overflow: hidden; } -.thinking-header::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(135deg, rgba(124, 169, 126, 0.1) 0%, rgba(143, 185, 150, 0.08) 100%); - opacity: 0; - transition: opacity 0.25s ease; - z-index: 0; -} +/* Removed gradient hover effects for clean appearance */ -.thinking-header:hover::before { - opacity: 1; +/* Removed hover effects for consistency */ + +.thinking-header:focus, +.thinking-header:focus-visible { + outline: none; } .thinking-header:hover { - color: rgba(143, 185, 150, 1); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(124, 169, 126, 0.15); + transform: none !important; + opacity: inherit !important; } -.thinking-header:focus { - outline: 2px solid rgba(124, 169, 126, 0.4); - outline-offset: 2px; +/* Override global button scale effect for input buttons */ +.close-chat-button:hover { + transform: translateY(calc(-50% - 1px)) !important; } -.thinking-header:active { - transform: translateY(0); - box-shadow: 0 2px 6px rgba(124, 169, 126, 0.1); +.send-button:hover { + transform: translateY(-1px) !important; +} + +.cancel-button:hover { + transform: translateY(-2px) !important; } +.send-button.disabled:hover { + transform: none !important; +} + +/* Remove Tool call buttons */ +.individual-tool-call button:focus, +.individual-tool-call button:focus-visible { + outline: none; + box-shadow: none; +} + +/* Removed active state effects for consistency */ + /* Update the emoji and arrow to be positioned correctly */ .thinking-header .header-content { display: flex; align-items: center; - position: relative; - z-index: 1; + justify-content: space-between; + width: 100%; +} +.thinking-header .header-text { + display: flex; + align-items: center; +} + +.thinking-header .header-badges { + display: flex; + align-items: center; + gap: 0.5rem; } -.thinking-header .header-content::before { +.thinking-header .header-text::before { content: '▼'; margin-right: 0.5rem; font-size: 0.7em; @@ -773,21 +875,21 @@ opacity: 0.7; } -.thinking-header.collapsed .header-content::before { +.thinking-header.collapsed .header-text::before { transform: rotate(-90deg); } .thinking-content { padding: 0.5rem 0.75rem; - background-color: rgba(124, 169, 126, 0.02); + background-color: transparent; font-family: monospace; color: rgba(143, 185, 150, 0.8); white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto; - transition: all 0.3s ease-out; - border-top: 1px solid rgba(124, 169, 126, 0.1); + /* Removed transition for simpler styling */ + border-top: none; } .thinking-content.collapsed { @@ -800,7 +902,7 @@ /* Tool calls section styles */ .tool-calls-section { margin: 0 0 0.5rem 0; - border: 1px solid rgba(124, 169, 126, 0.1); + border: none; border-radius: 4px; overflow: hidden; font-size: 0.85em; @@ -808,7 +910,7 @@ .tool-call-block { padding: 0.75rem 0; - border-bottom: 1px solid rgba(124, 169, 126, 0.05); + border-bottom: none; } .tool-call-block:last-child { @@ -834,6 +936,38 @@ text-transform: uppercase; letter-spacing: 0.5px; } +.executing-badge { + background-color: rgba(255, 193, 7, 0.2); + color: #ffc107; + padding: 0.1rem 0.4rem; + border-radius: 3px; + font-size: 0.7em; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + animation: pulse 2s infinite; +} +.time-badge { + background-color: rgba(76, 175, 80, 0.2); + color: #4caf50; + padding: 0.1rem 0.4rem; + border-radius: 3px; + font-size: 0.7em; + font-weight: 500; + letter-spacing: 0.5px; +} + +@keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.6; + } + 100% { + opacity: 1; + } +} .tool-call-args, .tool-call-result { diff --git a/src/renderer/components/ChatInterface/ChatInterface.tsx b/src/renderer/components/ChatInterface/ChatInterface.tsx index 2b9bcfc..cdeacb2 100644 --- a/src/renderer/components/ChatInterface/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface/ChatInterface.tsx @@ -1,12 +1,9 @@ import React, { useRef, useEffect, useState } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { Clipboard } from 'lucide-react'; import { v4 as uuidv4 } from 'uuid'; import log from 'electron-log/renderer'; - import useMessages from '../../hooks/useMessages'; import useScrollToBottom from '../../hooks/useScrollToBottom'; +import { MessageBlock, ChatInput } from '../MessageBlocks'; import './ChatInterface.css'; interface ChatInterfaceProps { @@ -14,43 +11,366 @@ interface ChatInterfaceProps { currentChatId: string | null; } -// Function to process message content and handle think tags and tool calls -const processMessageContent = (content: string) => { - // First extract tool calls - const toolCallMatch = content.match(/(.*?)<\/tool_calls>/s); - let toolCalls: any[] = []; - let contentWithoutToolCalls = content; +// Helper function to safely convert content to string +const safeStringify = (value: any): string => { + if (typeof value === 'string') return value; + if (value === null || value === undefined) return ''; + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return '[Object]'; + } + } + return String(value); +}; +const formatDuration = (durationMs: number): string => { + if (durationMs < 1000) { + return `${durationMs}ms`; + } + return `${(durationMs / 1000).toFixed(1)}s`; +}; +interface ExecutionEvent { + type: 'tool_start' | 'tool_complete' | 'thinking_start' | 'thinking_complete'; + id: string; + name?: string; + duration_ms?: number; + isError?: boolean; +} + +interface ExecutionState { + [id: string]: { + type: 'tool' | 'thinking'; + name?: string; + status: 'executing' | 'complete'; + duration_ms?: number; + isError?: boolean; + }; +} + +const processExecutionEvents = ( + content: string, +): { cleanContent: string; events: ExecutionEvent[] } => { + const events: ExecutionEvent[] = []; + let cleanContent = content; + + const eventMatches = content.matchAll( + /(.*?)<\/execution_event>/gs, + ); + Array.from(eventMatches).forEach((match) => { + try { + const event = JSON.parse(match[1]) as ExecutionEvent; + events.push(event); + cleanContent = cleanContent.replace(match[0], ''); + } catch (error) { + log.error('Failed to parse execution event:', error); + } + }); + + return { cleanContent, events }; +}; + +// Helper function to process tool-related content blocks +const processToolContentBlocks = (processedContent: string) => { + const toolCallsMap = new Map(); + // Process tool updates (executing status) + const toolUpdateMatches = processedContent.matchAll( + /(.*?)<\/tool_calls_update>/gs, + ); + Array.from(toolUpdateMatches).forEach((match) => { + try { + const updateToolCalls = JSON.parse(match[1]); + updateToolCalls.forEach((updateTool: any, index: number) => { + const key = `${updateTool.name}-${JSON.stringify(updateTool.arguments)}-${index}`; + + // Skip event block linking for update - updates should modify existing tool calls in map + + toolCallsMap.set(key, updateTool); + }); + } catch (error) { + log.error('Failed to parse tool call updates:', error); + } + }); + + // Process tool completions + const toolCompleteMatches = processedContent.matchAll( + /(.*?)<\/tool_calls_complete>/gs, + ); + Array.from(toolCompleteMatches).forEach((match) => { + try { + const completedToolCalls = JSON.parse(match[1]); + completedToolCalls.forEach((completedTool: any, index: number) => { + const key = `${completedTool.name}-${JSON.stringify(completedTool.arguments)}-${index}`; + const existingTool = toolCallsMap.get(key); + + // Skip event block linking for completion - completions should modify existing tool calls in map + + toolCallsMap.set(key, { + ...existingTool, + ...completedTool, + isExecuting: false, + }); + }); + } catch (error) { + log.error('Failed to parse tool call completions:', error); + } + }); + + // Process final tool calls + const toolCallMatch = processedContent.match( + /(.*?)<\/tool_calls>/s, + ); if (toolCallMatch) { try { - toolCalls = JSON.parse(toolCallMatch[1]); - contentWithoutToolCalls = content.replace( - /.*?<\/tool_calls>/s, - '', - ); + const finalToolCalls = JSON.parse(toolCallMatch[1]); + finalToolCalls.forEach((finalTool: any, index: number) => { + const key = `${finalTool.name}-${JSON.stringify(finalTool.arguments)}-${index}`; + const existingTool = toolCallsMap.get(key); + toolCallsMap.set(key, { + ...finalTool, + isExecuting: existingTool?.isExecuting || false, + }); + }); } catch (error) { - log.error('Failed to parse tool calls:', error); + log.error('Failed to parse final tool calls:', error); } } - // Then extract thinking blocks from the remaining content - const parts = contentWithoutToolCalls.split(/(.*?<\/think>)/gs); - const thinkingBlocks: string[] = []; - const regularContent: string[] = []; + return toolCallsMap; +}; + +// Helper function to process thinking content in a text part +// Uses messageId + index to create stable thinking block IDs +const processThinkingContentBlocks = ( + partContent: string, + messageId: string, + messageThinkingIndex: { current: number }, + contentBlocks: any[], +) => { + let partProcessedContent = partContent; + + // First, handle complete thinking blocks + const completeThinkingMatches = partProcessedContent.matchAll( + /(.*?)<\/think>/gs, + ); + Array.from(completeThinkingMatches).forEach((match) => { + const thinkingContent = match[1]; + if (thinkingContent.trim()) { + contentBlocks.push({ + type: 'thinking', + thinkingContent, + id: `thinking-${messageId}-${messageThinkingIndex.current}`, // Stable ID using message ID + index + isComplete: true, + thinkingBlockIndex: messageThinkingIndex.current, // Thinking block index within this message + }); + messageThinkingIndex.current += 1; + } + // Remove from content to avoid double processing + partProcessedContent = partProcessedContent.replace(match[0], ''); + }); + + // Then, handle incomplete thinking blocks (streaming) + const incompleteThinkingMatch = partProcessedContent.match( + /(.*?)(?!<\/think>)$/s, + ); + if (incompleteThinkingMatch) { + const thinkingContent = incompleteThinkingMatch[1]; + if (thinkingContent.trim()) { + contentBlocks.push({ + type: 'thinking', + thinkingContent, + id: `thinking-${messageId}-${messageThinkingIndex.current}`, // Stable ID using message ID + index + isComplete: false, + thinkingBlockIndex: messageThinkingIndex.current, // Thinking block index within this message + }); + messageThinkingIndex.current += 1; + } + // Remove from content + partProcessedContent = partProcessedContent.replace( + incompleteThinkingMatch[0], + '', + ); + } + + return partProcessedContent; +}; + +// Helper function to process text content blocks +const processTextContentBlocks = ( + partContent: string, + index: number, + contentBlocks: any[], + messageId: string, +) => { + if (partContent.trim()) { + contentBlocks.push({ + type: 'text', + content: safeStringify(partContent), + id: `text-${messageId}-${index}`, + }); + } +}; + +// Helper function to add tool call content blocks +const addToolCallContentBlock = ( + toolCallToUse: any, + currentToolCallIndex: number, + contentBlocks: any[], + messageId: string, +) => { + contentBlocks.push({ + type: 'tool_call', + toolCall: { + ...toolCallToUse, + blockIndex: currentToolCallIndex, // Store the intended index + }, + id: `tool-${messageId}-${currentToolCallIndex}`, + }); +}; + +// Removed addRemainingToolCalls function - was creating duplicate tool calls + +// Removed complex deduplication logic - tool calls should only be created once + +// Sequential content parsing for inline tool rendering +// messageId is used to create stable thinking block IDs that don't change on re-renders +const processMessageContent = (content: string, messageId: string) => { + // Create immediate tool call dropdowns from execution events + const { cleanContent, events } = processExecutionEvents(content); + const processedContent = cleanContent; + + const contentBlocks: Array<{ + type: 'text' | 'tool_call' | 'thinking'; + content?: string; + toolCall?: any; + thinkingContent?: string; + id?: string; + isComplete?: boolean; + thinkingBlockIndex?: number; + }> = []; + + // Store immediate tool call data from execution events (don't add to contentBlocks yet) + const executionEventBlocks = new Map(); + + events.forEach((event) => { + if (event.type === 'tool_start') { + const immediateToolCall = { + name: event.name || 'Unknown Tool', + arguments: 'Loading...', // Placeholder + result: 'Executing...', + isExecuting: true, + duration_ms: undefined, + isError: false, + executionEventId: event.id, // Store the event ID for badge mapping + }; + + executionEventBlocks.set(event.id, immediateToolCall); + } + }); + + // Process all tool-related content blocks + const toolCallsMap = processToolContentBlocks(processedContent); + + // Parse content using position markers for true inline tool calls + // Split by position markers to get exact tool call locations + const parts = processedContent.split(/()/g); + + let currentToolCallIndex = 0; + const messageThinkingIndex = { current: 0 }; // Counter for thinking blocks within this specific message + const toolCallsArray = Array.from(toolCallsMap.values()); - parts.forEach((part) => { - if (part.startsWith('') && part.endsWith('')) { - const thinkingContent = part.slice(7, -8); // Remove the tags - thinkingBlocks.push(thinkingContent); + parts.forEach((part, index) => { + if (part.startsWith('.*?<\/tool_calls_update>/gs, '') + .replace(/.*?<\/tool_calls_complete>/gs, '') + .replace(/.*?<\/tool_calls>/gs, ''); + + // Process thinking content first + const partProcessedContent = processThinkingContentBlocks( + cleanPart, + messageId, + messageThinkingIndex, + contentBlocks, + ); + + // Then handle any remaining text content + processTextContentBlocks( + partProcessedContent, + index, + contentBlocks, + messageId, + ); } }); + // Removed addRemainingToolCalls - no more extra tool calls + + // Return properly ordered content blocks return { - thinkingBlocks, - toolCalls, - regularContent: regularContent.join(''), + contentBlocks, + toolCalls: [], // Remove the complex merging - contentBlocks is what actually gets rendered + thinkingBlocks: contentBlocks + .filter((block) => block.type === 'thinking') + .map((block) => safeStringify(block.thinkingContent || '')), + regularContent: contentBlocks + .filter((block) => block.type === 'text') + .map((block) => safeStringify(block.content || '')) + .join(''), }; }; @@ -62,8 +382,15 @@ function ChatInterface({ [key: string]: boolean; }>({}); const [collapsedToolCalls, setCollapsedToolCalls] = useState<{ - [key: string]: boolean; + [messageId: string]: { [toolIndex: number]: boolean }; + }>({}); + const [manuallyToggledToolCalls, setManuallyToggledToolCalls] = useState<{ + [messageId: string]: { [toolIndex: number]: boolean }; }>({}); + const [executionState, setExecutionState] = useState<{ + [messageId: string]: ExecutionState; + }>({}); + const { messages, inputMessage, @@ -81,6 +408,13 @@ function ChatInterface({ const { ref: messagesEndRef } = useScrollToBottom(messages); const textareaRef = useRef(null); + const toolCallsRefs = useRef<{ + [messageId: string]: { [toolIndex: number]: HTMLDivElement | null }; + }>({}); + const thinkingBlockRefs = useRef<{ + [blockId: string]: HTMLDivElement | null; + }>({}); + // Removed autoCollapseScheduled refs - simplified dropdown behavior // Separate function to adjust textarea height for reuse const adjustTextareaHeight = () => { @@ -132,20 +466,182 @@ function ChatInterface({ } }; - const toggleThinking = (messageId: string) => { + const toggleThinking = (thinkingBlockId: string) => { setCollapsedThinking((prev) => ({ ...prev, - [messageId]: !prev[messageId], + [thinkingBlockId]: !prev[thinkingBlockId], })); }; - const toggleToolCalls = (messageId: string) => { + const toggleToolCall = (messageId: string, toolIndex: number) => { setCollapsedToolCalls((prev) => ({ ...prev, - [messageId]: prev[messageId] === false, + [messageId]: { + ...prev[messageId], + [toolIndex]: prev[messageId]?.[toolIndex] === false, + }, + })); + + // Mark this dropdown as manually toggled by user (prevents auto-collapse) + setManuallyToggledToolCalls((prev) => ({ + ...prev, + [messageId]: { + ...prev[messageId], + [toolIndex]: true, + }, })); }; + // Auto-open dropdown when tool calls are detected + useEffect(() => { + messages.forEach((message) => { + if (message.sender === 'assistant') { + const { contentBlocks } = processMessageContent( + message.content, + message.id, + ); + + // For each tool call block, if dropdown isn't explicitly set, open it + const toolCallBlocks = contentBlocks.filter( + (block) => block.type === 'tool_call', + ); + toolCallBlocks.forEach((block) => { + const toolIndex = block.toolCall?.blockIndex ?? 0; + if (collapsedToolCalls[message.id]?.[toolIndex] === undefined) { + setCollapsedToolCalls((prev) => ({ + ...prev, + [message.id]: { + ...prev[message.id], + [toolIndex]: true, // true = closed (default closed) + }, + })); + } + }); + + const thinkingBlocks = contentBlocks.filter( + (block) => block.type === 'thinking', + ); + // Auto-CLOSE thinking dropdowns when thinking blocks are detected (closed by default) + thinkingBlocks.forEach((block) => { + const blockId = block.id; + if (blockId && collapsedThinking[blockId] === undefined) { + setCollapsedThinking((prev) => ({ + ...prev, + [blockId]: true, // true = collapsed (closed by default) + })); + } + }); + } + }); + }, [ + messages, + collapsedToolCalls, + manuallyToggledToolCalls, + collapsedThinking, + ]); + + // Auto-scroll dropdown to bottom when tool calls and thinking blocks update + useEffect(() => { + messages.forEach((message) => { + if (message.sender === 'assistant') { + const { contentBlocks } = processMessageContent( + message.content, + message.id, + ); + + // For each executing tool, scroll its dropdown to bottom if open + const toolCallBlocks = contentBlocks.filter( + (block) => block.type === 'tool_call', + ); + toolCallBlocks.forEach((block) => { + const toolIndex = block.toolCall?.blockIndex ?? 0; + const { toolCall } = block; + + if ( + toolCall.isExecuting && + collapsedToolCalls[message.id]?.[toolIndex] === false + ) { + const toolCallContent = + toolCallsRefs.current[message.id]?.[toolIndex]; + if (toolCallContent) { + setTimeout(() => { + toolCallContent.scrollTop = toolCallContent.scrollHeight; + }, 100); + } + } + }); + + // For each thinking block that's still executing, scroll its dropdown to bottom if open + contentBlocks + .filter((block) => block.type === 'thinking') + .forEach((block) => { + if (block.id && !block.isComplete && !collapsedThinking[block.id]) { + const thinkingContent = thinkingBlockRefs.current[block.id]; + if (thinkingContent) { + setTimeout(() => { + thinkingContent.scrollTop = thinkingContent.scrollHeight; + }, 100); + } + } + }); + } + }); + }, [messages, collapsedToolCalls, collapsedThinking]); + + // Process execution events + useEffect(() => { + messages.forEach((message) => { + if (message.sender === 'assistant') { + const { events } = processExecutionEvents(message.content); + + if (events.length > 0) { + setExecutionState((prev) => { + const messageState = prev[message.id] + ? { ...prev[message.id] } + : {}; + + events.forEach((event) => { + if (event.type === 'tool_start') { + messageState[event.id] = { + type: 'tool', + name: event.name, + status: 'executing', + }; + } else if (event.type === 'tool_complete') { + if (messageState[event.id]) { + messageState[event.id] = { + ...messageState[event.id], + status: 'complete', + duration_ms: event.duration_ms, + isError: event.isError, + }; + } + } else if (event.type === 'thinking_start') { + messageState[event.id] = { + type: 'thinking', + status: 'executing', + }; + } else if (event.type === 'thinking_complete') { + if (messageState[event.id]) { + messageState[event.id] = { + ...messageState[event.id], + status: 'complete', + duration_ms: event.duration_ms, + }; + } + } + }); + + return { + ...prev, + [message.id]: messageState, + }; + }); + } + } + }); + }, [messages]); + return (
- {messages.map((message) => { - const { thinkingBlocks, toolCalls, regularContent } = - processMessageContent(message.content); - const hasThinking = thinkingBlocks.length > 0; - const hasToolCalls = toolCalls.length > 0; + {messages.map((message, messageIndex) => { + const { contentBlocks } = processMessageContent( + message.content, + message.id, + ); return ( -
- {message.sender === 'assistant' ? ( -
- {hasToolCalls && ( -
- -
- {toolCalls.map((toolCall, index) => ( - // eslint-disable-next-line react/no-array-index-key -
-
- {toolCall.name} - {toolCall.isError && ( - Error - )} -
-
-
Arguments:
-
-                                {toolCall.arguments}
-                              
-
-
-
Result:
-
- {toolCall.result} -
-
-
- ))} -
-
- )} - - {hasThinking && ( -
- -
- {thinkingBlocks.map((block, index) => ( - // eslint-disable-next-line react/no-array-index-key -
- {block} -
- ))} -
-
- )} - - - {regularContent} - - -
- -
-
- ) : ( - message.content - )} -
+ message={message} + messageIndex={messageIndex} + isLoading={isLoading} + contentBlocks={contentBlocks} + collapsedToolCalls={collapsedToolCalls} + toggleToolCall={toggleToolCall} + executionState={executionState} + toolCallsRefs={toolCallsRefs} + collapsedThinking={collapsedThinking} + toggleThinking={toggleThinking} + thinkingBlockRefs={thinkingBlockRefs} + formatDuration={formatDuration} + safeStringify={safeStringify} + totalMessages={messages.length} + /> ); })}
-
-
-
-
-