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
12 changed files with 1799 additions and 1 deletions
14
src/App.tsx
14
src/App.tsx
|
|
@ -3,6 +3,7 @@ import { InferenceProvider } from '@/contexts/InferenceContext'
|
|||
import { MCPProvider } from '@/contexts/MCPContext'
|
||||
import { InferenceTest } from '@/components/InferenceTest'
|
||||
import { MCPTest } from '@/components/MCPTest'
|
||||
import { ConversationApp } from '@/components/ConversationApp'
|
||||
import { OAuthCallback } from '@/components/OAuthCallback'
|
||||
|
||||
function App() {
|
||||
|
|
@ -19,7 +20,7 @@ function App() {
|
|||
return <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>
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
291
src/contexts/ConversationContext.tsx
Normal file
291
src/contexts/ConversationContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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
419
src/hooks/useAgentLoop.ts
Normal 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
126
src/types/conversation.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue