diff --git a/README.md b/README.md index 53bf29f..947daf2 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,105 @@ SolidJS chat components for AG-UI protocol integration with PydanticAI. [![npm version](https://badge.fury.io/js/@livefire2015%2Fsolid-ag-chat.svg)](https://www.npmjs.com/package/@livefire2015/solid-ag-chat) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +## ChatInterface Props Reference (v0.4.1+) + +### Core Props +```tsx +interface ChatInterfaceProps { + apiUrl?: string; // Backward compatibility only + mode?: 'local' | 'remote' | 'controlled'; + config?: Partial; + onEvents?: Partial; +} +``` + +### Config Object +```tsx +interface ChatConfig { + // API & Storage Configuration + apiConfig?: { + baseUrl: string; + headers?: Record; + endpoints?: { + streamMessage?: string; // '/agent/stream' + getConversations?: string; // '/api/chat/conversations' + getConversation?: string; // '/api/chat/c/{conversationId}' + createConversation?: string; // '/api/chat/conversations' + createConversationWithMessage?: string; // '/api/chat/conversations/with-message' + updateConversation?: string; // '/api/chat/c/{conversationId}' + deleteConversation?: string; // '/api/chat/c/{conversationId}' + generateTitle?: string; // '/api/chat/c/{conversationId}/generate-title' + getMessages?: string; // '/api/chat/c/{conversationId}/messages' + sendMessage?: string; // '/api/chat/c/{conversationId}/messages' + }; + }; + + storageConfig?: ApiConfig; // Falls back to apiConfig if not provided + + // Services (for dependency injection) + chatService?: ChatService; + storageAdapter?: StorageAdapter; + + // Conversation behavior + conversationId?: string; + autoTitle?: boolean; + createOnFirstMessage?: boolean; + + // UI Configuration + title?: string; + description?: string; + userName?: string; + suggestions?: SuggestionItem[]; + showSidebar?: boolean; + disclaimerText?: string; + + // Controlled mode data + conversations?: ConversationSummary[]; + currentConversationId?: string; +} +``` + +### Event Handlers +```tsx +interface ChatEventHandlers { + // Status monitoring + onStatusChange?: (status: ServiceStatus) => void; + + // Navigation + onNewConversation?: () => void; + + // Conversation lifecycle (for controlled mode) + onConversationCreate?: (data: Partial) => Promise; + onConversationSelect?: (id: string) => void; + onConversationUpdate?: (id: string, updates: Partial) => Promise; + onConversationDelete?: (id: string) => Promise; +} +``` + +### Supporting Types +```tsx +interface SuggestionItem { + id: string; + icon?: string; + category: string; + title: string; + description: string; +} + +interface ServiceStatus { + loading: boolean; + error?: string; + lastUpdated?: string; + details?: Record; +} +``` + +### Usage Modes + +- **`local`**: Data stored in browser localStorage, no backend required +- **`remote`**: Full backend integration with conversation persistence +- **`controlled`**: External state management, you control all data operations + ## Features - 🚀 Built with SolidJS for reactive, performant UI @@ -46,7 +145,92 @@ function App() { } ``` -### New Chat Homepage (v0.3.1+) +### Simple Configuration (v0.4.1+) + +```tsx +import { ChatInterface } from '@livefire2015/solid-ag-chat'; + +function App() { + return ( + showToast(status.error), + onNewConversation: () => navigate('/chat') + }} + /> + ); +} +``` + +### Local Chat (v0.4.1+) + +```tsx +function LocalChatPage() { + return ( + + ); +} +``` + +### Controlled Mode (v0.4.1+) + +```tsx +function ControlledChatPage() { + const [conversations, setConversations] = createSignal([]); + const [currentId, setCurrentId] = createSignal(null); + + return ( + { + const response = await fetch('/api/conversations', { + method: 'POST', + body: JSON.stringify(data) + }); + const conv = await response.json(); + setConversations([conv, ...conversations()]); + setCurrentId(conv.id); + return conv.id; + }, + onConversationSelect: (id) => { + setCurrentId(id); + navigate(`/chat/${id}`); + } + }} + /> + ); +} +``` + +### New Chat Homepage (v0.4.1+) For a seamless new chat experience where conversations are created on first message: @@ -56,23 +240,25 @@ import { ChatInterface } from '@livefire2015/solid-ag-chat'; function HomePage() { return ( ); } ``` -### With Suggestions and Empty State (v0.3.2+) +### With Suggestions and Empty State (v0.4.1+) Create an engaging welcome experience with suggestion cards: @@ -106,17 +292,21 @@ function ChatPage() { return ( ); } ``` -### New Chat Mode (v0.3.5+) +### New Chat Mode (v0.4.1+) For new chat interfaces that don't need to load existing conversations: @@ -126,19 +316,18 @@ import { ChatInterface } from '@livefire2015/solid-ag-chat'; function NewChatPage() { return ( ); } ``` -### With Routing +### With Routing (v0.4.1+) Using SolidJS Router to handle conversation URLs: @@ -150,13 +339,16 @@ function ChatPage() { return ( @@ -164,7 +356,7 @@ function ChatPage() { } ``` -### With Custom New Conversation Handler (v0.3.7+) +### With Custom New Conversation Handler (v0.4.1+) For routing-based navigation without API calls when creating new conversations: @@ -176,15 +368,72 @@ function ChatPage() { return ( { - // Navigate to new chat page without making API calls - navigate('/chat'); + mode="remote" + config={{ + apiConfig: { + baseUrl: 'http://localhost:3001', + endpoints: { + streamMessage: '/api/chat/c/{conversationId}/stream' + } + } + }} + onEvents={{ + onNewConversation: () => { + // Navigate to new chat page without making API calls + navigate('/chat'); + } + }} + /> + ); +} +``` + +### With Dependency Injection (v0.4.1+) + +Inject pre-configured services for custom auth, retries, or caching: + +```tsx +import { ChatInterface } from '@livefire2015/solid-ag-chat'; +import { createAGUIService } from '@livefire2015/solid-ag-chat'; +import { createRemoteStorageAdapter } from '@livefire2015/solid-ag-chat'; + +function ProductionChatPage() { + // Create auth-aware chat service + const chatService = createAGUIService({ + baseUrl: 'https://api.myapp.com', + headers: { + 'Authorization': `Bearer ${getAuthToken()}`, + 'X-App-Version': '1.0.0' + }, + endpoints: { + streamMessage: '/chat/stream' + } + }); + + // Create persistent storage adapter with custom config + const storageAdapter = createRemoteStorageAdapter({ + baseUrl: 'https://api.myapp.com', + headers: { + 'Authorization': `Bearer ${getAuthToken()}` + }, + endpoints: { + getConversations: '/conversations', + getConversation: '/conversations/{conversationId}', + createConversationWithMessage: '/conversations/create' + } + }); + + return ( + { + if (status.error) { + showNotification(`Chat error: ${status.error}`); + } } }} /> @@ -192,6 +441,40 @@ function ChatPage() { } ``` +### With Split API Configuration (v0.4.1+) + +Use different hosts for streaming vs CRUD operations: + +```tsx +function MultiHostChatPage() { + return ( + + ); +} +``` + + ## Components ### ChatInterface @@ -204,23 +487,69 @@ import { ChatInterface } from '@livefire2015/solid-ag-chat'; ``` -**Props:** -- `apiUrl` (optional, deprecated): The API endpoint for the AG-UI stream. Use `apiConfig` instead -- `apiConfig` (optional): API configuration object with `baseUrl` and custom `endpoints` -- `storageMode` (optional): Storage mode - `'local'` | `'remote'` | `'hybrid'`. Defaults to `'local'` -- `conversationId` (optional): Specific conversation ID to load (useful for routing) -- `newChatMode` (optional): Enable new chat mode for homepage (v0.3.1+). Defaults to `false` -- `autoGenerateTitle` (optional): Automatically generate conversation titles after assistant response (v0.3.1+). Defaults to `true` -- `createConversationOnFirstMessage` (optional): Create conversation on first message send (v0.3.1+). Defaults to `false` -- `title` (optional): Chat interface title. Defaults to `"Nova Chat"` -- `description` (optional): Chat interface description. Defaults to `"Let language become the interface"` -- `userName` (optional): User name displayed in empty state (v0.3.2+) -- `suggestions` (optional): Array of suggestion items for empty state (v0.3.2+) -- `showEmptyState` (optional): Whether to show empty state with suggestions. Defaults to `true` -- `disclaimerText` (optional): Custom disclaimer text in footer (v0.3.2+) -- `loadConversationsOnMount` (optional): Whether to load conversations on component mount. Defaults to `true` (v0.3.5+) -- `showSidebar` (optional): Whether to show the conversation sidebar. Defaults to `true` (v0.3.5+) -- `onNewConversation` (optional): Custom handler for new conversation creation, useful for routing without API calls (v0.3.7+) +**Props (v0.4.1+):** + +```tsx +interface ChatInterfaceProps { + apiUrl?: string; // Backward compatibility only + mode?: 'local' | 'remote' | 'controlled'; + config?: Partial; + onEvents?: Partial; +} +``` + +**Core Props:** +- `apiUrl` (optional, backward compatibility): Direct API endpoint (deprecated, use `config.apiConfig` instead) +- `mode` (optional): Chat mode - `'local'` | `'remote'` | `'controlled'`. Auto-detected if not specified +- `config` (optional): Configuration object containing all chat settings +- `onEvents` (optional): Event handlers object containing all callbacks + +**ChatConfig Options:** +```tsx +interface ChatConfig { + // API & Storage + apiConfig?: ApiConfig; + storageConfig?: ApiConfig; // Falls back to apiConfig if not provided + + // Services (for dependency injection) + chatService?: ChatService; + storageAdapter?: StorageAdapter; + + // Conversation behavior + conversationId?: string; + autoTitle?: boolean; + createOnFirstMessage?: boolean; + + // UI + title?: string; + description?: string; + userName?: string; + suggestions?: SuggestionItem[]; + showSidebar?: boolean; + disclaimerText?: string; + + // Controlled mode data + conversations?: ConversationSummary[]; + currentConversationId?: string; +} +``` + +**ChatEventHandlers Options:** +```tsx +interface ChatEventHandlers { + // Status + onStatusChange?: (status: ServiceStatus) => void; + + // Navigation + onNewConversation?: () => void; + + // Conversation lifecycle (for controlled mode) + onConversationCreate?: (data: Partial) => Promise; + onConversationSelect?: (id: string) => void; + onConversationUpdate?: (id: string, updates: Partial) => Promise; + onConversationDelete?: (id: string) => Promise; +} +``` ### MessageList @@ -378,7 +707,7 @@ const endpoints = { ## AG-UI Protocol -This package implements the AG-UI protocol for streaming agent interactions. The protocol supports: +This package implements the AG-UI protocol for streaming agent interactions using official `@ag-ui/core` types (v0.5.0+). The protocol supports: - **TEXT_MESSAGE_START/END**: Message lifecycle events - **TEXT_MESSAGE_CONTENT**: Full message updates @@ -388,6 +717,12 @@ This package implements the AG-UI protocol for streaming agent interactions. The - **TOOL_CALL_START/ARGS/END/RESULT**: Tool execution events - **ERROR**: Error handling +**Official AG-UI Integration (v0.5.0+):** +- Uses official `EventType` enum from `@ag-ui/core` +- Runtime validation with official `EventSchemas` (when available) +- Full compatibility with the AG-UI ecosystem +- Enhanced type safety and IntelliSense support + ## Types All TypeScript types are exported for use in your application: @@ -419,11 +754,13 @@ The library can automatically generate conversation titles after the assistant's ```tsx @@ -435,8 +772,9 @@ Perfect for homepage/landing page where you want the conversation to be created ```tsx ``` @@ -468,7 +806,37 @@ npm run dev ## Changelog -### v0.3.7 (Latest) +### v0.4.1 (Latest) - Dramatic API Simplification 🎯 +**BREAKING CHANGES - Simplified API:** +- 🔥 **30+ props → 4 props**: Eliminated "prop soup" for cleaner API +- 📦 **Consolidated Configuration**: All options now in single `config` object +- 🎯 **Event Handlers**: All callbacks consolidated into `onEvents` object +- 🧭 **Smart Mode Detection**: Automatic mode detection based on configuration +- 🔄 **Cleaner Separation**: Clear distinction between storage location and state control + +**New Clean API:** +```tsx +interface ChatInterfaceProps { + apiUrl?: string; // Backward compatibility only + mode?: 'local' | 'remote' | 'controlled'; + config?: Partial; + onEvents?: Partial; +} +``` + +### v0.4.0 - Major Architecture Refactor 🚀 +**Breaking Changes & Major Improvements:** +- 🏗️ **Dependency Injection**: Inject pre-configured `chatService`, `storageManager`, `storageAdapter` +- 🔄 **Memoized Storage**: Fixed cache-wiping issue in remote storage adapters +- 🎛️ **Controlled Mode**: Full external control over conversation lifecycle +- 📡 **Split API Configs**: Separate `chatApiConfig` and `storageApiConfig` for different concerns +- 📊 **Enhanced Loading States**: Proper loading propagation to all UI components +- 🔔 **Status Callbacks**: `onStatusChange`, `onChatStatusChange`, `onStorageStatusChange` +- 🧭 **Production Ready**: Designed for real-world remote API integrations + +**Migration Guide:** See [Migration Guides](#migration-guides) below. + +### v0.3.7 - ✨ Added onNewConversation prop for custom new conversation handling - 🧭 Enables pure frontend navigation without API calls for new chat creation - 🔄 Maintains backward compatibility with default conversation creation behavior @@ -516,6 +884,129 @@ npm run dev - AG-UI protocol implementation - Local storage support +## Migration Guides + +### v0.5.0 Migration Guide - @ag-ui/core Integration + +**NEW**: v0.5.0 integrates with the official `@ag-ui/core` TypeScript types and events for improved standardization and future compatibility. + +**What Changed:** +- Added `@ag-ui/core` dependency for official AG-UI protocol types +- Created compatibility layer for gradual migration +- Enhanced runtime validation with official EventSchemas (when available) +- Improved type safety and IntelliSense support + +**Benefits:** +- **Standardization**: Uses official AG-UI protocol types +- **Future-Proofing**: Seamless transition when `@ag-ui/core` API stabilizes +- **Type Safety**: Enhanced TypeScript support with official interfaces +- **Validation**: Runtime event validation with official schemas +- **Backward Compatibility**: All existing code continues to work + +**No Breaking Changes:** +All v0.4.x code continues to work unchanged. The migration is fully backward compatible. + +**Enhanced Features:** +```tsx +// Official AG-UI types are now used internally +import type { + EventType, // Official AG-UI event enum + AGUIMessage, // Official message interface + AGUIEvent, // Official event union type + EnhancedAGUIMessage, // Extended with solid-ag-chat features +} from '@livefire2015/solid-ag-chat'; + +// Runtime validation automatically uses official schemas when available +const chatService = createAGUIService({ + baseUrl: 'http://localhost:8000', + // EventSchemas validation is applied automatically +}); +``` + +**Migration Benefits:** +- **Immediate**: Better TypeScript IntelliSense and type checking +- **Future**: Automatic compatibility with official AG-UI ecosystem +- **Performance**: Optimized event handling with official types +- **Validation**: Runtime safety with official event schemas + +### v0.4.1 Migration Guide - Dramatic API Simplification + +**BREAKING CHANGES**: v0.4.1 dramatically simplifies the API from 30+ props to just 4 props. + +**Before (v0.4.0 and earlier) - "Prop Soup":** +```tsx + navigate('/chat')} + onStatusChange={(status) => showToast(status.error)} + chatApiConfig={{ + baseUrl: 'https://api.myapp.com', + headers: { 'Authorization': `Bearer ${token}` } + }} + storageApiConfig={{ + baseUrl: 'https://storage.myapp.com' + }} +/> +``` + +**After (v0.4.1) - Clean & Simple:** +```tsx + navigate('/chat'), + onStatusChange: (status) => showToast(status.error) + }} +/> +``` + +**Key Changes:** +1. **30+ props → 4 props**: `mode`, `config`, `onEvents`, `apiUrl` (legacy) +2. **Smart Mode Detection**: Auto-detects mode based on configuration +3. **Consolidated Config**: All settings in single `config` object +4. **Event Consolidation**: All callbacks in single `onEvents` object +5. **Backward Compatibility**: `apiUrl` prop still works + +### v0.4.0 Migration Guide - Architecture Refactor + +**Storage Adapter Improvements:** +- Storage adapters are now memoized and persistent across component lifecycle +- Fixes cache-wiping issues that caused duplicate API calls + +**Enhanced Loading States:** +- Loading states now properly propagate to all UI components + +**New Features:** +- Dependency injection for `chatService` and `storageAdapter` +- Split API configuration for different concerns +- Controlled mode for external state management +- Enhanced status callbacks + +**Backward Compatibility:** +All v0.3.x code continues to work with deprecation warnings. + ## License MIT diff --git a/package.json b/package.json index 49de3b5..58234d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@livefire2015/solid-ag-chat", - "version": "0.3.7", + "version": "0.4.1", "description": "SolidJS chat components for AG-UI protocol integration with PydanticAI", "type": "module", "main": "./dist/index.cjs", @@ -57,6 +57,7 @@ "url": "git+https://github.com/livefire2015/solid-ag-chat.git" }, "dependencies": { + "@ag-ui/core": "^0.1.0", "fast-json-patch": "^3.1.1" } } diff --git a/src/components/ChatInterface.tsx b/src/components/ChatInterface.tsx index 5dde036..d815e44 100644 --- a/src/components/ChatInterface.tsx +++ b/src/components/ChatInterface.tsx @@ -2,7 +2,14 @@ import { Component, Show, createSignal, onMount, createMemo } from 'solid-js'; import { createAGUIService } from '../services/agui-service'; import { createConversationStore } from '../stores/conversation-store'; import { StorageManager, createLocalStorageAdapter, createRemoteStorageAdapter } from '../services/storage'; -import type { ApiConfig, StorageMode } from '../services/types'; +import type { + ApiConfig, + ChatService, + ChatConfig, + ChatEventHandlers, + ChatMode, + ServiceStatus +} from '../services/types'; import MessageList from './MessageList'; import MessageInput, { type MessageInputHandle } from './MessageInput'; import StatePanel from './StatePanel'; @@ -11,67 +18,156 @@ import ThemeProvider, { ThemeToggle } from './ThemeProvider'; import EmptyState from './EmptyState'; interface ChatInterfaceProps { - /** @deprecated Use apiConfig instead */ + // Backward compatibility (keep only this) apiUrl?: string; - apiConfig?: ApiConfig; - storageMode?: StorageMode; - conversationId?: string; - autoGenerateTitle?: boolean; - createConversationOnFirstMessage?: boolean; - newChatMode?: boolean; - title?: string; - description?: string; - userName?: string; - suggestions?: import('../services/types').SuggestionItem[]; - showEmptyState?: boolean; - disclaimerText?: string; - loadConversationsOnMount?: boolean; - showSidebar?: boolean; - onNewConversation?: () => void; + + // Core mode selection + mode?: ChatMode; + + // Single configuration object with smart defaults + config?: Partial; + + // Single event handler object + onEvents?: Partial; } const ChatInterface: Component = (props) => { - // Create API config from props (backward compatibility) - const apiConfig: ApiConfig = props.apiConfig || (props.apiUrl ? { - endpoints: { - streamMessage: props.apiUrl + // Determine mode with smart defaults + const mode = createMemo((): ChatMode => { + if (props.mode) return props.mode; + + // Auto-detect based on configuration + if (props.config?.conversations || props.onEvents?.onConversationCreate) { + return 'controlled'; } - } : { - baseUrl: 'http://localhost:8000', - endpoints: { - streamMessage: '/agent/stream' + + if (props.config?.apiConfig || props.config?.storageConfig) { + return 'remote'; } + + return 'local'; // Default + }); + + // Detect controlled mode (controlled mode implies external state management) + const isControlled = createMemo(() => mode() === 'controlled'); + + // API configuration with smart defaults + const apiConfig = createMemo((): ApiConfig => { + // Check config object first + if (props.config?.apiConfig) return props.config.apiConfig; + + // Backward compatibility with apiUrl + if (props.apiUrl) return { + endpoints: { streamMessage: props.apiUrl } + }; + + // Default configuration + return { + baseUrl: 'http://localhost:8000', + endpoints: { streamMessage: '/agent/stream' } + }; + }); + + const storageConfig = createMemo((): ApiConfig => { + // Use dedicated storage config if provided + if (props.config?.storageConfig) return props.config.storageConfig; + + // Fall back to main API config + return apiConfig(); }); - // Create storage adapter based on mode - const createStorageAdapter = () => { - const mode = props.storageMode || 'local'; - switch (mode) { + // Memoized storage adapter creation (fixes cache-wiping issue) + const storageAdapter = createMemo(() => { + // Use injected adapter if provided + if (props.config?.storageAdapter) { + return props.config.storageAdapter; + } + + const currentMode = mode(); + switch (currentMode) { case 'remote': - return createRemoteStorageAdapter(apiConfig); - case 'hybrid': - // For now, hybrid mode falls back to local - console.warn('Hybrid storage mode not yet implemented, using local storage'); - return createLocalStorageAdapter(); + case 'controlled': // Controlled mode typically uses remote storage + return createRemoteStorageAdapter(storageConfig()); case 'local': default: return createLocalStorageAdapter(); } - }; + }); + + // Dependency injection for services + const chatService = createMemo(() => { + if (props.config?.chatService) { + return props.config.chatService; + } + return createAGUIService(apiConfig()); + }); + + const storageManager = createMemo(() => { + return new StorageManager(storageAdapter()); + }); + + // Conversation store creation (only for uncontrolled mode) + const shouldAutoLoad = createMemo(() => { + if (isControlled()) return false; + // Auto-load unless explicitly creating on first message + return !props.config?.createOnFirstMessage; + }); + + const conversationStore = createMemo(() => { + if (isControlled()) { + // Return a minimal store interface for controlled mode + return null; + } + return createConversationStore(storageManager(), shouldAutoLoad()); + }); - const chatService = createAGUIService(apiConfig); - const storageManager = new StorageManager(createStorageAdapter()); - // Only auto-load conversations if not in new chat mode and loadConversationsOnMount is not false - const shouldAutoLoad = props.loadConversationsOnMount !== false && !props.newChatMode && !props.createConversationOnFirstMessage; - const conversationStore = createConversationStore(storageManager, shouldAutoLoad); const [showConversations, setShowConversations] = createSignal(false); - // Only access conversations when sidebar should be shown + // Status and loading state management + const [serviceStatus, setServiceStatus] = createSignal({ + loading: false, + error: undefined + }); + + // Status change callbacks + const handleStatusChange = (status: ServiceStatus) => { + setServiceStatus(status); + props.onEvents?.onStatusChange?.(status); + }; + + // Combined loading state for UI + const isLoading = createMemo(() => { + if (isControlled()) { + // In controlled mode, no internal loading state + return false; + } + const store = conversationStore(); + const chat = chatService(); + return serviceStatus().loading || (store?.isLoading?.() ?? false) || (chat?.isLoading?.() ?? false); + }); + + // Combined error state for UI + const errorState = createMemo(() => { + if (isControlled()) { + return null; + } + const store = conversationStore(); + const chat = chatService(); + return serviceStatus().error || store?.error?.() || chat?.error?.() || null; + }); + + // Conversations list (controlled vs uncontrolled) const sidebarConversations = createMemo(() => { - if (showConversations() && props.showSidebar !== false) { - return conversationStore.conversations(); + if (!showConversations() || props.config?.showSidebar === false) { + return []; + } + + if (isControlled()) { + return props.config?.conversations || []; } - return []; + + const store = conversationStore(); + return store ? store.conversations() : []; }); // Reference to MessageInput for programmatic control @@ -86,150 +182,284 @@ const ChatInterface: Component = (props) => { // Auto-title generation callback const handleAutoTitleGeneration = async (conversationId: string) => { - if (!props.autoGenerateTitle) return; - - const conversation = conversationStore.currentConversation(); - if (!conversation) return; - - // Only generate title for conversations with generic titles - const genericTitles = ['New Chat', 'Welcome Chat', 'Chat', 'Conversation']; - const isGenericTitle = genericTitles.some(generic => - conversation.title.includes(generic) || conversation.title.match(/^Chat \d+$/) - ); - - if (isGenericTitle && props.storageMode === 'remote') { - try { - const storageAdapter = createStorageAdapter(); - if ('generateTitle' in storageAdapter) { - const newTitle = await (storageAdapter as any).generateTitle(conversationId); + if (!props.config?.autoTitle) return; + + try { + handleStatusChange({ loading: true }); + + if (isControlled()) { + // In controlled mode, delegate to parent + if (props.onEvents?.onConversationUpdate) { + // Generate title via storage adapter + const adapter = storageAdapter(); + if ('generateTitle' in adapter) { + const newTitle = await (adapter as any).generateTitle(conversationId); + if (newTitle) { + await props.onEvents.onConversationUpdate(conversationId, { title: newTitle }); + } + } + } + return; + } + + // Uncontrolled mode + const store = conversationStore(); + if (!store) return; + + const conversation = store.currentConversation(); + if (!conversation) return; + + // Only generate title for conversations with generic titles + const genericTitles = ['New Chat', 'Welcome Chat', 'Chat', 'Conversation']; + const isGenericTitle = genericTitles.some(generic => + conversation.title.includes(generic) || conversation.title.match(/^Chat \d+$/) + ); + + if (isGenericTitle && mode() === 'remote') { + const adapter = storageAdapter(); + if ('generateTitle' in adapter) { + const newTitle = await (adapter as any).generateTitle(conversationId); if (newTitle) { - await conversationStore.updateConversation(conversationId, { title: newTitle }); + await store.updateConversation(conversationId, { title: newTitle }); } } - } catch (error) { - console.error('Failed to generate title:', error); } + } catch (error) { + console.error('Failed to generate title:', error); + handleStatusChange({ loading: false, error: error instanceof Error ? error.message : 'Title generation failed' }); + } finally { + handleStatusChange({ loading: false }); } }; // Initialize conversation based on conversationId prop and mode onMount(async () => { - // Set up auto-title callback if enabled - if (props.autoGenerateTitle !== false) { - chatService.setAutoTitleCallback(handleAutoTitleGeneration); - } + try { + handleStatusChange({ loading: true }); - // Only load conversations if explicitly requested (defaults to true for backward compatibility) - const shouldLoadConversations = props.loadConversationsOnMount !== false; + // Set up auto-title callback if enabled + const chat = chatService(); + if (props.config?.autoTitle !== false && chat.setAutoTitleCallback) { + chat.setAutoTitleCallback(handleAutoTitleGeneration); + } - if (props.conversationId) { - // Load specific conversation from URL - await conversationStore.loadConversation(props.conversationId); - const conversation = conversationStore.currentConversation(); - if (conversation) { - chatService.loadMessages(conversation.messages); - } else { - // Conversation ID in URL doesn't exist, create a new one with that ID - console.warn(`Conversation ${props.conversationId} not found, creating new conversation`); - await conversationStore.createConversation('New Chat', props.conversationId); - await conversationStore.loadConversation(props.conversationId); + // In controlled mode, delegate initialization to parent + if (isControlled()) { + if (props.config?.conversationId && props.onEvents?.onConversationSelect) { + await props.onEvents.onConversationSelect(props.config.conversationId); + } + return; } - } else if (props.newChatMode || props.createConversationOnFirstMessage) { - // New chat mode - don't create conversation yet, wait for first message - console.log('New chat mode - conversation will be created on first message'); - // Skip loading conversations list for new chat mode unless explicitly requested - return; - } else if (shouldLoadConversations) { - // Traditional mode - create default conversation if none exist - if (conversationStore.conversations().length === 0) { - const newConversationId = await conversationStore.createConversation('Welcome Chat'); - await conversationStore.loadConversation(newConversationId); - } else { - // Load the most recent conversation - const conversations = conversationStore.conversations(); - await conversationStore.loadConversation(conversations[0].id); - const conversation = conversationStore.currentConversation(); + + // Uncontrolled mode initialization + const store = conversationStore(); + if (!store) return; + + if (props.config?.conversationId) { + // Load specific conversation from URL + await store.loadConversation(props.config.conversationId); + const conversation = store.currentConversation(); if (conversation) { - chatService.loadMessages(conversation.messages); + chat.loadMessages(conversation.messages); + } else { + // Conversation ID in URL doesn't exist, create a new one with that ID + console.warn(`Conversation ${props.config.conversationId} not found, creating new conversation`); + await store.createConversation('New Chat', props.config.conversationId); + await store.loadConversation(props.config.conversationId); + } + } else if (props.config?.createOnFirstMessage) { + // New chat mode - don't create conversation yet, wait for first message + console.log('New chat mode - conversation will be created on first message'); + return; + } else { + // Traditional mode - create default conversation if none exist + if (store.conversations().length === 0) { + const newConversationId = await store.createConversation('Welcome Chat'); + await store.loadConversation(newConversationId); + } else { + // Load the most recent conversation + const conversations = store.conversations(); + await store.loadConversation(conversations[0].id); + const conversation = store.currentConversation(); + if (conversation) { + chat.loadMessages(conversation.messages); + } } } + } catch (error) { + console.error('Failed to initialize chat:', error); + handleStatusChange({ + loading: false, + error: error instanceof Error ? error.message : 'Initialization failed' + }); + } finally { + handleStatusChange({ loading: false }); } }); const handleNewConversation = async () => { - // If external handler is provided, use it instead of internal logic - if (props.onNewConversation) { - props.onNewConversation(); - return; - } + try { + // If external handler is provided, use it instead of internal logic + if (props.onEvents?.onNewConversation) { + props.onEvents.onNewConversation(); + return; + } - // Default behavior for backward compatibility - const title = `Chat ${conversationStore.conversations().length + 1}`; - const newConversationId = await conversationStore.createConversation(title); - await conversationStore.loadConversation(newConversationId); - await conversationStore.loadConversations(); // Refresh list for remote storage - chatService.clearMessages(); + // Controlled mode - delegate to parent + if (isControlled() && props.onEvents?.onConversationCreate) { + const conversations = props.config?.conversations || []; + const title = `Chat ${conversations.length + 1}`; + await props.onEvents.onConversationCreate({ title }); + return; + } + + // Uncontrolled mode - default behavior for backward compatibility + const store = conversationStore(); + if (!store) return; + + handleStatusChange({ loading: true }); + const title = `Chat ${store.conversations().length + 1}`; + const newConversationId = await store.createConversation(title); + await store.loadConversation(newConversationId); + await store.loadConversations(); // Refresh list for remote storage + chatService().clearMessages(); + } catch (error) { + console.error('Failed to create new conversation:', error); + handleStatusChange({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to create conversation' + }); + } finally { + handleStatusChange({ loading: false }); + } }; const handleConversationSelect = async (conversationId: string) => { - await conversationStore.loadConversation(conversationId); - const conversation = conversationStore.currentConversation(); - if (conversation) { - chatService.loadMessages(conversation.messages); + try { + // Controlled mode - delegate to parent + if (isControlled()) { + if (props.onEvents?.onConversationSelect) { + await props.onEvents.onConversationSelect(conversationId); + } + setShowConversations(false); + return; + } + + // Uncontrolled mode + const store = conversationStore(); + if (!store) return; + + handleStatusChange({ loading: true }); + await store.loadConversation(conversationId); + const conversation = store.currentConversation(); + if (conversation) { + chatService().loadMessages(conversation.messages); + } + setShowConversations(false); + } catch (error) { + console.error('Failed to select conversation:', error); + handleStatusChange({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to load conversation' + }); + } finally { + handleStatusChange({ loading: false }); } - setShowConversations(false); }; const handleSendMessage = async (content: string, files?: any[]) => { - let currentConv = conversationStore.currentConversation(); - let conversationId = currentConv?.id || props.conversationId; - - // Lazy conversation creation for new chat mode - if (!currentConv && (props.newChatMode || props.createConversationOnFirstMessage)) { - try { - if (props.storageMode === 'remote') { - // Use createConversationWithMessage for remote storage - const storageAdapter = createStorageAdapter(); - if ('createConversationWithMessage' in storageAdapter) { - conversationId = await (storageAdapter as any).createConversationWithMessage( - 'New Chat', - content, - files - ); + try { + handleStatusChange({ loading: true }); + + // Controlled mode - delegate message sending to parent or use current conversation ID + if (isControlled()) { + const conversationId = props.config?.currentConversationId || props.config?.conversationId; + if (!conversationId) { + // Create new conversation in controlled mode if needed + if (props.onEvents?.onConversationCreate) { + const newConversationId = await props.onEvents.onConversationCreate({ title: 'New Chat' }); + await chatService().sendMessage(content, files, newConversationId); + return; } else { - // Fallback to regular creation - conversationId = await conversationStore.createConversation('New Chat'); + console.error('No conversation ID available and no onConversationCreate handler in controlled mode'); + return; } - } else { - // For local storage, create conversation normally - conversationId = await conversationStore.createConversation('New Chat'); } + await chatService().sendMessage(content, files, conversationId); + return; + } + + // Uncontrolled mode + const store = conversationStore(); + if (!store) return; + + let currentConv = store.currentConversation(); + let conversationId = currentConv?.id || props.config?.conversationId; - if (conversationId) { - await conversationStore.loadConversation(conversationId); - currentConv = conversationStore.currentConversation(); - // Refresh conversation list for remote storage - await conversationStore.loadConversations(); + // Lazy conversation creation for new chat mode + if (!currentConv && props.config?.createOnFirstMessage) { + try { + const currentMode = mode(); + if (currentMode === 'remote' || currentMode === 'controlled') { + // Use createConversationWithMessage for remote storage + const adapter = storageAdapter(); + if ('createConversationWithMessage' in adapter) { + conversationId = await (adapter as any).createConversationWithMessage( + 'New Chat', + content, + files + ); + } else { + // Fallback to regular creation + conversationId = await store.createConversation('New Chat'); + } + } else { + // For local storage, create conversation normally + conversationId = await store.createConversation('New Chat'); + } + + if (conversationId) { + await store.loadConversation(conversationId); + currentConv = store.currentConversation(); + // Refresh conversation list for remote storage + await store.loadConversations(); + } + } catch (error) { + console.error('Failed to create conversation:', error); + handleStatusChange({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to create conversation' + }); + return; } - } catch (error) { - console.error('Failed to create conversation:', error); - return; } - } - if (!conversationId) { - console.error('No conversation ID available for sending message'); - return; - } + if (!conversationId) { + console.error('No conversation ID available for sending message'); + handleStatusChange({ + loading: false, + error: 'No conversation ID available' + }); + return; + } - // Send the message - await chatService.sendMessage(content, files, conversationId); + // Send the message + await chatService().sendMessage(content, files, conversationId); - // Update conversation with new messages - if (currentConv) { - await conversationStore.updateConversation(currentConv.id, { - messages: chatService.messages() + // Update conversation with new messages + if (currentConv) { + await store.updateConversation(currentConv.id, { + messages: chatService().messages() + }); + } + } catch (error) { + console.error('Failed to send message:', error); + handleStatusChange({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to send message' }); + } finally { + handleStatusChange({ loading: false }); } }; @@ -237,23 +467,52 @@ const ChatInterface: Component = (props) => {
{/* Sidebar */} - +
{ + if (isControlled()) { + return props.config?.currentConversationId || null; + } + const store = conversationStore(); + return store?.currentConversationId?.() || null; + })()} onConversationSelect={handleConversationSelect} onConversationCreate={handleNewConversation} onConversationDelete={async (id: string) => { - await conversationStore.deleteConversation(id); - if (conversationStore.conversations().length > 0) { - const firstConv = conversationStore.conversations()[0]; - await handleConversationSelect(firstConv.id); + try { + if (isControlled() && props.onEvents?.onConversationDelete) { + await props.onEvents.onConversationDelete(id); + } else { + const store = conversationStore(); + if (store) { + await store.deleteConversation(id); + if (store.conversations().length > 0) { + const firstConv = store.conversations()[0]; + await handleConversationSelect(firstConv.id); + } + } + } + } catch (error) { + console.error('Failed to delete conversation:', error); } }} onConversationRename={async (id: string, newTitle: string) => { - await conversationStore.updateConversation(id, { title: newTitle }); + try { + if (isControlled() && props.onEvents?.onConversationUpdate) { + await props.onEvents.onConversationUpdate(id, { title: newTitle }); + } else { + const store = conversationStore(); + if (store) { + await store.updateConversation(id, { title: newTitle }); + } + } + } catch (error) { + console.error('Failed to rename conversation:', error); + } }} + isLoading={isLoading()} />
@@ -274,15 +533,15 @@ const ChatInterface: Component = (props) => {
-

{props.title || "Nova Chat"}

-

{props.description || "Let language become the interface"}

+

{props.config?.title || "Nova Chat"}

+

{props.config?.description || "Let language become the interface"}

{/* Error Display */} - +
@@ -291,11 +550,14 @@ const ChatInterface: Component = (props) => {
-

{chatService.error()}

+

{errorState()}

diff --git a/src/services/agui-service.ts b/src/services/agui-service.ts index 92d12e8..c983e3b 100644 --- a/src/services/agui-service.ts +++ b/src/services/agui-service.ts @@ -7,8 +7,14 @@ import type { AgentState, AGUIRequest, StreamingToolCall, - ApiConfig, + ApiConfig } from './types'; +import { EventSchemas } from './types'; +import { + convertToOfficialMessage, + convertFromOfficialMessage, + convertToOfficialRunInput +} from './types/ag-ui-compat'; import { buildEndpointUrl } from './types/api'; export interface ChatService { @@ -63,25 +69,31 @@ export function createAGUIService(apiConfigOrUrl?: string | ApiConfig): ChatServ try { // Send full conversation history (all messages including the new one) const allMessages = [...messages(), userMessage]; - const request: AGUIRequest = { + + // Use official AG-UI conversion function for compatibility + const request: AGUIRequest = convertToOfficialRunInput(allMessages, { threadId: threadId(), // Persistent thread ID for conversation runId: crypto.randomUUID(), // New run ID for each message state: null, - messages: allMessages.map(msg => ({ - id: crypto.randomUUID(), // Generate unique ID for each message - role: msg.role, - content: msg.content, - })), tools: [], context: [], forwardedProps: null, - }; + }); // Build the streaming endpoint URL const streamEndpoint = apiConfig.endpoints?.streamMessage || '/agent/stream'; - const streamUrl = conversationId && streamEndpoint.includes('{conversationId}') - ? buildEndpointUrl(apiConfig.baseUrl || '', streamEndpoint, { conversationId }) - : buildEndpointUrl(apiConfig.baseUrl || '', streamEndpoint); + + // If endpoint has conversationId placeholder, we need a conversationId + let streamUrl: string; + if (streamEndpoint.includes('{conversationId}')) { + const actualConversationId = conversationId || 'default'; + streamUrl = buildEndpointUrl(apiConfig.baseUrl || '', streamEndpoint, { + conversationId: actualConversationId + }); + } else { + // Endpoint doesn't need conversationId parameter + streamUrl = buildEndpointUrl(apiConfig.baseUrl || '', streamEndpoint); + } const response = await fetch(streamUrl, { method: 'POST', @@ -117,7 +129,23 @@ export function createAGUIService(apiConfigOrUrl?: string | ApiConfig): ChatServ const data = line.slice(6); // Remove 'data: ' prefix if (data === '[DONE]') continue; - const event: AGUIEvent = JSON.parse(data); + const rawEvent = JSON.parse(data); + + // Validate event using AG-UI core schemas (if available) + let event: AGUIEvent; + try { + if (EventSchemas && typeof EventSchemas === 'object' && 'parse' in EventSchemas) { + // Use official AG-UI validation + event = (EventSchemas as any).parse(rawEvent); + } else { + // Fallback to basic type assertion + event = rawEvent as AGUIEvent; + } + } catch (validationError) { + console.warn('Event validation failed:', validationError, 'Raw event:', rawEvent); + // Use raw event but log the validation failure + event = rawEvent as AGUIEvent; + } // Handle different event types switch (event.type) { diff --git a/src/services/types.ts b/src/services/types.ts index 7381fe7..a04fc6b 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -1,277 +1,103 @@ -// AG-UI Protocol Event Types -export const AG_UI_EVENT_TYPES = [ - 'RUN_STARTED', - 'RUN_FINISHED', - 'TEXT_MESSAGE_START', - 'TEXT_MESSAGE_CONTENT', - 'TEXT_MESSAGE_END', - 'TEXT_MESSAGE_DELTA', - 'TOOL_CALL_START', - 'TOOL_CALL_ARGS', - 'TOOL_CALL_END', - 'TOOL_CALL_RESULT', - 'TOOL_CALL_DELTA', - 'TOOL_OUTPUT', - 'STATE_SNAPSHOT', - 'STATE_DELTA', - 'ERROR', - 'AGENT_PROPOSAL', - 'AGENT_PROPOSAL_APPROVED', - 'AGENT_PROPOSAL_REJECTED', - 'HUMAN_INPUT_REQUIRED', - 'CONTEXT_UPDATE', -] as const; - -export type EventType = typeof AG_UI_EVENT_TYPES[number]; - -// Message Types -export interface AGUIMessage { - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp?: string; - toolCalls?: AGUIToolCall[]; -} - -export interface AGUIToolCall { - id: string; - name: string; - arguments: Record; - result?: unknown; -} - -// Enhanced Tool Call with streaming state -export interface StreamingToolCall { - id: string; - name: string; - arguments: string; // Raw arguments as they stream in - parsedArguments?: Record; // Parsed when complete - result?: string; - status: 'starting' | 'building_args' | 'executing' | 'completed' | 'error'; - parentMessageId?: string; - startedAt: string; - completedAt?: string; - error?: string; -} - -// Event Interfaces -export interface TextMessageStartEvent { - type: 'TEXT_MESSAGE_START'; - messageId: string; - role: 'assistant' | 'user' | 'system'; -} - -export interface TextMessageContentEvent { - type: 'TEXT_MESSAGE_CONTENT'; - messageId: string; - delta: string; -} - -export interface TextMessageEndEvent { - type: 'TEXT_MESSAGE_END'; - messageId: string; -} - -export interface TextMessageDeltaEvent { - type: 'TEXT_MESSAGE_DELTA'; - delta: string; -} - -// New Tool Call Events -export interface ToolCallStartEvent { - type: 'TOOL_CALL_START'; - toolCallId: string; - toolCallName: string; - parentMessageId?: string; -} - -export interface ToolCallArgsEvent { - type: 'TOOL_CALL_ARGS'; - toolCallId: string; - delta: string; -} - -export interface ToolCallEndEvent { - type: 'TOOL_CALL_END'; - toolCallId: string; -} - -export interface ToolCallResultEvent { - type: 'TOOL_CALL_RESULT'; - messageId: string; - toolCallId: string; - content: string; - role?: string; -} - -// Legacy Tool Call Events (keeping for backward compatibility) -export interface ToolCallDeltaEvent { - type: 'TOOL_CALL_DELTA'; - toolCallId: string; - delta: Partial; -} - -export interface ToolOutputEvent { - type: 'TOOL_OUTPUT'; - toolCallId: string; - output: unknown; -} - -export interface RunStartedEvent { - type: 'RUN_STARTED'; - runId: string; - timestamp: string; -} - -export interface RunFinishedEvent { - type: 'RUN_FINISHED'; - runId: string; - timestamp: string; -} - -export interface StateSnapshotEvent { - type: 'STATE_SNAPSHOT'; - state: AgentState; -} - -export interface StateDeltaEvent { - type: 'STATE_DELTA'; - delta: Array; // JSON Patch operations -} - -export interface ErrorEvent { - type: 'ERROR'; - error: string; - code?: string; -} - -export interface AgentProposalEvent { - type: 'AGENT_PROPOSAL'; - proposal: AgentProposal; -} - -export interface AgentProposalApprovedEvent { - type: 'AGENT_PROPOSAL_APPROVED'; - proposalId: string; -} - -export interface AgentProposalRejectedEvent { - type: 'AGENT_PROPOSAL_REJECTED'; - proposalId: string; - reason?: string; -} - -export interface HumanInputRequiredEvent { - type: 'HUMAN_INPUT_REQUIRED'; - prompt: string; - inputId: string; -} - -export interface ContextUpdateEvent { - type: 'CONTEXT_UPDATE'; - context: Record; -} - -// Union type for all events -export type AGUIEvent = - | TextMessageStartEvent - | TextMessageContentEvent - | TextMessageEndEvent - | TextMessageDeltaEvent - | ToolCallStartEvent - | ToolCallArgsEvent - | ToolCallEndEvent - | ToolCallResultEvent - | ToolCallDeltaEvent - | ToolOutputEvent - | RunStartedEvent - | RunFinishedEvent - | StateSnapshotEvent - | StateDeltaEvent - | ErrorEvent - | AgentProposalEvent - | AgentProposalApprovedEvent - | AgentProposalRejectedEvent - | HumanInputRequiredEvent - | ContextUpdateEvent; - -// Agent State Types -export interface AgentState { - [key: string]: any; // Support arbitrary state structure - // Common fields that might be used: - currentThought?: string; - workingMemory?: Record; - reasoningChain?: string[]; - nextActions?: string[]; - progress?: number; - version?: string; - lastUpdated?: string; -} - -export interface AgentProposal { - id: string; - title: string; - description: string; - impact: 'low' | 'medium' | 'high'; - requiresApproval: boolean; - timestamp: string; -} - -// Stream Event Type +// Import official AG-UI types and compatibility layer +import { + EventType, + AG_UI_EVENT_TYPES, + AGUIMessage, + AGUIToolCall, + AGUIRequest, + AgentState, + EnhancedAGUIMessage, + StreamingToolCall, + FileAttachment, + FileAttachmentStatus, + // Event interfaces + BaseEvent, + TextMessageStartEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageDeltaEvent, + ToolCallStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallDeltaEvent, + ToolOutputEvent, + RunStartedEvent, + RunFinishedEvent, + StateSnapshotEvent, + StateDeltaEvent, + ErrorEvent, + AgentProposal, + AgentProposalEvent, + AgentProposalApprovedEvent, + AgentProposalRejectedEvent, + HumanInputRequiredEvent, + ContextUpdateEvent, + AGUIEvent, + // EventSchemas for validation + EventSchemas +} from './types/ag-ui-compat'; + +// Re-export for backward compatibility +export { + EventType, + AG_UI_EVENT_TYPES, + EventSchemas +}; +export type { + AGUIMessage, + AGUIToolCall, + AGUIRequest, + AgentState, + EnhancedAGUIMessage, + StreamingToolCall, + FileAttachment, + FileAttachmentStatus, + // Event interfaces + BaseEvent, + TextMessageStartEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageDeltaEvent, + ToolCallStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallDeltaEvent, + ToolOutputEvent, + RunStartedEvent, + RunFinishedEvent, + StateSnapshotEvent, + StateDeltaEvent, + ErrorEvent, + AgentProposal, + AgentProposalEvent, + AgentProposalApprovedEvent, + AgentProposalRejectedEvent, + HumanInputRequiredEvent, + ContextUpdateEvent, + AGUIEvent +}; + +// All event interfaces are now imported from ag-ui-compat.ts +// This provides compatibility with @ag-ui/core while maintaining backward compatibility + +// The following types are now imported from the compatibility layer. +// We don't need to redefine them here since they're properly exported from ag-ui-compat.ts + +// Stream Event Type (not part of AG-UI core, solid-ag-chat specific) export interface StreamEvent { event: string; data: string; } -// Request Types -export interface AGUIRequest { - threadId: string; - runId: string; - state: any; - messages: Array<{ - id: string; - role: 'user' | 'assistant' | 'system'; - content: string; - }>; - tools: Array; - context: Array; - forwardedProps: any; -} - -// Tool Definition +// Tool Definition (legacy, use Tool from ag-ui-compat instead) export interface AGUITool { name: string; description: string; parameters: Record; } -// File Attachment Types -export interface FileAttachment { - id: string; - name: string; - size: number; - type: string; - url?: string; - data?: string | ArrayBuffer; - uploadProgress?: number; - uploaded: boolean; - error?: string; - preview?: string; -} - -export type FileAttachmentStatus = 'pending' | 'uploading' | 'uploaded' | 'error'; - -// Enhanced Message Types -export interface EnhancedAGUIMessage extends AGUIMessage { - id: string; - conversationId: string; - attachments?: FileAttachment[]; - isMarkdown?: boolean; - isEdited?: boolean; - editedAt?: string; - metadata?: Record; - streamingToolCalls?: StreamingToolCall[]; -} - export type MessageAction = 'copy' | 'edit' | 'delete' | 'retry' | 'react'; // Conversation Types @@ -415,5 +241,88 @@ export interface UIState { error: string | null; } +// Service Status Types (v0.4.0+) +export interface ServiceStatus { + loading: boolean; + error?: string; + lastUpdated?: string; + details?: Record; +} + +export type StatusChangeCallback = (status: ServiceStatus) => void; + +// Chat Service Types (v0.4.0+) +export interface ChatService { + messages: () => EnhancedAGUIMessage[]; + isLoading: () => boolean; + error: () => string | null; + agentState: () => AgentState | null; + sendMessage: (content: string, files?: any[], conversationId?: string) => Promise; + loadMessages: (messages: EnhancedAGUIMessage[]) => void; + clearMessages: () => void; + clearAgentState: () => void; + setAutoTitleCallback?: (callback: (conversationId: string) => Promise) => void; +} + +export interface ChatServiceConfig { + apiConfig: import('./types/api').ApiConfig; + onAutoTitle?: (conversationId: string) => Promise; + onStatusChange?: StatusChangeCallback; +} + +// Note: StorageManager is a class imported from '../services/storage' +// No interface needed here since we use the actual class + +export interface StorageManagerConfig { + adapter: StorageAdapter; + onStatusChange?: StatusChangeCallback; +} + +// Simplified Chat Configuration (v0.4.1+) +export interface ChatConfig { + // API & Storage + apiConfig?: import('./types/api').ApiConfig; + storageConfig?: import('./types/api').ApiConfig; // Falls back to apiConfig if not provided + + // Services (for dependency injection) + chatService?: ChatService; + storageAdapter?: StorageAdapter; + + // Conversation behavior + conversationId?: string; + autoTitle?: boolean; + createOnFirstMessage?: boolean; + + // UI + title?: string; + description?: string; + userName?: string; + suggestions?: SuggestionItem[]; + showSidebar?: boolean; + disclaimerText?: string; + + // Controlled mode data + conversations?: ConversationSummary[]; + currentConversationId?: string; +} + +// Simplified Event Handlers (v0.4.1+) +export interface ChatEventHandlers { + // Status + onStatusChange?: (status: ServiceStatus) => void; + + // Navigation + onNewConversation?: () => void; + + // Conversation lifecycle (for controlled mode) + onConversationCreate?: (data: Partial) => Promise; + onConversationSelect?: (id: string) => void; + onConversationUpdate?: (id: string, updates: Partial) => Promise; + onConversationDelete?: (id: string) => Promise; +} + +// Chat Mode Types (v0.4.1+) +export type ChatMode = 'local' | 'remote' | 'controlled'; + // Export API types export * from './types/api'; diff --git a/src/services/types/ag-ui-compat.ts b/src/services/types/ag-ui-compat.ts new file mode 100644 index 0000000..b00f16a --- /dev/null +++ b/src/services/types/ag-ui-compat.ts @@ -0,0 +1,395 @@ +/** + * AG-UI Core Compatibility Layer + * + * This file provides compatibility between the current custom AG-UI implementation + * and the official @ag-ui/core package. It allows for gradual migration while + * maintaining backward compatibility. + */ + +// Import official AG-UI core types (when @ag-ui/core is installed) +// For now, we'll define compatible types that match the AG-UI core spec +// These will be replaced with actual imports when @ag-ui/core is available + +// AG-UI EventType enum (complete set) +export enum EventType { + RUN_STARTED = 'RUN_STARTED', + RUN_FINISHED = 'RUN_FINISHED', + TEXT_MESSAGE_START = 'TEXT_MESSAGE_START', + TEXT_MESSAGE_CONTENT = 'TEXT_MESSAGE_CONTENT', + TEXT_MESSAGE_END = 'TEXT_MESSAGE_END', + TEXT_MESSAGE_DELTA = 'TEXT_MESSAGE_DELTA', + TOOL_CALL_START = 'TOOL_CALL_START', + TOOL_CALL_ARGS = 'TOOL_CALL_ARGS', + TOOL_CALL_END = 'TOOL_CALL_END', + TOOL_CALL_RESULT = 'TOOL_CALL_RESULT', + TOOL_CALL_DELTA = 'TOOL_CALL_DELTA', + TOOL_OUTPUT = 'TOOL_OUTPUT', + STATE_SNAPSHOT = 'STATE_SNAPSHOT', + STATE_DELTA = 'STATE_DELTA', + ERROR = 'ERROR', + AGENT_PROPOSAL = 'AGENT_PROPOSAL', + AGENT_PROPOSAL_APPROVED = 'AGENT_PROPOSAL_APPROVED', + AGENT_PROPOSAL_REJECTED = 'AGENT_PROPOSAL_REJECTED', + HUMAN_INPUT_REQUIRED = 'HUMAN_INPUT_REQUIRED', + CONTEXT_UPDATE = 'CONTEXT_UPDATE' +} + +export type AGUIEventType = EventType; + +// AG-UI Role type +export type Role = 'user' | 'assistant' | 'system' | 'tool' | 'developer'; + +// AG-UI Message types +export interface Message { + role: Role; + content: string; +} + +export interface UserMessage extends Message { + role: 'user'; +} + +export interface AssistantMessage extends Message { + role: 'assistant'; +} + +export interface SystemMessage extends Message { + role: 'system'; +} + +export interface ToolMessage extends Message { + role: 'tool'; +} + +// AG-UI ToolCall interface +export interface ToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + +// AG-UI RunAgentInput interface +export interface RunAgentInput { + threadId: string; + runId: string; + state: any; + messages: Message[]; + tools: Tool[]; + context: Context[]; + forwardedProps: any; +} + +// AG-UI State interface +export interface State { + [key: string]: any; +} + +// AG-UI Context interface +export interface Context { + [key: string]: any; +} + +// AG-UI Tool interface +export interface Tool { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +// EventSchemas placeholder (will be replaced when @ag-ui/core is available) +export const EventSchemas = null; + +// All types are now defined above - no need for re-exports + +// Type aliases for backward compatibility +export type AGUIMessage = Message; +export type AGUIToolCall = ToolCall; +export type AGUIRequest = RunAgentInput; +export type AgentState = State; + +// EventType is now defined above + +// Legacy event type constants for backward compatibility +export const AG_UI_EVENT_TYPES = [ + 'RUN_STARTED', + 'RUN_FINISHED', + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'TEXT_MESSAGE_DELTA', + 'TOOL_CALL_START', + 'TOOL_CALL_ARGS', + 'TOOL_CALL_END', + 'TOOL_CALL_RESULT', + 'TOOL_CALL_DELTA', + 'TOOL_OUTPUT', + 'STATE_SNAPSHOT', + 'STATE_DELTA', + 'ERROR', + 'AGENT_PROPOSAL', + 'AGENT_PROPOSAL_APPROVED', + 'AGENT_PROPOSAL_REJECTED', + 'HUMAN_INPUT_REQUIRED', + 'CONTEXT_UPDATE', +] as const; + +// Legacy event type union for compatibility (deprecated, use EventType enum above) +export type LegacyEventType = typeof AG_UI_EVENT_TYPES[number]; + +// Enhanced message interface that extends the official Message type +export interface EnhancedAGUIMessage extends Message { + id: string; + conversationId: string; + timestamp?: string; + toolCalls?: ToolCall[]; + attachments?: FileAttachment[]; + isMarkdown?: boolean; + isEdited?: boolean; + editedAt?: string; + metadata?: Record; + streamingToolCalls?: StreamingToolCall[]; +} + +// Streaming tool call interface (solid-ag-chat specific) +export interface StreamingToolCall { + id: string; + name: string; + arguments: string; // Raw arguments as they stream in + parsedArguments?: Record; // Parsed when complete + result?: string; + status: 'starting' | 'building_args' | 'executing' | 'completed' | 'error'; + parentMessageId?: string; + startedAt: string; + completedAt?: string; + error?: string; +} + +// File attachment types (solid-ag-chat specific) +export interface FileAttachment { + id: string; + name: string; + size: number; + type: string; + url?: string; + data?: string | ArrayBuffer; + uploadProgress?: number; + uploaded: boolean; + error?: string; + preview?: string; +} + +export type FileAttachmentStatus = 'pending' | 'uploading' | 'uploaded' | 'error'; + +// Base event interface matching AG-UI core pattern +export interface BaseEvent { + type: EventType; + timestamp?: number; + rawEvent?: any; +} + +// Event interfaces based on AG-UI core specifications +export interface TextMessageStartEvent extends BaseEvent { + type: EventType.TEXT_MESSAGE_START; + messageId: string; + role: 'assistant' | 'user' | 'system'; +} + +export interface TextMessageContentEvent extends BaseEvent { + type: EventType.TEXT_MESSAGE_CONTENT; + messageId: string; + delta: string; +} + +export interface TextMessageEndEvent extends BaseEvent { + type: EventType.TEXT_MESSAGE_END; + messageId: string; +} + +export interface TextMessageDeltaEvent extends BaseEvent { + type: EventType.TEXT_MESSAGE_DELTA; + delta: string; +} + +export interface ToolCallStartEvent extends BaseEvent { + type: EventType.TOOL_CALL_START; + toolCallId: string; + toolCallName: string; + parentMessageId?: string; +} + +export interface ToolCallArgsEvent extends BaseEvent { + type: EventType.TOOL_CALL_ARGS; + toolCallId: string; + delta: string; +} + +export interface ToolCallEndEvent extends BaseEvent { + type: EventType.TOOL_CALL_END; + toolCallId: string; +} + +export interface ToolCallResultEvent extends BaseEvent { + type: EventType.TOOL_CALL_RESULT; + messageId: string; + toolCallId: string; + content: string; + role?: string; +} + +export interface RunStartedEvent extends BaseEvent { + type: EventType.RUN_STARTED; + runId: string; + runTimestamp: string; +} + +export interface RunFinishedEvent extends BaseEvent { + type: EventType.RUN_FINISHED; + runId: string; + runTimestamp: string; +} + +export interface StateSnapshotEvent extends BaseEvent { + type: EventType.STATE_SNAPSHOT; + state: State; +} + +export interface StateDeltaEvent extends BaseEvent { + type: EventType.STATE_DELTA; + delta: Array; // JSON Patch operations +} + +export interface ErrorEvent extends BaseEvent { + type: EventType.ERROR; + error: string; + code?: string; +} + +// Legacy Tool Call Events (keeping for backward compatibility) +export interface ToolCallDeltaEvent extends BaseEvent { + type: EventType.TOOL_CALL_DELTA; + toolCallId: string; + delta: Partial; +} + +export interface ToolOutputEvent extends BaseEvent { + type: EventType.TOOL_OUTPUT; + toolCallId: string; + output: unknown; +} + +// Additional solid-ag-chat specific events +export interface AgentProposal { + id: string; + title: string; + description: string; + impact: 'low' | 'medium' | 'high'; + requiresApproval: boolean; + timestamp: string; +} + +export interface AgentProposalEvent extends BaseEvent { + type: EventType.AGENT_PROPOSAL; + proposal: AgentProposal; +} + +export interface AgentProposalApprovedEvent extends BaseEvent { + type: EventType.AGENT_PROPOSAL_APPROVED; + proposalId: string; +} + +export interface AgentProposalRejectedEvent extends BaseEvent { + type: EventType.AGENT_PROPOSAL_REJECTED; + proposalId: string; + reason?: string; +} + +export interface HumanInputRequiredEvent extends BaseEvent { + type: EventType.HUMAN_INPUT_REQUIRED; + prompt: string; + inputId: string; +} + +export interface ContextUpdateEvent extends BaseEvent { + type: EventType.CONTEXT_UPDATE; + context: Record; +} + +// Union type for all AG-UI events +export type AGUIEvent = + | TextMessageStartEvent + | TextMessageContentEvent + | TextMessageEndEvent + | TextMessageDeltaEvent + | ToolCallStartEvent + | ToolCallArgsEvent + | ToolCallEndEvent + | ToolCallResultEvent + | ToolCallDeltaEvent + | ToolOutputEvent + | RunStartedEvent + | RunFinishedEvent + | StateSnapshotEvent + | StateDeltaEvent + | ErrorEvent + | AgentProposalEvent + | AgentProposalApprovedEvent + | AgentProposalRejectedEvent + | HumanInputRequiredEvent + | ContextUpdateEvent; + +// Converter functions for migrating between types +export function convertToOfficialMessage(legacyMessage: EnhancedAGUIMessage): Message { + const { role, content } = legacyMessage; + + const baseMessage = { + role, + content, + } as Message; + + // Add tool calls if present + if (legacyMessage.toolCalls) { + (baseMessage as any).tool_calls = legacyMessage.toolCalls; + } + + return baseMessage; +} + +export function convertFromOfficialMessage(officialMessage: Message, additionalProps?: Partial): EnhancedAGUIMessage { + return { + ...officialMessage, + id: additionalProps?.id || crypto.randomUUID(), + conversationId: additionalProps?.conversationId || 'default', + timestamp: additionalProps?.timestamp || new Date().toISOString(), + isMarkdown: additionalProps?.isMarkdown ?? false, + isEdited: additionalProps?.isEdited ?? false, + ...additionalProps + } as EnhancedAGUIMessage; +} + +export function convertToOfficialRunInput( + messages: EnhancedAGUIMessage[], + options: { + threadId: string; + runId: string; + state?: any; + tools?: Tool[]; + context?: Context[]; + forwardedProps?: any; + } +): RunAgentInput { + return { + threadId: options.threadId, + runId: options.runId, + state: options.state || null, + messages: messages.map(convertToOfficialMessage), + tools: options.tools || [], + context: options.context || [], + forwardedProps: options.forwardedProps || null, + }; +} \ No newline at end of file