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
12 changed files with 1799 additions and 1 deletions

View file

@ -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 <OAuthCallback type="mcp" />;
}
const [activeTab, setActiveTab] = useState<'inference' | 'mcp'>('inference');
const [activeTab, setActiveTab] = useState<'conversations' | 'inference' | 'mcp'>('conversations');
return (
<InferenceProvider>
@ -29,6 +30,16 @@ function App() {
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-6xl mx-auto px-6">
<div className="flex space-x-8">
<button
onClick={() => setActiveTab('conversations')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'conversations'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
Conversations
</button>
<button
onClick={() => setActiveTab('inference')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
@ -54,6 +65,7 @@ function App() {
</div>
{/* Tab Content */}
{activeTab === 'conversations' && <ConversationApp />}
{activeTab === 'inference' && <InferenceTest />}
{activeTab === 'mcp' && <MCPTest />}
</div>

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

View file

@ -0,0 +1,291 @@
// React context for conversation management and agent loop orchestration
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import type {
Conversation,
ConversationMessage,
ConversationContextValue,
AgentLoopState,
ConversationContentBlock,
} from '@/types/conversation';
import { useAgentLoop } from '@/hooks/useAgentLoop';
const ConversationContext = createContext<ConversationContextValue | null>(null);
interface ConversationProviderProps {
children: ReactNode;
}
export function ConversationProvider({ children }: ConversationProviderProps) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConversationId, setActiveConversationId] = useState<string>();
const [agentLoopStates, setAgentLoopStates] = useState<Map<string, AgentLoopState>>(new Map());
const hasLoadedPersisted = useRef(false);
const { executeAgentLoop, stopLoop, getLoopState } = useAgentLoop();
// Load persisted conversations from localStorage on mount
useEffect(() => {
if (hasLoadedPersisted.current) return;
hasLoadedPersisted.current = true;
try {
const persistedData = localStorage.getItem('conversations');
if (persistedData) {
const persistedConversations: Conversation[] = JSON.parse(persistedData);
// Convert date strings back to Date objects
const restored = persistedConversations.map(conv => ({
...conv,
createdAt: new Date(conv.createdAt),
updatedAt: new Date(conv.updatedAt),
messages: conv.messages.map(msg => ({
...msg,
timestamp: new Date(msg.timestamp),
})),
}));
setConversations(restored);
// Set active conversation to the most recent one
if (restored.length > 0) {
const mostRecent = restored.reduce((latest, conv) =>
conv.updatedAt > latest.updatedAt ? conv : latest
);
setActiveConversationId(mostRecent.id);
}
}
} catch (error) {
console.error('Failed to load persisted conversations:', error);
}
}, []);
// Persist conversations to localStorage when they change
useEffect(() => {
if (hasLoadedPersisted.current && conversations.length > 0) {
try {
localStorage.setItem('conversations', JSON.stringify(conversations));
} catch (error) {
console.error('Failed to persist conversations:', error);
}
}
}, [conversations]);
// Helper: Generate conversation title from first user message
const generateConversationTitle = useCallback((firstMessage: string): string => {
// Take first 50 characters and clean up
const title = firstMessage
.replace(/[^\w\s]/g, '')
.trim()
.substring(0, 50);
return title || 'New Conversation';
}, []);
const createConversation = useCallback((title?: string): string => {
const conversationId = uuidv4();
const now = new Date();
const newConversation: Conversation = {
id: conversationId,
title: title || 'New Conversation',
messages: [],
createdAt: now,
updatedAt: now,
status: 'idle',
};
setConversations(prev => [newConversation, ...prev]);
setActiveConversationId(conversationId);
return conversationId;
}, []);
const deleteConversation = useCallback((conversationId: string) => {
// Stop any running agent loop
stopLoop(conversationId);
// Remove from conversations
setConversations(prev => prev.filter(conv => conv.id !== conversationId));
// Remove agent loop state
setAgentLoopStates(prev => {
const newMap = new Map(prev);
newMap.delete(conversationId);
return newMap;
});
// If this was the active conversation, set to the next available one
if (activeConversationId === conversationId) {
setConversations(current => {
const remaining = current.filter(conv => conv.id !== conversationId);
setActiveConversationId(remaining.length > 0 ? remaining[0].id : undefined);
return remaining;
});
}
}, [activeConversationId, stopLoop]);
const setActiveConversation = useCallback((conversationId: string) => {
setActiveConversationId(conversationId);
}, []);
const updateConversationTitle = useCallback((conversationId: string, title: string) => {
setConversations(prev =>
prev.map(conv =>
conv.id === conversationId
? { ...conv, title, updatedAt: new Date() }
: conv
)
);
}, []);
const getConversation = useCallback((conversationId: string): Conversation | undefined => {
return conversations.find(conv => conv.id === conversationId);
}, [conversations]);
const addUserMessage = useCallback((conversationId: string, content: string) => {
const userMessage: ConversationMessage = {
id: uuidv4(),
role: 'user',
content: [{ type: 'text', text: content }],
timestamp: new Date(),
};
setConversations(prev =>
prev.map(conv => {
if (conv.id === conversationId) {
// Auto-generate title from first user message
const title = conv.messages.length === 0
? generateConversationTitle(content)
: conv.title;
return {
...conv,
title,
messages: [...conv.messages, userMessage],
updatedAt: new Date(),
status: 'idle' as const,
};
}
return conv;
})
);
}, [generateConversationTitle]);
// Update conversation state from agent loop
const updateConversation = useCallback((updatedConversation: Conversation) => {
setConversations(prev =>
prev.map(conv =>
conv.id === updatedConversation.id ? updatedConversation : conv
)
);
}, []);
const sendMessage = useCallback(async (conversationId: string, content: string): Promise<void> => {
// Add user message first
addUserMessage(conversationId, content);
// Get the updated conversation
const conversation = conversations.find(conv => conv.id === conversationId);
if (!conversation) {
throw new Error(`Conversation ${conversationId} not found`);
}
// Create updated conversation with the new user message
const userMessage: ConversationMessage = {
id: uuidv4(),
role: 'user',
content: [{ type: 'text', text: content }],
timestamp: new Date(),
};
const updatedConversation: Conversation = {
...conversation,
messages: [...conversation.messages, userMessage],
updatedAt: new Date(),
status: 'thinking',
};
// Start agent loop
try {
await executeAgentLoop(updatedConversation, updateConversation);
} catch (error) {
// Update conversation with error state
const errorConversation: Conversation = {
...updatedConversation,
status: 'error',
error: error instanceof Error ? error.message : 'Agent loop failed',
updatedAt: new Date(),
};
updateConversation(errorConversation);
throw error;
}
}, [conversations, addUserMessage, executeAgentLoop, updateConversation]);
const continueConversation = useCallback(async (conversationId: string): Promise<void> => {
const conversation = conversations.find(conv => conv.id === conversationId);
if (!conversation) {
throw new Error(`Conversation ${conversationId} not found`);
}
try {
await executeAgentLoop(conversation, updateConversation);
} catch (error) {
const errorConversation: Conversation = {
...conversation,
status: 'error',
error: error instanceof Error ? error.message : 'Agent loop failed',
updatedAt: new Date(),
};
updateConversation(errorConversation);
throw error;
}
}, [conversations, executeAgentLoop, updateConversation]);
const stopAgentLoop = useCallback((conversationId: string) => {
stopLoop(conversationId);
// Update conversation status
setConversations(prev =>
prev.map(conv =>
conv.id === conversationId
? { ...conv, status: 'idle' as const, updatedAt: new Date() }
: conv
)
);
}, [stopLoop]);
const getAgentLoopState = useCallback((conversationId: string): AgentLoopState | undefined => {
return getLoopState(conversationId);
}, [getLoopState]);
const contextValue: ConversationContextValue = {
conversations,
activeConversationId,
agentLoopStates,
createConversation,
deleteConversation,
setActiveConversation,
updateConversationTitle,
getConversation,
addUserMessage,
sendMessage,
continueConversation,
stopAgentLoop,
getAgentLoopState,
};
return (
<ConversationContext.Provider value={contextValue}>
{children}
</ConversationContext.Provider>
);
}
export function useConversation(): ConversationContextValue {
const context = useContext(ConversationContext);
if (!context) {
throw new Error('useConversation must be used within a ConversationProvider');
}
return context;
}

View file

@ -17,6 +17,7 @@ interface InferenceContextValue {
// Provider actions
setProvider: (provider: InferenceProvider) => void;
clearProvider: () => void;
refreshAuthState: () => void; // Force refresh of auth state
// Inference actions
generateResponse: (request: InferenceRequest) => Promise<InferenceResponse>;
@ -40,11 +41,13 @@ export function InferenceProvider({ children }: InferenceProviderProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string | undefined>(undefined);
const [authStateVersion, setAuthStateVersion] = useState(0); // Force re-renders on auth changes
const setProvider = useCallback((newProvider: InferenceProvider) => {
setProviderState(newProvider);
setSelectedModelId(undefined);
setError(null);
setAuthStateVersion(prev => prev + 1); // Trigger re-render
}, []);
const clearProvider = useCallback(() => {
@ -54,8 +57,13 @@ export function InferenceProvider({ children }: InferenceProviderProps) {
setProviderState(null);
setSelectedModelId(undefined);
setError(null);
setAuthStateVersion(prev => prev + 1);
}, [provider]);
const refreshAuthState = useCallback(() => {
setAuthStateVersion(prev => prev + 1);
}, []);
const generateResponse = useCallback(async (request: InferenceRequest): Promise<InferenceResponse> => {
if (!provider) {
throw new Error('No inference provider configured');
@ -120,6 +128,7 @@ export function InferenceProvider({ children }: InferenceProviderProps) {
error,
setProvider,
clearProvider,
refreshAuthState,
generateResponse,
selectModel,
loadModels,

419
src/hooks/useAgentLoop.ts Normal file
View file

@ -0,0 +1,419 @@
// Agent loop hook with MCP and test tool integration
import { useCallback, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import type {
AgentLoopConfig,
AgentLoopState,
UseAgentLoopReturn,
ConversationMessage,
Conversation,
ToolUseBlock,
ToolResultBlock,
TestTool,
} from '@/types/conversation';
import type { ChatMessage, Tool, ToolCall, InferenceRequest } from '@/types/inference';
import { useInference } from '@/contexts/InferenceContext';
import { useMCP } from '@/contexts/MCPContext';
// Test tools that work alongside MCP tools
const testTools: TestTool[] = [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get current weather for a location',
parameters: {
type: 'object',
properties: {
location: {
type: 'string',
description: 'The city and state, e.g. San Francisco, CA',
},
},
required: ['location'],
},
},
execute: async (args) => {
// Mock weather data
const weather = {
location: args.location,
temperature: Math.floor(Math.random() * 30) + 10,
condition: ['sunny', 'cloudy', 'rainy', 'partly cloudy'][Math.floor(Math.random() * 4)],
humidity: Math.floor(Math.random() * 40) + 30,
};
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
return weather;
},
},
{
type: 'function',
function: {
name: 'calculate',
description: 'Perform basic arithmetic calculations',
parameters: {
type: 'object',
properties: {
expression: {
type: 'string',
description: 'Mathematical expression to evaluate (e.g., "2 + 2", "10 * 3")',
},
},
required: ['expression'],
},
},
execute: async (args) => {
try {
// Simple expression evaluator (basic safety check)
const expression = args.expression.replace(/[^0-9+\-*/().\s]/g, '');
if (expression !== args.expression) {
throw new Error('Invalid characters in expression');
}
const result = eval(expression);
return { expression: args.expression, result };
} catch (error) {
throw new Error(`Calculation error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
},
},
{
type: 'function',
function: {
name: 'get_current_time',
description: 'Get the current date and time',
parameters: {
type: 'object',
properties: {
timezone: {
type: 'string',
description: 'Timezone (e.g., "UTC", "America/New_York")',
},
},
},
},
execute: async (args) => {
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short',
};
if (args.timezone) {
options.timeZone = args.timezone;
}
return {
timestamp: new Date().toISOString(),
formatted: new Date().toLocaleString('en-US', options),
timezone: args.timezone || 'local',
};
},
},
];
const DEFAULT_CONFIG: AgentLoopConfig = {
maxIterations: 10,
systemMessage: 'You are a helpful assistant with access to various tools. Use them when needed to answer questions accurately.',
temperature: 0.7,
stopOnError: false,
};
export function useAgentLoop(config: Partial<AgentLoopConfig> = {}): UseAgentLoopReturn {
const finalConfig = { ...DEFAULT_CONFIG, ...config };
const { currentProvider } = useInference();
const { getAllTools, callTool: callMCPTool, connections } = useMCP();
// Track running loops
const loopStates = useRef<Map<string, AgentLoopState>>(new Map());
// Helper: Convert conversation messages to inference format
const toInferenceMessages = useCallback((messages: ConversationMessage[]): ChatMessage[] => {
return messages.map(msg => {
// Convert content blocks back to the inference format
if (msg.content.length === 1 && msg.content[0].type === 'text') {
// Simple text message
return {
role: msg.role,
content: msg.content[0].text,
};
}
// Complex message with tool calls/results - reconstruct the format
let content = '';
const toolCalls: ToolCall[] = [];
let toolCallId: string | undefined;
for (const block of msg.content) {
if (block.type === 'text') {
content += block.text;
} else if (block.type === 'tool_use') {
toolCalls.push({
id: block.id,
type: 'function',
function: {
name: block.name,
arguments: block.input,
},
});
} else if (block.type === 'tool_result') {
toolCallId = block.tool_use_id;
// For tool result messages, content comes from the tool result
if (typeof block.content === 'string') {
content = block.content;
} else if (Array.isArray(block.content)) {
content = block.content.map(c => c.text || c.error || '').join('');
}
}
}
return {
role: msg.role,
content: content || '',
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolCallId,
};
});
}, []);
// Helper: Create conversation message from inference response
const fromInferenceResponse = useCallback((response: ChatMessage): ConversationMessage => {
const content: any[] = [];
// Add text content if present
if (typeof response.content === 'string' && response.content) {
content.push({
type: 'text',
text: response.content,
});
}
// Add tool calls if present
if (response.toolCalls) {
for (const toolCall of response.toolCalls) {
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.function.name,
input: toolCall.function.arguments,
});
}
}
return {
id: uuidv4(),
role: response.role,
content,
timestamp: new Date(),
toolCalls: response.toolCalls,
toolCallId: response.toolCallId,
};
}, []);
// Helper: Execute a tool call
const executeTool = useCallback(async (toolCall: ToolCall): Promise<{ result: any; error?: string }> => {
try {
// Check if it's a test tool
const testTool = testTools.find(t => t.function.name === toolCall.function.name);
if (testTool) {
const result = await testTool.execute(toolCall.function.arguments);
return { result };
}
// Check if it's an MCP tool (prefixed with server name)
if (toolCall.function.name.includes('.')) {
// Extract server name from tool name (format: "server.tool_name")
const [serverName] = toolCall.function.name.split('.');
// Find the connection ID for this server name
const connection = connections.find(conn => conn.name === serverName);
if (connection) {
const result = await callMCPTool(connection.id, toolCall.function.name, toolCall.function.arguments);
return { result };
} else {
throw new Error(`MCP server "${serverName}" not found or not connected`);
}
}
throw new Error(`Unknown tool: ${toolCall.function.name}`);
} catch (error) {
return {
result: null,
error: error instanceof Error ? error.message : 'Tool execution failed',
};
}
}, [getAllTools, callMCPTool, connections]);
// Main agent loop execution - processes the conversation and generates responses with tool calls
const executeAgentLoop = useCallback(async (
conversation: Conversation,
onUpdate: (conversation: Conversation) => void
) => {
if (!currentProvider?.isAuthenticated) {
throw new Error('No authenticated inference provider available');
}
const conversationId = conversation.id;
// Initialize loop state
const abortController = new AbortController();
const loopState: AgentLoopState = {
isRunning: true,
conversationId,
currentStep: 'inference',
iteration: 0,
maxIterations: finalConfig.maxIterations,
abortController,
};
loopStates.current.set(conversationId, loopState);
try {
let currentConversation = { ...conversation };
for (let iteration = 0; iteration < finalConfig.maxIterations; iteration++) {
if (abortController.signal.aborted) {
break;
}
loopState.iteration = iteration;
loopState.currentStep = 'inference';
// Convert to inference format and add system message if needed
const inferenceMessages = toInferenceMessages(currentConversation.messages);
if (finalConfig.systemMessage && (inferenceMessages.length === 0 || inferenceMessages[0].role !== 'system')) {
inferenceMessages.unshift({
role: 'system',
content: finalConfig.systemMessage,
});
}
// Get available tools (test tools + MCP tools)
const allTools: Tool[] = [
...testTools.map(t => ({ type: t.type, function: t.function })),
...getAllTools(),
];
// Make inference request
const request: InferenceRequest = {
messages: inferenceMessages,
tools: allTools.length > 0 ? allTools : undefined,
temperature: finalConfig.temperature,
};
const response = await currentProvider.generateResponse(request);
if (abortController.signal.aborted) {
break;
}
// Add assistant response to conversation
const assistantMessage = fromInferenceResponse(response.message);
currentConversation = {
...currentConversation,
messages: [...currentConversation.messages, assistantMessage],
updatedAt: new Date(),
status: response.message.toolCalls ? 'calling_tools' : 'idle',
};
onUpdate(currentConversation);
// Check if we have tool calls to execute
if (!response.message.toolCalls || response.message.toolCalls.length === 0) {
// No tool calls, we're done
break;
}
// Execute tool calls
loopState.currentStep = 'tool_execution';
currentConversation.status = 'calling_tools';
onUpdate(currentConversation);
const toolResults: ToolResultBlock[] = [];
for (const toolCall of response.message.toolCalls) {
if (abortController.signal.aborted) {
break;
}
const { result, error } = await executeTool(toolCall);
const toolResult: ToolResultBlock = {
type: 'tool_result',
tool_use_id: toolCall.id,
content: error
? [{ type: 'error', error }]
: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
is_error: !!error,
};
toolResults.push(toolResult);
}
// Add tool results as separate messages
const toolResultMessages: ConversationMessage[] = toolResults.map(result => ({
id: uuidv4(),
role: 'tool' as const,
content: [result],
timestamp: new Date(),
toolCallId: result.tool_use_id,
}));
currentConversation = {
...currentConversation,
messages: [...currentConversation.messages, ...toolResultMessages],
status: 'thinking',
updatedAt: new Date(),
};
onUpdate(currentConversation);
// Continue the loop to process tool results
}
// Mark as complete
loopState.currentStep = 'complete';
loopState.isRunning = false;
if (currentConversation) {
currentConversation.status = 'idle';
onUpdate(currentConversation);
}
} catch (error) {
loopState.error = error instanceof Error ? error.message : 'Agent loop failed';
loopState.isRunning = false;
if (finalConfig.stopOnError) {
throw error;
}
} finally {
loopStates.current.delete(conversationId);
}
}, [currentProvider, finalConfig, toInferenceMessages, fromInferenceResponse, getAllTools, executeTool]);
const stopLoop = useCallback((conversationId: string) => {
const loopState = loopStates.current.get(conversationId);
if (loopState?.abortController) {
loopState.abortController.abort();
loopState.isRunning = false;
}
}, []);
const getLoopState = useCallback((conversationId: string): AgentLoopState | undefined => {
return loopStates.current.get(conversationId);
}, []);
return {
executeAgentLoop,
stopLoop,
getLoopState,
};
}

126
src/types/conversation.ts Normal file
View file

@ -0,0 +1,126 @@
// Conversation and agent loop types that extend existing inference types
import type { ChatMessage, ToolCall, Tool } from './inference';
// Extend ContentBlock to support tool use and tool result blocks
export interface ToolUseBlock {
type: 'tool_use';
id: string;
name: string;
input: Record<string, any>;
}
export interface ToolResultBlock {
type: 'tool_result';
tool_use_id: string;
content: string | { type: 'text' | 'image' | 'error'; text?: string; image?: string; error?: string }[];
is_error?: boolean;
}
export interface TextBlock {
type: 'text';
text: string;
}
// Union of all content block types
export type ConversationContentBlock = TextBlock | ToolUseBlock | ToolResultBlock;
// Extend ChatMessage for conversation persistence with structured content
export interface ConversationMessage extends Omit<ChatMessage, 'content'> {
id: string;
content: ConversationContentBlock[];
timestamp: Date;
}
export interface Conversation {
id: string;
title: string;
messages: ConversationMessage[];
createdAt: Date;
updatedAt: Date;
status: 'idle' | 'thinking' | 'calling_tools' | 'error';
error?: string;
// Track ongoing tool calls for UI display
pendingToolCalls?: {
id: string;
name: string;
input: Record<string, any>;
startedAt: Date;
}[];
}
export interface AgentLoopState {
isRunning: boolean;
conversationId: string;
currentStep: 'inference' | 'tool_execution' | 'complete';
iteration: number;
maxIterations: number;
error?: string;
// AbortController for stopping the loop
abortController?: AbortController;
}
export interface ConversationContextValue {
// Conversation management
conversations: Conversation[];
activeConversationId?: string;
agentLoopStates: Map<string, AgentLoopState>;
// Conversation operations
createConversation: (title?: string) => string; // Returns conversation ID
deleteConversation: (conversationId: string) => void;
setActiveConversation: (conversationId: string) => void;
updateConversationTitle: (conversationId: string, title: string) => void;
getConversation: (conversationId: string) => Conversation | undefined;
// Message operations
addUserMessage: (conversationId: string, content: string) => void;
// Agent loop operations
sendMessage: (conversationId: string, content: string) => Promise<void>;
continueConversation: (conversationId: string) => Promise<void>; // Continue after tool calls or follow-ups
stopAgentLoop: (conversationId: string) => void;
getAgentLoopState: (conversationId: string) => AgentLoopState | undefined;
}
export interface AgentLoopConfig {
maxIterations: number;
systemMessage?: string;
temperature?: number;
stopOnError: boolean;
}
// Test tools that can be used alongside MCP tools
export interface TestTool extends Tool {
execute: (args: Record<string, any>) => Promise<any>;
}
// Hook interface for the agent loop
export interface UseAgentLoopReturn {
executeAgentLoop: (
conversation: Conversation,
onUpdate: (conversation: Conversation) => void
) => Promise<void>;
stopLoop: (conversationId: string) => void;
getLoopState: (conversationId: string) => AgentLoopState | undefined;
}
export type AgentLoopHook = (config?: Partial<AgentLoopConfig>) => UseAgentLoopReturn;
// Helper functions for converting between formats
export interface ConversationHelpers {
// Convert conversation messages to inference messages
toInferenceMessages: (messages: ConversationMessage[]) => ChatMessage[];
// Convert inference response to conversation message
fromInferenceResponse: (response: ChatMessage) => ConversationMessage;
// Create tool use blocks from tool calls
createToolUseBlocks: (toolCalls: ToolCall[]) => ToolUseBlock[];
// Create tool result blocks from tool execution
createToolResultBlocks: (results: { toolCallId: string; result: any; error?: string }[]) => ToolResultBlock[];
// Generate conversation title from first message
generateConversationTitle: (firstMessage: string) => string;
}