From af81ca819f789d2977c1e3a4dc4f1d4f353a8218 Mon Sep 17 00:00:00 2001 From: Tomobobo710 <64335998+Tomobobo710@users.noreply.github.com> Date: Sun, 8 Jun 2025 00:26:54 -0600 Subject: [PATCH 1/5] Implement conductor mode --- src/cobolt-backend/chat.ts | 10 +- src/cobolt-backend/chat_history.ts | 33 +- .../data_models/app_metadata.ts | 9 + .../generators/conductor_generator.ts | 569 ++++++++++++ .../generators/sequential_generator.ts | 326 +++++++ .../generators/tool_execution_utils.ts | 73 ++ src/cobolt-backend/memory.ts | 21 +- src/cobolt-backend/ollama_client.ts | 127 +-- src/cobolt-backend/query_engine.ts | 272 ++---- src/cobolt-backend/query_engine_backup.ts | 869 ++++++++++++++++++ src/cobolt-backend/simple_ollama_stream.ts | 75 ++ src/cobolt-backend/utils/cancellation.ts | 40 +- src/main/main.ts | 21 + src/main/menu.ts | 47 +- src/main/preload.ts | 17 +- .../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 + .../SettingsPanel/SettingsPanel.tsx | 35 + src/renderer/hooks/useMessages.ts | 5 +- src/renderer/preload.d.ts | 4 +- 26 files changed, 3462 insertions(+), 571 deletions(-) create mode 100644 src/cobolt-backend/generators/conductor_generator.ts create mode 100644 src/cobolt-backend/generators/sequential_generator.ts create mode 100644 src/cobolt-backend/generators/tool_execution_utils.ts create mode 100644 src/cobolt-backend/query_engine_backup.ts create mode 100644 src/cobolt-backend/simple_ollama_stream.ts 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.ts b/src/cobolt-backend/chat.ts index ba0702b..5d29f04 100644 --- a/src/cobolt-backend/chat.ts +++ b/src/cobolt-backend/chat.ts @@ -10,7 +10,7 @@ async function main() { }); console.log("Chat with AI (type 'exit' to quit)"); - console.log("Available modes: chat, contextaware"); + console.log("Available modes: chat, contextaware, conductor"); console.log("Usage: /mode to switch modes (e.g. /mode contextaware)"); const askQuestion = (): Promise => { @@ -21,7 +21,7 @@ async function main() { }); }; - let currentMode: 'CHAT' | 'CONTEXT_AWARE' = 'CHAT'; + let currentMode: 'CHAT' | 'CONTEXT_AWARE' | 'CONDUCTOR' = 'CONDUCTOR'; const chatHistory = new ChatHistory(); try { @@ -36,12 +36,12 @@ async function main() { if (userInput.startsWith('/mode ')) { const mode = userInput.slice(6).toUpperCase(); - if (['CHAT', 'CONTEXT_AWARE'].includes(mode)) { - currentMode = mode as 'CHAT' | 'CONTEXT_AWARE'; + if (['CHAT', 'CONTEXT_AWARE', 'CONDUCTOR'].includes(mode)) { + currentMode = mode as 'CHAT' | 'CONTEXT_AWARE' | 'CONDUCTOR'; console.log(`Switched to ${currentMode} mode`); continue; } else { - console.log("Invalid mode. Available modes: chat, contextaware"); + console.log("Invalid mode. Available modes: chat, contextaware, conductor"); continue; } } 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/data_models/app_metadata.ts b/src/cobolt-backend/data_models/app_metadata.ts index 5733dee..0b9108a 100644 --- a/src/cobolt-backend/data_models/app_metadata.ts +++ b/src/cobolt-backend/data_models/app_metadata.ts @@ -3,6 +3,7 @@ import Store from 'electron-store'; type AppMetadataSchema = { setupComplete: boolean; memoryEnabled: boolean; + conductorEnabled: boolean; }; interface TypedStore> extends Store { @@ -16,6 +17,7 @@ const store = new Store({ defaults: { setupComplete: false, memoryEnabled: false, + conductorEnabled: true, }, }) as TypedStore; @@ -40,6 +42,13 @@ const appMetadata = { setMemoryEnabled: (enabled: boolean) => { store.set('memoryEnabled', enabled); }, + getConductorEnabled: (): boolean => { + return store.get('conductorEnabled'); + }, + + setConductorEnabled: (enabled: boolean) => { + store.set('conductorEnabled', enabled); + }, }; export default appMetadata; \ No newline at end of file diff --git a/src/cobolt-backend/generators/conductor_generator.ts b/src/cobolt-backend/generators/conductor_generator.ts new file mode 100644 index 0000000..1d2b239 --- /dev/null +++ b/src/cobolt-backend/generators/conductor_generator.ts @@ -0,0 +1,569 @@ +import { RequestContext, TraceLogger } from '../logger'; +import { getOllamaClient } from '../ollama_client'; +import { MODELS } from '../model_manager'; +import { FunctionTool } from '../ollama_tools'; +import { Message } from 'ollama'; +import { CancellationToken, globalCancellationToken } from '../utils/cancellation'; +import { ExecutionEvent, ThinkingState, ToolExecutionUtils } from './tool_execution_utils'; + +export class ConductorGenerator { + + /** + * Creates conductor mode following exact pseudocode with active monitoring and stopping + */ + async *createConductorResponseGenerator( + requestContext: RequestContext, + systemPrompt: string, + toolCalls: FunctionTool[], + memories: string, + cancellationToken: CancellationToken = globalCancellationToken + ): AsyncGenerator { + + // Build initial conversation messages + const messages: Message[] = [ + { role: 'system', content: systemPrompt }, + ]; + + if (memories) { + messages.push({ role: 'tool', content: 'User Memories: ' + memories }); + } + + if (requestContext.chatHistory.length > 0) { + requestContext.chatHistory.toOllamaMessages().forEach((message) => { + messages.push(message); + }); + } + + messages.push({ role: 'user', content: requestContext.question }); + + try { + const conversationMessages = [...messages]; + let conversationActive = true; + let currentPhase = 1; + let totalPhaseCount = 0; + const MAX_PHASES = 50; + + while (conversationActive && !cancellationToken.isCancelled && totalPhaseCount < MAX_PHASES) { + totalPhaseCount++; + console.log(`\n[Conductor] CONDUCTOR MODE - Phase ${currentPhase} (Total: ${totalPhaseCount}/${MAX_PHASES})`); + + // Check for max phase limit + if (totalPhaseCount >= MAX_PHASES) { + console.log(`[Conductor] CONDUCTOR: Hit max phase limit (${MAX_PHASES}), ending conversation`); + yield `\n\n[Conductor] **Note**: Conversation ended after ${MAX_PHASES} phases to prevent infinite loops.`; + break; + } + + // EXACT PSEUDOCODE IMPLEMENTATION + switch (currentPhase) { + case 1: { // Initial processing + // inject_context(rag_retrieve("phase_1_thinking")) + const thinkingContext = await this.ragRetrieve("phase_1_thinking"); + conversationMessages.push({ role: 'system', content: thinkingContext }); + + // continue_generating() + // await_block_completion() -> stop_generating() -> scrub_context() -> inject_context(rag_retrieve("phase_1_response") + yield* this.streamAndStopOnThinking(conversationMessages, toolCalls, cancellationToken); + + const responseContext = await this.ragRetrieve("phase_1_response"); + conversationMessages.push({ role: 'system', content: responseContext }); + + // continue_generating() + phase = 2 + yield* this.streamUntilNaturalEnd(conversationMessages, toolCalls, cancellationToken); + currentPhase = 2; + break; + } + + case 2: { // Tool or End Decision + // await_block_completion() -> inject_context(rag_retrieve("phase_2_decision") + yield* this.streamUntilNaturalEnd(conversationMessages, toolCalls, cancellationToken); + + const decisionContext = await this.ragRetrieve("phase_2_decision"); + conversationMessages.push({ role: 'system', content: decisionContext }); + + // continue_generating() + phase = 3 + currentPhase = 3; + break; + } + + case 3: { // Post-tool execution + // await_any_block_completion() -> stop_generating() -> scrub_context() + // detect_tool_call_or_end_of_turn() + const result = yield* this.streamAndStopOnToolCall(conversationMessages, toolCalls, cancellationToken); + + if (result.toolCall) { + // Execute tools first + const toolExecutionGenerator = this.executeConductorTools( + result.toolCalls, + requestContext, + conversationMessages + ); + + let toolGenResult; + do { + toolGenResult = await toolExecutionGenerator.next(); + if (!toolGenResult.done && toolGenResult.value) { + yield toolGenResult.value; + } + } while (!toolGenResult.done); + + // inject_context(rag_retrieve("phase_3_reflection") + const reflectionContext = await this.ragRetrieve("phase_3_reflection"); + conversationMessages.push({ role: 'system', content: reflectionContext }); + + // continue_generating() + phase = 4 + currentPhase = 4; + } else { + // Done - end of conversation + conversationActive = false; + } + break; + } + + case 4: { // Next Action Decision + // await_block_completion() -> stop_generating() -> scrub_context() -> inject_context(rag_retrieve("phase_4_decision") + yield* this.streamAndStopOnThinking(conversationMessages, toolCalls, cancellationToken); + + const decisionContext = await this.ragRetrieve("phase_4_decision"); + conversationMessages.push({ role: 'system', content: decisionContext }); + + // continue_generating() + phase = 3 + yield* this.streamUntilNaturalEnd(conversationMessages, toolCalls, cancellationToken); + currentPhase = 3; + break; + } + + default: + conversationActive = false; + break; + } + } + + } catch (error) { + console.error('[Conductor] CONDUCTOR ERROR:', error); + console.error('[Conductor] ERROR STACK:', error instanceof Error ? error.stack : 'No stack'); + const errorMessage = error instanceof Error ? error.message : String(error); + yield `\n[Conductor] Error in conductor mode: ${errorMessage}`; + } + } + + /** + * RAG retrieval for phase-specific content - UPDATED per new pseudocode + */ + private async ragRetrieve(phaseKey: string): Promise { + const phasePrompts = { + "phase_1_thinking": "\nYou MUST think through this query before responding. DO NOT use XML tags unless specifically requested to use them. Provide your thoughts inbetween and tags.\n", + "phase_1_response": "\nProvide an initial response to the user's query.\n", + "phase_2_decision": "\nYou must now choose: call a single tool OR end the conversation turn with a brief message.\n", + "phase_3_reflection": "\nYou must think about the tool call results before proceeding. Provide your thoughts inbetween and tags.\n", + "phase_4_decision": "\nYou MUST choose ONE of the following options: Call another tool, OR end the conversation turn with a brief message.\n" + }; + + return phasePrompts[phaseKey as keyof typeof phasePrompts] || ""; + } + + /** + * Stream and stop when we detect - used for phases 1 and 4 + */ + private async *streamAndStopOnThinking( + conversationMessages: Message[], + toolCalls: FunctionTool[], + cancellationToken: CancellationToken + ): AsyncGenerator { + + // Create abort controller for this specific request + const abortController = new AbortController(); + cancellationToken.setAbortController(abortController); + + let content = ''; + let toolCallsFound: any[] = []; + let shouldStop = false; + let stopReason = ''; + + const thinkingState: ThinkingState = { + isInThinkingBlock: false, + thinkingContent: '', + currentThinkingId: null, + thinkingStartTime: null + }; + + console.log(`[Conductor] Streaming until detected...`); + + try { + const ollama = getOllamaClient(); + const response = await ollama.chat({ + model: MODELS.CHAT_MODEL, + messages: conversationMessages, + tools: toolCalls.map((toolCall) => toolCall.toolDefinition), + keep_alive: -1, + options: { + temperature: 1.0, + top_k: 64, + top_p: 0.95, + num_ctx: MODELS.CHAT_MODEL_CONTEXT_LENGTH, + }, + stream: true, + signal: abortController.signal // ← Add abort signal + }); + + // Phase 1: Stream and detect stop conditions + for await (const part of response) { + // Check for user cancellation first + if (cancellationToken.isCancelled) { + console.log(`[Conductor] User cancellation detected: ${cancellationToken.cancelReason}`); + break; + } + + if (part.message?.content) { + // Process thinking events + const thinkingEvents = ToolExecutionUtils.processThinkingInContent(part.message.content, thinkingState); + for (const thinkingEvent of thinkingEvents) { + yield thinkingEvent; + } + + content += part.message.content; + yield part.message.content; + + // DETECT stop condition but DON'T scrub yet + if (content.includes('') && !shouldStop) { + shouldStop = true; + stopReason = 'think_block_complete'; + console.log('[Conductor] DETECTED - cancelling AI generation'); + abortController.abort(); + // Continue reading to drain any buffered content + } + } + + if (part.message?.tool_calls) { + toolCallsFound.push(...part.message.tool_calls); + } + } + + } catch (error) { + if (error.name === 'AbortError') { + console.log(`[Conductor] AI generation cancelled successfully: ${stopReason}`); + } else { + console.error('[Conductor] Unexpected error during streaming:', error); + throw error; + } + } + + // Phase 2: AI is confirmed stopped, now scrub if needed + if (shouldStop && stopReason === 'think_block_complete' && content.includes('')) { + console.log('[Conductor] AI confirmed stopped - performing content scrub'); + const thinkEndIndex = content.indexOf('') + ''.length; + content = content.substring(0, thinkEndIndex); + console.log(`[Conductor] SCRUBBED TO: "${content}"`); + } + + // Phase 3: Add final message to conversation + conversationMessages.push({ + role: 'assistant', + content: content, + tool_calls: toolCallsFound.length > 0 ? toolCallsFound : undefined + }); + } + + /** + * Stream until natural end - used for phase 2 + */ + private async *streamUntilNaturalEnd( + conversationMessages: Message[], + toolCalls: FunctionTool[], + cancellationToken: CancellationToken + ): AsyncGenerator { + + // Create abort controller for this specific request + const abortController = new AbortController(); + cancellationToken.setAbortController(abortController); + + let content = ''; + let toolCallsFound: any[] = []; + + const thinkingState: ThinkingState = { + isInThinkingBlock: false, + thinkingContent: '', + currentThinkingId: null, + thinkingStartTime: null + }; + + console.log(`[Conductor] Streaming until natural end...`); + + try { + const ollama = getOllamaClient(); + const response = await ollama.chat({ + model: MODELS.CHAT_MODEL, + messages: conversationMessages, + tools: toolCalls.map((toolCall) => toolCall.toolDefinition), + keep_alive: -1, + options: { + temperature: 1.0, + top_k: 64, + top_p: 0.95, + num_ctx: MODELS.CHAT_MODEL_CONTEXT_LENGTH, + }, + stream: true, + signal: abortController.signal // ← Add abort signal + }); + + for await (const part of response) { + // Check for user cancellation + if (cancellationToken.isCancelled) { + console.log(`[Conductor] User cancellation detected: ${cancellationToken.cancelReason}`); + break; + } + + if (part.message?.content) { + const thinkingEvents = ToolExecutionUtils.processThinkingInContent(part.message.content, thinkingState); + for (const thinkingEvent of thinkingEvents) { + yield thinkingEvent; + } + + content += part.message.content; + yield part.message.content; + } + + if (part.message?.tool_calls) { + toolCallsFound.push(...part.message.tool_calls); + } + } + + } catch (error) { + if (error.name === 'AbortError') { + console.log('[Conductor] AI generation cancelled successfully (natural end)'); + } else { + console.error('[Conductor] Unexpected error during streaming:', error); + throw error; + } + } + + // Add complete message to conversation + conversationMessages.push({ + role: 'assistant', + content: content, + tool_calls: toolCallsFound.length > 0 ? toolCallsFound : undefined + }); + } + + /** + * Stream and stop when we detect tool call - used for phase 3 + */ + private async *streamAndStopOnToolCall( + conversationMessages: Message[], + toolCalls: FunctionTool[], + cancellationToken: CancellationToken + ): AsyncGenerator { + + // Create abort controller for this specific request + const abortController = new AbortController(); + cancellationToken.setAbortController(abortController); + + let content = ''; + let toolCallsFound: any[] = []; + let shouldStop = false; + let stopReason = ''; + + const thinkingState: ThinkingState = { + isInThinkingBlock: false, + thinkingContent: '', + currentThinkingId: null, + thinkingStartTime: null + }; + + console.log(`[Conductor] Streaming until tool call or natural end...`); + + try { + const ollama = getOllamaClient(); + const response = await ollama.chat({ + model: MODELS.CHAT_MODEL, + messages: conversationMessages, + tools: toolCalls.map((toolCall) => toolCall.toolDefinition), + keep_alive: -1, + options: { + temperature: 1.0, + top_k: 64, + top_p: 0.95, + num_ctx: MODELS.CHAT_MODEL_CONTEXT_LENGTH, + }, + stream: true, + signal: abortController.signal // ← Add abort signal + }); + + for await (const part of response) { + // Check for user cancellation first + if (cancellationToken.isCancelled) { + console.log(`[Conductor] User cancellation detected: ${cancellationToken.cancelReason}`); + break; + } + + if (part.message?.content) { + const thinkingEvents = ToolExecutionUtils.processThinkingInContent(part.message.content, thinkingState); + for (const thinkingEvent of thinkingEvents) { + yield thinkingEvent; + } + + content += part.message.content; + yield part.message.content; + } + + if (part.message?.tool_calls && !shouldStop) { + shouldStop = true; + stopReason = 'tool_calls_detected'; + console.log(`[Conductor] DETECTED TOOL CALL - cancelling AI generation`); + abortController.abort(); + toolCallsFound.push(...part.message.tool_calls); + // Continue reading to drain remaining content + } + } + + } catch (error) { + if (error.name === 'AbortError') { + console.log(`[Conductor] AI generation cancelled successfully: ${stopReason}`); + } else { + console.error('[Conductor] Unexpected error during streaming:', error); + throw error; + } + } + + // No content scrubbing needed for tool calls - just save what we got + conversationMessages.push({ + role: 'assistant', + content: content, + tool_calls: toolCallsFound.length > 0 ? toolCallsFound : undefined + }); + + return { + toolCall: toolCallsFound.length > 0, + toolCalls: toolCallsFound + }; + } + + /** + * Execute tools detected in conductor mode with visual UI markers + */ + private async *executeConductorTools( + toolCalls: any[], + requestContext: RequestContext, + conversationMessages: Message[] + ): AsyncGenerator { + const toolResults: any[] = []; + + for (const toolCall of toolCalls) { + const toolName = toolCall.function.name; + const toolArguments = JSON.stringify(toolCall.function.arguments, null, 2); + const toolStartTime = Date.now(); + + // Create unique tool ID + const toolCallKey = `${toolName}-${JSON.stringify(toolCall.function.arguments)}`; + const displayToolId = `tool-${toolCallKey.replace(/[^a-zA-Z0-9-]/g, '-')}`; + + // Show tool call position marker + yield ``; + + // Show tool execution starting + yield `${JSON.stringify([{ + name: toolName, + arguments: toolArguments, + result: 'Executing...', + isExecuting: true + }])}`; + + yield ToolExecutionUtils.emitExecutionEvent({type: 'tool_start', id: displayToolId, name: toolName}); + + try { + // Use existing tool execution logic + const result = await this.executeToolCall(toolCall, requestContext); + + toolResults.push({ + toolName: toolCall.function.name, + content: result.content, + isError: result.isError || false + }); + + // Add tool result to conversation + conversationMessages.push({ + role: 'tool', + content: `Tool ${toolCall.function.name} result: ${result.content}` + }); + + // Show completion + const duration_ms = Date.now() - toolStartTime; + const toolCallInfo = ToolExecutionUtils.createToolCallSuccessInfo(toolName, toolArguments, result.content, duration_ms, result.isError); + + yield ToolExecutionUtils.emitExecutionEvent({type: 'tool_complete', id: displayToolId, duration_ms, isError: result.isError}); + yield `${JSON.stringify([toolCallInfo])}`; + + } catch (error) { + const errorMessage = `Error: ${error instanceof Error ? error.message : String(error)}`; + const errorResult = { + toolName: toolCall.function.name, + content: errorMessage, + isError: true + }; + toolResults.push(errorResult); + + conversationMessages.push({ + role: 'tool', + content: `Tool ${toolCall.function.name} error: ${errorResult.content}` + }); + + // Show error completion + const duration_ms = Date.now() - toolStartTime; + const toolCallInfo = ToolExecutionUtils.createToolCallErrorInfo(toolName, toolArguments, errorMessage, duration_ms); + + yield ToolExecutionUtils.emitExecutionEvent({type: 'tool_complete', id: displayToolId, duration_ms, isError: true}); + yield `${JSON.stringify([toolCallInfo])}`; + } + } + + return toolResults; + } + + /** + * Execute a single tool call (simplified version for conductor mode) + */ + private async executeToolCall( + toolCall: any, + requestContext: RequestContext + ): Promise<{ content: string; isError: boolean }> { + + const toolName = toolCall.function.name; + + // Find the tool in available tools + const { McpClient } = await import('../connectors/mcp_client'); + const toolCalls: FunctionTool[] = McpClient.toolCache; + const tool = toolCalls.find((tool) => tool.toolDefinition.function.name === toolName); + + if (!tool || tool.type !== "mcp") { + return { + content: `Error: Tool '${toolName}' not found`, + isError: true + }; + } + + try { + // Execute tool using existing MCP function + const toolResponse = await tool.mcpFunction(requestContext, toolCall); + + let resultText = ''; + if (toolResponse.isError) { + resultText = toolResponse.content?.map(c => c.type === 'text' ? c.text : JSON.stringify(c)).join('') || 'Tool call failed'; + return { content: resultText, isError: true }; + } else if (!toolResponse.content || toolResponse.content.length === 0) { + resultText = 'Tool executed successfully (no content returned)'; + } else { + resultText = toolResponse.content.map(item => + item.type === "text" ? item.text as string : JSON.stringify(item) + ).join(''); + } + + return { content: resultText, isError: false }; + + } catch (error: any) { + const errorMessage = `Tool execution failed: ${error.message || String(error)}`; + return { content: errorMessage, isError: true }; + } + } + + +} diff --git a/src/cobolt-backend/generators/sequential_generator.ts b/src/cobolt-backend/generators/sequential_generator.ts new file mode 100644 index 0000000..19d73a7 --- /dev/null +++ b/src/cobolt-backend/generators/sequential_generator.ts @@ -0,0 +1,326 @@ +import { RequestContext, TraceLogger } from '../logger'; +import { simpleChatOllamaStream } from '../simple_ollama_stream'; +import { getOllamaClient } from '../ollama_client'; +import { MODELS } from '../model_manager'; +import { createQueryWithToolResponsePrompt } from '../prompt_templates'; +import { addToMemory, isMemoryEnabled } from '../memory'; +import { FunctionTool } from '../ollama_tools'; +import { Message } from 'ollama'; +import { CancellationToken, globalCancellationToken } from '../utils/cancellation'; +import { ExecutionEvent, ThinkingState, ToolExecutionUtils } from './tool_execution_utils'; + +type StreamingToolInfo = { + name: string; + arguments: string; + toolId: string; + isComplete: boolean; +}; + +export class SequentialGenerator { + private globalExecutedToolIds: Map>; + + constructor() { + this.globalExecutedToolIds = new Map(); + } + + /** + * Clear executed tool tracking for a specific request or all requests + */ + public clearExecutedTools(requestId?: string): void { + if (requestId) { + this.globalExecutedToolIds.delete(requestId); + } else { + this.globalExecutedToolIds.clear(); + } + } + + /** + * Creates a generator for sequential inline tool calling response + */ + async *createSequentialResponseGenerator( + requestContext: RequestContext, + systemPrompt: string, + toolCalls: FunctionTool[], + memories: string, + cancellationToken: CancellationToken = globalCancellationToken + ): AsyncGenerator { + + // Build conversation messages + const messages: Message[] = [ + { role: 'system', content: systemPrompt }, + ]; + + if (memories) { + messages.push({ role: 'tool', content: 'User Memories: ' + memories }); + } + + if (requestContext.chatHistory.length > 0) { + requestContext.chatHistory.toOllamaMessages().forEach((message) => { + messages.push(message); + }); + } + + messages.push({ role: 'user', content: requestContext.question }); + + try { + const conversationMessages = [...messages]; + let conversationComplete = false; + + while (!conversationComplete && !cancellationToken.isCancelled) { + TraceLogger.trace(requestContext, 'conversation-round', `Starting conversation round with ${conversationMessages.length} messages`); + + // Get ollama client and constants + const ollama = getOllamaClient(); + const defaultTemperature = 1.0; + const defaultTopK = 64; + const defaultTopP = 0.95; + + // Try to start conversation with tools enabled first + let response; + + // Create abort controller for this request + const abortController = new AbortController(); + cancellationToken.setAbortController(abortController); + + try { + response = await ollama.chat({ + model: MODELS.CHAT_MODEL, // Use chat model, not tools model + messages: conversationMessages, + tools: toolCalls.map((toolCall) => toolCall.toolDefinition), + keep_alive: -1, + options: { + temperature: defaultTemperature, + top_k: defaultTopK, + top_p: defaultTopP, + num_ctx: MODELS.CHAT_MODEL_CONTEXT_LENGTH, + }, + stream: true, + signal: abortController.signal // ← Add abort signal + }); + } catch (error: any) { + // Check if the error is due to cancellation first + if (error.name === 'AbortError') { + console.log('[Sequential] AI generation cancelled by user'); + yield '\n\n*Message generation cancelled by user.*'; + return; + } + + // Check if the error is due to model not supporting tools + const errorMessage = error.message || String(error); + if (errorMessage.includes('does not support tools')) { + TraceLogger.trace(requestContext, 'model-tools-fallback', `Model ${MODELS.CHAT_MODEL} does not support tools, falling back to simple chat`); + + // For models without tool calling, use simple chat + const simpleChatStream = simpleChatOllamaStream(requestContext, systemPrompt, memories); + + // Stream the response directly from simple chat + for await (const content of simpleChatStream) { + if (cancellationToken.isCancelled) { + return; + } + yield content; + } + + // Exit early since we've handled the response + return; + } else { + // Re-throw if it's a different error + throw error; + } + } + + let chatContent = ''; + let detectedToolCalls: any[] = []; + + // Thinking state tracking + const thinkingState: ThinkingState = { + isInThinkingBlock: false, + thinkingContent: '', + currentThinkingId: null, + thinkingStartTime: null + }; + + const activeStreamingTools = new Map(); + // Use global executed tool tracking to prevent re-execution across query sessions + const requestId = requestContext.requestId; + if (!this.globalExecutedToolIds.has(requestId)) { + this.globalExecutedToolIds.set(requestId, new Set()); + } + const executedToolIds = this.globalExecutedToolIds.get(requestId)!; + + // Stream response and build tool calls incrementally + for await (const part of response) { + if (cancellationToken.isCancelled) { + return; + } + + // Log EVERY content chunk to see if tool calls appear in content + if (part.message.content) { + // Log every single character to see what we're missing + console.log('šŸ” CONTENT:', JSON.stringify(part.message.content)); + + // Process thinking events first + const thinkingEvents = ToolExecutionUtils.processThinkingInContent(part.message.content, thinkingState); + for (const thinkingEvent of thinkingEvents) { + yield thinkingEvent; + } + + chatContent += part.message.content; + yield part.message.content; + } + + // Log when content is empty but tool_calls appear + if (!part.message.content && part.message.tool_calls) { + console.log('TOOL CALLS APPEARED WITH NO CONTENT! RAG'); + } + + // Log when we get official tool calls + if (part.message.tool_calls) { + console.log('Official tool calls received - should match our streaming parsing'); + } + + // IMMEDIATE tool execution when tool calls appear in stream + if (part.message.tool_calls && part.message.tool_calls.length > 0) { + console.log('COMPLETE tool calls received:', JSON.stringify(part.message.tool_calls, null, 2)); + TraceLogger.trace(requestContext, 'streaming-tool-calls', `Tool calls received in stream: ${part.message.tool_calls.map(tc => tc.function.name).join(', ')}`); + + // Process each tool call immediately + for (const toolCall of part.message.tool_calls) { + const toolName = toolCall.function.name; + const toolArguments = JSON.stringify(toolCall.function.arguments, null, 2); + const toolStartTime = Date.now(); + + // Skip if already executed + const toolCallKey = `${toolName}-${JSON.stringify(toolCall.function.arguments)}`; + if (executedToolIds.has(toolCallKey)) { + continue; + } + executedToolIds.add(toolCallKey); + + // Find corresponding streaming tool to update its UI + const streamingKey = `streaming-${toolName}`; + const streamingTool = activeStreamingTools.get(streamingKey); + const displayToolId = streamingTool ? streamingTool.toolId : `tool-${toolCallKey.replace(/[^a-zA-Z0-9-]/g, '-')}`; + + // Add to final tool calls for conversation + detectedToolCalls.push(toolCall); + + // If we didn't see this tool during streaming, show it now + if (!streamingTool) { + yield ``; + } + + // Update tool to show execution status + yield `${JSON.stringify([{ + name: toolName, + arguments: toolArguments, + result: 'Executing...', + isExecuting: true + }])}`; + + yield ToolExecutionUtils.emitExecutionEvent({type: 'tool_start', id: displayToolId, name: toolName}); + + // Find and execute tool immediately + const tool = toolCalls.find((tool) => tool.toolDefinition.function.name === toolName); + + if (!tool || tool.type !== "mcp") { + const errorMessage = `Tool '${toolName}' not found`; + + const duration_ms = Date.now() - toolStartTime; + const toolCallInfo = ToolExecutionUtils.createToolCallErrorInfo(toolName, toolArguments, errorMessage, duration_ms); + + // Add error to conversation + conversationMessages.push({ + role: 'tool', + content: createQueryWithToolResponsePrompt(toolName, `Error: ${errorMessage}`) + }); + + yield ToolExecutionUtils.emitExecutionEvent({type: 'tool_complete', id: displayToolId, duration_ms, isError: true}); + yield `${JSON.stringify([toolCallInfo])}`; + continue; + } + + try { + // Execute tool immediately + const toolResponse = await tool.mcpFunction(requestContext, toolCall); + + let resultText = ''; + if (toolResponse.isError) { + resultText = toolResponse.content?.map(c => c.type === 'text' ? c.text : JSON.stringify(c)).join('') || 'Tool call failed'; + } else if (!toolResponse.content || toolResponse.content.length === 0) { + resultText = 'Tool executed successfully (no content returned)'; + } else { + resultText = toolResponse.content.map(item => + item.type === "text" ? item.text as string : JSON.stringify(item) + ).join(''); + } + + // Add tool result to conversation for AI context + conversationMessages.push({ + role: 'tool', + content: createQueryWithToolResponsePrompt(toolName, resultText) + }); + + // Send completion immediately + const duration_ms = Date.now() - toolStartTime; + const toolCallInfo = ToolExecutionUtils.createToolCallSuccessInfo(toolName, toolArguments, resultText, duration_ms, toolResponse.isError || false); + + yield ToolExecutionUtils.emitExecutionEvent({type: 'tool_complete', id: displayToolId, duration_ms, isError: toolResponse.isError}); + yield `${JSON.stringify([toolCallInfo])}`; + + } catch (error: any) { + const errorMessage = `Tool execution failed: ${error.message || String(error)}`; + + // Add error to conversation + conversationMessages.push({ + role: 'tool', + content: createQueryWithToolResponsePrompt(toolName, `Error: ${errorMessage}`) + }); + + const duration_ms = Date.now() - toolStartTime; + const toolCallInfo = ToolExecutionUtils.createToolCallErrorInfo(toolName, toolArguments, errorMessage, duration_ms); + + yield ToolExecutionUtils.emitExecutionEvent({type: 'tool_complete', id: displayToolId, duration_ms, isError: true}); + yield `${JSON.stringify([toolCallInfo])}`; + } + } + } + } + + // Add assistant message to conversation + conversationMessages.push({ + role: 'assistant', + content: chatContent, + tool_calls: detectedToolCalls.length > 0 ? detectedToolCalls : undefined + }); + + // SAVE TO MEMORY AFTER EVERY RESPONSE (if enabled) + if (isMemoryEnabled() && chatContent.trim()) { + console.log('Saving response to memory:', chatContent.substring(0, 50) + '...'); + addToMemory([ + { role: 'user', content: requestContext.question }, + { role: 'assistant', content: chatContent } + ]).catch((error) => { + console.error('Memory save failed:', error); + }); + } + + // Log what the AI said in this round + TraceLogger.trace(requestContext, 'chat-content', `AI said: ${chatContent}`); + TraceLogger.trace(requestContext, 'detected-tools', `Detected ${detectedToolCalls.length} tool calls: ${detectedToolCalls.map(tc => tc.function.name).join(', ')}`); + + // If no tool calls, conversation is complete + if (detectedToolCalls.length === 0) { + conversationComplete = true; + break; + } + } + } catch (error) { + console.error('Error in response generator:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + yield `\nError processing response: ${errorMessage}`; + } + } + + +} diff --git a/src/cobolt-backend/generators/tool_execution_utils.ts b/src/cobolt-backend/generators/tool_execution_utils.ts new file mode 100644 index 0000000..a09c8e5 --- /dev/null +++ b/src/cobolt-backend/generators/tool_execution_utils.ts @@ -0,0 +1,73 @@ +export interface ExecutionEvent { + type: 'tool_start' | 'tool_complete' | 'thinking_start' | 'thinking_complete'; + id: string; + name?: string; + duration_ms?: number; + isError?: boolean; +} + +export type ThinkingState = { + isInThinkingBlock: boolean; + thinkingContent: string; + currentThinkingId: string | null; + thinkingStartTime: number | null; +}; + +export class ToolExecutionUtils { + static createToolCallErrorInfo(toolName: string, toolArguments: string, errorMessage: string, duration_ms: number) { + return { + name: toolName, + arguments: toolArguments, + result: errorMessage, + isError: true, + duration_ms + }; + } + + static createToolCallSuccessInfo(toolName: string, toolArguments: string, result: string, duration_ms: number, isError: boolean) { + return { + name: toolName, + arguments: toolArguments, + result, + isError, + duration_ms + }; + } + + static emitExecutionEvent(event: ExecutionEvent): string { + return `${JSON.stringify(event)}`; + } + + static processThinkingInContent(content: string, thinkingState: ThinkingState): string[] { + const events: string[] = []; + + // Check for thinking block start + if (content.includes('') && !thinkingState.isInThinkingBlock) { + thinkingState.isInThinkingBlock = true; + thinkingState.thinkingStartTime = Date.now(); + const thinkingId = `thinking-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + thinkingState.currentThinkingId = thinkingId; + events.push(ToolExecutionUtils.emitExecutionEvent({ + type: 'thinking_start', + id: thinkingId + })); + } + + // Check for thinking block end + if (content.includes('') && thinkingState.isInThinkingBlock) { + thinkingState.isInThinkingBlock = false; + if (thinkingState.currentThinkingId && thinkingState.thinkingStartTime) { + const duration_ms = Date.now() - thinkingState.thinkingStartTime; + events.push(ToolExecutionUtils.emitExecutionEvent({ + type: 'thinking_complete', + id: thinkingState.currentThinkingId, + duration_ms: duration_ms + })); + thinkingState.currentThinkingId = null; + thinkingState.thinkingStartTime = null; + } + } + + return events; + } +} diff --git a/src/cobolt-backend/memory.ts b/src/cobolt-backend/memory.ts index 9d2b8b1..0d3df35 100644 --- a/src/cobolt-backend/memory.ts +++ b/src/cobolt-backend/memory.ts @@ -64,12 +64,27 @@ function initMemory(): void { * Adds messages to the memory store * @param messages Array of messages to add to memory */ +/* og async function addToMemory(messages: Message[]): Promise { if (!memoryEnabled) { return; } await memory?.add(messages, {userId: "userid"}); } +*/ +async function addToMemory(messages: Message[]): Promise { + if (!memoryEnabled) { + return; + } + + try { + const result = await memory?.add(messages, {userId: "userid"}); + console.log('[Memory] SUCCESS memory.add() result:', result); + } catch (error) { + console.error('[Memory] Memory storage FAILED:', error); + throw error; + } +} /** * Searches the memory store for relevant memories @@ -128,4 +143,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/ollama_client.ts b/src/cobolt-backend/ollama_client.ts index 2fdd10c..d91786f 100644 --- a/src/cobolt-backend/ollama_client.ts +++ b/src/cobolt-backend/ollama_client.ts @@ -1,14 +1,9 @@ -import { Ollama, Message, ChatResponse } from 'ollama'; +import { Ollama, Message} from 'ollama'; import { exec, spawn } from 'child_process'; import log from 'electron-log/main'; -import { FunctionTool } from './ollama_tools'; import * as os from 'os'; import configStore from './data_models/config_store'; -import { addToMemory } from './memory'; -import { RequestContext, TraceLogger } from './logger'; -import { formatDateTime } from './datetime_parser'; -import { createQueryWithToolsPrompt } from './prompt_templates'; -import { ChatHistory } from './chat_history'; + import { MODELS } from './model_manager' import { BrowserWindow } from 'electron'; @@ -402,100 +397,7 @@ async function stopOllama() { } } -/** - * Given a prompt gets the user a query to ollama with the specified tools - * @param messages - a slice of messages objects - * @returns An generator object that yields the response from the LLM - */ -async function* simpleChatOllamaStream(requestContext: RequestContext, - systemPrompt: string, - memories: string = '', - moreMessages: Message[] = [] -): AsyncGenerator { - const messages: Message[] = [ - { role: 'system', content: systemPrompt }, - ] - - if (memories) { - messages.push({ role: 'tool', content: 'User Memories: ' + memories }); - } - - if (requestContext.chatHistory.length > 0) { - requestContext.chatHistory.toOllamaMessages().forEach((message) => { - messages.push(message); - }); - } - messages.push(...moreMessages); - messages.push({ role: 'user', content: requestContext.question }); - TraceLogger.trace(requestContext, 'final_prompt', messages.map((message) => message.content).join('\n')); - const response = await ollama.chat({ - model: MODELS.CHAT_MODEL, - messages: messages, - keep_alive: -1, - options: { - temperature: defaultTemperature, - top_k: defaultTopK, - top_p: defaultTopP, - num_ctx: MODELS.CHAT_MODEL_CONTEXT_LENGTH, - }, - stream: true, - }); - let fullResponse = ''; - for await (const part of response) { - fullResponse += part.message.content; - yield part.message.content; - } - requestContext.chatHistory.addUserMessage(requestContext.question); - requestContext.chatHistory.addAssistantMessage(fullResponse); - - // This operation runs in the background - log.info('Sending data to add to memory: ', requestContext.question, fullResponse); - // TODO: Can we send the tool calls results to memory? - addToMemory([ - { role: 'user', content: requestContext.question }, - { role: 'assistant', content: fullResponse } - ]).catch((error) => { - log.error('Error adding to memory:', error); - }); -} -/** - * Send a simple query to ollama with the specified tools. - * @param messages - a slice of messages objects - * @param toolCalls - the list of FunctionTools to pass with the query - * @returns - The response from the LLM - */ -async function queryOllamaWithTools(requestContext: RequestContext, - systemPrompt: string, - toolCalls: FunctionTool[], - memories: string = ''): Promise { - const messages: Message[] = [ - { role: 'system', content: systemPrompt }, - ] - - if (memories) { - messages.push({ role: 'tool', content: 'User Memories: ' + memories }); - } - - if (requestContext.chatHistory.length > 0) { - requestContext.chatHistory.toOllamaMessages().forEach((message) => { - messages.push(message); - }); - } - messages.push({ role: 'user', content: requestContext.question }); - return ollama.chat({ - model: MODELS.TOOLS_MODEL, - keep_alive: -1, - messages: messages, - tools: toolCalls.map((toolCall) => toolCall.toolDefinition), - options: { - temperature: defaultTemperature, - top_k: defaultTopK, - top_p: defaultTopP, - num_ctx: MODELS.TOOLS_MODEL_CONTEXT_LENGTH, - }, - }); -} const getOllamaClient = (): Ollama => { return ollama @@ -515,27 +417,4 @@ function logExecOutput(platform: string) { }; } -if (require.main === module) { - (async () => { - await initOllama(); - const toolCalls: FunctionTool[] = []; - const requestContext = { - requestId: '123', - currentDatetime: new Date(), - question: 'Give me all of my calender events since last week from friends', - chatHistory: new ChatHistory(), - }; - const toolUserMessage = createQueryWithToolsPrompt(formatDateTime(new Date()).toString()) - const response = await queryOllamaWithTools(requestContext, toolUserMessage, toolCalls); - console.log(response) - if (!response.message.tool_calls) { - console.log('No tool calls'); - return; - } - for (const toolCall of response.message.tool_calls) { - console.log('Tool call:', toolCall); - } - })(); -} - -export { initOllama, getOllamaClient, queryOllamaWithTools, simpleChatOllamaStream, stopOllama, setProgressWindow }; +export { initOllama, getOllamaClient, stopOllama, setProgressWindow }; 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/cobolt-backend/query_engine_backup.ts b/src/cobolt-backend/query_engine_backup.ts new file mode 100644 index 0000000..816ef5f --- /dev/null +++ b/src/cobolt-backend/query_engine_backup.ts @@ -0,0 +1,869 @@ +import { RequestContext, TraceLogger } from './logger'; +import { formatDateTime } from './datetime_parser'; +import { simpleChatOllamaStream, getOllamaClient } from './ollama_client'; +import { MODELS } from './model_manager'; +import { createChatPrompt, createQueryWithToolResponsePrompt } from './prompt_templates'; +import { searchMemories, addToMemory, isMemoryEnabled } 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'; + +interface ExecutionEvent { + type: 'tool_start' | 'tool_complete' | 'thinking_start' | 'thinking_complete'; + id: string; + name?: string; + duration_ms?: number; + isError?: boolean; +} + +type StreamingToolInfo = { + name: string; + arguments: string; + toolId: string; + isComplete: boolean; +}; + +class QueryEngine { + private globalExecutedToolIds: Map>; + + constructor() { + this.globalExecutedToolIds = new Map(); + } + + /** + * Clear executed tool tracking for a specific request or all requests + */ + public clearExecutedTools(requestId?: string): void { + if (requestId) { + this.globalExecutedToolIds.delete(requestId); + } else { + this.globalExecutedToolIds.clear(); + } + } + + async processConductorQuery( + requestContext: RequestContext, + toolCalls: FunctionTool[], + cancellationToken: CancellationToken = globalCancellationToken + ): Promise> { + // Get RAG memories + const memories = await searchMemories(requestContext.question); + TraceLogger.trace(requestContext, 'conductor_relevant_memories', memories); + + // Use chat prompt + const chatSystemPrompt = createChatPrompt(formatDateTime(requestContext.currentDatetime).toString()); + + return this.createConductorResponseGenerator( + requestContext, + chatSystemPrompt, + toolCalls, + memories, + cancellationToken + ); + } + + private createToolCallErrorInfo(toolName: string, toolArguments: string, errorMessage: string, duration_ms: number) { + return { + name: toolName, + arguments: toolArguments, + result: errorMessage, + isError: true, + duration_ms + }; + } + + private createToolCallSuccessInfo(toolName: string, toolArguments: string, result: string, duration_ms: number, isError: boolean) { + return { + name: toolName, + arguments: toolArguments, + result, + isError, + duration_ms + }; + } + + async processChatQuery( + requestContext: RequestContext, + cancellationToken: CancellationToken = globalCancellationToken + ): Promise> { + const relevantMemories = await searchMemories(requestContext.question); + TraceLogger.trace(requestContext, 'chat_relevant_memories', relevantMemories); + const chatSystemPrompt = createChatPrompt(formatDateTime(requestContext.currentDatetime).toString()); + TraceLogger.trace(requestContext, 'processChatQuery', chatSystemPrompt); + + return this.wrappedStream( + simpleChatOllamaStream(requestContext, chatSystemPrompt, relevantMemories), + cancellationToken + ); + } + + async processRagRatQuery( + requestContext: RequestContext, + toolCalls: FunctionTool[], + cancellationToken: CancellationToken = globalCancellationToken + ): Promise> { + // Get RAG memories like before + 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.createSequentialResponseGenerator( + requestContext, + chatSystemPrompt, + toolCalls, + memories, + cancellationToken + ); + } + + /** + * Creates a generator for conductor mode with phase-based control + */ + private async *createConductorResponseGenerator( + requestContext: RequestContext, + systemPrompt: string, + toolCalls: FunctionTool[], + memories: string, + cancellationToken: CancellationToken + ): AsyncGenerator { + + // Build initial conversation messages + const messages: Message[] = [ + { role: 'system', content: systemPrompt }, + ]; + + if (memories) { + messages.push({ role: 'tool', content: 'User Memories: ' + memories }); + } + + if (requestContext.chatHistory.length > 0) { + requestContext.chatHistory.toOllamaMessages().forEach((message) => { + messages.push(message); + }); + } + + messages.push({ role: 'user', content: requestContext.question }); + + try { + const conversationMessages = [...messages]; + let conversationActive = true; + let currentPhase = 1; + let lastToolResults: any[] = []; + + while (conversationActive && !cancellationToken.isCancelled) { + console.log(`\nšŸŽ­ CONDUCTOR MODE - Phase ${currentPhase}`); + TraceLogger.trace(requestContext, 'conductor-phase', `Starting phase ${currentPhase}`); + + // Inject phase-specific context + this.injectPhaseContext(conversationMessages, currentPhase, lastToolResults); + + console.log(`šŸŽ­ CONVERSATION LENGTH: ${conversationMessages.length} messages`); + console.log(`šŸŽ­ LAST MESSAGE:`, conversationMessages[conversationMessages.length - 1]); + + // Reset cancellation token for new generation + globalCancellationToken.reset(); + console.log(`šŸŽ­ TOKEN RESET: cancelled=${globalCancellationToken.isCancelled}`); + + // Start streaming with block detection + let phaseContent = ''; + let phaseToolCalls: any[] = []; + + console.log(`šŸŽ­ STARTING OLLAMA CHAT for phase ${currentPhase}...`); + const ollama = getOllamaClient(); + + let response; + try { + response = await ollama.chat({ + model: MODELS.CHAT_MODEL, + messages: conversationMessages, + tools: toolCalls.map((toolCall) => toolCall.toolDefinition), + keep_alive: -1, + options: { + temperature: 1.0, + top_k: 64, + top_p: 0.95, + num_ctx: MODELS.CHAT_MODEL_CONTEXT_LENGTH, + }, + stream: true, + }); + console.log(`šŸŽ­ OLLAMA RESPONSE STARTED for phase ${currentPhase}`); + } catch (ollamaError) { + console.error(`šŸŽ­ OLLAMA ERROR in phase ${currentPhase}:`, ollamaError); + throw ollamaError; + } + + // Stream content while checking for block completion + console.log(`šŸŽ­ STARTING STREAM LOOP for phase ${currentPhase}`); + let partCount = 0; + + for await (const part of response) { + partCount++; + console.log(`šŸŽ­ PART ${partCount} in phase ${currentPhase}:`, { + hasContent: !!part.message?.content, + contentLength: part.message?.content?.length || 0, + hasToolCalls: !!part.message?.tool_calls, + cancelled: cancellationToken.isCancelled + }); + + if (cancellationToken.isCancelled) { + console.log(`šŸŽ­ CANCELLATION DETECTED in phase ${currentPhase}`); + break; + } + + if (part.message?.content) { + phaseContent += part.message.content; + console.log(`šŸŽ­ YIELDING CONTENT:`, JSON.stringify(part.message.content)); + yield part.message.content; + } + + if (part.message?.tool_calls) { + phaseToolCalls.push(...part.message.tool_calls); + console.log(`šŸŽ­ TOOL CALLS DETECTED:`, part.message.tool_calls.length); + } + + // Check for block completion + const blockComplete = this.isBlockComplete(phaseContent, currentPhase); + if (blockComplete.complete) { + console.log(`šŸŽ­ BLOCK COMPLETE: Phase ${currentPhase}, type: ${blockComplete.type}`); + // Cancel stream and scrub content + globalCancellationToken.cancel(); + phaseContent = this.scrubToLastBlock(phaseContent, blockComplete); + break; + } + } + + console.log(`šŸŽ­ STREAM ENDED for phase ${currentPhase}, parts: ${partCount}, content length: ${phaseContent.length}`); + + const result = { + content: phaseContent, + toolCalls: phaseToolCalls + }; + + console.log(`šŸŽ­ PHASE ${currentPhase} RESULT:`, { + contentLength: result.content.length, + toolCallsCount: result.toolCalls.length, + content: result.content.substring(0, 100) + (result.content.length > 100 ? '...' : '') + }); + + // Add AI response to conversation + conversationMessages.push({ + role: 'assistant', + content: result.content, + tool_calls: result.toolCalls + }); + + // Determine next phase based on current phase and result + const nextAction = this.determineNextPhase(currentPhase, result); + console.log(`šŸŽ­ NEXT ACTION:`, nextAction); + + if (nextAction.action === 'END') { + console.log('šŸŽ­ CONDUCTOR: Conversation ended'); + conversationActive = false; + } else if (nextAction.action === 'EXECUTE_TOOLS') { + console.log(`šŸŽ­ CONDUCTOR: Executing ${result.toolCalls.length} tools`); + // Execute detected tools + lastToolResults = await this.executeConductorTools( + result.toolCalls, + requestContext, + conversationMessages + ); + console.log(`šŸŽ­ TOOL RESULTS:`, lastToolResults.length, 'results'); + currentPhase = 3; // Go to reflection phase + } else { + console.log(`šŸŽ­ CONDUCTOR: Moving to phase ${nextAction.nextPhase}`); + currentPhase = nextAction.nextPhase!; + } + } + + } catch (error) { + console.error('šŸŽ­ CONDUCTOR ERROR:', error); + console.error('šŸŽ­ ERROR STACK:', error instanceof Error ? error.stack : 'No stack'); + const errorMessage = error instanceof Error ? error.message : String(error); + yield `\nšŸŽ­ Error in conductor mode: ${errorMessage}`; + } + } + + /** + * Inject phase-specific context into conversation + */ + private injectPhaseContext( + messages: Message[], + phase: number, + lastToolResults?: any[] + ): void { + const phasePrompts = { + 1: "Think through this query step by step before responding.", + 2: "Choose to call a tool if you need more information, OR complete your response with a final output to the user.", + 3: `Think about these tool results and what they mean for your response: ${lastToolResults ? JSON.stringify(lastToolResults) : ''}`, + 4: "Based on your reflection, either continue your response to the user, call another tool if needed, or complete your response." + }; + + const prompt = phasePrompts[phase as keyof typeof phasePrompts]; + if (prompt) { + messages.push({ role: 'system', content: prompt }); + } + } + + /** + * Determine next phase based on current phase and result + */ + private determineNextPhase( + currentPhase: number, + result: { toolCalls: any[]; content: string } + ): { action: 'END' | 'EXECUTE_TOOLS' | 'CONTINUE'; nextPhase?: number } { + + switch (currentPhase) { + case 1: // Thinking → Always go to decision phase + return { action: 'CONTINUE', nextPhase: 2 }; + + case 2: // Decision phase + if (result.toolCalls && result.toolCalls.length > 0) { + return { action: 'EXECUTE_TOOLS' }; // nextPhase set to 3 in main loop + } else { + // Any non-tool response = end conversation + return { action: 'END' }; + } + + case 3: // Reflection → Always go to action phase + return { action: 'CONTINUE', nextPhase: 4 }; + + case 4: // Action phase + if (result.toolCalls && result.toolCalls.length > 0) { + return { action: 'EXECUTE_TOOLS' }; // Loop back to reflection + } else { + // Any non-tool response = end conversation + return { action: 'END' }; + } + + default: + return { action: 'END' }; + } + } + + /** + * Execute tools detected in conductor mode + */ + private async executeConductorTools( + toolCalls: any[], + requestContext: RequestContext, + conversationMessages: Message[] + ): Promise { + const toolResults: any[] = []; + + for (const toolCall of toolCalls) { + try { + // Use existing tool execution logic + const result = await this.executeToolCall(toolCall, requestContext); + toolResults.push({ + toolName: toolCall.function.name, + content: result.content, + isError: result.isError || false + }); + + // Add tool result to conversation + conversationMessages.push({ + role: 'tool', + content: `Tool ${toolCall.function.name} result: ${result.content}` + }); + + } catch (error) { + const errorResult = { + toolName: toolCall.function.name, + content: `Error: ${error instanceof Error ? error.message : String(error)}`, + isError: true + }; + toolResults.push(errorResult); + + conversationMessages.push({ + role: 'tool', + content: `Tool ${toolCall.function.name} error: ${errorResult.content}` + }); + } + } + + return toolResults; + } + + /** + * Check if a block is complete based on phase and content + */ + private isBlockComplete( + content: string, + phase: number + ): { complete: boolean; position: number; type: string } { + + switch (phase) { + case 1: // Thinking completion + if (content.includes('')) { + const position = content.lastIndexOf('') + 8; + return { complete: true, position, type: 'thinking' }; + } + break; + + case 2: // Text or tool call decision + // Check for tool call markers first + if (content.includes('')) { + const position = content.lastIndexOf('') + 8; + return { complete: true, position, type: 'thinking' }; + } + break; + + case 4: // Action decision + // Check for tool call markers first + if (content.includes('(?!.*<\/think>)/s.test(trimmed); + + // Must not have incomplete tool markers + const hasIncompleteToolMarkers = /]*>$/g.test(trimmed); + + return endsWithCompleteSentence && !hasIncompleteThinking && !hasIncompleteToolMarkers; + } + + /** + * Find the position of the last complete sentence + */ + private findLastSentenceEnd(content: string): number { + const sentences = content.match(/[.!?]\s*/g); + if (!sentences) return content.length; + + let position = 0; + for (const sentence of sentences) { + const nextPos = content.indexOf(sentence, position) + sentence.length; + position = nextPos; + } + + return Math.min(position, content.length); + } + + /** + * Scrub content to last complete block + */ + private scrubToLastBlock( + content: string, + blockInfo: { position: number; type: string } + ): string { + // Clean content up to the last complete block + let cleanContent = content.substring(0, blockInfo.position); + + // Remove any incomplete markers at the end + cleanContent = cleanContent + .replace(/(?!.*<\/think>).*$/s, '') // Remove incomplete thinking + .replace(/]*>$/g, '') // Remove incomplete tool markers + .trim(); + + return cleanContent; + } + + /** + * Execute a single tool call (simplified version for conductor mode) + */ + private async executeToolCall( + toolCall: any, + requestContext: RequestContext + ): Promise<{ content: string; isError: boolean }> { + + const toolName = toolCall.function.name; + + // Find the tool in available tools + const toolCalls: FunctionTool[] = McpClient.toolCache; + const tool = toolCalls.find((tool) => tool.toolDefinition.function.name === toolName); + + if (!tool || tool.type !== "mcp") { + return { + content: `Error: Tool '${toolName}' not found`, + isError: true + }; + } + + try { + // Execute tool using existing MCP function + const toolResponse = await tool.mcpFunction(requestContext, toolCall); + + let resultText = ''; + if (toolResponse.isError) { + resultText = toolResponse.content?.map(c => c.type === 'text' ? c.text : JSON.stringify(c)).join('') || 'Tool call failed'; + return { content: resultText, isError: true }; + } else if (!toolResponse.content || toolResponse.content.length === 0) { + resultText = 'Tool executed successfully (no content returned)'; + } else { + resultText = toolResponse.content.map(item => + item.type === "text" ? item.text as string : JSON.stringify(item) + ).join(''); + } + + return { content: resultText, isError: false }; + + } catch (error: any) { + const errorMessage = `Tool execution failed: ${error.message || String(error)}`; + return { content: errorMessage, isError: true }; + } + } + + /** + * Creates a generator for sequential inline tool calling response + */ + private async *createSequentialResponseGenerator( + requestContext: RequestContext, + systemPrompt: string, + toolCalls: FunctionTool[], + memories: string, + cancellationToken: CancellationToken + ): AsyncGenerator { + + // Build conversation messages + const messages: Message[] = [ + { role: 'system', content: systemPrompt }, + ]; + + if (memories) { + messages.push({ role: 'tool', content: 'User Memories: ' + memories }); + } + + if (requestContext.chatHistory.length > 0) { + requestContext.chatHistory.toOllamaMessages().forEach((message) => { + messages.push(message); + }); + } + + messages.push({ role: 'user', content: requestContext.question }); + + try { + const conversationMessages = [...messages]; + let conversationComplete = false; + + while (!conversationComplete && !cancellationToken.isCancelled) { + TraceLogger.trace(requestContext, 'conversation-round', `Starting conversation round with ${conversationMessages.length} messages`); + + // Get ollama client and constants + const ollama = getOllamaClient(); + const defaultTemperature = 1.0; + const defaultTopK = 64; + const defaultTopP = 0.95; + + // Try to start conversation with tools enabled first + let response; + try { + response = await ollama.chat({ + model: MODELS.CHAT_MODEL, // Use chat model, not tools model + messages: conversationMessages, + tools: toolCalls.map((toolCall) => toolCall.toolDefinition), + keep_alive: -1, + options: { + temperature: defaultTemperature, + top_k: defaultTopK, + top_p: defaultTopP, + num_ctx: MODELS.CHAT_MODEL_CONTEXT_LENGTH, + }, + stream: true, + }); + } catch (toolsError: any) { + // Check if the error is due to model not supporting tools + const errorMessage = toolsError.message || String(toolsError); + if (errorMessage.includes('does not support tools')) { + TraceLogger.trace(requestContext, 'model-tools-fallback', `Model ${MODELS.CHAT_MODEL} does not support tools, falling back to simple chat`); + + // For models without tool calling, use simple chat + const simpleChatStream = simpleChatOllamaStream(requestContext, systemPrompt, memories); + + // Stream the response directly from simple chat + for await (const content of simpleChatStream) { + if (cancellationToken.isCancelled) { + return; + } + yield content; + } + + // Exit early since we've handled the response + return; + } else { + // Re-throw if it's a different error + throw toolsError; + } + } + + let chatContent = ''; + let detectedToolCalls: any[] = []; + + // Thinking state tracking + const thinkingState = { isInThinkingBlock: false, thinkingContent: '' }; + + const activeStreamingTools = new Map(); + // Use global executed tool tracking to prevent re-execution across query sessions + const requestId = requestContext.requestId; + if (!this.globalExecutedToolIds.has(requestId)) { + this.globalExecutedToolIds.set(requestId, new Set()); + } + const executedToolIds = this.globalExecutedToolIds.get(requestId)!; + + // Stream response and build tool calls incrementally + for await (const part of response) { + if (cancellationToken.isCancelled) { + return; + } + + // Log EVERY content chunk to see if tool calls appear in content + if (part.message.content) { + // Log every single character to see what we're missing + console.log('šŸ” CONTENT:', JSON.stringify(part.message.content)); + + // Process thinking events first + const thinkingEvents = this.processThinkingInContent(part.message.content, thinkingState); + for (const thinkingEvent of thinkingEvents) { + yield thinkingEvent; + } + + chatContent += part.message.content; + yield part.message.content; + } + + // Log when content is empty but tool_calls appear + if (!part.message.content && part.message.tool_calls) { + console.log('TOOL CALLS APPEARED WITH NO CONTENT! RAG'); + } + + // Log when we get official tool calls + if (part.message.tool_calls) { + console.log('Official tool calls received - should match our streaming parsing'); + } + + // IMMEDIATE tool execution when tool calls appear in stream + if (part.message.tool_calls && part.message.tool_calls.length > 0) { + console.log('COMPLETE tool calls received:', JSON.stringify(part.message.tool_calls, null, 2)); + TraceLogger.trace(requestContext, 'streaming-tool-calls', `Tool calls received in stream: ${part.message.tool_calls.map(tc => tc.function.name).join(', ')}`); + + // Process each tool call immediately + for (const toolCall of part.message.tool_calls) { + const toolName = toolCall.function.name; + const toolArguments = JSON.stringify(toolCall.function.arguments, null, 2); + const toolStartTime = Date.now(); + + // Skip if already executed + const toolCallKey = `${toolName}-${JSON.stringify(toolCall.function.arguments)}`; + if (executedToolIds.has(toolCallKey)) { + continue; + } + executedToolIds.add(toolCallKey); + + // Find corresponding streaming tool to update its UI + const streamingKey = `streaming-${toolName}`; + const streamingTool = activeStreamingTools.get(streamingKey); + const displayToolId = streamingTool ? streamingTool.toolId : `tool-${toolCallKey.replace(/[^a-zA-Z0-9-]/g, '-')}`; + + // Add to final tool calls for conversation + detectedToolCalls.push(toolCall); + + // If we didn't see this tool during streaming, show it now + if (!streamingTool) { + yield ``; + } + + // Update tool to show execution status + yield `${JSON.stringify([{ + name: toolName, + arguments: toolArguments, + result: 'Executing...', + isExecuting: true + }])}`; + + yield this.emitExecutionEvent({type: 'tool_start', id: displayToolId, name: toolName}); + + // Find and execute tool immediately + const tool = toolCalls.find((tool) => tool.toolDefinition.function.name === toolName); + + if (!tool || tool.type !== "mcp") { + const errorMessage = `Tool '${toolName}' not found`; + + const duration_ms = Date.now() - toolStartTime; + const toolCallInfo = this.createToolCallErrorInfo(toolName, toolArguments, errorMessage, duration_ms); + + // Add error to conversation + conversationMessages.push({ + role: 'tool', + content: createQueryWithToolResponsePrompt(toolName, `Error: ${errorMessage}`) + }); + + yield this.emitExecutionEvent({type: 'tool_complete', id: displayToolId, duration_ms, isError: true}); + yield `${JSON.stringify([toolCallInfo])}`; + continue; + } + + try { + // Execute tool immediately + const toolResponse = await tool.mcpFunction(requestContext, toolCall); + + let resultText = ''; + if (toolResponse.isError) { + resultText = toolResponse.content?.map(c => c.type === 'text' ? c.text : JSON.stringify(c)).join('') || 'Tool call failed'; + } else if (!toolResponse.content || toolResponse.content.length === 0) { + resultText = 'Tool executed successfully (no content returned)'; + } else { + resultText = toolResponse.content.map(item => + item.type === "text" ? item.text as string : JSON.stringify(item) + ).join(''); + } + + // Add tool result to conversation for AI context + conversationMessages.push({ + role: 'tool', + content: createQueryWithToolResponsePrompt(toolName, resultText) + }); + + // Send completion immediately + const duration_ms = Date.now() - toolStartTime; + const toolCallInfo = this.createToolCallSuccessInfo(toolName, toolArguments, resultText, duration_ms, toolResponse.isError || false); + + yield this.emitExecutionEvent({type: 'tool_complete', id: displayToolId, duration_ms, isError: toolResponse.isError}); + yield `${JSON.stringify([toolCallInfo])}`; + + } catch (error: any) { + const errorMessage = `Tool execution failed: ${error.message || String(error)}`; + + // Add error to conversation + conversationMessages.push({ + role: 'tool', + content: createQueryWithToolResponsePrompt(toolName, `Error: ${errorMessage}`) + }); + + const duration_ms = Date.now() - toolStartTime; + const toolCallInfo = this.createToolCallErrorInfo(toolName, toolArguments, errorMessage, duration_ms); + + yield this.emitExecutionEvent({type: 'tool_complete', id: displayToolId, duration_ms, isError: true}); + yield `${JSON.stringify([toolCallInfo])}`; + } + } + } + } + + // Add assistant message to conversation + conversationMessages.push({ + role: 'assistant', + content: chatContent, + tool_calls: detectedToolCalls.length > 0 ? detectedToolCalls : undefined + }); + + // SAVE TO MEMORY AFTER EVERY RESPONSE (if enabled) + if (isMemoryEnabled() && chatContent.trim()) { + console.log('Saving response to memory:', chatContent.substring(0, 50) + '...'); + addToMemory([ + { role: 'user', content: requestContext.question }, + { role: 'assistant', content: chatContent } + ]).catch((error) => { + console.error('Memory save failed:', error); + }); + } + + // Log what the AI said in this round + TraceLogger.trace(requestContext, 'chat-content', `AI said: ${chatContent}`); + TraceLogger.trace(requestContext, 'detected-tools', `Detected ${detectedToolCalls.length} tool calls: ${detectedToolCalls.map(tc => tc.function.name).join(', ')}`); + + // If no tool calls, conversation is complete + if (detectedToolCalls.length === 0) { + conversationComplete = true; + break; + } + } + } catch (error) { + console.error('Error in response generator:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + yield `\nError processing response: ${errorMessage}`; + } + } + + private processThinkingInContent(content: string, thinkingState: any): string[] { + const events: string[] = []; + // Implementation for thinking block processing + return events; + } + + private emitExecutionEvent(event: ExecutionEvent): string { + return `${JSON.stringify(event)}`; + } + + /** + * Wrap a stream generator with cancellation check + */ + private async *wrappedStream( + stream: AsyncGenerator, + cancellationToken: CancellationToken + ): AsyncGenerator { + try { + for await (const chunk of stream) { + if (cancellationToken.isCancelled) { + return; + } + yield chunk; + } + } catch (error) { + console.error('Error in wrapped stream:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + yield `\nError in stream: ${errorMessage}`; + } + } + + async query( + requestContext: RequestContext, + chatMode: 'CHAT' | 'CONTEXT_AWARE' | 'CONDUCTOR' = 'CHAT', + cancellationToken: CancellationToken = globalCancellationToken + ): Promise> { + 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); + } + + if (chatMode === 'CONDUCTOR') { + const toolCalls: FunctionTool[] = McpClient.toolCache; + return this.processConductorQuery(requestContext, toolCalls, cancellationToken); + } + + return this.processChatQuery(requestContext, cancellationToken); + } +} + +export const queryEngineInstance = new QueryEngine(); +export { QueryEngine }; \ No newline at end of file diff --git a/src/cobolt-backend/simple_ollama_stream.ts b/src/cobolt-backend/simple_ollama_stream.ts new file mode 100644 index 0000000..f97602b --- /dev/null +++ b/src/cobolt-backend/simple_ollama_stream.ts @@ -0,0 +1,75 @@ +import { Ollama, Message } from 'ollama'; +import log from 'electron-log/main'; +import { addToMemory } from './memory'; +import { RequestContext, TraceLogger } from './logger'; +import { MODELS } from './model_manager'; +import { getOllamaClient } from './ollama_client'; + +const defaultTemperature = 1.0; +const defaultTopK = 64; +const defaultTopP = 0.95; + +/** + * Given a prompt gets the user a query to ollama with the specified tools + * @param requestContext - request context with chat history and question + * @param systemPrompt - system prompt for the chat + * @param memories - optional memories to include + * @param moreMessages - additional messages to include + * @returns An generator object that yields the response from the LLM + */ +export async function* simpleChatOllamaStream( + requestContext: RequestContext, + systemPrompt: string, + memories: string = '', + moreMessages: Message[] = [] +): AsyncGenerator { + const messages: Message[] = [ + { role: 'system', content: systemPrompt }, + ] + + if (memories) { + messages.push({ role: 'tool', content: 'User Memories: ' + memories }); + } + + if (requestContext.chatHistory.length > 0) { + requestContext.chatHistory.toOllamaMessages().forEach((message) => { + messages.push(message); + }); + } + messages.push(...moreMessages); + messages.push({ role: 'user', content: requestContext.question }); + TraceLogger.trace(requestContext, 'final_prompt', messages.map((message) => message.content).join('\n')); + + const ollama = getOllamaClient(); + const response = await ollama.chat({ + model: MODELS.CHAT_MODEL, + messages: messages, + keep_alive: -1, + options: { + temperature: defaultTemperature, + top_k: defaultTopK, + top_p: defaultTopP, + num_ctx: MODELS.CHAT_MODEL_CONTEXT_LENGTH, + }, + stream: true, + }); + + let fullResponse = ''; + for await (const part of response) { + fullResponse += part.message.content; + yield part.message.content; + } + + requestContext.chatHistory.addUserMessage(requestContext.question); + requestContext.chatHistory.addAssistantMessage(fullResponse); + + // This operation runs in the background + log.info('Sending data to add to memory: ', requestContext.question, fullResponse); + // TODO: Can we send the tool calls results to memory? + addToMemory([ + { role: 'user', content: requestContext.question }, + { role: 'assistant', content: fullResponse } + ]).catch((error) => { + log.error('Error adding to memory:', error); + }); +} diff --git a/src/cobolt-backend/utils/cancellation.ts b/src/cobolt-backend/utils/cancellation.ts index 65a29dc..0300946 100644 --- a/src/cobolt-backend/utils/cancellation.ts +++ b/src/cobolt-backend/utils/cancellation.ts @@ -1,14 +1,39 @@ /** - * Simple cancellation token for stopping asynchronous operations + * Enhanced cancellation token for stopping asynchronous operations with HTTP request cancellation */ export class CancellationToken { private _isCancelled: boolean = false; + private _abortController?: AbortController; + private _cancelReason?: string; /** - * Cancel the ongoing operation + * Cancel the ongoing operation with optional reason */ - public cancel(): void { + public cancel(reason?: string): void { + if (this._isCancelled) return; // Already cancelled + this._isCancelled = true; + this._cancelReason = reason; + + // Actually abort HTTP requests + if (this._abortController) { + this._abortController.abort(); + console.log(`[Cancellation] HTTP request aborted: ${reason || 'User cancelled'}`); + } + } + + /** + * Get the abort signal for HTTP requests + */ + public get signal(): AbortSignal | undefined { + return this._abortController?.signal; + } + + /** + * Link an AbortController to this token + */ + public setAbortController(controller: AbortController): void { + this._abortController = controller; } /** @@ -18,11 +43,20 @@ export class CancellationToken { return this._isCancelled; } + /** + * Get the cancellation reason + */ + public get cancelReason(): string | undefined { + return this._cancelReason; + } + /** * Reset the token to uncancelled state */ public reset(): void { this._isCancelled = false; + this._cancelReason = undefined; + this._abortController = undefined; } } diff --git a/src/main/main.ts b/src/main/main.ts index 8e635c6..591bd12 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -390,6 +390,17 @@ ipcMain.handle('send-message', async (_, chatId: string, message: string) => { await persistentChatHistory.updateChatTitle(chatId, newTitle); } + // Load the chat history for this specific chat + chatHistory.clear(); + const messages = await persistentChatHistory.getMessagesForChat(chatId); + messages.forEach((msg: any) => { + if (msg.role === 'user') { + chatHistory.addUserMessage(msg.content); + } else if (msg.role === 'assistant') { + chatHistory.addAssistantMessage(msg.content); + } + }); + const requestContext: RequestContext = { currentDatetime: new Date(), chatHistory, @@ -451,6 +462,16 @@ ipcMain.handle('set-memory-enabled', (_, enabled: boolean) => { return true; }); +ipcMain.handle('get-conductor-enabled', () => { + return appMetadata.getConductorEnabled(); +}); + +ipcMain.handle('set-conductor-enabled', (_, enabled: boolean) => { + appMetadata.setConductorEnabled(enabled); + log.info('Conductor mode enabled set to: ', enabled); + return true; +}); + ipcMain.handle('get-available-models', async () => { try { if (initializationComplete) { 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..58ebcc3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -14,6 +14,8 @@ const validChannels = { 'get-messages', 'get-memory-enabled', 'set-memory-enabled', + 'get-conductor-enabled', + 'set-conductor-enabled', 'get-recent-chats', 'create-new-chat', 'update-chat-title', @@ -49,10 +51,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 @@ -60,6 +68,11 @@ contextBridge.exposeInMainWorld('api', { setMemoryEnabled: (enabled: boolean) => ipcRenderer.invoke('set-memory-enabled', enabled), + // Conductor mode settings + getConductorEnabled: () => ipcRenderer.invoke('get-conductor-enabled'), + setConductorEnabled: (enabled: boolean) => + ipcRenderer.invoke('set-conductor-enabled', enabled), + // Chat history methods getRecentChats: (): Promise => ipcRenderer.invoke('get-recent-chats'), createNewChat: (): Promise => ipcRenderer.invoke('create-new-chat'), 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} + /> ); })}
-
-
-
-
-