Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40c02d2d98 | ||
|
|
8337ff0a68 |
||
|
|
8952e3b47d | ||
|
|
043efbcf12 |
||
|
|
c47ea828c3 |
||
|
|
c3ee0e9b09 |
||
|
|
5b54994da7 | ||
|
|
c260e971d9 |
||
|
|
85efc2de59 | ||
|
|
448e633eef | ||
|
|
c7e3e2303d |
||
|
|
156c956112 | ||
|
|
42621bfe02 | ||
|
|
977fa6f8b4 | ||
|
|
3af42b0e77 | ||
|
|
94ba6ff372 |
||
|
|
e682ad8396 | ||
|
|
6a42f9dc93 | ||
|
|
e0743c35e4 | ||
|
|
b909730ae2 | ||
|
|
d4220ec5f2 | ||
|
|
7a923c3509 | ||
|
|
8377abf268 | ||
|
|
8373adfdf9 | ||
|
|
25797dd4bb | ||
|
|
f50a1c588f | ||
|
|
ec0eae5ea3 | ||
|
|
ac52a72557 | ||
|
|
26965cb6a3 |
||
|
|
c189d5af65 | ||
|
|
eb9432aed2 | ||
|
|
feeaade511 | ||
|
|
f770d57e0a |
||
|
|
d80a7e68b0 |
||
|
|
0ea0bc48c6 |
||
|
|
fe65d50ec3 |
||
|
|
7f019fed7f |
||
|
|
ace3220fca |
||
|
|
6a3130a075 | ||
|
|
effb28af19 | ||
|
|
8276b337fe | ||
|
|
85e21784fd | ||
|
|
31d3108225 | ||
|
|
381001eb74 | ||
|
|
fa0180330f | ||
|
|
84b58759ed | ||
|
|
a225929ae2 | ||
|
|
b326e36ccc | ||
|
|
ef08e97a5f | ||
|
|
2b8b3773b5 | ||
|
|
960631bb74 |
82 changed files with 18231 additions and 633 deletions
50
.github/workflows/ci.yml
vendored
Normal file
50
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies - root
|
||||
run: pnpm install
|
||||
|
||||
- name: Install dependencies - inspector example
|
||||
run: pnpm install
|
||||
working-directory: examples/inspector
|
||||
|
||||
- name: Install dependencies - cf-agents example
|
||||
run: pnpm install
|
||||
working-directory: examples/servers/cf-agents
|
||||
|
||||
- name: Install dependencies - hono-mcp example
|
||||
run: pnpm install
|
||||
working-directory: examples/servers/hono-mcp
|
||||
|
||||
- name: Install dependencies - test
|
||||
run: pnpm install && pnpm exec playwright install
|
||||
working-directory: test
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:headless
|
||||
working-directory: test
|
||||
13
.github/workflows/publish.yml
vendored
13
.github/workflows/publish.yml
vendored
|
|
@ -1,12 +1,11 @@
|
|||
name: Publish Any Commit
|
||||
on:
|
||||
# Temporarily disabled - uncomment to re-enable
|
||||
# pull_request:
|
||||
# push:
|
||||
# branches:
|
||||
# - '**'
|
||||
# tags:
|
||||
# - '!**'
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- '!**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,4 +5,4 @@ dist
|
|||
.aider*
|
||||
|
||||
**/.claude/settings.local.json
|
||||
public
|
||||
/public
|
||||
|
|
|
|||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
|
|
@ -0,0 +1 @@
|
|||
./scripts/pre-commit
|
||||
13
AGENT.md
13
AGENT.md
|
|
@ -1,10 +1,21 @@
|
|||
# use-mcp Project Guidelines
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
- `pnpm dev`: Run development build with watch mode
|
||||
- `pnpm dev`: Run development build with watch mode and start all examples/servers
|
||||
- Chat UI: http://localhost:5002
|
||||
- Inspector: http://localhost:5001
|
||||
- Hono MCP Server: http://localhost:5101
|
||||
- CF Agents MCP Server: http://localhost:5102
|
||||
- `pnpm build`: Build the project
|
||||
- `pnpm check`: Run prettier checks and TypeScript type checking
|
||||
|
||||
### Integration Tests (in /test directory)
|
||||
- `cd test && pnpm test`: Run integration tests headlessly (default)
|
||||
- `cd test && pnpm test:headed`: Run integration tests with visible browser
|
||||
- `cd test && pnpm test:headless`: Run integration tests headlessly
|
||||
- `cd test && pnpm test:watch`: Run integration tests in watch mode
|
||||
- `cd test && pnpm test:ui`: Run integration tests with interactive UI
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports
|
||||
|
|
|
|||
74
README.md
74
README.md
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
A lightweight React hook for connecting to [Model Context Protocol (MCP)](https://github.com/modelcontextprotocol) servers. Simplifies authentication and tool calling for AI systems implementing the MCP standard.
|
||||
|
||||
Try it out: [MCP Inspector](https://inspector.use-mcp.dev) | [Cloudflare Workers AI Playground](https://playground.ai.cloudflare.com/)
|
||||
Try it out: [Chat Demo](https://chat.use-mcp.dev) | [MCP Inspector](https://inspector.use-mcp.dev) | [Cloudflare Workers AI Playground](https://playground.ai.cloudflare.com/)
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -20,11 +20,39 @@ pnpm add use-mcp
|
|||
yarn add use-mcp
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
To run the development environment with all examples and servers:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This starts:
|
||||
- **Inspector**: http://localhost:5001 - MCP server debugging tool
|
||||
- **Chat UI**: http://localhost:5002 - Example chat interface
|
||||
- **Hono MCP Server**: http://localhost:5101 - Example MCP server
|
||||
- **CF Agents MCP Server**: http://localhost:5102 - Cloudflare Workers AI MCP server
|
||||
|
||||
### Testing
|
||||
|
||||
Integration tests are located in the `test/` directory and run headlessly by default:
|
||||
|
||||
```bash
|
||||
cd test && pnpm test # Run tests headlessly (default)
|
||||
cd test && pnpm test:headed # Run tests with visible browser
|
||||
cd test && pnpm test:watch # Run tests in watch mode
|
||||
cd test && pnpm test:ui # Run tests with interactive UI
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 🔄 Automatic connection management with reconnection and retries
|
||||
- 🔐 OAuth authentication flow handling with popup and fallback support
|
||||
- 📦 Simple React hook interface for MCP integration
|
||||
- 🧰 Full support for MCP tools, resources, and prompts
|
||||
- 📄 Access server resources and read their contents
|
||||
- 💬 Use server-provided prompt templates
|
||||
- 🧰 TypeScript types for editor assistance and type checking
|
||||
- 📝 Comprehensive logging for debugging
|
||||
- 🌐 Works with both HTTP and SSE (Server-Sent Events) transports
|
||||
|
|
@ -36,10 +64,14 @@ import { useMcp } from 'use-mcp/react'
|
|||
|
||||
function MyAIComponent() {
|
||||
const {
|
||||
state, // Connection state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed'
|
||||
state, // Connection state: 'discovering' | 'pending_auth' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed'
|
||||
tools, // Available tools from MCP server
|
||||
resources, // Available resources from MCP server
|
||||
prompts, // Available prompts from MCP server
|
||||
error, // Error message if connection failed
|
||||
callTool, // Function to call tools on the MCP server
|
||||
readResource, // Function to read resource contents
|
||||
getPrompt, // Function to get prompt messages
|
||||
retry, // Retry connection manually
|
||||
authenticate, // Manually trigger authentication
|
||||
clearStorage, // Clear stored tokens and credentials
|
||||
|
|
@ -83,6 +115,32 @@ function MyAIComponent() {
|
|||
))}
|
||||
</ul>
|
||||
<button onClick={handleSearch}>Search</button>
|
||||
|
||||
{/* Example: Display and read resources */}
|
||||
{resources.length > 0 && (
|
||||
<div>
|
||||
<h3>Resources: {resources.length}</h3>
|
||||
<button onClick={async () => {
|
||||
const content = await readResource(resources[0].uri)
|
||||
console.log('Resource content:', content)
|
||||
}}>
|
||||
Read First Resource
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Example: Use prompts */}
|
||||
{prompts.length > 0 && (
|
||||
<div>
|
||||
<h3>Prompts: {prompts.length}</h3>
|
||||
<button onClick={async () => {
|
||||
const result = await getPrompt(prompts[0].name)
|
||||
console.log('Prompt messages:', result.messages)
|
||||
}}>
|
||||
Get First Prompt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -167,17 +225,27 @@ function useMcp(options: UseMcpOptions): UseMcpResult
|
|||
| `debug` | `boolean` | Whether to enable verbose debug logging |
|
||||
| `autoRetry` | `boolean \| number` | Auto retry connection if initial connection fails, with delay in ms |
|
||||
| `autoReconnect` | `boolean \| number` | Auto reconnect if an established connection is lost, with delay in ms (default: 3000) |
|
||||
| `transportType` | `'auto' \| 'http' \| 'sse'` | Transport type preference: 'auto' (HTTP with SSE fallback), 'http' (HTTP only), 'sse' (SSE only) (default: 'auto') |
|
||||
| `preventAutoAuth` | `boolean` | Prevent automatic authentication popup on initial connection (default: false) |
|
||||
| `onPopupWindow` | `(url: string, features: string, window: Window \| null) => void` | Callback invoked just after the authentication popup window is opened |
|
||||
|
||||
#### Return Value
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `state` | `string` | Current connection state: 'discovering', 'authenticating', 'connecting', 'loading', 'ready', 'failed' |
|
||||
| `state` | `string` | Current connection state: 'discovering', 'pending_auth', 'authenticating', 'connecting', 'loading', 'ready', 'failed' |
|
||||
| `tools` | `Tool[]` | Available tools from the MCP server |
|
||||
| `resources` | `Resource[]` | Available resources from the MCP server |
|
||||
| `resourceTemplates` | `ResourceTemplate[]` | Available resource templates from the MCP server |
|
||||
| `prompts` | `Prompt[]` | Available prompts from the MCP server |
|
||||
| `error` | `string \| undefined` | Error message if connection failed |
|
||||
| `authUrl` | `string \| undefined` | Manual authentication URL if popup is blocked |
|
||||
| `log` | `{ level: 'debug' \| 'info' \| 'warn' \| 'error'; message: string; timestamp: number }[]` | Array of log messages |
|
||||
| `callTool` | `(name: string, args?: Record<string, unknown>) => Promise<any>` | Function to call a tool on the MCP server |
|
||||
| `listResources` | `() => Promise<void>` | Refresh the list of available resources |
|
||||
| `readResource` | `(uri: string) => Promise<{ contents: Array<...> }>` | Read the contents of a specific resource |
|
||||
| `listPrompts` | `() => Promise<void>` | Refresh the list of available prompts |
|
||||
| `getPrompt` | `(name: string, args?: Record<string, string>) => Promise<{ messages: Array<...> }>` | Get a specific prompt with optional arguments |
|
||||
| `retry` | `() => void` | Manually attempt to reconnect |
|
||||
| `disconnect` | `() => void` | Disconnect from the MCP server |
|
||||
| `authenticate` | `() => void` | Manually trigger authentication |
|
||||
|
|
|
|||
3
examples/chat-ui/.env.example
Normal file
3
examples/chat-ui/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# OpenRouter OAuth Configuration
|
||||
# Note: OpenRouter OAuth doesn't require client ID, only callback URL configuration
|
||||
VITE_OPENROUTER_REDIRECT_URI=http://localhost:5002/oauth/openrouter/callback
|
||||
92
examples/chat-ui/MODEL_SELECTOR_GUIDE.md
Normal file
92
examples/chat-ui/MODEL_SELECTOR_GUIDE.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Model Selector Guide
|
||||
|
||||
This guide explains how to use the new favorites-based model selector system.
|
||||
|
||||
## Overview
|
||||
|
||||
The chat UI now supports:
|
||||
- **39 models** from 3 providers (Anthropic, Groq, OpenRouter)
|
||||
- **Favorites system** - star your preferred models
|
||||
- **Search functionality** - find models by name or provider
|
||||
- **Tool filtering** - show only models that support tool calling
|
||||
- **OAuth authentication** - seamless OpenRouter integration
|
||||
|
||||
## Features
|
||||
|
||||
### Model Selection
|
||||
- Click the model selector to view all available models
|
||||
- Models are loaded from the live models.dev API
|
||||
- Each model shows provider logo, name, and capabilities
|
||||
|
||||
### Favorites System
|
||||
- ⭐ Star models to add them to your favorites
|
||||
- Starred models are saved to local storage
|
||||
- Use the "Favorites" filter to show only starred models
|
||||
|
||||
### Search & Filtering
|
||||
- 🔍 Search box to find models by name or provider
|
||||
- 🔧 "Tools Only" filter (appears when MCP tools are available)
|
||||
- ⭐ "Favorites" filter to show only starred models
|
||||
|
||||
### Authentication
|
||||
- **API Keys**: Enter manually for Anthropic and Groq
|
||||
- **OAuth**: One-click authentication for OpenRouter
|
||||
- Authentication status shown with icons (✓ or ⚠️)
|
||||
|
||||
## Provider Support
|
||||
|
||||
### Anthropic
|
||||
- **Models**: 9 models including Claude 4 Sonnet
|
||||
- **Auth**: API key (requires manual entry)
|
||||
- **Tools**: All models support tool calling
|
||||
|
||||
### Groq
|
||||
- **Models**: 13 models including Llama and Qwen variants
|
||||
- **Auth**: API key (requires manual entry)
|
||||
- **Tools**: 11 models support tool calling
|
||||
|
||||
### OpenRouter
|
||||
- **Models**: 17 models from various providers
|
||||
- **Auth**: OAuth PKCE flow (one-click authentication)
|
||||
- **Tools**: All models support tool calling
|
||||
|
||||
## Setting Up OpenRouter OAuth
|
||||
|
||||
1. Create an OpenRouter account at https://openrouter.ai
|
||||
2. Go to your dashboard and create an OAuth app
|
||||
3. Set the redirect URI to: `http://localhost:5002/oauth/openrouter/callback`
|
||||
4. Copy your client ID to your `.env` file:
|
||||
```
|
||||
VITE_OPENROUTER_CLIENT_ID=your_client_id_here
|
||||
```
|
||||
|
||||
## Usage Tips
|
||||
|
||||
1. **Star your favorites** - This makes model selection much faster
|
||||
2. **Use search** - With 39 models, search helps find what you need
|
||||
3. **Filter by tools** - When using MCP tools, enable "Tools Only" filter
|
||||
4. **Try different providers** - Each has unique models with different strengths
|
||||
|
||||
## Model Data Updates
|
||||
|
||||
Model data is fetched from models.dev API and can be updated with:
|
||||
|
||||
```bash
|
||||
pnpm update-models
|
||||
```
|
||||
|
||||
This will refresh the model list with the latest information from the API.
|
||||
|
||||
## Local Storage
|
||||
|
||||
The following preferences are saved locally:
|
||||
- **Favorites**: `aiChatTemplate_favorites_v1`
|
||||
- **Tokens**: `aiChatTemplate_token_[provider]`
|
||||
- **Selected Model**: `aiChatTemplate_selectedModel`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **OAuth popup blocked**: Allow popups for this site
|
||||
- **Authentication failed**: Check your API keys or re-authenticate
|
||||
- **Model not working**: Verify the model supports the features you're using
|
||||
- **Tools not showing**: Enable "Tools Only" filter when MCP tools are configured
|
||||
|
|
@ -1,30 +1,36 @@
|
|||
# AI chat template
|
||||
# AI Chat with MCP
|
||||
|
||||
An unofficial template for ⚛️ React ⨉ ⚡️ Vite ⨉ ⛅️ Cloudflare Workers AI.
|
||||
A React-based AI chat application demonstrating [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) integration with multiple AI providers.
|
||||
|
||||
Full-stack AI chat application using Workers for the APIs (using the Cloudflare [vite-plugin](https://www.npmjs.com/package/@cloudflare/vite-plugin)) and Vite for the React application (hosted using [Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/)). Provides chat functionality with [Workers AI](https://developers.cloudflare.com/workers-ai/), stores conversations in the browser's [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API), and uses [ai-sdk](https://sdk.vercel.ai/docs/introduction), [tailwindcss](https://tailwindcss.com/) and [workers-ai-provider](https://github.com/cloudflare/workers-ai-provider).
|
||||
This static web application showcases how to use the [`use-mcp`](../../) library to connect to MCP servers, providing extensible AI capabilities through external tools and services. The app supports multiple AI models, stores conversations locally in IndexedDB, and includes OAuth authentication for MCP server connections.
|
||||
|
||||
**Live demo**: [chat.use-mcp.dev](https://chat.use-mcp.dev)
|
||||
|
||||
## Features
|
||||
|
||||
- **MCP Integration**: Connect to MCP servers with OAuth authentication support
|
||||
- **Multi-model Support**: Anthropic (Claude) and Groq (Llama) models with API key authentication
|
||||
- **Local Storage**: Conversations stored in browser's IndexedDB
|
||||
- **Static Deployment**: Builds to static assets for deployment anywhere
|
||||
- **Modern Stack**: React 19, TypeScript, Tailwind CSS, Vite
|
||||
|
||||
## Get started
|
||||
|
||||
Create the project using [create-cloudflare](https://www.npmjs.com/package/create-cloudflare):
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Build and deploy:
|
||||
|
||||
```sh
|
||||
npm create cloudflare@latest -- --template thomasgauvin/ai-chat-template
|
||||
pnpm build
|
||||
pnpm run deploy # deploys to Cloudflare Pages
|
||||
```
|
||||
|
||||
Run the project and deploy it:
|
||||
## Development
|
||||
|
||||
```sh
|
||||
cd <project-name>
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## What's next?
|
||||
|
||||
- Change the name of the package (in `package.json`)
|
||||
- Change the name of the worker (in `wrangler.jsonc`)
|
||||
- **Dev server**: `pnpm dev` (runs on port 5002)
|
||||
- **Build**: `pnpm build`
|
||||
- **Lint**: `pnpm lint`
|
||||
- **Test**: `pnpm test` (Playwright E2E tests)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --port=5002",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
|
|
@ -12,11 +12,13 @@
|
|||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:html": "playwright test --reporter=html"
|
||||
"test:html": "playwright test --reporter=html",
|
||||
"update-models": "tsx scripts/update-models.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^1.2.12",
|
||||
"@ai-sdk/groq": "^1.2.9",
|
||||
"@ai-sdk/openai": "^1.3.23",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"ai": "^4.1.61",
|
||||
|
|
@ -30,7 +32,6 @@
|
|||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"use-mcp": "link:../..",
|
||||
"workers-ai-provider": "^0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -47,8 +48,10 @@
|
|||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"prettier": "^3.5.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"use-mcp": "link:../..",
|
||||
"vite": "^6.2.0",
|
||||
"wrangler": "^4.1.0"
|
||||
},
|
||||
|
|
|
|||
71
examples/chat-ui/pnpm-lock.yaml
generated
71
examples/chat-ui/pnpm-lock.yaml
generated
|
|
@ -14,12 +14,15 @@ importers:
|
|||
'@ai-sdk/groq':
|
||||
specifier: ^1.2.9
|
||||
version: 1.2.9(zod@3.24.4)
|
||||
'@ai-sdk/openai':
|
||||
specifier: ^1.3.23
|
||||
version: 1.3.23(zod@3.24.4)
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.16
|
||||
version: 0.5.16(tailwindcss@4.1.6)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.0.14
|
||||
version: 4.1.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))
|
||||
version: 4.1.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))
|
||||
ai:
|
||||
specifier: ^4.1.61
|
||||
version: 4.3.15(react@19.1.0)(zod@3.24.4)
|
||||
|
|
@ -53,9 +56,6 @@ importers:
|
|||
tailwindcss:
|
||||
specifier: ^4.0.14
|
||||
version: 4.1.6
|
||||
use-mcp:
|
||||
specifier: link:../..
|
||||
version: link:../..
|
||||
workers-ai-provider:
|
||||
specifier: ^0.1.3
|
||||
version: 0.1.3(react@19.1.0)
|
||||
|
|
@ -65,7 +65,7 @@ importers:
|
|||
version: 1.2.11(zod@3.24.4)
|
||||
'@cloudflare/vite-plugin':
|
||||
specifier: ^0.1.12
|
||||
version: 0.1.21(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))(workerd@1.20250507.0)(wrangler@4.14.4(@cloudflare/workers-types@4.20250510.0))
|
||||
version: 0.1.21(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))(workerd@1.20250507.0)(wrangler@4.14.4(@cloudflare/workers-types@4.20250510.0))
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^4.20250317.0
|
||||
version: 4.20250510.0
|
||||
|
|
@ -83,7 +83,7 @@ importers:
|
|||
version: 19.1.3(@types/react@19.1.3)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.4
|
||||
version: 4.4.1(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))
|
||||
version: 4.4.1(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))
|
||||
eslint:
|
||||
specifier: ^9.21.0
|
||||
version: 9.26.0(jiti@2.4.2)
|
||||
|
|
@ -99,15 +99,21 @@ importers:
|
|||
prettier:
|
||||
specifier: ^3.5.3
|
||||
version: 3.5.3
|
||||
tsx:
|
||||
specifier: ^4.20.3
|
||||
version: 4.20.3
|
||||
typescript:
|
||||
specifier: ~5.7.2
|
||||
version: 5.7.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.24.1
|
||||
version: 8.32.0(eslint@9.26.0(jiti@2.4.2))(typescript@5.7.3)
|
||||
use-mcp:
|
||||
specifier: link:../..
|
||||
version: link:../..
|
||||
vite:
|
||||
specifier: ^6.2.0
|
||||
version: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)
|
||||
version: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3)
|
||||
wrangler:
|
||||
specifier: ^4.1.0
|
||||
version: 4.14.4(@cloudflare/workers-types@4.20250510.0)
|
||||
|
|
@ -126,6 +132,12 @@ packages:
|
|||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
|
||||
'@ai-sdk/openai@1.3.23':
|
||||
resolution: {integrity: sha512-86U7rFp8yacUAOE/Jz8WbGcwMCqWvjK33wk5DXkfnAOEn3mx2r7tNSJdjukQFZbAK97VMXGPPHxF+aEARDXRXQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
|
||||
'@ai-sdk/provider-utils@1.0.22':
|
||||
resolution: {integrity: sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -1496,6 +1508,9 @@ packages:
|
|||
get-source@2.0.12:
|
||||
resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==}
|
||||
|
||||
get-tsconfig@4.10.1:
|
||||
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
@ -2186,6 +2201,9 @@ packages:
|
|||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
|
@ -2358,6 +2376,11 @@ packages:
|
|||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsx@4.20.3:
|
||||
resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -2572,6 +2595,12 @@ snapshots:
|
|||
'@ai-sdk/provider-utils': 2.2.8(zod@3.24.4)
|
||||
zod: 3.24.4
|
||||
|
||||
'@ai-sdk/openai@1.3.23(zod@3.24.4)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.1.3
|
||||
'@ai-sdk/provider-utils': 2.2.8(zod@3.24.4)
|
||||
zod: 3.24.4
|
||||
|
||||
'@ai-sdk/provider-utils@1.0.22(zod@3.24.4)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 0.0.26
|
||||
|
|
@ -2738,7 +2767,7 @@ snapshots:
|
|||
optionalDependencies:
|
||||
workerd: 1.20250507.0
|
||||
|
||||
'@cloudflare/vite-plugin@0.1.21(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))(workerd@1.20250507.0)(wrangler@4.14.4(@cloudflare/workers-types@4.20250510.0))':
|
||||
'@cloudflare/vite-plugin@0.1.21(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))(workerd@1.20250507.0)(wrangler@4.14.4(@cloudflare/workers-types@4.20250510.0))':
|
||||
dependencies:
|
||||
'@cloudflare/unenv-preset': 2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250507.0)
|
||||
'@hattip/adapter-node': 0.0.49
|
||||
|
|
@ -2747,7 +2776,7 @@ snapshots:
|
|||
picocolors: 1.1.1
|
||||
tinyglobby: 0.2.13
|
||||
unenv: 2.0.0-rc.15
|
||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)
|
||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3)
|
||||
wrangler: 4.14.4(@cloudflare/workers-types@4.20250510.0)
|
||||
ws: 8.18.0
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -3222,12 +3251,12 @@ snapshots:
|
|||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 4.1.6
|
||||
|
||||
'@tailwindcss/vite@4.1.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))':
|
||||
'@tailwindcss/vite@4.1.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.1.6
|
||||
'@tailwindcss/oxide': 4.1.6
|
||||
tailwindcss: 4.1.6
|
||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)
|
||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3)
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
|
|
@ -3365,14 +3394,14 @@ snapshots:
|
|||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@4.4.1(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))':
|
||||
'@vitejs/plugin-react@4.4.1(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))':
|
||||
dependencies:
|
||||
'@babel/core': 7.27.1
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1)
|
||||
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.1)
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.17.0
|
||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)
|
||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -3884,6 +3913,10 @@ snapshots:
|
|||
data-uri-to-buffer: 2.0.2
|
||||
source-map: 0.6.1
|
||||
|
||||
get-tsconfig@4.10.1:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -4758,6 +4791,8 @@ snapshots:
|
|||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rollup@4.40.2:
|
||||
|
|
@ -4985,6 +5020,13 @@ snapshots:
|
|||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.20.3:
|
||||
dependencies:
|
||||
esbuild: 0.25.4
|
||||
get-tsconfig: 4.10.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
|
@ -5091,7 +5133,7 @@ snapshots:
|
|||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.2
|
||||
|
||||
vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2):
|
||||
vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.4
|
||||
fdir: 6.4.4(picomatch@4.0.2)
|
||||
|
|
@ -5103,6 +5145,7 @@ snapshots:
|
|||
fsevents: 2.3.3
|
||||
jiti: 2.4.2
|
||||
lightningcss: 1.29.2
|
||||
tsx: 4.20.3
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
|
|
|
|||
105
examples/chat-ui/scripts/update-models.ts
Normal file
105
examples/chat-ui/scripts/update-models.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env tsx
|
||||
|
||||
import { writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
interface ModelData {
|
||||
id: string
|
||||
name: string
|
||||
attachment: boolean
|
||||
reasoning: boolean
|
||||
temperature: boolean
|
||||
tool_call: boolean
|
||||
knowledge: string
|
||||
release_date: string
|
||||
last_updated: string
|
||||
modalities: {
|
||||
input: string[]
|
||||
output: string[]
|
||||
}
|
||||
open_weights: boolean
|
||||
limit: {
|
||||
context: number
|
||||
output: number
|
||||
}
|
||||
cost?: {
|
||||
input: number
|
||||
output: number
|
||||
cache_read?: number
|
||||
cache_write?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderData {
|
||||
models: Record<string, ModelData>
|
||||
}
|
||||
|
||||
interface ModelsDevData {
|
||||
anthropic: ProviderData
|
||||
groq: ProviderData
|
||||
openrouter: ProviderData
|
||||
[key: string]: ProviderData
|
||||
}
|
||||
|
||||
const SUPPORTED_PROVIDERS = ['anthropic', 'groq', 'openrouter'] as const
|
||||
type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]
|
||||
|
||||
async function fetchModelsData(): Promise<ModelsDevData> {
|
||||
console.log('Fetching models data from models.dev...')
|
||||
|
||||
const response = await fetch('https://models.dev/api.json')
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch models data: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
function filterAndTransformModels(data: ModelsDevData) {
|
||||
const filtered: Record<SupportedProvider, Record<string, ModelData>> = {
|
||||
anthropic: {},
|
||||
groq: {},
|
||||
openrouter: {},
|
||||
}
|
||||
|
||||
// Filter by supported providers
|
||||
for (const provider of SUPPORTED_PROVIDERS) {
|
||||
if (data[provider] && data[provider].models) {
|
||||
filtered[provider] = data[provider].models
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const data = await fetchModelsData()
|
||||
const filteredData = filterAndTransformModels(data)
|
||||
|
||||
const outputPath = join(process.cwd(), 'src', 'data', 'models.json')
|
||||
writeFileSync(outputPath, JSON.stringify(filteredData, null, 2))
|
||||
|
||||
console.log(`✅ Models data updated successfully at ${outputPath}`)
|
||||
|
||||
// Print summary
|
||||
let totalModels = 0
|
||||
let toolSupportingModels = 0
|
||||
|
||||
for (const [provider, models] of Object.entries(filteredData)) {
|
||||
const modelCount = Object.keys(models).length
|
||||
const toolModels = Object.values(models).filter((m) => m.tool_call).length
|
||||
|
||||
console.log(` ${provider}: ${modelCount} models (${toolModels} support tools)`)
|
||||
totalModels += modelCount
|
||||
toolSupportingModels += toolModels
|
||||
}
|
||||
|
||||
console.log(`\nTotal: ${totalModels} models (${toolSupportingModels} support tools)`)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update models data:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import ChatApp from './components/ChatApp'
|
||||
import { OAuthCallback } from './components/OAuthCallback'
|
||||
import PkceCallback from './components/PkceCallback.tsx'
|
||||
import { OAuthCallback } from './components/OAuthCallback.tsx'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/oauth/groq/callback" element={<PkceCallback provider="groq" />} />
|
||||
<Route path="/oauth/openrouter/callback" element={<PkceCallback provider="openrouter" />} />
|
||||
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route path="/" element={<ChatApp />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, Eye, EyeOff } from 'lucide-react'
|
||||
import { type ModelProvider } from '../types/models'
|
||||
import { type Provider } from '../types/models'
|
||||
|
||||
interface ApiKeyModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
provider: ModelProvider
|
||||
provider: Provider
|
||||
onSave: (apiKey: string) => void
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import ConversationThread from './ConversationThread.tsx'
|
||||
import ChatSidebar from './ChatSidebar'
|
||||
import ChatNavbar from './ChatNavbar'
|
||||
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 { useModels } from '../hooks/useModels'
|
||||
import { type IDBPDatabase } from 'idb'
|
||||
import { type Tool } from 'use-mcp/react'
|
||||
|
||||
|
|
@ -14,17 +17,123 @@ interface ChatAppProps {}
|
|||
const ChatApp: React.FC<ChatAppProps> = () => {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([])
|
||||
const [conversationId, setConversationId] = useState<number | undefined>(undefined)
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false)
|
||||
|
||||
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 { models, addToFavorites, toggleFavorite, isFavorite, getFavoriteModels } = useModels()
|
||||
|
||||
const handleApiKeyUpdate = () => {
|
||||
setApiKeyUpdateTrigger((prev) => prev + 1)
|
||||
}
|
||||
|
||||
// Handle OAuth success messages from popups
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
console.log('DEBUG: Received message in parent window:', event.data)
|
||||
if (event.data.type === 'oauth_success') {
|
||||
console.log('DEBUG: OAuth success message received, triggering API key update')
|
||||
handleApiKeyUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
}, [])
|
||||
|
||||
// Poll for OAuth token changes (fallback for when popup messaging doesn't work)
|
||||
useEffect(() => {
|
||||
const oauthProviders = ['groq', 'openrouter'] as const
|
||||
let initialTokens: Record<string, string | null> = {}
|
||||
|
||||
// Capture initial token state
|
||||
const captureInitialTokens = () => {
|
||||
for (const providerId of oauthProviders) {
|
||||
const tokenKey = `aiChatTemplate_token_${providerId}`
|
||||
initialTokens[providerId] = localStorage.getItem(tokenKey)
|
||||
}
|
||||
}
|
||||
|
||||
const checkForNewTokens = () => {
|
||||
for (const providerId of oauthProviders) {
|
||||
const tokenKey = `aiChatTemplate_token_${providerId}`
|
||||
const currentToken = localStorage.getItem(tokenKey)
|
||||
|
||||
// Check if token was added or changed
|
||||
if (currentToken !== initialTokens[providerId]) {
|
||||
try {
|
||||
const parsedToken = JSON.parse(currentToken || '{}')
|
||||
if (parsedToken.access_token) {
|
||||
console.log('DEBUG: New OAuth token detected for', providerId, 'via polling')
|
||||
handleApiKeyUpdate()
|
||||
return true // Stop polling once we find a new token
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid token format, continue checking
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let pollInterval: number | null = null
|
||||
|
||||
const startPolling = () => {
|
||||
// Capture initial state
|
||||
captureInitialTokens()
|
||||
|
||||
// Check immediately for existing valid tokens (in case we just redirected from OAuth)
|
||||
console.log('DEBUG: Checking for existing OAuth tokens on startup')
|
||||
for (const providerId of oauthProviders) {
|
||||
const tokenKey = `aiChatTemplate_token_${providerId}`
|
||||
const currentToken = localStorage.getItem(tokenKey)
|
||||
|
||||
console.log(`DEBUG: Checking ${providerId} token:`, currentToken ? 'exists' : 'not found')
|
||||
|
||||
if (currentToken) {
|
||||
try {
|
||||
const parsedToken = JSON.parse(currentToken)
|
||||
console.log(`DEBUG: Parsed ${providerId} token:`, parsedToken)
|
||||
if (parsedToken.access_token) {
|
||||
console.log('DEBUG: Found existing OAuth token for', providerId, 'on startup')
|
||||
handleApiKeyUpdate()
|
||||
return // Don't start polling if we found a valid token
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`DEBUG: Failed to parse ${providerId} token:`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling every 500ms for new tokens
|
||||
pollInterval = setInterval(() => {
|
||||
if (checkForNewTokens()) {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
console.log('DEBUG: Stopped polling for OAuth tokens')
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
|
||||
console.log('DEBUG: Started polling for OAuth token changes')
|
||||
}
|
||||
|
||||
// Start polling after a short delay to allow for popup messages first
|
||||
const delayedStart = setTimeout(startPolling, 100)
|
||||
|
||||
return () => {
|
||||
if (delayedStart) clearTimeout(delayedStart)
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
console.log('DEBUG: Cleaned up OAuth token polling')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleModelChange = (model: Model) => {
|
||||
setSelectedModel(model)
|
||||
saveSelectedModel(model)
|
||||
|
|
@ -65,14 +174,12 @@ const ChatApp: React.FC<ChatAppProps> = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 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
|
||||
|
|
@ -99,29 +206,27 @@ const ChatApp: React.FC<ChatAppProps> = () => {
|
|||
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}*/}
|
||||
{/* />*/}
|
||||
{/* </>*/}
|
||||
{/*)}*/}
|
||||
{/* Sidebar and Navbar components */}
|
||||
{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}
|
||||
mcpTools={mcpTools}
|
||||
/>
|
||||
<ChatNavbar sidebarVisible={sidebarVisible} setSidebarVisible={setSidebarVisible} />
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col flex-grow h-full w-full">
|
||||
<ConversationThread
|
||||
conversations={conversations}
|
||||
|
|
@ -135,6 +240,11 @@ const ChatApp: React.FC<ChatAppProps> = () => {
|
|||
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
|
||||
mcpTools={mcpTools}
|
||||
onMcpToolsUpdate={setMcpTools}
|
||||
addToFavorites={addToFavorites}
|
||||
models={models}
|
||||
toggleFavorite={toggleFavorite}
|
||||
isFavorite={isFavorite}
|
||||
getFavoriteModels={getFavoriteModels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ interface ChatSidebarProps {
|
|||
onModelChange: (model: Model) => void
|
||||
apiKeyUpdateTrigger: number
|
||||
onMcpToolsUpdate: (tools: Tool[]) => void
|
||||
mcpTools: Tool[]
|
||||
}
|
||||
|
||||
const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
|
|
@ -40,6 +41,7 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
|||
onModelChange,
|
||||
apiKeyUpdateTrigger,
|
||||
onMcpToolsUpdate,
|
||||
mcpTools,
|
||||
}) => {
|
||||
const handleConversationClick = (id: number | undefined) => {
|
||||
setConversationId(id)
|
||||
|
|
@ -135,7 +137,12 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
|||
|
||||
{/* 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} />
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
|
||||
toolsAvailable={mcpTools.length > 0}
|
||||
/>
|
||||
<McpServers onToolsUpdate={onMcpToolsUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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'
|
||||
|
|
@ -29,6 +30,11 @@ interface ConversationThreadProps {
|
|||
apiKeyUpdateTrigger: number
|
||||
mcpTools: Tool[]
|
||||
onMcpToolsUpdate: (tools: Tool[]) => void
|
||||
addToFavorites: (modelId: string) => void
|
||||
models: Model[]
|
||||
toggleFavorite: (modelId: string) => void
|
||||
isFavorite: (modelId: string) => boolean
|
||||
getFavoriteModels: () => Model[]
|
||||
}
|
||||
|
||||
const ConversationThread: React.FC<ConversationThreadProps> = ({
|
||||
|
|
@ -43,6 +49,11 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
|
|||
apiKeyUpdateTrigger,
|
||||
mcpTools,
|
||||
onMcpToolsUpdate,
|
||||
addToFavorites,
|
||||
models,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
getFavoriteModels,
|
||||
}) => {
|
||||
const [input, setInput] = useState<string>('')
|
||||
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
|
||||
|
|
@ -257,7 +268,17 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
|
|||
<ApiKeyModal
|
||||
isOpen={apiKeyModal.isOpen}
|
||||
onClose={handleApiKeyCancel}
|
||||
provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }}
|
||||
provider={
|
||||
apiKeyModal.model?.provider ?? {
|
||||
id: 'unknown' as any,
|
||||
name: 'Unknown',
|
||||
baseUrl: '',
|
||||
logo: '',
|
||||
documentationUrl: '',
|
||||
authType: 'apiKey',
|
||||
apiKeyHeader: '',
|
||||
}
|
||||
}
|
||||
onSave={handleApiKeySave}
|
||||
/>
|
||||
|
||||
|
|
@ -267,6 +288,11 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
|
|||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
|
||||
addToFavorites={addToFavorites}
|
||||
models={models}
|
||||
toggleFavorite={toggleFavorite}
|
||||
isFavorite={isFavorite}
|
||||
getFavoriteModels={getFavoriteModels}
|
||||
/>
|
||||
|
||||
<McpServerModal isOpen={mcpServerModal} onClose={() => setMcpServerModal(false)} onToolsUpdate={onMcpToolsUpdate} />
|
||||
|
|
|
|||
|
|
@ -11,13 +11,7 @@ interface McpServer {
|
|||
}
|
||||
|
||||
// MCP Connection wrapper for a single server
|
||||
function McpConnection({
|
||||
server,
|
||||
onConnectionUpdate,
|
||||
}: {
|
||||
server: McpServer
|
||||
onConnectionUpdate: (serverId: string, data: any) => void
|
||||
}) {
|
||||
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,
|
||||
|
|
@ -25,6 +19,7 @@ function McpConnection({
|
|||
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
|
||||
|
|
@ -54,28 +49,12 @@ const McpServerModal: React.FC<McpServerModalProps> = ({ isOpen, onClose, onTool
|
|||
const stored = localStorage.getItem('mcpServerToolCounts')
|
||||
return stored ? JSON.parse(stored) : {}
|
||||
})
|
||||
const [transportType, setTransportType] = useState<'auto' | 'http' | 'sse'>(() => {
|
||||
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 transport types
|
||||
const cycleTransportType = () => {
|
||||
setTransportType((current) => {
|
||||
switch (current) {
|
||||
case 'auto':
|
||||
return 'http'
|
||||
case 'http':
|
||||
return 'sse'
|
||||
case 'sse':
|
||||
return 'auto'
|
||||
default:
|
||||
return 'auto'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper to cycle through new server transport types
|
||||
const cycleNewServerTransportType = () => {
|
||||
setNewServerTransportType((current) => {
|
||||
|
|
@ -222,6 +201,8 @@ const McpServerModal: React.FC<McpServerModalProps> = ({ isOpen, onClose, onTool
|
|||
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':
|
||||
|
|
@ -315,18 +296,31 @@ const McpServerModal: React.FC<McpServerModalProps> = ({ isOpen, onClose, onTool
|
|||
<div className="text-sm text-red-600 p-3 bg-red-50 rounded border mb-3">{error}</div>
|
||||
)}
|
||||
|
||||
{authUrl && (
|
||||
<div className="p-3 bg-orange-50 border border-orange-200 rounded mb-3">
|
||||
<p className="text-sm mb-2">Authentication required. Please click the link below:</p>
|
||||
<a
|
||||
href={authUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-orange-700 hover:text-orange-800 underline"
|
||||
onClick={() => handleManualAuth(server.id)}
|
||||
>
|
||||
Authenticate in new window
|
||||
</a>
|
||||
{(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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ function McpConnection({ serverUrl, onConnectionUpdate }: { serverUrl: string; o
|
|||
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
|
||||
|
|
@ -103,6 +104,8 @@ export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[])
|
|||
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':
|
||||
|
|
@ -184,19 +187,32 @@ export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[])
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Authentication Link if needed */}
|
||||
{authUrl && (
|
||||
<div className="p-3 bg-orange-50 border border-orange-200 rounded">
|
||||
<p className="text-xs mb-2">Authentication required. Please click the link below:</p>
|
||||
<a
|
||||
href={authUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-orange-700 hover:text-orange-800 underline"
|
||||
onClick={handleManualAuth}
|
||||
>
|
||||
Authenticate in new window
|
||||
</a>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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 { X, AlertCircle, CheckCircle, Star, ArrowLeft, Search } from 'lucide-react'
|
||||
import { type Model, type Provider, providers } from '../types/models'
|
||||
import { hasApiKey, setApiKey, clearApiKey, beginOAuthFlow } from '../utils/auth'
|
||||
import ApiKeyModal from './ApiKeyModal'
|
||||
|
||||
interface ModelSelectionModalProps {
|
||||
|
|
@ -10,6 +10,11 @@ interface ModelSelectionModalProps {
|
|||
selectedModel: Model
|
||||
onModelChange: (model: Model) => void
|
||||
apiKeyUpdateTrigger: number
|
||||
addToFavorites: (modelId: string) => void
|
||||
models: Model[]
|
||||
toggleFavorite: (modelId: string) => void
|
||||
isFavorite: (modelId: string) => boolean
|
||||
getFavoriteModels: () => Model[]
|
||||
}
|
||||
|
||||
const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
|
||||
|
|
@ -18,20 +23,28 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
|
|||
selectedModel,
|
||||
onModelChange,
|
||||
apiKeyUpdateTrigger,
|
||||
addToFavorites,
|
||||
models,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
getFavoriteModels,
|
||||
}) => {
|
||||
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
|
||||
isOpen: false,
|
||||
model: null,
|
||||
})
|
||||
const [currentView, setCurrentView] = useState<'main' | 'provider'>('main')
|
||||
const [selectedProvider, setSelectedProvider] = useState<Provider | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [apiKeyStatuses, setApiKeyStatuses] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const statuses: Record<string, boolean> = {}
|
||||
availableModels.forEach((model) => {
|
||||
models.forEach((model) => {
|
||||
statuses[model.provider.id] = hasApiKey(model.provider.id)
|
||||
})
|
||||
setApiKeyStatuses(statuses)
|
||||
}, [apiKeyUpdateTrigger])
|
||||
}, [apiKeyUpdateTrigger, models])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
|
|
@ -45,33 +58,104 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
|
|||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleModelSelect = (model: Model) => {
|
||||
const hasKey = hasApiKey(model.provider.id)
|
||||
|
||||
if (!hasKey) {
|
||||
setApiKeyModal({ isOpen: true, model })
|
||||
} else {
|
||||
onModelChange(model)
|
||||
onClose()
|
||||
// Handle OAuth success - show provider models when OAuth completes
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data.type === 'oauth_success' && event.data.provider && isOpen) {
|
||||
const provider = providers[event.data.provider as keyof typeof providers]
|
||||
if (provider) {
|
||||
setCurrentView('provider')
|
||||
setSelectedProvider(provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
}, [isOpen])
|
||||
|
||||
// Reset view when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setCurrentView('main')
|
||||
setSelectedProvider(null)
|
||||
setSearchTerm('')
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleApiKeySave = (apiKey: string) => {
|
||||
if (apiKeyModal.model) {
|
||||
setApiKey(apiKeyModal.model.provider.id, apiKey)
|
||||
setApiKeyStatuses((prev) => ({ ...prev, [apiKeyModal.model!.provider.id]: true }))
|
||||
onModelChange(apiKeyModal.model)
|
||||
|
||||
// Show provider models after API key is saved
|
||||
const provider = apiKeyModal.model.provider
|
||||
setApiKeyModal({ isOpen: false, model: null })
|
||||
onClose()
|
||||
setCurrentView('provider')
|
||||
setSelectedProvider(provider)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearApiKey = (providerId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
clearApiKey(providerId)
|
||||
clearApiKey(providerId as any)
|
||||
setApiKeyStatuses((prev) => ({ ...prev, [providerId]: false }))
|
||||
}
|
||||
|
||||
const handleProviderSelect = (provider: Provider) => {
|
||||
if (provider.authType === 'oauth') {
|
||||
// Check if already authenticated
|
||||
if (hasApiKey(provider.id)) {
|
||||
// Show provider models to select from
|
||||
setCurrentView('provider')
|
||||
setSelectedProvider(provider)
|
||||
} else {
|
||||
// Start OAuth flow
|
||||
beginOAuthFlow(provider.id)
|
||||
.then(() => {
|
||||
// OAuth flow started successfully - modal will be shown via OAuth success handler
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start OAuth flow:', error)
|
||||
alert('Failed to start authentication. Please try again.')
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Check if already has API key
|
||||
if (hasApiKey(provider.id)) {
|
||||
// Show provider models to select from
|
||||
setCurrentView('provider')
|
||||
setSelectedProvider(provider)
|
||||
} else {
|
||||
// Show API key modal first
|
||||
setApiKeyModal({ isOpen: true, model: { provider } as Model })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackToMain = () => {
|
||||
setCurrentView('main')
|
||||
setSelectedProvider(null)
|
||||
setSearchTerm('')
|
||||
}
|
||||
|
||||
const getProviderModels = () => {
|
||||
if (!selectedProvider) return []
|
||||
return models.filter((model) => model.provider.id === selectedProvider.id)
|
||||
}
|
||||
|
||||
const getFilteredModels = () => {
|
||||
const providerModels = getProviderModels()
|
||||
if (!searchTerm) return providerModels
|
||||
return providerModels.filter((model) => model.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
}
|
||||
|
||||
const handleModelSelect = (model: Model) => {
|
||||
onModelChange(model)
|
||||
addToFavorites(model.id)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const getStatusIcon = (providerId: string) => {
|
||||
const hasKey = apiKeyStatuses[providerId]
|
||||
|
||||
|
|
@ -91,57 +175,211 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
|
|||
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>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-zinc-200 flex-shrink-0">
|
||||
{currentView === 'provider' && selectedProvider ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleBackToMain} className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<span className="text-2xl">{selectedProvider.logo}</span>
|
||||
<h2 className="text-xl font-semibold text-zinc-900">{selectedProvider.name} Models</h2>
|
||||
</div>
|
||||
) : (
|
||||
<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 className="p-6 overflow-y-auto flex-1 min-h-0">
|
||||
{currentView === 'main' ? (
|
||||
<>
|
||||
{/* Providers Section */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-medium text-zinc-900 mb-3">Providers</h3>
|
||||
<div className="space-y-2">
|
||||
{Object.values(providers).map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="border rounded-lg p-4 cursor-pointer transition-colors hover:border-zinc-300 hover:bg-zinc-50"
|
||||
onClick={() => handleProviderSelect(provider)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{provider.logo}</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-zinc-900">{provider.name}</h4>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{provider.authType === 'oauth' ? 'OAuth Authentication' : 'API Key Authentication'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(provider.id)}
|
||||
{apiKeyStatuses[provider.id] && (
|
||||
<button
|
||||
onClick={(e) => handleClearApiKey(provider.id, e)}
|
||||
className="text-zinc-400 hover:text-red-500 p-1"
|
||||
title="Clear credentials"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
{/* Starred Models Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-zinc-900 mb-3 flex items-center gap-2">
|
||||
<Star size={20} fill="currentColor" className="text-yellow-500" />
|
||||
Starred Models
|
||||
</h3>
|
||||
{getFavoriteModels().length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-500">
|
||||
<Star size={48} className="mx-auto mb-3 text-zinc-300" />
|
||||
<p className="text-lg font-medium">No starred models yet</p>
|
||||
<p className="text-sm">Click on a provider above to explore and star models</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{getFavoriteModels().map((model) => {
|
||||
const isConfigured = apiKeyStatuses[model.provider.id]
|
||||
const isSelected = model.id === selectedModel.id
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-200'
|
||||
: 'border-zinc-200 hover:border-zinc-300 hover:bg-zinc-50'
|
||||
} ${!isConfigured ? 'opacity-50' : ''}`}
|
||||
onClick={() => {
|
||||
if (isConfigured) {
|
||||
onModelChange(model)
|
||||
addToFavorites(model.id)
|
||||
} else {
|
||||
handleProviderSelect(model.provider)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Star size={16} fill="currentColor" className="text-yellow-500" />
|
||||
{isSelected && <span className="text-blue-500">✓</span>}
|
||||
<span className="text-lg">{model.provider.logo}</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-zinc-900">{model.name}</h4>
|
||||
<div className="text-sm text-zinc-500 flex items-center gap-2">
|
||||
{model.provider.name}
|
||||
{model.supportsTools && <span className="text-green-600">🔧</span>}
|
||||
{model.reasoning && <span className="text-purple-600">🧠</span>}
|
||||
{model.attachment && <span className="text-blue-600">📎</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isConfigured && (
|
||||
<div title="Provider not configured">
|
||||
<AlertCircle size={16} className="text-orange-500" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFavorite(model.id)
|
||||
}}
|
||||
className="text-yellow-500 hover:text-yellow-600 p-1"
|
||||
title="Remove from favorites"
|
||||
>
|
||||
<Star size={16} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Provider Models View */
|
||||
selectedProvider && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="text-zinc-600 mb-4">Select models to add to your favorites</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-4">
|
||||
<Search size={20} className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-zinc-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Models List */}
|
||||
<div className="space-y-2">
|
||||
{getFilteredModels().map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className="border rounded-lg p-4 hover:border-zinc-300 hover:bg-zinc-50 transition-colors cursor-pointer"
|
||||
onClick={() => handleModelSelect(model)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFavorite(model.id)
|
||||
}}
|
||||
className={`p-1 rounded ${isFavorite(model.id) ? 'text-yellow-500' : 'text-zinc-300 hover:text-yellow-500'}`}
|
||||
title={isFavorite(model.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star size={16} fill={isFavorite(model.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<div>
|
||||
<h4 className="font-medium text-zinc-900">{model.name}</h4>
|
||||
<div className="text-sm text-zinc-500 flex items-center gap-2">
|
||||
{model.supportsTools && <span className="text-green-600">🔧 Tools</span>}
|
||||
{model.reasoning && <span className="text-purple-600">🧠 Reasoning</span>}
|
||||
{model.attachment && <span className="text-blue-600">📎 Attachments</span>}
|
||||
<span>•</span>
|
||||
<span>{(model.contextLimit / 1000).toFixed(0)}K context</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-400">Click to select</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{getFilteredModels().length === 0 && (
|
||||
<div className="text-center py-8 text-zinc-500">
|
||||
<p>No models found matching "{searchTerm}"</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center text-sm text-zinc-500">
|
||||
{getProviderModels().length} model{getProviderModels().length !== 1 ? 's' : ''} available
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -149,7 +387,17 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
|
|||
<ApiKeyModal
|
||||
isOpen={apiKeyModal.isOpen}
|
||||
onClose={() => setApiKeyModal({ isOpen: false, model: null })}
|
||||
provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }}
|
||||
provider={
|
||||
apiKeyModal.model?.provider ?? {
|
||||
id: 'unknown' as any,
|
||||
name: 'Unknown',
|
||||
baseUrl: '',
|
||||
logo: '',
|
||||
documentationUrl: '',
|
||||
authType: 'apiKey',
|
||||
apiKeyHeader: '',
|
||||
}
|
||||
}
|
||||
onSave={handleApiKeySave}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue