Adding a full chat example (#19)

* Copying chat example across

* MCP tool calling woo!

* Moving most of the debug logging behind localStorage.USE_MCP_DEBUG

* Working on tool call display

* working tool call ui!

* deployng to workers.dev for sharing

* Simplify chat UI to focus on main input with pastel gradient background

- Hide sidebar (conversations, model picker, MCP servers) while preserving components
- Hide navbar/title bar while preserving component
- Remove title text and description above input field
- Add pastel gradient background (pink-purple-indigo)
- Expand conversation thread to full width
- Create minimal, focused interface with only white input field visible

* Add animated rotating conical gradient background

- Create large 500% conical gradient with pastel color cycle
- Position gradient off-screen (-200% top/left) for smooth rotation
- Add 8s linear infinite rotation animation for fast testing
- Use ::before pseudo-element to avoid repainting main content
- Cycle through various pastel shades (pink, purple, blue, green, yellow, orange)
- Performance-optimized: only transforms, no gradient repainting

* Switch to hue-rotate animation for background gradient

- Revert complex conical gradient approach
- Return to original linear gradient (pink-100 → purple-50 → indigo-100)
- Add hue-rotate filter animation cycling through 360 degrees
- 8s duration for fast testing
- Simpler implementation with continuous color shifting
- Less control over saturation but smoother performance

* Slow down hue-rotate animation to 60 seconds

- Change animation duration from 8s to 60s for subtle color shifting
- Creates gentle, barely noticeable background color transitions
- Maintains visual interest without being distracting

* Add random starting point for hue-rotate animation

- Generate random negative animation-delay between 0 and -60 seconds
- Starts animation at random point in the 60-second cycle
- Each page load will show different initial colors
- Uses useState with initializer function to set delay once on mount
- CSS doesn't have random functions, so JavaScript handles the randomization

* Use CSS custom property for cleaner random animation delay

- Move animation-delay to CSS using custom property --random-delay
- JavaScript still required to generate random value (CSS has no random functions)
- Cleaner separation between styling (CSS) and logic (JavaScript)
- Maintains same functionality with better organization

* Add brain emoji model selector with fullscreen modal

- 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

* Isolate hue-rotate animation to background layer only

- Create separate background layer using ::before pseudo-element
- Move hue-rotate animation to background layer with z-index: -1
- Remove filter from main container to prevent inheritance
- Use equivalent gradient colors in CSS (pink-100, purple-50, indigo-100)
- Foreground elements (brain emoji, text) now maintain original colors
- Background still animates smoothly without affecting content

* Replace red X with grey 'none' text and add pointer cursor

- Change unconfigured model indicator from red ✕ to grey 'none' text
- Add explicit cursor-pointer class to brain emoji button
- Maintains grey text color consistent with configured model names
- Cleaner, less alarming visual for unconfigured state

* Add MCP server indicator with plug emoji and modal

- Create McpServerModal component based on existing McpServers component
- Add plug emoji (🔌) indicator on right side, opposite to brain emoji
- Show '0' when no servers connected, '1 (N)' when connected with N tools
- Fullscreen modal with MCP server configuration UI
- Support for single MCP server connection (foundation for multiple servers)
- Integrate with existing MCP tools logic and useMcp hook
- Clean layout with brain emoji on left, plug emoji on right

* Move number to left of plug emoji

- Change layout from '🔌 0' to '0 🔌'
- Number now appears before the plug icon for better visual balance
- Maintains same functionality and styling

* Keep MCP modal in React tree to maintain connection

- Replace conditional rendering with CSS visibility (hidden/block)
- Remove early return null when modal is closed
- McpConnection component stays mounted to preserve MCP server connection
- Modal is always in DOM but visually hidden when isOpen is false
- Prevents MCP disconnection when modal is dismissed

* Fix MCP tools count update in plug icon indicator

- Add onMcpToolsUpdate prop to ConversationThread interface
- Pass setMcpTools function from ChatApp down to ConversationThread
- Connect McpServerModal onToolsUpdate to parent state update
- Now plug icon will correctly show '1 (N)' when N tools are available
- Fixes issue where tool count remained at 0 despite successful connection

* Add cursor pointer to X button and click-outside-to-dismiss

- Add cursor-pointer class to modal X button
- Add onClick handler to backdrop overlay to close modal
- Add stopPropagation to modal content to prevent backdrop clicks
- Now modal can be dismissed by clicking outside or using X button

* Add cursor pointer and click-outside-to-dismiss to model selector modal

- Add cursor-pointer class to X button in ModelSelectionModal
- Add onClick handler to backdrop overlay to close modal
- Add stopPropagation to modal content to prevent backdrop clicks
- Consistent modal behavior between model selector and MCP server modals

* Implement multiple MCP servers support

- Completely rewrite McpServerModal to support multiple servers
- Add McpServer interface with id, url, enabled, and name fields
- Use localStorage 'mcpServers' array instead of single server URL
- Add server list UI with status badges, toggle, and delete buttons
- Add 'Add New Server' section at bottom with input and Add button
- Support enabling/disabling individual servers with Power/PowerOff icons
- Delete servers with Trash2 icon in top right of each server block
- Aggregate tools from all enabled servers for parent component
- Render multiple McpConnection components for enabled servers
- Update plug indicator to show correct server count
- Add debug information panel showing server and tool counts
- Handle server names using hostname from URL
- Each server gets unique connection state tracking by server ID

* Update plug indicator to show enabled vs total counts

- Change indicator format to show enabled/total for both servers and tools
- Format: 'X (Y servers) A (B tools)' where:
  - X = enabled servers, Y = total servers
  - A = tools from enabled servers, B = total tools from all servers
- Track tool counts per server in localStorage (mcpServerToolCounts)
- Persist tool counts even when servers are disabled
- Examples:
  - No servers: '0 (0 servers) 0 (0 tools)'
  - One disabled server with 2 tools: '0 (1 servers) 0 (2 tools)'
  - One enabled server with 2 tools: '1 (1 servers) 2 (2 tools)'
- Clean up tool counts when servers are deleted

* Update plug indicator format with comma separation

- Change format from '0 (1 servers) 0 (2 tools)' to '0 (1) servers, 0 (2) tools'
- Move words 'servers' and 'tools' outside brackets
- Add comma separation between server and tool counts
- More concise and readable format

* Change plug indicator format to use forward slashes

- Change from '0 (1) servers, 0 (2) tools' to '0/1 servers, 0/2 tools'
- Use forward slash format showing enabled/total for both servers and tools
- Cleaner, more compact visual representation
- Examples:
  - No servers: '0/0 servers, 0/0 tools'
  - One disabled server with 2 tools: '0/1 servers, 0/2 tools'
  - One enabled server with 2 tools: '1/1 servers, 2/2 tools'

* Soften modal background with radial gradient

- Replace solid black 50% opacity with radial gradient
- Center: 80% black (rgba(0,0,0,0.8))
- Corners: 70% black (rgba(0,0,0,0.7))
- Apply to both ModelSelectionModal and McpServerModal
- Creates softer, more visually appealing backdrop
- Remove bg-black bg-opacity-50 classes and use inline style

* Replace gradient with uniform 80% black background

- Remove radial gradient to fix banding issues
- Use solid 80% black background (rgba(0,0,0,0.8))
- Apply to both ModelSelectionModal and McpServerModal
- Clean, consistent backdrop without visual artifacts

* Implement OAuth callback support for MCP servers

- Add react-router-dom dependency for routing support
- Create OAuthCallback component with animated background and loading spinner
- Add routing to App.tsx with /oauth/callback route
- Match styling with main app using animated-bg-container
- Use onMcpAuthorization from use-mcp library for OAuth handling
- Library already handles multiple server differentiation via state parameter
- Each MCP server gets unique OAuth flow identified by state containing server URL
- Clean up unused imports and variables in McpServerModal

* Deploying to chat.use-mcp.dev

* Add Qwen 3 32B model under Groq provider

- Add qwen-3-32b model entry using qwen/qwen3-32b model ID
- Uses same Groq provider as Llama 3.3, sharing API token
- Model appears in selection modal between Llama and Claude
- Clean name 'Qwen 3 32B' for user interface

* Make gradient background fill entire content height

- Change from h-dvh to min-h-screen on main container
- Remove overflow-clip to allow content to expand
- Update inner containers to use min-h-screen for proper height
- Background gradient now extends with content rather than fixed to viewport
- Allows for scrolling when content exceeds viewport height

* Make gradient background repeat every 100vh

- Add background-size: 100% 100vh to fix gradient height to viewport
- Add background-repeat: repeat-y to tile gradient vertically
- Gradient pattern now repeats down the page as content grows
- Animation applies consistently to all repeated background tiles
- Creates seamless repeating pattern while maintaining hue rotation

* Doing some manual CSS hax for a change

* Update AGENT.md with chat UI development guidelines

- Add React Router and use-mcp to tech stack
- Document background animation best practices (hue-rotate, pseudo-elements)
- Modal patterns for UX (click-outside, cursor-pointer, backdrop styling)
- MCP server management (multiple servers, localStorage patterns, OAuth)
- State persistence guidelines (localStorage vs sessionStorage)
- UI indicator patterns (emoji-based, count formatting)
- Component organization for stateful elements
- Routing and OAuth callback implementation
- Performance considerations for animations and aggregation

* I am dum. CSS is smrt

* BG css tweaks

* Added a simple example server for the demo

* feat: Add reasoning token support with expandable thinking blocks

- Add reasoning field to AssistantMessage type
- Create ReasoningBlock component with truncate/expand functionality
- Add reasoning-capable models (Qwen QwQ, DeepSeek R1, Qwen 3 32B)
- Implement reasoning event handling in stream processing
- Enable reasoning format for Groq and Anthropic models
- Style reasoning blocks with grey border and reduced font size
- Display reasoning above assistant messages when available

The reasoning blocks show truncated thinking tokens by default and
expand on click to show full reasoning process from models like
Qwen3-32b that support step-by-step reasoning output.

* fix: Use extractReasoningMiddleware to properly parse thinking blocks

- Import extractReasoningMiddleware and wrapLanguageModel from AI SDK
- Wrap reasoning-capable models with extractReasoningMiddleware
- Use 'think' as tagName to parse <think> XML blocks from models
- Enable startWithReasoning option to handle models missing reasoning tags
- Extract reasoning from result.reasoning after stream completes
- Remove manual reasoning event handling and provider-specific config

This fixes the issue where thinking blocks appeared as raw XML tags
instead of parsed reasoning content. The middleware automatically
extracts reasoning from <think> tags and exposes it via result.reasoning.

* Added a simpe Hono server to test

* fix: Remove thinking XML tags from main message content

- Add regex-based cleaning to remove <think>...</think> tags from display text
- Handle both closed and unclosed think tags in streaming content
- Apply cleaning only for reasoning-capable models to avoid unnecessary processing
- Update conversation content with cleaned response instead of raw response

This ensures thinking tags are extracted for reasoning display but don't
appear in the main message content, providing a clean user experience.

* fix: Improve thinking tag removal with more aggressive cleaning

- Add multiple regex passes to handle various think tag scenarios
- Remove standalone <think> and </think> tags that might remain
- Clean leading whitespace after tag removal
- Add debug logging to track text cleaning process
- Handle streaming cases where tags appear before closing tags arrive

This provides more robust cleaning to ensure no think tags appear
in the final message content during streaming scenarios.

* fix: Add detailed debugging and use result.text for cleaned content

- Add debug logging to track aiResponse vs result.text content
- Use result.text from extractReasoningMiddleware for final content
- This should provide the properly cleaned text without thinking tags
- Comment out unused sidebar and edit functionality to fix TS errors

The result.text should contain content that has been automatically
cleaned by the extractReasoningMiddleware, while result.reasoning
contains the extracted thinking content.

* fix: Capture reasoning events during streaming instead of from result

- Add back reasoning variable to track content during streaming
- Handle reasoning events from extractReasoningMiddleware in stream processing
- Build up reasoning content from individual reasoning textDelta events
- Remove dependency on result.reasoning which wasn't being populated
- Add reasoning to assistant message after stream completes

The logs showed reasoning events were coming through correctly but we
weren't capturing them. This fixes the issue by collecting reasoning
content during streaming as the events arrive.

* debug: Add more detailed logging for reasoning event handling

- Add debug logs inside reasoning event handler to see if it's being triggered
- Log reasoning content length as it's being collected
- Add final reasoning content summary at end of stream
- Log why reasoning isn't being added if conditions aren't met
- Cast event to any to avoid potential TypeScript issues

This will help identify why reasoning events aren't being captured
despite appearing in the event stream logs.

* fix: Clean think tags from reasoning content before display

- Add cleaning logic to remove <think> and </think> tags from reasoning
- Trim whitespace after tag removal for clean display
- Add debug logging to show before/after cleaning
- The middleware extracts reasoning but includes the original tags

This ensures the reasoning blocks show clean thinking content without
the XML tags that were being captured along with the reasoning text.

* feat: Implement live reasoning streaming with timing display

- Update ReasoningBlock to start expanded and show live reasoning content
- Add timing tracking with start/end timestamps for reasoning duration
- Auto-collapse reasoning blocks after completion with 500ms delay
- Show timing summary (e.g. 'Thought for 0.4s') instead of content when collapsed
- Add streaming cursor animation and real-time content updates
- Update types to support reasoning timing and streaming state
- Stream reasoning content live as tokens arrive during thinking phase

This provides a much better UX where users can see the model thinking
in real-time, then get a clean timing summary after completion.

* fix: Create assistant message immediately when reasoning starts

- Create assistant message as soon as first reasoning event arrives
- This ensures reasoning block is visible from the beginning of thinking
- Prevent duplicate message creation in text-delta handler
- Move reasoning end time logic to text-delta start (when thinking finishes)

This fixes the issue where reasoning blocks weren't appearing because
the assistant message was only created after reasoning completed.

* fix: Remove delay for instant reasoning block collapse

- Remove 500ms setTimeout delay when reasoning finishes
- Reasoning block now collapses immediately when response starts
- Creates smoother transition from thinking to response phase
- Eliminates jarring delay between reasoning end and collapse

The reasoning block now collapses instantly as soon as the model
starts generating the actual response, providing better UX flow.

* feat: Add sliding window effect for streaming reasoning text

- Use overflow:hidden + whitespace:nowrap to prevent line breaks
- Apply justify-end flexbox to push content to the right
- New tokens push older ones out to the left edge and hide them
- Creates smooth sliding window effect showing only recent tokens
- Only applies during streaming, keeps normal wrap after completion

This creates a nice visual effect where you can see the latest reasoning
tokens flowing in from right to left as the model thinks.

* Add debug logging and Accept headers for MCP transport debugging

- Added extensive debug logging to transport creation and connection flows
- Added proper Accept header (application/json, text/event-stream) to transport options
- Fixed scroll jittering during streaming with instant scroll behavior
- Investigating HTTP transport 'Not connected' errors and SSE connection hanging

* Add httpOnly option to disable SSE fallback for debugging

- Added httpOnly option to UseMcpOptions to skip SSE fallback
- Updated all HTTP transport failure paths to respect httpOnly flag
- Temporarily enabled httpOnly in chat UI for streamable HTTP debugging
- Updated AGENT.md to require frequent commits after changes

* Replace httpOnly with transportType option and add UI controls

- Replace httpOnly boolean with transportType ('auto'/'http'/'sse') option
- Add transport type cycling button next to 'Add New Server'
- Add blue transport badge next to connected server status
- Save transport preference to localStorage
- Update connection orchestration to handle sse-only and http-only modes
- Remove explicit fallback messaging since orchestration now controls it

* Fix tool results being mixed into thinking blocks

- Add missing tool-result event handling in useStreamResponse
- Tool results now appear as separate ToolResultMessage components
- Prevents tool response content from being incorrectly added to reasoning content
- Maintains proper message order: thinking → tool call → tool result → assistant response

* Fix duplicate tool results and post-tool reasoning being hidden

- Add deduplication logic for tool results to prevent duplicates
- Detect when tool results have been processed and convert subsequent reasoning events to text content
- This fixes the issue where the final assistant response was hidden in the thinking block
- Tool results should now appear once and final response should appear as separate text

* Fix message ordering and prevent content duplication in thinking/final response

- Collect post-tool reasoning content separately instead of processing immediately
- Process collected content as final assistant response after stream completes
- This ensures proper message order: thinking → tool call → tool result → final response
- Prevents duplication of thinking content in final response

* Add intelligent content filtering to extract clean final answer

- Extract only the final answer from post-tool reasoning content
- Filter out thinking/reasoning patterns to prevent duplication
- Use regex matching and line filtering to identify clean response content
- Skip content that appears to be thinking rather than final answer
- Limit response length to prevent long thinking content from appearing

* Fix duplicate assistant messages by updating existing message

- Check if there's already an assistant message with only reasoning content
- Update existing assistant message with final content instead of creating new one
- Add duplicate detection to prevent multiple messages with same content
- Ensures only one assistant response appears after tool calls

* Fix message ordering - create final response at end instead of updating existing

- Remove logic that updates existing assistant message
- Always create new final response message at end of conversation
- This ensures proper order: thinking → tool call → tool result → final response
- Still prevents duplicates by checking existing content

* Make thinking block collapse immediately when tool call starts

- End reasoning phase as soon as first tool-call event is detected
- Set reasoningEndTime and stop reasoning streaming immediately
- Creates linear flow: thinking expands → tool call (thinking collapses) → tool result → final response
- More aggressive/responsive UX that doesn't wait for final response to collapse thinking

* Fix reasoning/content separation and eliminate empty message divs

- Remove extractReasoningMiddleware as it expects <think> tags but API sends reasoning fields directly
- Ensure reasoning and content tokens create separate assistant messages
- Filter empty assistant messages at conversation level to prevent empty divs
- Add deduplication for tool results to prevent duplicates
- Only render content div when message has actual content

* Clean up debug logging in useStreamResponse.ts

- Remove all debugLog calls and debug infrastructure
- Simplify error logging by removing verbose prefixes
- Fix unused options parameter to eliminate warnings
- Clean up formatting and extra blank lines

* Add targeted debug logging for message operations

- Add debugMessages helper function that logs with [Messages] prefix
- Log when new messages are added to conversation (not updates)
- Log reasoning assistant messages, tool calls, tool results, and content messages
- Log stream processing completion and function completion
- Debug logging enabled with localStorage.setItem('USE_MCP_DEBUG', 'true')

* Enhance debug logging to show complete conversation state

- Show entire messages array after each new message addition
- Include message index, role, content/reasoning status, and tool info
- Add final conversation state with content/reasoning lengths
- Provides complete visibility into data structure evolution

* Simplify debug logging to show raw JSON messages

- Replace complex message summaries with JSON.stringify(conv.messages)
- Provides direct, unfiltered view of message data structure
- Easier to inspect actual data and debug issues

* Remove all duplicate detection logic

- Remove existingToolCall and existingToolResult checks
- Add every API event as a new message regardless of duplicates
- Provides complete unfiltered log of all API data after middleware
- Useful for debugging and understanding raw data flow

* Branching off

* fixing prettier

* lfg manual style

* wip, changing the logic in useStreamResponse

* wip, at least prettier likes it

* Big sigh, finally getting control of my markup

* Starting to realise how dumb the vibecoded UI was oops

* Ok turns out I can be dumb too

* Handling reasoning then messages at least

* tool responses are easy now too

* visual tweaks

* Only updating one conversation at a time

* Ok _much_ better timing and just me understanding this stuff

* Almost ready

* fin

* Default transport type to HTTP instead of auto

* Move transport toggle to left and make it only affect new servers

- Add newServerTransportType state for next server to be added
- Move toggle to left side directly after 'Add New Server' label
- Add transportType to McpServer interface to store per-server transport
- Update connections to use server-specific transport type
- Change toggle styling to rounded-full (pill style)

* Round corners more on blue connected server transport type badge

Change from rounded-md to rounded-lg for better visual consistency

* Tighten markdown spacing in chat messages

- Add custom prose-tight CSS class to reduce excessive spacing
- Reduce line height from 1.75 to 1.5 for better readability
- Decrease heading margins (1rem top, 0.5rem bottom)
- Reduce paragraph and list item spacing
- Apply tighter spacing to both user and assistant messages

* removing dupe server

* Forcing parsed reasoning on groq

* Added inline in-conversation errors

* Add `preventAutoAuth` (to chat example) (#18)

* feat: add preventAutoAuth option to prevent automatic authentication popups

- Add preventAutoAuth option to UseMcpOptions to control automatic popup behavior
- Introduce pending_auth state for when auth is required but auto-popup is prevented
- Enhance authentication flow to check for existing tokens before triggering auth
- Add prepareAuthorizationUrl() helper method in BrowserOAuthClientProvider
- Update authenticate() method to handle pending_auth state transitions
- Improve chat UI components to handle new authentication states gracefully
- Add clear call-to-action UI with primary popup button and fallback new tab link
- Maintain backward compatibility - existing code continues to work unchanged

This prevents browser popup blockers from interfering with authentication flows
when users return to pages with MCP servers, providing better UX and control.

* Fixing couple of things

---------

Co-authored-by: Glen Maddern <glen@glenmaddern.com>

* Chat example cooked and ready

* I aint got time for this typescript nonsense rn

* fixing pnpm lock

* prettier

---------

Co-authored-by: Glen Maddern <glen@glenmaddern.com>
This commit is contained in:
Glen Maddern 2025-07-02 16:24:03 +10:00 • committed by GitHub
commit 26965cb6a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 9026 additions and 13 deletions

View file

@ -0,0 +1,16 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import ChatApp from './components/ChatApp'
import { OAuthCallback } from './components/OAuthCallback'
function App() {
return (
<Router>
<Routes>
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/" element={<ChatApp />} />
</Routes>
</Router>
)
}
export default App

View file

@ -0,0 +1,104 @@
import React, { useState } from 'react'
import { createPortal } from 'react-dom'
import { X, Eye, EyeOff } from 'lucide-react'
import { type ModelProvider } from '../types/models'
interface ApiKeyModalProps {
isOpen: boolean
onClose: () => void
provider: ModelProvider
onSave: (apiKey: string) => void
}
const ApiKeyModal: React.FC<ApiKeyModalProps> = ({ isOpen, onClose, provider, onSave }) => {
const [apiKey, setApiKey] = useState('')
const [showApiKey, setShowApiKey] = useState(false)
if (!isOpen) return null
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (apiKey.trim()) {
onSave(apiKey.trim())
setApiKey('')
onClose()
}
}
const handleClose = () => {
setApiKey('')
onClose()
}
const modalContent = (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={handleClose}>
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-zinc-900">
{provider.logo} {provider.name} API Key
</h2>
<button onClick={handleClose} className="text-zinc-400 hover:text-zinc-600 p-1">
<X size={20} />
</button>
</div>
<p className="text-sm text-zinc-600 mb-4">
Enter your {provider.name} API key to use this model. Your key will be stored locally in your browser.
</p>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label htmlFor="apiKey" className="block text-sm font-medium text-zinc-700 mb-2">
API Key
</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
id="apiKey"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="w-full px-3 py-2 border border-zinc-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent pr-10"
placeholder={`Enter your ${provider.name} API key`}
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-zinc-400 hover:text-zinc-600"
>
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div className="flex items-center justify-between">
<a
href={provider.documentationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800"
>
Get API key →
</a>
<div className="flex gap-2">
<button type="button" onClick={handleClose} className="px-4 py-2 text-sm text-zinc-600 hover:text-zinc-800">
Cancel
</button>
<button
type="submit"
disabled={!apiKey.trim()}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-zinc-300 disabled:cursor-not-allowed"
>
Save
</button>
</div>
</div>
</form>
</div>
</div>
)
return createPortal(modalContent, document.body)
}
export default ApiKeyModal

View file

@ -0,0 +1,145 @@
import React, { useState, useEffect } from 'react'
import ConversationThread from './ConversationThread.tsx'
import { storeName } from '../consts.ts'
import { type Conversation } from '../types'
import { useIndexedDB } from '../hooks/useIndexedDB'
import { type Model } from '../types/models'
import { getSelectedModel, setSelectedModel as saveSelectedModel } from '../utils/modelPreferences'
import { type IDBPDatabase } from 'idb'
import { type Tool } from 'use-mcp/react'
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ChatAppProps {}
const ChatApp: React.FC<ChatAppProps> = () => {
const [conversations, setConversations] = useState<Conversation[]>([])
const [conversationId, setConversationId] = useState<number | undefined>(undefined)
const [selectedModel, setSelectedModel] = useState<Model>(getSelectedModel())
const [apiKeyUpdateTrigger, setApiKeyUpdateTrigger] = useState<number>(0)
const [mcpTools, setMcpTools] = useState<Tool[]>([])
const [animationDelay] = useState<number>(() => -Math.random() * 60)
const db = useIndexedDB()
const handleApiKeyUpdate = () => {
setApiKeyUpdateTrigger((prev) => prev + 1)
}
const handleModelChange = (model: Model) => {
setSelectedModel(model)
saveSelectedModel(model)
}
// set up conversations on app load
useEffect(() => {
getConversations()
deleteUnusedConversations()
startNewConversation()
}, [db])
// Initialize sidebar visibility based on screen size
useEffect(() => {
// const isMobile = window.matchMedia("(max-width: 768px)").matches;
// setSidebarVisible(!isMobile);
}, [])
const getConversations = async () => {
if (!db) return
const conversations = (await db.getAll(storeName)) as Conversation[]
const inverseConversations = conversations.reverse()
setConversations(inverseConversations)
}
const deleteConversation = async (id: number, showPromptToUser = true) => {
try {
if (showPromptToUser && !window.confirm('Are you sure you want to delete this conversation?')) {
return
}
await db?.delete(storeName, id)
setConversations((prev) => prev.filter((conv) => conv.id !== id))
setConversationId(conversations[0]?.id)
} catch (error) {
console.error('Failed to delete conversation:', error)
}
}
// const editConversationTitle = async (id: number, newTitle: string) => {
// const conversation = (await db!.get(storeName, id)) as Conversation;
// conversation.title = newTitle;
// await db!.put(storeName, conversation);
// setConversations((prev) =>
// prev.map((conv) => (conv.id === id ? { ...conv, title: newTitle } : conv))
// );
// };
const startNewConversation = async () => {
//create unique id for new conversation
setConversationId(Date.now() + Math.floor(Math.random() * 1000))
// if (window.matchMedia("(max-width: 768px)").matches) {
// setSidebarVisible(false);
// }
}
// delete conversations with no messages
const deleteUnusedConversations = async () => {
if (!db) return
const conversations = (await db.getAll(storeName)) as Conversation[]
const unusedConversations = conversations.filter((conversation) => conversation.messages.length === 0)
for (const conversation of unusedConversations) {
deleteConversation(conversation.id as number, false)
}
}
return (
<div
className="flex min-h-screen w-screen animated-bg-container"
style={{ '--random-delay': `${animationDelay}s` } as React.CSSProperties}
>
<div className="flex flex-row flex-grow flex-1 min-h-screen relative">
{/* Sidebar and Navbar components hidden but kept in codebase */}
{/*{false && (*/}
{/* <>*/}
{/* <ChatSidebar*/}
{/* sidebarVisible={sidebarVisible}*/}
{/* setSidebarVisible={setSidebarVisible}*/}
{/* conversations={conversations}*/}
{/* conversationId={conversationId}*/}
{/* setConversationId={setConversationId}*/}
{/* deleteConversation={deleteConversation}*/}
{/* editConversationTitle={editConversationTitle}*/}
{/* startNewConversation={startNewConversation}*/}
{/* selectedModel={selectedModel}*/}
{/* onModelChange={handleModelChange}*/}
{/* apiKeyUpdateTrigger={apiKeyUpdateTrigger}*/}
{/* onMcpToolsUpdate={setMcpTools}*/}
{/* />*/}
{/* <ChatNavbar*/}
{/* sidebarVisible={sidebarVisible}*/}
{/* setSidebarVisible={setSidebarVisible}*/}
{/* />*/}
{/* </>*/}
{/*)}*/}
<div className="flex flex-col flex-grow h-full w-full">
<ConversationThread
conversations={conversations}
conversationId={conversationId}
setConversationId={setConversationId}
setConversations={setConversations}
db={db as IDBPDatabase}
selectedModel={selectedModel}
onApiKeyUpdate={handleApiKeyUpdate}
onModelChange={handleModelChange}
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
mcpTools={mcpTools}
onMcpToolsUpdate={setMcpTools}
/>
</div>
</div>
</div>
)
}
export default ChatApp

View file

@ -0,0 +1,65 @@
import React, { FormEvent, KeyboardEvent, useRef, useEffect, Dispatch, SetStateAction } from 'react'
import { Send, Pause } from 'lucide-react'
interface ChatInputProps {
input: string
setInput: Dispatch<SetStateAction<string>>
handleSubmit: (e: FormEvent) => void
isLoading: boolean
streamStarted: boolean
controller: AbortController
messagesCount: number
}
const ChatInput: React.FC<ChatInputProps> = ({ input, setInput, handleSubmit, isLoading, streamStarted, controller, messagesCount }) => {
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
}
}, [input])
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e as unknown as FormEvent)
}
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault()
setInput((prev: string) => prev + '\n')
}
}
return (
<div className="relative rounded-lg border border-zinc-200 bg-white shadow-sm">
<form onSubmit={handleSubmit} className={`w-full items-center`}>
<div className="flex justify-end items-center">
<textarea
id="chat-input"
ref={textareaRef}
value={input}
rows={1}
onFocus={() => console.clear()}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full flex-1 p-3 bg-transparent resize-none focus:outline-none"
placeholder={messagesCount === 0 ? 'Ask anything...' : 'Type your message...'}
style={{ maxHeight: '200px' }}
/>
<button
type={isLoading && streamStarted ? 'button' : 'submit'}
onClick={isLoading && streamStarted ? () => controller.abort() : undefined}
className="m-2 hover:bg-zinc-100 rounded-lg p-2 text-zinc-600 hover:text-zinc-900"
disabled={!isLoading && !input.trim()}
>
{isLoading && streamStarted ? <Pause size={20} /> : <Send size={20} />}
</button>
</div>
</form>
</div>
)
}
export default ChatInput

View file

@ -0,0 +1,34 @@
import React from 'react'
import { PanelLeftOpen } from 'lucide-react'
interface ChatNavbarProps {
sidebarVisible: boolean
setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>>
}
const ChatNavbar: React.FC<ChatNavbarProps> = ({ sidebarVisible, setSidebarVisible }) => {
return (
<div className="sticky top-0 bg-white border-b border-zinc-200 z-10">
<div className="my-3 mx-2 flex items-center justify-between">
<div className="flex items-center">
{!sidebarVisible && (
<div>
<button
onClick={() => setSidebarVisible(!sidebarVisible)}
className="
rounded-lg p-[0.4em]
hover:bg-zinc-100 hover:cursor-pointer
mr-2 transition-colors text-zinc-600 hover:text-zinc-800"
>
<PanelLeftOpen size={20} />
</button>
</div>
)}
<h1 className="text-base font-semibold text-zinc-600 ml-2">AI Chat Template</h1>
</div>
</div>
</div>
)
}
export default ChatNavbar

View file

@ -0,0 +1,147 @@
import React from 'react'
import { Edit, Trash2, PanelLeftClose, PanelLeftOpen, SquarePen } from 'lucide-react'
import ModelSelector from './ModelSelector'
import { McpServers } from './McpServers'
import { type Model } from '../types/models'
import { type Message } from '../types'
import { type Tool } from 'use-mcp/react'
interface Conversation {
id?: number
title: string
messages: Message[]
}
interface ChatSidebarProps {
sidebarVisible: boolean
setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>>
conversations: Conversation[]
conversationId: number | undefined
setConversationId: React.Dispatch<React.SetStateAction<number | undefined>>
deleteConversation: (id: number) => void
editConversationTitle: (id: number, newTitle: string) => void
startNewConversation: () => void
selectedModel: Model
onModelChange: (model: Model) => void
apiKeyUpdateTrigger: number
onMcpToolsUpdate: (tools: Tool[]) => void
}
const ChatSidebar: React.FC<ChatSidebarProps> = ({
sidebarVisible,
setSidebarVisible,
conversations,
conversationId,
setConversationId,
deleteConversation,
editConversationTitle,
startNewConversation,
selectedModel,
onModelChange,
apiKeyUpdateTrigger,
onMcpToolsUpdate,
}) => {
const handleConversationClick = (id: number | undefined) => {
setConversationId(id)
if (window.matchMedia('(max-width: 768px)').matches) {
setSidebarVisible(false)
}
}
return (
<>
{/* Mobile overlay */}
{sidebarVisible && <div className="md:hidden fixed inset-0 bg-black/30 z-20" onClick={() => setSidebarVisible(false)} />}
<div
className={`
fixed md:relative
z-30 md:z-auto
h-full
bg-zinc-50
transition-all duration-300
${sidebarVisible ? 'w-64 lg:w-[32rem] translate-x-0' : 'w-0 -translate-x-full md:translate-x-0'}
`}
>
<div
className={`m-2 flex items-center justify-between
${sidebarVisible ? 'sticky' : 'hidden'}
`}
>
<button
onClick={() => setSidebarVisible(!sidebarVisible)}
className="
rounded-lg p-[0.4em]
hover:bg-zinc-100 hover:cursor-pointer
transition-colors text-zinc-600 hover:text-zinc-800"
>
{sidebarVisible ? <PanelLeftClose size={20} /> : <PanelLeftOpen size={20} />}
</button>
<button
className="rounded-lg p-[0.4em]
hover:bg-zinc-100 hover:cursor-pointer
transition-colors text-zinc-600 hover:text-zinc-800"
onClick={startNewConversation}
>
<SquarePen size={20} />
</button>
</div>
<div
className=" h-[calc(100%-3rem)]
overflow-y-scroll scrollbar-thin
flex flex-col justify-between border-r border-zinc-200 transition-all duration-300"
>
<div className="flex flex-col">
<ul className="p-2 space-y-1">
{conversations.map((conversation) => (
<li
key={conversation.id}
className={`cursor-pointer p-2 transition-colors rounded-lg ${
conversation.id === conversationId || (!conversationId && !conversation.id)
? 'bg-zinc-200 text-zinc-900'
: 'hover:bg-zinc-200 text-zinc-600'
}`}
onClick={() => handleConversationClick(conversation.id)}
>
<div className="flex items-center justify-between">
<span className="truncate flex-grow text-sm">{conversation.title}</span>
<div className="flex space-x-2 ml-2">
<button
className="opacity-60 hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
const newTitle = prompt('Enter new title:', conversation.title)
if (newTitle) editConversationTitle(conversation.id!, newTitle)
}}
>
<Edit size={14} />
</button>
<button
className="opacity-60 hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
deleteConversation(conversation.id!)
setConversationId(undefined)
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
</li>
))}
</ul>
</div>
{/* Model Selector and MCP Servers at bottom */}
<div className="p-2 border-t border-zinc-200 space-y-3">
<ModelSelector selectedModel={selectedModel} onModelChange={onModelChange} apiKeyUpdateTrigger={apiKeyUpdateTrigger} />
<McpServers onToolsUpdate={onMcpToolsUpdate} />
</div>
</div>
</div>
</>
)
}
export default ChatSidebar

View file

@ -0,0 +1,278 @@
import React, { useState, useEffect, FormEvent } from 'react'
import { storeName } from '../consts'
import '../styles/scrollbar.css'
import '../styles/github.css'
import '../styles/markdown.css'
import { type Conversation, type Message } from '../types'
import { type Model } from '../types/models'
import { type IDBPDatabase } from 'idb'
import { type Tool } from 'use-mcp/react'
import ChatMessage from './messages/ChatMessage.tsx'
import ChatInput from './ChatInput'
import ModelSelectionModal from './ModelSelectionModal'
import McpServerModal from './McpServerModal'
import { useAutoscroll } from '../hooks/useAutoscroll'
import { useStreamResponse } from '../hooks/useStreamResponse'
import { useConversationUpdater } from '../hooks/useConversationUpdater'
import { setApiKey } from '../utils/apiKeys'
import { hasApiKey } from '../utils/apiKeys'
import ApiKeyModal from './ApiKeyModal'
interface ConversationThreadProps {
conversations: Conversation[]
conversationId?: number
setConversationId: (id: number) => void
setConversations: React.Dispatch<React.SetStateAction<Conversation[]>>
db: IDBPDatabase
selectedModel: Model
onApiKeyUpdate: () => void
onModelChange: (model: Model) => void
apiKeyUpdateTrigger: number
mcpTools: Tool[]
onMcpToolsUpdate: (tools: Tool[]) => void
}
const ConversationThread: React.FC<ConversationThreadProps> = ({
conversations,
conversationId,
setConversationId,
setConversations,
db,
selectedModel,
onApiKeyUpdate,
onModelChange,
apiKeyUpdateTrigger,
mcpTools,
onMcpToolsUpdate,
}) => {
const [input, setInput] = useState<string>('')
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
isOpen: false,
model: null,
})
const [modelSelectionModal, setModelSelectionModal] = useState(false)
const [mcpServerModal, setMcpServerModal] = useState(false)
const { messagesEndRef, messagesContainerRef, scrollToBottom } = useAutoscroll()
const handleApiKeyRequired = async (model: Model): Promise<boolean> => {
return new Promise((resolve) => {
setApiKeyModal({
isOpen: true,
model,
})
// Store the resolve function to call when modal is closed
window.apiKeyModalResolve = resolve
})
}
const handleApiKeySave = (apiKey: string) => {
if (apiKeyModal.model) {
setApiKey(apiKeyModal.model.provider.id, apiKey)
setApiKeyModal({ isOpen: false, model: null })
// Notify parent that API key was updated
onApiKeyUpdate()
// Resolve the promise to continue with the request
if (window.apiKeyModalResolve) {
window.apiKeyModalResolve(true)
delete window.apiKeyModalResolve
}
}
}
const handleApiKeyCancel = () => {
setApiKeyModal({ isOpen: false, model: null })
// Resolve the promise with false to cancel the request
if (window.apiKeyModalResolve) {
window.apiKeyModalResolve(false)
delete window.apiKeyModalResolve
}
}
const { updateConversation } = useConversationUpdater({
conversationId,
setConversations,
})
const { isLoading, setIsLoading, streamStarted, controller, streamResponse, aiResponseRef } = useStreamResponse({
conversationId,
setConversations,
scrollToBottom,
selectedModel,
onApiKeyRequired: handleApiKeyRequired,
mcpTools,
})
const currentConversation = conversations.find((conv) => conv.id === conversationId) || { messages: [], title: '' }
//when new message chunks are streamed in, scroll to bottom
useEffect(() => {
scrollToBottom(isLoading && streamStarted)
}, [aiResponseRef.current])
//when conversation changes, scroll to bottom
useEffect(scrollToBottom, [conversationId])
//when conversation changes, reset input
useEffect(() => {
setInput('')
}, [conversationId])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!input.trim()) return
if (currentConversation.messages.length === 0) {
setConversations((prev) => {
const updated = [...prev]
updated.unshift({
id: conversationId,
title: 'New conversation',
messages: [],
})
return updated
})
}
const userMessage: Message = { role: 'user', content: input }
setInput('')
setIsLoading(true)
updateConversation((conv) => ({
...conv,
messages: [...conv.messages, userMessage],
}))
await streamResponse([...currentConversation.messages, userMessage])
setIsLoading(false)
}
const storeMessages = async () => {
if (!currentConversation.messages || currentConversation.messages.length === 0) {
return
}
const store = db.transaction(storeName, 'readwrite').objectStore(storeName)
const objectData = {
id: conversationId,
title: currentConversation.title,
messages: currentConversation.messages,
}
const value = await store.put(objectData)
setConversationId(Number(value))
}
useEffect(() => {
if (db && conversationId) {
storeMessages()
}
}, [conversations])
// console.log({ currentConversation })
return (
<div className={`flex flex-col min-h-screen py-12 w-full ${currentConversation.messages.length === 0 ? 'justify-center' : ''}`}>
<div
ref={messagesContainerRef}
className={`
overflow-x-hidden
${currentConversation.messages.length === 0 ? 'flex items-center justify-center pb-6' : 'flex-1 overflow-y-scroll'}`}
>
<div className="max-w-2xl mx-auto w-full px-4">
{currentConversation.messages.length === 0 ? (
<div className="text-center">{/*<h1 className="text-9xl font-semibold text-zinc-800 h-20 overflow-auto">use-mcp</h1>*/}</div>
) : (
<div className="py-4 px-4 space-y-4">
{currentConversation.messages.map((message, index) => (
<ChatMessage key={index} message={message} />
))}
{isLoading && !streamStarted && <div className="text-center text-sm text-zinc-600">Thinking...</div>}
<div ref={messagesEndRef} />
</div>
)}
</div>
</div>
<div className={`p-4 ${currentConversation.messages.length === 0 ? 'pb-20' : ''}`}>
<div className="max-w-2xl mx-auto">
<ChatInput
input={input}
setInput={setInput}
handleSubmit={handleSubmit}
isLoading={isLoading}
streamStarted={streamStarted}
controller={controller}
messagesCount={currentConversation.messages.length}
/>
{/* Model selector and MCP server indicators */}
<div className="flex items-center justify-between mt-2">
<button
onClick={() => setModelSelectionModal(true)}
className="flex items-center gap-2 text-zinc-600 hover:text-zinc-900 transition-colors cursor-pointer"
>
<span className="text-lg">🧠</span>
<span className="text-sm text-zinc-500">
{hasApiKey(selectedModel.provider.id) ? selectedModel.name + ' (' + selectedModel.provider.name + ')' : 'none'}
</span>
</button>
<button
onClick={() => setMcpServerModal(true)}
className="flex items-center gap-2 text-zinc-600 hover:text-zinc-900 transition-colors cursor-pointer"
>
<span className="text-sm text-zinc-500">
{(() => {
try {
const servers = JSON.parse(localStorage.getItem('mcpServers') || '[]')
const toolCounts = JSON.parse(localStorage.getItem('mcpServerToolCounts') || '{}')
const enabledServers = servers.filter((s: any) => s.enabled).length
const totalServers = servers.length
const enabledTools = mcpTools.length
// Calculate total tools across all servers (including disabled ones that were previously connected)
const totalTools = servers.reduce((sum: number, server: any) => {
return sum + (toolCounts[server.id] || 0)
}, 0)
if (totalServers === 0) {
return '0/0 servers, 0/0 tools'
}
return `${enabledServers}/${totalServers} servers, ${enabledTools}/${totalTools} tools`
} catch {
return mcpTools.length > 0 ? `1/1 servers, ${mcpTools.length}/${mcpTools.length} tools` : '0/0 servers, 0/0 tools'
}
})()}
</span>
<span className="text-lg">🔌</span>
</button>
</div>
</div>
</div>
<ApiKeyModal
isOpen={apiKeyModal.isOpen}
onClose={handleApiKeyCancel}
provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }}
onSave={handleApiKeySave}
/>
<ModelSelectionModal
isOpen={modelSelectionModal}
onClose={() => setModelSelectionModal(false)}
selectedModel={selectedModel}
onModelChange={onModelChange}
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
/>
<McpServerModal isOpen={mcpServerModal} onClose={() => setMcpServerModal(false)} onToolsUpdate={onMcpToolsUpdate} />
</div>
)
}
export default ConversationThread

View file

@ -0,0 +1,418 @@
import React, { useState, useEffect } from 'react'
import { X, Info, Settings, Plus, Trash2, Power, PowerOff } from 'lucide-react'
import { useMcp, type Tool } from 'use-mcp/react'
interface McpServer {
id: string
url: string
enabled: boolean
name?: string
transportType: 'auto' | 'http' | 'sse'
}
// MCP Connection wrapper for a single server
function McpConnection({ server, onConnectionUpdate }: { server: McpServer; onConnectionUpdate: (serverId: string, data: any) => void }) {
// Use the MCP hook with the server URL
const connection = useMcp({
url: server.url,
debug: true,
autoRetry: false,
popupFeatures: 'width=500,height=600,resizable=yes,scrollbars=yes',
transportType: server.transportType,
preventAutoAuth: true, // Prevent automatic popups on page load
})
// Update parent component with connection data
useEffect(() => {
onConnectionUpdate(server.id, connection)
}, [server.id, connection.state, connection.tools, connection.error, connection.log.length, connection.authUrl])
// Return null as this is just a hook wrapper
return null
}
interface McpServerModalProps {
isOpen: boolean
onClose: () => void
onToolsUpdate?: (tools: Tool[]) => void
}
const McpServerModal: React.FC<McpServerModalProps> = ({ isOpen, onClose, onToolsUpdate }) => {
const [servers, setServers] = useState<McpServer[]>(() => {
const stored = localStorage.getItem('mcpServers')
return stored ? JSON.parse(stored) : []
})
const [newServerUrl, setNewServerUrl] = useState('')
const [showSettings, setShowSettings] = useState(false)
const [connectionData, setConnectionData] = useState<Record<string, any>>({})
const [serverToolCounts, setServerToolCounts] = useState<Record<string, number>>(() => {
const stored = localStorage.getItem('mcpServerToolCounts')
return stored ? JSON.parse(stored) : {}
})
const [transportType, _setTransportType] = useState<'auto' | 'http' | 'sse'>(() => {
const stored = localStorage.getItem('mcpTransportType')
return (stored as 'auto' | 'http' | 'sse') || 'http'
})
const [newServerTransportType, setNewServerTransportType] = useState<'auto' | 'http' | 'sse'>('http')
// Helper to cycle through new server transport types
const cycleNewServerTransportType = () => {
setNewServerTransportType((current) => {
switch (current) {
case 'auto':
return 'http'
case 'http':
return 'sse'
case 'sse':
return 'auto'
default:
return 'http'
}
})
}
// const logRef = useRef<HTMLDivElement>(null) // Removed for now as debug logs not implemented in multi-server version
// Save servers to localStorage whenever they change
useEffect(() => {
localStorage.setItem('mcpServers', JSON.stringify(servers))
}, [servers])
// Save tool counts to localStorage whenever they change
useEffect(() => {
localStorage.setItem('mcpServerToolCounts', JSON.stringify(serverToolCounts))
}, [serverToolCounts])
// Save transport type to localStorage whenever it changes
useEffect(() => {
localStorage.setItem('mcpTransportType', transportType)
}, [transportType])
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [isOpen])
// Aggregate all tools from enabled servers and notify parent
useEffect(() => {
const allTools: Tool[] = []
servers.forEach((server) => {
if (server.enabled && connectionData[server.id]?.tools) {
const serverTools = connectionData[server.id].tools.map((t: Tool) => ({
...t,
callTool: (args: Record<string, unknown>) => connectionData[server.id].callTool(t.name, args),
}))
allTools.push(...serverTools)
}
})
if (onToolsUpdate) {
onToolsUpdate(allTools)
}
}, [servers, connectionData, onToolsUpdate])
// Handle adding a new server
const handleAddServer = () => {
if (!newServerUrl.trim()) return
const newServer: McpServer = {
id: Date.now().toString(),
url: newServerUrl.trim(),
enabled: true,
name: new URL(newServerUrl.trim()).hostname,
transportType: newServerTransportType,
}
setServers((prev) => [...prev, newServer])
setNewServerUrl('')
}
// Handle removing a server
const handleRemoveServer = (serverId: string) => {
// Disconnect if connected
const connection = connectionData[serverId]
if (connection?.disconnect) {
connection.disconnect()
}
setServers((prev) => prev.filter((s) => s.id !== serverId))
setConnectionData((prev) => {
const newData = { ...prev }
delete newData[serverId]
return newData
})
setServerToolCounts((prev) => {
const newCounts = { ...prev }
delete newCounts[serverId]
return newCounts
})
}
// Handle toggling server enabled state
const handleToggleServer = (serverId: string) => {
setServers((prev) => prev.map((server) => (server.id === serverId ? { ...server, enabled: !server.enabled } : server)))
// If disabling, disconnect
const server = servers.find((s) => s.id === serverId)
if (server?.enabled && connectionData[serverId]?.disconnect) {
connectionData[serverId].disconnect()
}
}
// Handle connection update for a specific server
const handleConnectionUpdate = (serverId: string, data: any) => {
setConnectionData((prev) => ({
...prev,
[serverId]: data,
}))
// Store tool count for this server (even if it gets disabled later)
if (data.tools && Array.isArray(data.tools)) {
setServerToolCounts((prev) => ({
...prev,
[serverId]: data.tools.length,
}))
}
}
// Handle authentication for a specific server
const handleManualAuth = async (serverId: string) => {
try {
const connection = connectionData[serverId]
if (connection?.authenticate) {
await connection.authenticate()
}
} catch (err) {
console.error('Authentication error:', err)
}
}
// Generate status badge based on connection state
const getStatusBadge = (state: string) => {
const baseClasses = 'px-2 py-1 rounded-full text-xs font-medium'
switch (state) {
case 'discovering':
return <span className={`${baseClasses} bg-blue-100 text-blue-800`}>Discovering</span>
case 'pending_auth':
return <span className={`${baseClasses} bg-orange-100 text-orange-800`}>Authentication Required</span>
case 'authenticating':
return <span className={`${baseClasses} bg-purple-100 text-purple-800`}>Authenticating</span>
case 'connecting':
return <span className={`${baseClasses} bg-yellow-100 text-yellow-800`}>Connecting</span>
case 'loading':
return <span className={`${baseClasses} bg-orange-100 text-orange-800`}>Loading</span>
case 'ready':
return <span className={`${baseClasses} bg-green-100 text-green-800`}>Connected</span>
case 'failed':
return <span className={`${baseClasses} bg-red-100 text-red-800`}>Failed</span>
case 'not-connected':
default:
return <span className={`${baseClasses} bg-gray-100 text-gray-800`}>Not Connected</span>
}
}
return (
<>
<div
className={`fixed inset-0 z-50 flex items-center justify-center p-4 ${isOpen ? 'block' : 'hidden'}`}
style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}
onClick={onClose}
>
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[80vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-6 border-b border-zinc-200">
<h2 className="text-xl font-semibold text-zinc-900">MCP Servers</h2>
<div className="flex items-center gap-2">
<button className="rounded-md border border-gray-200 p-1 hover:bg-gray-50" onClick={() => setShowSettings(!showSettings)}>
<Settings size={16} className="text-gray-500" />
</button>
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer">
<X size={24} />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[60vh]">
<div className="flex items-center mb-6">
<Info size={16} className="text-gray-400 mr-2" />
<span className="text-sm text-gray-600">
Connect to Model Context Protocol (MCP) servers to access additional AI capabilities.
</span>
</div>
{/* Server List */}
<div className="space-y-4 mb-6">
{servers.map((server) => {
const connection = connectionData[server.id] || { state: 'not-connected', tools: [], error: undefined, authUrl: undefined }
const { state, tools, error, authUrl } = connection
return (
<div
key={server.id}
className={`border rounded-lg p-4 ${server.enabled ? 'border-zinc-200' : 'border-zinc-100 bg-zinc-50'}`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div>
<div className="font-medium text-zinc-900">{server.name || 'MCP Server'}</div>
<div className="text-sm text-zinc-500 break-all">{server.url}</div>
</div>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(server.enabled ? state : 'disabled')}
{server.enabled && state === 'ready' && (
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-lg font-mono">
{server.transportType.toUpperCase()}
</span>
)}
<button
onClick={() => handleToggleServer(server.id)}
className={`p-1 rounded ${server.enabled ? 'text-green-600 hover:bg-green-50' : 'text-gray-400 hover:bg-gray-50'}`}
title={server.enabled ? 'Disable server' : 'Enable server'}
>
{server.enabled ? <Power size={16} /> : <PowerOff size={16} />}
</button>
<button
onClick={() => handleRemoveServer(server.id)}
className="p-1 text-red-500 hover:bg-red-50 rounded"
title="Delete server"
>
<Trash2 size={16} />
</button>
</div>
</div>
{server.enabled && (
<>
{error && state === 'failed' && (
<div className="text-sm text-red-600 p-3 bg-red-50 rounded border mb-3">{error}</div>
)}
{(state === 'pending_auth' || authUrl) && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded mb-3">
<p className="text-sm mb-2">
{state === 'pending_auth'
? 'Authentication is required to connect to this server.'
: 'Authentication popup was blocked. You can open the authentication page manually:'}
</p>
<div className="space-y-2">
<button
className="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm font-medium"
onClick={() => handleManualAuth(server.id)}
>
Open Authentication Popup
</button>
{authUrl && (
<a
href={authUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-center text-sm text-blue-700 hover:text-blue-800 underline"
>
Or open in new tab instead
</a>
)}
</div>
</div>
)}
{state === 'ready' && tools.length > 0 && (
<div>
<h4 className="font-medium text-sm mb-2">Available Tools ({tools.length})</h4>
<div className="border border-gray-200 rounded p-2 bg-gray-50 max-h-24 overflow-y-auto space-y-1">
{tools.map((tool: Tool, index: number) => (
<div key={index} className="text-xs">
<span className="font-medium">{tool.name}</span>
{tool.description && <span className="text-gray-600 ml-2">- {tool.description}</span>}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)
})}
{servers.length === 0 && (
<div className="text-center py-8 text-gray-500">
<Info size={24} className="mx-auto mb-2" />
<p>No MCP servers configured yet.</p>
<p className="text-sm">Add your first server below.</p>
</div>
)}
</div>
{/* Add New Server */}
<div className="border-t pt-4">
<div className="flex items-center gap-3 mb-3">
<h3 className="font-medium text-sm">Add New Server</h3>
<button
onClick={cycleNewServerTransportType}
className="px-3 py-1 text-xs border border-gray-300 rounded-full bg-gray-50 hover:bg-gray-100 font-mono"
title="Click to cycle through transport types"
>
{newServerTransportType.toUpperCase()}
</button>
</div>
<div className="flex gap-2">
<input
type="text"
className="flex-1 p-3 border border-gray-200 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter MCP server URL"
value={newServerUrl}
onChange={(e) => setNewServerUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddServer()
}
}}
/>
<button
className="px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium disabled:opacity-50 flex items-center gap-2"
onClick={handleAddServer}
disabled={!newServerUrl.trim()}
>
<Plus size={16} />
Add
</button>
</div>
</div>
{/* Debug Info */}
{showSettings && (
<div className="border-t pt-4 mt-4">
<h3 className="font-medium text-sm mb-3">Debug Information</h3>
<div className="text-xs space-y-2">
<div>Total Servers: {servers.length}</div>
<div>Enabled Servers: {servers.filter((s) => s.enabled).length}</div>
<div>Connected Servers: {Object.values(connectionData).filter((c: any) => c.state === 'ready').length}</div>
<div>Total Tools: {Object.values(connectionData).reduce((sum: number, c: any) => sum + (c.tools?.length || 0), 0)}</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Render MCP connections for all enabled servers */}
{servers
.filter((s) => s.enabled)
.map((server) => (
<McpConnection key={server.id} server={server} onConnectionUpdate={handleConnectionUpdate} />
))}
</>
)
}
export default McpServerModal

View file

@ -0,0 +1,281 @@
import { useState, useRef, useEffect } from 'react'
import { useMcp, type Tool } from 'use-mcp/react'
import { Settings, Info } from 'lucide-react'
// MCP Connection wrapper that only renders when active
function McpConnection({ serverUrl, onConnectionUpdate }: { serverUrl: string; onConnectionUpdate: (data: any) => void }) {
// Use the MCP hook with the server URL
const connection = useMcp({
url: serverUrl,
debug: true,
autoRetry: false,
popupFeatures: 'width=500,height=600,resizable=yes,scrollbars=yes',
preventAutoAuth: true, // Prevent automatic popups on page load
})
// Update parent component with connection data
useEffect(() => {
onConnectionUpdate(connection)
}, [connection.state, connection.tools, connection.error, connection.log.length, connection.authUrl])
// Return null as this is just a hook wrapper
return null
}
export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[]) => void }) {
const [serverUrl, setServerUrl] = useState(() => {
return sessionStorage.getItem('mcpServerUrl') || ''
})
const [isActive, setIsActive] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [connectionData, setConnectionData] = useState<any>({
state: 'not-connected',
tools: [],
error: undefined,
log: [],
authUrl: undefined,
retry: () => {},
disconnect: () => {},
authenticate: () => Promise.resolve(undefined),
callTool: (_name: string, _args?: Record<string, unknown>) => Promise.resolve(undefined),
clearStorage: () => {},
})
const logRef = useRef<HTMLDivElement>(null)
// Extract connection properties
const { state, tools, error, log, authUrl, disconnect, authenticate } = connectionData
// Notify parent component when tools change
useEffect(() => {
if (onToolsUpdate && tools.length > 0) {
onToolsUpdate(
tools.map((t: Tool) => ({
...t,
callTool: (args: Record<string, unknown>) => connectionData.callTool(t.name, args),
}))
)
}
}, [tools, onToolsUpdate])
// Handle connection
const handleConnect = () => {
if (!serverUrl.trim()) return
setIsActive(true)
}
// Handle disconnection
const handleDisconnect = () => {
disconnect()
setIsActive(false)
setConnectionData({
state: 'not-connected',
tools: [],
error: undefined,
log: [],
authUrl: undefined,
retry: () => {},
disconnect: () => {},
authenticate: () => Promise.resolve(undefined),
callTool: (_name: string, _args?: Record<string, unknown>) => Promise.resolve(undefined),
clearStorage: () => {},
})
}
// Handle authentication if popup was blocked
const handleManualAuth = async () => {
try {
await authenticate()
} catch (err) {
console.error('Authentication error:', err)
}
}
// Auto-scroll log to bottom
useEffect(() => {
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight
}
}, [log])
// Generate status badge based on connection state
const getStatusBadge = () => {
const baseClasses = 'px-2 py-1 rounded-full text-xs font-medium'
switch (state) {
case 'discovering':
return <span className={`${baseClasses} bg-blue-100 text-blue-800`}>Discovering</span>
case 'pending_auth':
return <span className={`${baseClasses} bg-orange-100 text-orange-800`}>Authentication Required</span>
case 'authenticating':
return <span className={`${baseClasses} bg-purple-100 text-purple-800`}>Authenticating</span>
case 'connecting':
return <span className={`${baseClasses} bg-yellow-100 text-yellow-800`}>Connecting</span>
case 'loading':
return <span className={`${baseClasses} bg-orange-100 text-orange-800`}>Loading</span>
case 'ready':
return <span className={`${baseClasses} bg-green-100 text-green-800`}>Connected</span>
case 'failed':
return <span className={`${baseClasses} bg-red-100 text-red-800`}>Failed</span>
case 'not-connected':
default:
return <span className={`${baseClasses} bg-gray-100 text-gray-800`}>Not Connected</span>
}
}
// Log the tools to console when they change (for debugging)
useEffect(() => {
if (tools.length > 0) {
console.log('MCP Tools available:', tools)
}
}, [tools])
return (
<section className="rounded-lg bg-white p-4 border border-zinc-200">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-sm font-semibold">MCP Servers</span>
<div className="ml-2">
<Info size={14} className="text-gray-400" />
</div>
</div>
<button className="rounded-md border border-gray-200 p-1 hover:bg-gray-50" onClick={() => setShowSettings(!showSettings)}>
<Settings size={16} className="text-gray-500" />
</button>
</div>
<p className="text-gray-500 text-xs mt-1 mb-3">
Connect to Model Context Protocol (MCP) servers to access additional AI capabilities.
</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="font-medium text-xs">Status:</label>
{getStatusBadge()}
</div>
{error && state === 'failed' && <div className="text-xs text-red-600 p-2 bg-red-50 rounded border">{error}</div>}
<div className="space-y-2">
<input
type="text"
className="w-full p-2 border border-gray-200 rounded text-sm placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-300"
placeholder="Enter MCP server URL"
value={serverUrl}
onChange={(e) => {
const newValue = e.target.value
setServerUrl(newValue)
sessionStorage.setItem('mcpServerUrl', newValue)
}}
disabled={isActive && state !== 'failed'}
/>
{state === 'ready' || (isActive && state !== 'not-connected' && state !== 'failed') ? (
<button
className="w-full px-3 py-2 bg-orange-100 hover:bg-orange-200 text-orange-900 rounded text-sm font-medium"
onClick={handleDisconnect}
>
Disconnect
</button>
) : (
<button
className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 px-4 text-sm font-medium disabled:opacity-50"
onClick={handleConnect}
disabled={isActive || !serverUrl.trim()}
>
Connect
</button>
)}
</div>
{/* Authentication Action for pending_auth or existing authUrl */}
{(state === 'pending_auth' || authUrl) && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded">
<p className="text-xs mb-2">
{state === 'pending_auth'
? 'Authentication is required to connect to this server.'
: 'Authentication popup was blocked. You can open the authentication page manually:'}
</p>
<div className="space-y-2">
<button
className="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs font-medium"
onClick={handleManualAuth}
>
Open Authentication Popup
</button>
{authUrl && (
<a
href={authUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-center text-xs text-blue-700 hover:text-blue-800 underline"
>
Or open in new tab instead
</a>
)}
</div>
</div>
)}
{/* Tools display when connected */}
{state === 'ready' && tools.length > 0 && (
<div>
<h3 className="font-medium text-xs mb-2">Available Tools ({tools.length})</h3>
<div className="border border-gray-200 rounded p-2 bg-gray-50 max-h-32 overflow-y-auto space-y-2">
{tools.map((tool: Tool, index: number) => (
<div key={index} className="text-xs pb-2 border-b border-gray-100 last:border-b-0">
<span className="font-medium">{tool.name}</span>
{tool.description && <p className="text-gray-500 mt-1 text-xs">{tool.description}</p>}
</div>
))}
</div>
</div>
)}
{/* Debug Log */}
{showSettings && (
<div>
<label className="font-medium text-xs block mb-2">Debug Log</label>
<div ref={logRef} className="border border-gray-200 rounded p-2 bg-gray-50 h-32 overflow-y-auto font-mono text-xs">
{log.length > 0 ? (
log.map((entry: any, index: number) => (
<div
key={index}
className={`py-0.5 ${
entry.level === 'debug'
? 'text-gray-500'
: entry.level === 'info'
? 'text-blue-600'
: entry.level === 'warn'
? 'text-orange-600'
: 'text-red-600'
}`}
>
[{entry.level}] {entry.message}
</div>
))
) : (
<div className="text-gray-400">No log entries yet</div>
)}
</div>
{connectionData?.state !== 'not-connected' && (
<button
onClick={() => {
connectionData?.clearStorage()
if (isActive) {
handleDisconnect()
}
}}
className="text-xs text-orange-600 hover:text-orange-800 hover:underline mt-2"
>
Clear stored authentication
</button>
)}
</div>
)}
</div>
{/* Only render the actual MCP connection when active */}
{isActive && <McpConnection serverUrl={serverUrl} onConnectionUpdate={setConnectionData} />}
</section>
)
}

View file

@ -0,0 +1,159 @@
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<ModelSelectionModalProps> = ({
isOpen,
onClose,
selectedModel,
onModelChange,
apiKeyUpdateTrigger,
}) => {
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
isOpen: false,
model: null,
})
const [apiKeyStatuses, setApiKeyStatuses] = useState<Record<string, boolean>>({})
useEffect(() => {
const statuses: Record<string, boolean> = {}
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 <CheckCircle size={20} className="text-green-500" />
} else {
return <AlertCircle size={20} className="text-red-500" />
}
}
if (!isOpen) return null
return (
<>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}
onClick={onClose}
>
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-6 border-b border-zinc-200">
<h2 className="text-xl font-semibold text-zinc-900">Select Model</h2>
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer">
<X size={24} />
</button>
</div>
<div className="p-6 overflow-y-auto">
<div className="space-y-3">
{availableModels.map((model) => {
const isSelected = model.id === selectedModel.id
const hasKey = apiKeyStatuses[model.provider.id]
return (
<div
key={model.id}
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
isSelected ? 'border-blue-500 bg-blue-50' : 'border-zinc-200 hover:border-zinc-300 hover:bg-zinc-50'
}`}
onClick={() => handleModelSelect(model)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{model.provider.logo}</span>
<div>
<div className="font-medium text-zinc-900">{model.name}</div>
<div className="text-sm text-zinc-500">{model.provider.name}</div>
</div>
</div>
<div className="flex items-center gap-2">
{getStatusIcon(model.provider.id)}
<span className={`text-sm ${hasKey ? 'text-green-600' : 'text-red-600'}`}>
{hasKey ? 'Configured' : 'Not configured'}
</span>
{hasKey && (
<button
onClick={(e) => handleClearApiKey(model.provider.id, e)}
className="text-zinc-400 hover:text-red-500 p-1 ml-2"
title="Clear API key"
>
<X size={16} />
</button>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
<ApiKeyModal
isOpen={apiKeyModal.isOpen}
onClose={() => setApiKeyModal({ isOpen: false, model: null })}
provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }}
onSave={handleApiKeySave}
/>
</>
)
}
export default ModelSelectionModal

View file

@ -0,0 +1,138 @@
import React, { useState, useEffect, useRef } from 'react'
import { ChevronDown, AlertCircle, X } from 'lucide-react'
import { availableModels, type Model } from '../types/models'
import { hasApiKey, setApiKey, clearApiKey } from '../utils/apiKeys'
import ApiKeyModal from './ApiKeyModal'
interface ModelSelectorProps {
selectedModel: Model
onModelChange: (model: Model) => void
apiKeyUpdateTrigger: number
}
const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelChange, apiKeyUpdateTrigger }) => {
const [isOpen, setIsOpen] = useState(false)
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
isOpen: false,
model: null,
})
const [apiKeyStatuses, setApiKeyStatuses] = useState<Record<string, boolean>>({})
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const statuses: Record<string, boolean> = {}
availableModels.forEach((model) => {
statuses[model.provider.id] = hasApiKey(model.provider.id)
})
setApiKeyStatuses(statuses)
}, [apiKeyUpdateTrigger])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
const handleModelSelect = (model: Model) => {
const hasKey = hasApiKey(model.provider.id)
if (!hasKey) {
setApiKeyModal({ isOpen: true, model })
} else {
onModelChange(model)
}
setIsOpen(false)
}
const handleApiKeySave = (apiKey: string) => {
if (apiKeyModal.model) {
setApiKey(apiKeyModal.model.provider.id, apiKey)
setApiKeyStatuses((prev) => ({ ...prev, [apiKeyModal.model!.provider.id]: true }))
onModelChange(apiKeyModal.model)
}
}
const handleClearApiKey = (providerId: string, e: React.MouseEvent) => {
e.stopPropagation()
clearApiKey(providerId)
setApiKeyStatuses((prev) => ({ ...prev, [providerId]: false }))
}
const getApiKeyIcon = (providerId: string) => {
const hasKey = apiKeyStatuses[providerId]
if (hasKey) {
return (
<button onClick={(e) => handleClearApiKey(providerId, e)} className="text-zinc-400 hover:text-red-500 p-1" title="Clear token">
<X size={14} />
</button>
)
} else {
return (
<div className="text-red-500 p-1" title="API Key not found">
<AlertCircle size={14} />
</div>
)
}
}
return (
<>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-3 text-left bg-white border border-zinc-200 rounded-lg hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div className="flex items-center gap-2">
<span className="text-lg">{selectedModel.provider.logo}</span>
<div>
<div className="font-medium text-zinc-900">{selectedModel.name}</div>
<div className="text-xs text-zinc-500">{selectedModel.provider.name}</div>
</div>
</div>
<div className="flex items-center gap-1">
{getApiKeyIcon(selectedModel.provider.id)}
<ChevronDown size={16} className={`text-zinc-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</div>
</button>
{isOpen && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white border border-zinc-200 rounded-lg shadow-lg z-50">
{availableModels.map((model) => (
<button
key={model.id}
onClick={() => handleModelSelect(model)}
className="w-full flex items-center justify-between p-3 text-left hover:bg-zinc-50 first:rounded-t-lg last:rounded-b-lg"
>
<div className="flex items-center gap-2">
<span className="text-lg">{model.provider.logo}</span>
<div>
<div className="font-medium text-zinc-900">{model.name}</div>
<div className="text-xs text-zinc-500">{model.provider.name}</div>
</div>
</div>
{getApiKeyIcon(model.provider.id)}
</button>
))}
</div>
)}
</div>
<ApiKeyModal
isOpen={apiKeyModal.isOpen}
onClose={() => setApiKeyModal({ isOpen: false, model: null })}
provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }}
onSave={handleApiKeySave}
/>
</>
)
}
export default ModelSelector

View file

@ -0,0 +1,22 @@
import { useEffect } from 'react'
import { onMcpAuthorization } from 'use-mcp'
export function OAuthCallback() {
useEffect(() => {
onMcpAuthorization()
}, [])
return (
<div className="min-h-screen animated-bg-container flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 text-center max-w-md w-full">
<h1 className="text-2xl font-bold text-zinc-900 mb-4">Authenticating...</h1>
<p className="text-zinc-600 mb-2">Please wait while we complete your authentication.</p>
<p className="text-sm text-zinc-500">This window should close automatically.</p>
<div className="mt-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,31 @@
import React from 'react'
import { type AssistantMessage } from '../../types'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
interface AssistantMessageProps {
message: AssistantMessage
}
const AssistantMessage: React.FC<AssistantMessageProps> = ({ message }) => {
return (
<div className={`flex justify-start`}>
<div className={`text-zinc-900 w-full`}>
<div className="prose prose-zinc prose-tight">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
table: ({ children }) => <div className="overflow-x-scroll text-sm">{children}</div>,
}}
>
{message.content}
</ReactMarkdown>
</div>
</div>
</div>
)
}
export default AssistantMessage

View file

@ -0,0 +1,58 @@
import React from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { type Message } from '../../types'
import ToolCallMessage from './ToolCallMessage.tsx'
import ToolResultMessage from './ToolResultMessage.tsx'
import AssistantMessage from './AssistantMessage.tsx'
import ReasoningMessage from './ReasoningMessage.tsx'
import ErrorMessage from './ErrorMessage.tsx'
interface ChatMessageProps {
message: Message
}
const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
// Handle tool calls and results differently
if (message.role === 'tool-call') {
return <ToolCallMessage message={message} />
}
if (message.role === 'tool-result') {
return <ToolResultMessage message={message} />
}
if (message.role === 'error') {
return <ErrorMessage error={message.content} />
}
if (message.role === 'assistant') {
if (message.type === 'reasoning') {
return <ReasoningMessage message={message} />
} else {
return <AssistantMessage message={message} />
}
}
// Handle regular messages (user, assistant, system)
return (
<div className={`flex justify-end`}>
<div className={`max-w-[80%] rounded-2xl px-5 py-3 shadow bg-white text-black mb-3`}>
<div className="prose prose-zinc prose-tight">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
table: ({ children }) => <div className="overflow-x-scroll text-sm">{children}</div>,
}}
>
{message.content}
</ReactMarkdown>
</div>
</div>
</div>
)
}
export default ChatMessage

View file

@ -0,0 +1,38 @@
import React, { useState } from 'react'
interface ErrorMessageProps {
error: string
}
const ErrorMessage: React.FC<ErrorMessageProps> = ({ error }) => {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 my-2">
<div className="flex items-start gap-2">
<span className="text-red-500 text-sm font-medium">⚠️ Error:</span>
<div className="flex-1 min-w-0">
<div
className={`text-red-700 text-xs leading-relaxed ${!isExpanded ? 'max-h-[120px] overflow-hidden' : ''}`}
style={{
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
>
{error}
</div>
{error.length > 200 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-red-600 hover:text-red-800 text-xs mt-1 underline focus:outline-none"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
</div>
</div>
)
}
export default ErrorMessage

View file

@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react'
import { type ReasoningMessage } from '../../types'
interface ReasoningMessageProps {
message: ReasoningMessage
}
const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message }) => {
const [isExpanded, setIsExpanded] = useState(message.isReasoningStreaming) // Start expanded when streaming
// Auto-collapse instantly when streaming finishes
useEffect(() => {
if (!message.isReasoningStreaming && message.reasoningStartTime && message.reasoningEndTime) {
setIsExpanded(false)
}
}, [message.isReasoningStreaming, message.reasoningStartTime, message.reasoningEndTime])
if (!message.content || message.content.trim().length === 0) {
return null
}
const toggleExpanded = () => {
setIsExpanded(!isExpanded)
}
// Calculate thinking duration
const duration =
message.reasoningStartTime && message.reasoningEndTime ? (message.reasoningEndTime - message.reasoningStartTime) / 1000 : null
// Format duration nicely
const formatDuration = (seconds: number) => {
if (seconds < 0.001) return `< 1ms`
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`
return `${seconds.toFixed(1)}s`
}
return (
<div className={`flex justify-start`}>
<div className={`text-zinc-900 w-full`}>
<div
className={`text-xs/5 text-zinc-600 border border-zinc-200 rounded-lg p-3 bg-zinc-50 cursor-pointer hover:bg-zinc-100 ${
isExpanded ? 'border-zinc-300' : ''
}`}
onClick={toggleExpanded}
>
<div className="flex items-start gap-2">
<span className="text-zinc-400 text-xs mt-0.5 flex-shrink-0">💭</span>
{isExpanded ? (
// Expanded view: show reasoning content
<div
className={`flex-1 ${message.isReasoningStreaming ? 'overflow-hidden whitespace-nowrap flex justify-end' : 'whitespace-pre-wrap'}`}
>
<span className={message.isReasoningStreaming ? 'inline-block' : ''}>
{message.content}
{message.isReasoningStreaming && <span className="inline-block w-2 h-4 bg-zinc-400 ml-1 animate-pulse"></span>}
</span>
</div>
) : (
// Collapsed view: show timing summary
<div className="flex-1">
<span className="text-zinc-500">
{message.isReasoningStreaming
? 'Thinking...'
: typeof duration === 'number'
? `Thought for ${formatDuration(duration)}`
: 'Thought process'}
</span>
</div>
)}
<button
className="text-zinc-400 hover:text-zinc-600 text-xs ml-auto flex-shrink-0"
onClick={(e) => {
e.stopPropagation()
toggleExpanded()
}}
>
{/*{isExpanded ? '↑' : '↓'}*/}
</button>
</div>
</div>
</div>
</div>
)
}
export default ReasoningMessage

View file

@ -0,0 +1,54 @@
import { useState } from 'react'
import { ChevronDown, Wrench } from 'lucide-react'
import { type ToolCallMessage } from '../../types'
interface ToolCallMessageProps {
message: ToolCallMessage
}
const ToolCallMessage: React.FC<ToolCallMessageProps> = ({ message }) => {
const [expanded, setExpanded] = useState(false)
const json = JSON.stringify(message.toolArgs)
const argsPreview = json.substring(0, 100)
const shouldTruncate = json.length > 100
return (
<div className="flex gap-3 py-2 px-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className={`flex-shrink-0 h-full ${shouldTruncate ? 'mt-1' : 'mt-2.5'}`}>
<Wrench size={16} className="text-blue-600" />
</div>
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2 mb-1 overflow-hidden">
<span className="font-medium text-blue-800 text-sm flex-shrink-0">Tool Call</span>
<span className="text-blue-600 text-sm font-mono bg-blue-100 px-2 py-0.5 rounded">{message.toolName}</span>
{!shouldTruncate && (
<div className={`text-sm text-blue-700 font-mono bg-blue-100 p-2 rounded ml-auto`}>
<span className={'truncate'}>{argsPreview}</span>
</div>
)}
</div>
{shouldTruncate && (
<div
className={`text-sm text-blue-700 font-mono bg-blue-100 p-2 rounded cursor-pointer hover:bg-blue-200`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<pre className="mt-2 whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{JSON.stringify(message.toolArgs, null, 2)}
</pre>
) : (
<div className="flex items-start justify-between">
<span className={'truncate'}>{argsPreview}</span>
<button className="ml-2 flex-shrink-0">{<ChevronDown size={14} />}</button>
</div>
)}
</div>
)}
</div>
</div>
)
}
export default ToolCallMessage

View file

@ -0,0 +1,54 @@
import { useState } from 'react'
import { CheckCircle, ChevronDown } from 'lucide-react'
import { type ToolResultMessage } from '../../types'
interface ToolResultMessageProps {
message: ToolResultMessage
}
const ToolResultMessage: React.FC<ToolResultMessageProps> = ({ message }) => {
const [expanded, setExpanded] = useState(false)
const json = JSON.stringify(message.toolResult)
const resultPreview = json.substring(0, 48)
const shouldTruncate = json.length > 48
return (
<div className="flex gap-3 py-3 px-4 bg-green-50 border border-green-300 rounded-lg">
<div className={`flex-shrink-0 h-full ${shouldTruncate ? 'mt-1' : 'mt-2.5'}`}>
<CheckCircle size={16} className="text-green-600" />
</div>
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2 mb-1 overflow-hidden">
<span className="font-medium text-green-800 text-sm flex-shrink-0">Tool Result</span>
<span className="text-green-600 text-sm font-mono bg-green-100 px-2 py-0.5 rounded">{message.toolName}</span>
{!shouldTruncate && (
<div className={`text-sm text-green-700 font-mono bg-green-100 p-2 rounded ml-auto`}>
<span className={'truncate'}>{resultPreview}</span>
</div>
)}
</div>
{shouldTruncate && (
<div
className={`text-sm text-green-700 font-mono bg-green-100 p-2 rounded cursor-pointer hover:bg-green-200`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<pre className="mt-2 whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{JSON.stringify(message.toolResult, null, 2)}
</pre>
) : (
<div className="flex items-start justify-between">
<span className={'truncate'}>{resultPreview}</span>
<button className="ml-2 flex-shrink-0">{<ChevronDown size={14} />}</button>
</div>
)}
</div>
)}
</div>
</div>
)
}
export default ToolResultMessage

View file

@ -0,0 +1,2 @@
export const storeName = 'conversations'
export const dbName = 'localgpt'

View file

@ -0,0 +1,34 @@
import { useEffect, useRef } from 'react'
export const useAutoscroll = () => {
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const shouldAutoScrollRef = useRef(true)
useEffect(() => {
const container = messagesContainerRef.current
if (!container) return
const handleScroll = () => {
const isAtBottom = container.scrollHeight - container.clientHeight - container.scrollTop <= 200
shouldAutoScrollRef.current = isAtBottom
}
container.addEventListener('scroll', handleScroll)
return () => {
container.removeEventListener('scroll', handleScroll)
}
}, [])
const scrollToBottom = (force?: boolean) => {
if (shouldAutoScrollRef.current || force) {
messagesEndRef.current?.scrollIntoView({ behavior: force ? 'instant' : 'smooth' })
}
}
return {
messagesEndRef,
messagesContainerRef,
scrollToBottom,
}
}

Some files were not shown because too many files have changed in this diff Show more