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:
Jerome 2025-06-11 18:16:27 +01:00
commit 3a4a5cdc52
No known key found for this signature in database
7 changed files with 1799 additions and 1 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}