Implement complete conversation system with agent loops
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
12cb59e765
commit
3a4a5cdc52
7 changed files with 1799 additions and 1 deletions
145
src/components/ChatInterface.tsx
Normal file
145
src/components/ChatInterface.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<h2 className="text-2xl font-bold mb-4">Welcome to MCP Chat</h2>
|
||||
<p className="mb-2">Start a new conversation to begin chatting with AI.</p>
|
||||
<p className="text-sm">
|
||||
The AI has access to both test tools and any connected MCP servers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show auth prompt if not authenticated
|
||||
if (!currentProvider?.isAuthenticated) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<InferenceLogin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isGenerating = agentLoopState?.isRunning || isLoading;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
{/* Chat Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{activeConversation.title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Model: {currentProvider.selectedModel?.name || 'None selected'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isGenerating && (
|
||||
<button
|
||||
onClick={handleStopGeneration}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Stop Generation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
{agentLoopState && (
|
||||
<div className="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
{agentLoopState.isRunning && (
|
||||
<span className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
|
||||
<span>
|
||||
{agentLoopState.currentStep === 'inference' && 'Thinking...'}
|
||||
{agentLoopState.currentStep === 'tool_execution' && 'Using tools...'}
|
||||
{agentLoopState.currentStep === 'complete' && 'Complete'}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
(Step {agentLoopState.iteration + 1}/{agentLoopState.maxIterations})
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
||||
<MessageList
|
||||
messages={activeConversation.messages}
|
||||
isLoading={isGenerating}
|
||||
/>
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<MessageInput
|
||||
onSendMessage={handleSendMessage}
|
||||
disabled={isGenerating}
|
||||
placeholder={
|
||||
isGenerating
|
||||
? "AI is thinking..."
|
||||
: "Type your message..."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/ConversationApp.tsx
Normal file
45
src/components/ConversationApp.tsx
Normal file
|
|
@ -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 (
|
||||
<ConversationProvider>
|
||||
<div className="h-screen flex bg-gray-100 dark:bg-gray-900">
|
||||
{/* Left Sidebar - Conversations */}
|
||||
<div className="w-80 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Conversations
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setShowMCPStatus(!showMCPStatus)}
|
||||
className="text-sm px-3 py-1 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
{showMCPStatus ? 'Hide' : 'Show'} MCP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMCPStatus ? (
|
||||
<MCPStatus />
|
||||
) : (
|
||||
<ConversationSidebar />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<ChatInterface />
|
||||
</div>
|
||||
</div>
|
||||
</ConversationProvider>
|
||||
);
|
||||
}
|
||||
120
src/components/ConversationSidebar.tsx
Normal file
120
src/components/ConversationSidebar.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* New Conversation Button */}
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={handleNewConversation}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ New Conversation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Conversations List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
|
||||
<p>No conversations yet.</p>
|
||||
<p className="text-sm mt-1">Click "New Conversation" to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 px-2">
|
||||
{conversations.map((conversation) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{conversation.title}
|
||||
</h3>
|
||||
<span className="text-xs">
|
||||
{getStatusIndicator(conversation.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{formatDate(conversation.updatedAt)}
|
||||
</p>
|
||||
{conversation.messages.length > 0 && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300 mt-1 truncate">
|
||||
{conversation.messages[conversation.messages.length - 1]?.content?.[0]?.type === 'text'
|
||||
? conversation.messages[conversation.messages.length - 1].content[0].text
|
||||
: 'Tool interaction'
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteConversation(conversation.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all p-1"
|
||||
title="Delete conversation"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
src/components/InferenceLogin.tsx
Normal file
183
src/components/InferenceLogin.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<div className="max-w-md mx-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Connect to AI Provider
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Choose a provider and authenticate to start chatting
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/50 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider Selection */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
AI Provider
|
||||
</label>
|
||||
<select
|
||||
value={selectedProviderId}
|
||||
onChange={(e) => setSelectedProviderId(e.target.value)}
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="">Select a provider...</option>
|
||||
{availableProviders.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedProvider && capabilities && (
|
||||
<div className="space-y-4">
|
||||
{/* API Key Authentication */}
|
||||
{capabilities.authMethods.includes('api_key') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => 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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleApiKeyLogin}
|
||||
disabled={!apiKey.trim() || isLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Your API key is stored locally and never sent to our servers
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Authentication */}
|
||||
{capabilities.authMethods.includes('oauth') && (
|
||||
<div>
|
||||
{capabilities.authMethods.includes('api_key') && (
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleOAuthLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Redirecting...' : `Sign in with ${selectedProvider.name} OAuth`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProvider?.isAuthenticated && models.length > 0 && (
|
||||
<div className="mt-6 p-3 bg-green-50 dark:bg-green-900/50 border border-green-200 dark:border-green-800 rounded-md">
|
||||
<div className="flex items-center">
|
||||
<span className="text-green-600 dark:text-green-400 mr-2">✓</span>
|
||||
<span className="text-sm text-green-700 dark:text-green-300">
|
||||
Connected successfully! You can now start chatting.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/components/MCPStatus.tsx
Normal file
150
src/components/MCPStatus.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
MCP Summary
|
||||
</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium">{connectedServers.length}</span> servers connected
|
||||
</p>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium">{allTools.length}</span> tools available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server List */}
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
Servers ({connections.length})
|
||||
</h3>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No MCP servers configured. Go to the "MCP Provider Test" tab to add servers.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{connections.map((connection) => (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="bg-white dark:bg-gray-700 rounded-lg p-3 border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm">
|
||||
{getStatusIcon(connection.status)}
|
||||
</span>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{connection.name}
|
||||
</h4>
|
||||
</div>
|
||||
<p className={`text-xs mt-1 ${getStatusColor(connection.status)}`}>
|
||||
{connection.status}
|
||||
</p>
|
||||
{connection.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{connection.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connection.tools.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
Tools ({connection.tools.length}):
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{connection.tools.slice(0, 3).map((tool, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block px-2 py-1 bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 text-xs rounded"
|
||||
>
|
||||
{tool.function.name.split('.').pop()}
|
||||
</span>
|
||||
))}
|
||||
{connection.tools.length > 3 && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
+{connection.tools.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Available Tools */}
|
||||
{allTools.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
Available Tools ({allTools.length})
|
||||
</h3>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{allTools.map((tool, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-xs bg-gray-50 dark:bg-gray-700 rounded px-2 py-1"
|
||||
>
|
||||
<span className="font-mono text-gray-900 dark:text-white">
|
||||
{tool.function.name}
|
||||
</span>
|
||||
{tool.function.description && (
|
||||
<p className="text-gray-600 dark:text-gray-300 mt-1">
|
||||
{tool.function.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/components/MessageInput.tsx
Normal file
98
src/components/MessageInput.tsx
Normal file
|
|
@ -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<HTMLTextAreaElement>(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 (
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="w-full resize-none rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-3 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed max-h-32 overflow-y-auto"
|
||||
style={{ minHeight: '48px' }}
|
||||
/>
|
||||
|
||||
{/* Character count (optional) */}
|
||||
{message.length > 0 && (
|
||||
<div className="absolute bottom-1 right-2 text-xs text-gray-400">
|
||||
{message.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !message.trim()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 self-end"
|
||||
title={disabled ? "Please authenticate first" : "Send message"}
|
||||
>
|
||||
<span>Send</span>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Press Enter to send, Shift+Enter for new line
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
200
src/components/MessageList.tsx
Normal file
200
src/components/MessageList.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// Message list component to display conversation messages with tool calls
|
||||
|
||||
import React from 'react';
|
||||
import type { ConversationMessage, ToolUseBlock, ToolResultBlock } from '@/types/conversation';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ConversationMessage[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isLoading }: MessageListProps) {
|
||||
const renderContentBlock = (block: any, messageId: string, blockIndex: number) => {
|
||||
const key = `${messageId}-${blockIndex}`;
|
||||
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<div key={key} className="prose dark:prose-invert max-w-none">
|
||||
<p className="whitespace-pre-wrap">{block.text}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'tool_use':
|
||||
return (
|
||||
<div key={key} className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-2">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span className="text-blue-600 dark:text-blue-400 font-mono text-sm">🔧</span>
|
||||
<span className="font-medium text-blue-900 dark:text-blue-100">
|
||||
Using tool: {block.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded border p-3">
|
||||
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{JSON.stringify(block.input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'tool_result':
|
||||
const isError = block.is_error;
|
||||
return (
|
||||
<div key={key} className={`border rounded-lg p-4 my-2 ${
|
||||
isError
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
}`}>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span className="text-sm">
|
||||
{isError ? '❌' : '✅'}
|
||||
</span>
|
||||
<span className={`font-medium ${
|
||||
isError
|
||||
? 'text-red-900 dark:text-red-100'
|
||||
: 'text-green-900 dark:text-green-100'
|
||||
}`}>
|
||||
Tool result
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded border p-3">
|
||||
{typeof block.content === 'string' ? (
|
||||
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{block.content}
|
||||
</pre>
|
||||
) : (
|
||||
block.content.map((contentItem: any, idx: number) => (
|
||||
<div key={idx} className="text-xs text-gray-700 dark:text-gray-300">
|
||||
{contentItem.text && (
|
||||
<pre className="whitespace-pre-wrap">{contentItem.text}</pre>
|
||||
)}
|
||||
{contentItem.error && (
|
||||
<pre className="whitespace-pre-wrap text-red-600 dark:text-red-400">
|
||||
Error: {contentItem.error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div key={key} className="bg-gray-100 dark:bg-gray-800 rounded p-2 text-sm">
|
||||
<span className="text-gray-500">Unknown content type: {block.type}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const getRoleColor = (role: string): string => {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
case 'assistant':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'tool':
|
||||
return 'text-purple-600 dark:text-purple-400';
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: string): string => {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
return 'You';
|
||||
case 'assistant':
|
||||
return 'Assistant';
|
||||
case 'tool':
|
||||
return 'Tool';
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
};
|
||||
|
||||
if (messages.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<p className="text-lg mb-2">Start a conversation</p>
|
||||
<p className="text-sm">
|
||||
Ask a question or request help with something. The AI can use tools to help you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="flex space-x-4">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium ${
|
||||
message.role === 'user'
|
||||
? 'bg-blue-500'
|
||||
: message.role === 'assistant'
|
||||
? 'bg-green-500'
|
||||
: 'bg-purple-500'
|
||||
}`}>
|
||||
{message.role === 'user' ? 'U' : message.role === 'assistant' ? 'A' : 'T'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className={`font-medium text-sm ${getRoleColor(message.role)}`}>
|
||||
{getRoleLabel(message.role)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatTime(message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Render content blocks */}
|
||||
<div className="space-y-2">
|
||||
{message.content.map((block, index) =>
|
||||
renderContentBlock(block, message.id, index)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center text-white text-sm font-medium">
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span className="font-medium text-sm text-green-600 dark:text-green-400">
|
||||
Assistant
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
thinking...
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue