From 3a4a5cdc5201b7995f240f15cf69a25b363d1bbf Mon Sep 17 00:00:00 2001 From: Jerome Date: Wed, 11 Jun 2025 18:16:27 +0100 Subject: [PATCH] Implement complete conversation system with agent loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add conversation management with persistence and multi-conversation support - Create agent loop hook with test tools and MCP tool integration - Implement full chat UI with message display, tool call visualization - Add inline authentication flow for inference providers - Support both API key and OAuth authentication methods - Add conversation sidebar with conversation list and management - Integrate MCP status display for debugging - Create message input with proper disabled states and tooltips 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/App.tsx | 14 +- src/components/ChatInterface.tsx | 145 +++++++++ src/components/ConversationApp.tsx | 45 +++ src/components/ConversationSidebar.tsx | 120 +++++++ src/components/InferenceLogin.tsx | 183 +++++++++++ src/components/MCPStatus.tsx | 150 +++++++++ src/components/MessageInput.tsx | 98 ++++++ src/components/MessageList.tsx | 200 ++++++++++++ src/contexts/ConversationContext.tsx | 291 +++++++++++++++++ src/contexts/InferenceContext.tsx | 9 + src/hooks/useAgentLoop.ts | 419 +++++++++++++++++++++++++ src/types/conversation.ts | 126 ++++++++ 12 files changed, 1799 insertions(+), 1 deletion(-) create mode 100644 src/components/ChatInterface.tsx create mode 100644 src/components/ConversationApp.tsx create mode 100644 src/components/ConversationSidebar.tsx create mode 100644 src/components/InferenceLogin.tsx create mode 100644 src/components/MCPStatus.tsx create mode 100644 src/components/MessageInput.tsx create mode 100644 src/components/MessageList.tsx create mode 100644 src/contexts/ConversationContext.tsx create mode 100644 src/hooks/useAgentLoop.ts create mode 100644 src/types/conversation.ts diff --git a/src/App.tsx b/src/App.tsx index 1338fa6..d1da0da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { InferenceProvider } from '@/contexts/InferenceContext' import { MCPProvider } from '@/contexts/MCPContext' import { InferenceTest } from '@/components/InferenceTest' import { MCPTest } from '@/components/MCPTest' +import { ConversationApp } from '@/components/ConversationApp' import { OAuthCallback } from '@/components/OAuthCallback' function App() { @@ -19,7 +20,7 @@ function App() { return ; } - const [activeTab, setActiveTab] = useState<'inference' | 'mcp'>('inference'); + const [activeTab, setActiveTab] = useState<'conversations' | 'inference' | 'mcp'>('conversations'); return ( @@ -29,6 +30,16 @@ function App() {
+
{/* Tab Content */} + {activeTab === 'conversations' && } {activeTab === 'inference' && } {activeTab === 'mcp' && }
diff --git a/src/components/ChatInterface.tsx b/src/components/ChatInterface.tsx new file mode 100644 index 0000000..e1bf3eb --- /dev/null +++ b/src/components/ChatInterface.tsx @@ -0,0 +1,145 @@ +// Main chat interface with message display and input + +import React, { useState, useRef, useEffect } from 'react'; +import { useConversation } from '@/contexts/ConversationContext'; +import { useInference } from '@/contexts/InferenceContext'; +import { MessageList } from './MessageList'; +import { MessageInput } from './MessageInput'; +import { InferenceLogin } from './InferenceLogin'; + +export function ChatInterface() { + const { + activeConversationId, + getConversation, + sendMessage, + stopAgentLoop, + getAgentLoopState, + } = useConversation(); + + const { currentProvider } = useInference(); + const [isLoading, setIsLoading] = useState(false); + const messagesEndRef = useRef(null); + + const activeConversation = activeConversationId ? getConversation(activeConversationId) : undefined; + const agentLoopState = activeConversationId ? getAgentLoopState(activeConversationId) : undefined; + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [activeConversation?.messages]); + + const handleSendMessage = async (content: string) => { + if (!activeConversationId || !currentProvider?.isAuthenticated) { + return; + } + + setIsLoading(true); + try { + await sendMessage(activeConversationId, content); + } catch (error) { + console.error('Failed to send message:', error); + } finally { + setIsLoading(false); + } + }; + + const handleStopGeneration = () => { + if (activeConversationId) { + stopAgentLoop(activeConversationId); + } + }; + + // Show empty state if no conversation is selected + if (!activeConversationId || !activeConversation) { + return ( +
+
+

Welcome to MCP Chat

+

Start a new conversation to begin chatting with AI.

+

+ The AI has access to both test tools and any connected MCP servers. +

+
+
+ ); + } + + // Show auth prompt if not authenticated + if (!currentProvider?.isAuthenticated) { + return ( +
+ +
+ ); + } + + const isGenerating = agentLoopState?.isRunning || isLoading; + + return ( +
+ {/* Chat Header */} +
+
+
+

+ {activeConversation.title} +

+

+ Model: {currentProvider.selectedModel?.name || 'None selected'} +

+
+ + {isGenerating && ( + + )} +
+ + {/* Status Indicator */} + {agentLoopState && ( +
+ {agentLoopState.isRunning && ( + +
+ + {agentLoopState.currentStep === 'inference' && 'Thinking...'} + {agentLoopState.currentStep === 'tool_execution' && 'Using tools...'} + {agentLoopState.currentStep === 'complete' && 'Complete'} + + + (Step {agentLoopState.iteration + 1}/{agentLoopState.maxIterations}) + +
+ )} +
+ )} +
+ + {/* Messages Area */} +
+ +
+
+ + {/* Input Area */} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ConversationApp.tsx b/src/components/ConversationApp.tsx new file mode 100644 index 0000000..86576aa --- /dev/null +++ b/src/components/ConversationApp.tsx @@ -0,0 +1,45 @@ +// Main conversation application with sidebar and chat interface + +import React, { useState } from 'react'; +import { ConversationProvider } from '@/contexts/ConversationContext'; +import { ConversationSidebar } from './ConversationSidebar'; +import { ChatInterface } from './ChatInterface'; +import { MCPStatus } from './MCPStatus'; + +export function ConversationApp() { + const [showMCPStatus, setShowMCPStatus] = useState(false); + + return ( + +
+ {/* Left Sidebar - Conversations */} +
+
+
+

+ Conversations +

+ +
+
+ + {showMCPStatus ? ( + + ) : ( + + )} +
+ + {/* Main Chat Area */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ConversationSidebar.tsx b/src/components/ConversationSidebar.tsx new file mode 100644 index 0000000..ca2490d --- /dev/null +++ b/src/components/ConversationSidebar.tsx @@ -0,0 +1,120 @@ +// Conversation sidebar with conversation list and management + +import React from 'react'; +import { useConversation } from '@/contexts/ConversationContext'; + +export function ConversationSidebar() { + const { + conversations, + activeConversationId, + createConversation, + deleteConversation, + setActiveConversation, + } = useConversation(); + + const handleNewConversation = () => { + createConversation(); + }; + + const formatDate = (date: Date): string => { + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else if (days === 1) { + return 'Yesterday'; + } else if (days < 7) { + return date.toLocaleDateString([], { weekday: 'short' }); + } else { + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } + }; + + const getStatusIndicator = (status: string) => { + switch (status) { + case 'thinking': + return '💭'; + case 'calling_tools': + return '🔧'; + case 'error': + return '❌'; + default: + return ''; + } + }; + + return ( +
+ {/* New Conversation Button */} +
+ +
+ + {/* Conversations List */} +
+ {conversations.length === 0 ? ( +
+

No conversations yet.

+

Click "New Conversation" to get started.

+
+ ) : ( +
+ {conversations.map((conversation) => ( +
setActiveConversation(conversation.id)} + className={`p-3 rounded-lg cursor-pointer transition-colors group ${ + activeConversationId === conversation.id + ? 'bg-blue-100 dark:bg-blue-900/50 border border-blue-200 dark:border-blue-800' + : 'hover:bg-gray-100 dark:hover:bg-gray-700' + }`} + > +
+
+
+

+ {conversation.title} +

+ + {getStatusIndicator(conversation.status)} + +
+

+ {formatDate(conversation.updatedAt)} +

+ {conversation.messages.length > 0 && ( +

+ {conversation.messages[conversation.messages.length - 1]?.content?.[0]?.type === 'text' + ? conversation.messages[conversation.messages.length - 1].content[0].text + : 'Tool interaction' + } +

+ )} +
+ + +
+
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/InferenceLogin.tsx b/src/components/InferenceLogin.tsx new file mode 100644 index 0000000..dbb2163 --- /dev/null +++ b/src/components/InferenceLogin.tsx @@ -0,0 +1,183 @@ +// Inference provider login component for inline authentication + +import React, { useState, useMemo } from 'react'; +import { useInference } from '@/contexts/InferenceContext'; +import { OpenRouterApiProvider } from '@/providers/openrouter/api-provider'; +import { OpenRouterOAuthProvider } from '@/providers/openrouter/oauth-provider'; + +export function InferenceLogin() { + const { provider: currentProvider, setProvider, models, refreshAuthState } = useInference(); + const [selectedProviderId, setSelectedProviderId] = useState(currentProvider?.id || ''); + const [apiKey, setApiKey] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Create available providers + const availableProviders = useMemo(() => [ + new OpenRouterApiProvider(), + new OpenRouterOAuthProvider(), + ], []); + + const selectedProvider = availableProviders.find(p => p.id === selectedProviderId); + + const handleApiKeyLogin = async () => { + if (!selectedProvider || !apiKey.trim()) return; + + setIsLoading(true); + setError(null); + + try { + await selectedProvider.authenticate({ + type: 'api_key', + apiKey: apiKey.trim(), + }); + + // Load models after authentication + await selectedProvider.loadModels(); + + // Set this as the active provider + setProvider(selectedProvider); + refreshAuthState(); // Force re-render to show auth state + setApiKey(''); // Clear the API key input + } catch (err) { + setError(err instanceof Error ? err.message : 'Authentication failed'); + } finally { + setIsLoading(false); + } + }; + + const handleOAuthLogin = async () => { + if (!selectedProvider) return; + + setIsLoading(true); + setError(null); + + try { + await selectedProvider.authenticate({ + type: 'oauth', + }); + + // Set this as the active provider + setProvider(selectedProvider); + refreshAuthState(); // Force re-render to show auth state + } catch (err) { + setError(err instanceof Error ? err.message : 'OAuth authentication failed'); + } finally { + setIsLoading(false); + } + }; + + const capabilities = selectedProvider?.getCapabilities(); + + return ( +
+
+

+ Connect to AI Provider +

+

+ Choose a provider and authenticate to start chatting +

+
+ + {error && ( +
+

{error}

+
+ )} + + {/* Provider Selection */} +
+ + +
+ + {selectedProvider && capabilities && ( +
+ {/* API Key Authentication */} + {capabilities.authMethods.includes('api_key') && ( +
+ +
+ setApiKey(e.target.value)} + placeholder="Enter your API key..." + className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + onKeyDown={(e) => { + if (e.key === 'Enter' && apiKey.trim()) { + handleApiKeyLogin(); + } + }} + /> + +
+

+ Your API key is stored locally and never sent to our servers +

+
+ )} + + {/* OAuth Authentication */} + {capabilities.authMethods.includes('oauth') && ( +
+ {capabilities.authMethods.includes('api_key') && ( +
+
+
+
+
+ + or + +
+
+ )} + + +
+ )} +
+ )} + + {currentProvider?.isAuthenticated && models.length > 0 && ( +
+
+ ✓ + + Connected successfully! You can now start chatting. + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/MCPStatus.tsx b/src/components/MCPStatus.tsx new file mode 100644 index 0000000..819d34b --- /dev/null +++ b/src/components/MCPStatus.tsx @@ -0,0 +1,150 @@ +// MCP status display for the conversation sidebar + +import React from 'react'; +import { useMCP } from '@/contexts/MCPContext'; + +export function MCPStatus() { + const { connections, getAllTools } = useMCP(); + + const connectedServers = connections.filter(conn => conn.status === 'connected'); + const allTools = getAllTools(); + + const getStatusColor = (status: string) => { + switch (status) { + case 'connected': + return 'text-green-600 dark:text-green-400'; + case 'connecting': + return 'text-yellow-600 dark:text-yellow-400'; + case 'failed': + return 'text-red-600 dark:text-red-400'; + default: + return 'text-gray-600 dark:text-gray-400'; + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'connected': + return '🟢'; + case 'connecting': + return '🟡'; + case 'failed': + return '🔴'; + default: + return '⚪'; + } + }; + + return ( +
+
+ {/* Summary */} +
+

+ MCP Summary +

+
+

+ {connectedServers.length} servers connected +

+

+ {allTools.length} tools available +

+
+
+ + {/* Server List */} +
+

+ Servers ({connections.length}) +

+ + {connections.length === 0 ? ( +

+ No MCP servers configured. Go to the "MCP Provider Test" tab to add servers. +

+ ) : ( +
+ {connections.map((connection) => ( +
+
+
+
+ + {getStatusIcon(connection.status)} + +

+ {connection.name} +

+
+

+ {connection.status} +

+ {connection.error && ( +

+ {connection.error} +

+ )} +
+
+ + {connection.tools.length > 0 && ( +
+

+ Tools ({connection.tools.length}): +

+
+ {connection.tools.slice(0, 3).map((tool, index) => ( + + {tool.function.name.split('.').pop()} + + ))} + {connection.tools.length > 3 && ( + + +{connection.tools.length - 3} more + + )} +
+
+ )} +
+ ))} +
+ )} +
+ + {/* Available Tools */} + {allTools.length > 0 && ( +
+

+ Available Tools ({allTools.length}) +

+
+ {allTools.map((tool, index) => ( +
+ + {tool.function.name} + + {tool.function.description && ( +

+ {tool.function.description} +

+ )} +
+ ))} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/MessageInput.tsx b/src/components/MessageInput.tsx new file mode 100644 index 0000000..ba31244 --- /dev/null +++ b/src/components/MessageInput.tsx @@ -0,0 +1,98 @@ +// Message input component with send functionality + +import React, { useState, useRef, useEffect } from 'react'; + +interface MessageInputProps { + onSendMessage: (content: string) => void; + disabled?: boolean; + placeholder?: string; +} + +export function MessageInput({ + onSendMessage, + disabled = false, + placeholder = "Type your message..." +}: MessageInputProps) { + const [message, setMessage] = useState(''); + const textareaRef = useRef(null); + + // Auto-resize textarea + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + } + }, [message]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedMessage = message.trim(); + if (!trimmedMessage || disabled) return; + + onSendMessage(trimmedMessage); + setMessage(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Send on Enter, new line on Shift+Enter + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + return ( +
+
+
+