From 6a792643154ef0349d7649ebf811a59f3a5fb156 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 23 Jun 2025 15:49:45 +1000 Subject: [PATCH] Add brain emoji model selector with fullscreen modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create ModelSelectionModal component with list-style interface - Add brain emoji (🧠) indicator below chat input, left-aligned - Show current model name in lowercase or red X if not configured - Fullscreen modal displays all available models with configuration status - Green checkmark for configured models, red alert for unconfigured - Integrate with existing API key management and model selection logic - Modal handles API key setup workflow when selecting unconfigured models - Clean separation between compact indicator and full selection interface --- examples/chat-ui/src/components/ChatApp.tsx | 2 + .../src/components/ConversationThread.tsx | 32 ++++ .../src/components/ModelSelectionModal.tsx | 160 ++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 examples/chat-ui/src/components/ModelSelectionModal.tsx diff --git a/examples/chat-ui/src/components/ChatApp.tsx b/examples/chat-ui/src/components/ChatApp.tsx index 30c5776..d747e4e 100644 --- a/examples/chat-ui/src/components/ChatApp.tsx +++ b/examples/chat-ui/src/components/ChatApp.tsx @@ -140,6 +140,8 @@ const ChatApp: React.FC = () => { db={db as IDBPDatabase} selectedModel={selectedModel} onApiKeyUpdate={handleApiKeyUpdate} + onModelChange={handleModelChange} + apiKeyUpdateTrigger={apiKeyUpdateTrigger} mcpTools={mcpTools} /> diff --git a/examples/chat-ui/src/components/ConversationThread.tsx b/examples/chat-ui/src/components/ConversationThread.tsx index 82543cf..451cf95 100644 --- a/examples/chat-ui/src/components/ConversationThread.tsx +++ b/examples/chat-ui/src/components/ConversationThread.tsx @@ -8,9 +8,11 @@ import { type IDBPDatabase } from 'idb' import { type Tool } from 'use-mcp/react' import ChatMessage from './ChatMessage' import ChatInput from './ChatInput' +import ModelSelectionModal from './ModelSelectionModal' import { useAutoscroll } from '../hooks/useAutoscroll' import { useStreamResponse } from '../hooks/useStreamResponse' import { setApiKey } from '../utils/apiKeys' +import { hasApiKey } from '../utils/apiKeys' import ApiKeyModal from './ApiKeyModal' interface ConversationThreadProps { @@ -21,6 +23,8 @@ interface ConversationThreadProps { db: IDBPDatabase selectedModel: Model onApiKeyUpdate: () => void + onModelChange: (model: Model) => void + apiKeyUpdateTrigger: number mcpTools: Tool[] } @@ -32,6 +36,8 @@ const ConversationThread: React.FC = ({ db, selectedModel, onApiKeyUpdate, + onModelChange, + apiKeyUpdateTrigger, mcpTools, }) => { const [input, setInput] = useState('') @@ -39,6 +45,7 @@ const ConversationThread: React.FC = ({ isOpen: false, model: null, }) + const [modelSelectionModal, setModelSelectionModal] = useState(false) const { messagesEndRef, messagesContainerRef, scrollToBottom } = useAutoscroll() @@ -212,6 +219,23 @@ const ConversationThread: React.FC = ({ controller={controller} messagesCount={currentConversation.messages.length} /> + + {/* Model selector indicator */} +
+ +
@@ -221,6 +245,14 @@ const ConversationThread: React.FC = ({ provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }} onSave={handleApiKeySave} /> + + setModelSelectionModal(false)} + selectedModel={selectedModel} + onModelChange={onModelChange} + apiKeyUpdateTrigger={apiKeyUpdateTrigger} + /> ) } diff --git a/examples/chat-ui/src/components/ModelSelectionModal.tsx b/examples/chat-ui/src/components/ModelSelectionModal.tsx new file mode 100644 index 0000000..edfbf6c --- /dev/null +++ b/examples/chat-ui/src/components/ModelSelectionModal.tsx @@ -0,0 +1,160 @@ +import React, { useState, useEffect } from 'react' +import { X, AlertCircle, CheckCircle } from 'lucide-react' +import { type Model, availableModels } from '../types/models' +import { hasApiKey, setApiKey, clearApiKey } from '../utils/apiKeys' +import ApiKeyModal from './ApiKeyModal' + +interface ModelSelectionModalProps { + isOpen: boolean + onClose: () => void + selectedModel: Model + onModelChange: (model: Model) => void + apiKeyUpdateTrigger: number +} + +const ModelSelectionModal: React.FC = ({ + isOpen, + onClose, + selectedModel, + onModelChange, + apiKeyUpdateTrigger, +}) => { + const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({ + isOpen: false, + model: null, + }) + const [apiKeyStatuses, setApiKeyStatuses] = useState>({}) + + useEffect(() => { + const statuses: Record = {} + availableModels.forEach(model => { + statuses[model.provider.id] = hasApiKey(model.provider.id) + }) + setApiKeyStatuses(statuses) + }, [apiKeyUpdateTrigger]) + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = 'unset' + } + + return () => { + document.body.style.overflow = 'unset' + } + }, [isOpen]) + + const handleModelSelect = (model: Model) => { + const hasKey = hasApiKey(model.provider.id) + + if (!hasKey) { + setApiKeyModal({ isOpen: true, model }) + } else { + onModelChange(model) + onClose() + } + } + + const handleApiKeySave = (apiKey: string) => { + if (apiKeyModal.model) { + setApiKey(apiKeyModal.model.provider.id, apiKey) + setApiKeyStatuses(prev => ({ ...prev, [apiKeyModal.model!.provider.id]: true })) + onModelChange(apiKeyModal.model) + setApiKeyModal({ isOpen: false, model: null }) + onClose() + } + } + + const handleClearApiKey = (providerId: string, e: React.MouseEvent) => { + e.stopPropagation() + clearApiKey(providerId) + setApiKeyStatuses(prev => ({ ...prev, [providerId]: false })) + } + + const getStatusIcon = (providerId: string) => { + const hasKey = apiKeyStatuses[providerId] + + if (hasKey) { + return + } else { + return + } + } + + if (!isOpen) return null + + return ( + <> +
+
+
+

Select Model

+ +
+ +
+
+ {availableModels.map((model) => { + const isSelected = model.id === selectedModel.id + const hasKey = apiKeyStatuses[model.provider.id] + + return ( +
handleModelSelect(model)} + > +
+
+ {model.provider.logo} +
+
{model.name}
+
{model.provider.name}
+
+
+ +
+ {getStatusIcon(model.provider.id)} + + {hasKey ? 'Configured' : 'Not configured'} + + {hasKey && ( + + )} +
+
+
+ ) + })} +
+
+
+
+ + setApiKeyModal({ isOpen: false, model: null })} + provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }} + onSave={handleApiKeySave} + /> + + ) +} + +export default ModelSelectionModal