Initial commit
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
cloud/*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.bin/*
|
||||
data/
|
||||
logs/*
|
||||
source/*
|
||||
.cursor/*
|
||||
docs/*
|
||||
test/*
|
||||
bin/*
|
||||
open-sse/test/*
|
||||
RM.vn.md
|
||||
RM.md
|
||||
cursor/*
|
||||
stitch_router4_landing_page/*
|
||||
60
.npmignore
Normal file
@@ -0,0 +1,60 @@
|
||||
# Database files - NEVER publish
|
||||
data/
|
||||
**/data/
|
||||
**/db.json
|
||||
|
||||
# Development
|
||||
src/
|
||||
docs/
|
||||
test/
|
||||
agents/
|
||||
scripts/
|
||||
worker/
|
||||
shared-sse/
|
||||
copilot-api/
|
||||
CLIProxyAPI/
|
||||
|
||||
# Config files
|
||||
*.md
|
||||
!README.md
|
||||
.gitignore
|
||||
.env*
|
||||
jsconfig.json
|
||||
eslint.config.mjs
|
||||
postcss.config.mjs
|
||||
next.config.mjs
|
||||
tsconfig.json
|
||||
|
||||
# Build artifacts that shouldn't be published
|
||||
.next/cache/
|
||||
.next/standalone/data/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
155
README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 🚀 9ROUTER
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://github.com/yourusername/9router/blob/main/LICENSE)
|
||||
|
||||
AI endpoint proxy with web dashboard - A JavaScript port of [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
|
||||
|
||||

|
||||
|
||||
## 📖 Introduction
|
||||
|
||||
**9Router** is a powerful AI API proxy server that provides unified access to multiple AI providers through a single endpoint. It features automatic format translation, intelligent fallback routing, OAuth authentication, and a modern web dashboard for easy management.
|
||||
|
||||
**Key Highlights:**
|
||||
- **JavaScript Port**: Converted from [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) (Go) to JavaScript/Node.js for better accessibility and easier deployment
|
||||
- **Universal CLI Support**: Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, AmpCode, and other CLI tools
|
||||
- **Cross-Platform**: Runs on Windows, Linux, and macOS
|
||||
- **Easy Deployment**: Simple installation via npx, or deploy to VPS
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### Core Features
|
||||
- **🔄 Multi-Provider Support**: Unified endpoint for 15+ AI providers (Claude, OpenAI, Gemini, GitHub Copilot, Qwen, iFlow, DeepSeek, Kimi, MiniMax, GLM, etc.)
|
||||
- **🔐 OAuth & API Key Authentication**: Supports both OAuth2 flow and API key authentication
|
||||
- **🎯 Format Translation**: Automatic request/response translation between OpenAI, Claude, Gemini, Codex, and Ollama formats
|
||||
- **🌐 Web Dashboard**: Beautiful React-based dashboard for managing providers, combos, API keys, and settings
|
||||
- **📊 Usage Tracking**: Real-time monitoring and analytics for all API requests
|
||||
|
||||
### Advanced Features
|
||||
- **🎲 Combo System**: Create model combos with automatic fallback support
|
||||
- **♻️ Intelligent Fallback**: Automatic account rotation when rate limits or errors occur
|
||||
- **⚡ Response Caching**: Optimized caching for Claude Code (1-hour cache vs default 5 minutes)
|
||||
- **🔧 Model Aliases**: Create custom model aliases for easier management
|
||||
- **🔍 WebSearch Hook**: Model-based web search integration for Claude Code CLI
|
||||
- **☁️ Cloud Deployment**: Deploy to Cloud for Cursor IDE integration with global edge performance
|
||||
|
||||
### Format Support
|
||||
- **OpenAI Format**: Standard OpenAI Chat Completions API
|
||||
- **Claude Format**: Anthropic Messages API
|
||||
- **Gemini Format**: Google Generative AI API
|
||||
- **OpenAI Responses API**: Codex CLI format
|
||||
- **Ollama Format**: Compatible with Ollama-based tools
|
||||
|
||||
### CLI Integration
|
||||
- Works with: Claude Code, OpenAI Codex, Cline, RooCode, AmpCode, Cursor, and more
|
||||
- Seamless integration with popular AI coding assistants
|
||||
- WebSearch hook for enhanced Claude Code capabilities
|
||||
|
||||
## 📦 Install
|
||||
|
||||
```bash
|
||||
# Run directly with npx (recommended)
|
||||
npx 9router
|
||||
|
||||
# Or install globally
|
||||
npm install -g 9router
|
||||
9router
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
9router # Start server with default settings
|
||||
9router --port 8080 # Custom port
|
||||
9router --no-browser # Don't open browser
|
||||
9router --skip-update # Skip auto-update check
|
||||
9router --help # Show help
|
||||
```
|
||||
|
||||
**Dashboard**: `http://localhost:20128/dashboard`
|
||||
|
||||
## 💾 Data Location
|
||||
|
||||
User data stored at:
|
||||
- macOS/Linux: `~/.9router/db.json`
|
||||
- Windows: `%APPDATA%/9router/db.json`
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/yourusername/9router.git
|
||||
cd 9router
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
9router/
|
||||
├── bin/ # CLI entry point
|
||||
│ ├── cli.js # Main CLI script
|
||||
│ └── hooks/ # CLI hooks (WebSearch)
|
||||
├── src/
|
||||
│ ├── app/ # Next.js app (dashboard & API routes)
|
||||
│ ├── lib/ # Core libraries (DB, OAuth, etc.)
|
||||
│ ├── shared/ # Shared components & utilities
|
||||
│ └── sse/ # SSE streaming handlers
|
||||
├── open-sse/ # Core proxy engine (translator, handlers)
|
||||
│ ├── translator/ # Format translators
|
||||
│ ├── handlers/ # Request handlers
|
||||
│ ├── services/ # Core services
|
||||
│ └── config/ # Provider configurations
|
||||
└── public/ # Static assets
|
||||
```
|
||||
|
||||
## 📤 Build & Publish
|
||||
|
||||
```bash
|
||||
# Build standalone binary
|
||||
npm run build:standalone
|
||||
|
||||
# Test locally
|
||||
npm link
|
||||
9router --help
|
||||
|
||||
# Publish to npm
|
||||
npm login
|
||||
npm publish
|
||||
```
|
||||
|
||||
## 🧰 Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Runtime** | Node.js 20+ / Bun |
|
||||
| **Framework** | Next.js 15 |
|
||||
| **Dashboard** | React 19 + Tailwind CSS 4 |
|
||||
| **Database** | LowDB (JSON file-based) |
|
||||
| **CLI** | Node.js CLI with auto-update |
|
||||
| **Streaming** | Server-Sent Events (SSE) |
|
||||
| **Auth** | OAuth 2.0 (PKCE) + API Keys |
|
||||
| **Deployment** | Standalone / VPS |
|
||||
| **State Management** | Zustand |
|
||||
|
||||
### Core Libraries
|
||||
- **lowdb**: Lightweight JSON database
|
||||
- **undici**: High-performance HTTP client
|
||||
- **uuid**: Unique identifier generation
|
||||
- **open**: Cross-platform browser launcher
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Special thanks to:
|
||||
|
||||
- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)**: The original Go implementation that inspired this project. 9Router is a JavaScript port with enhanced features and web dashboard.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
16
eslint.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
BIN
images/9router.png
Normal file
|
After Width: | Height: | Size: 292 KiB |
12
jsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"open-sse": ["../open-sse"],
|
||||
"open-sse/*": ["../open-sse/*"]
|
||||
},
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
44
next.config.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
env: {
|
||||
NEXT_PUBLIC_CLOUD_URL: "https://9router.com",
|
||||
},
|
||||
webpack: (config, { isServer }) => {
|
||||
// Ignore fs/path modules in browser bundle
|
||||
if (!isServer) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
path: false,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/v1/v1/:path*",
|
||||
destination: "/api/v1/:path*"
|
||||
},
|
||||
{
|
||||
source: "/v1/v1",
|
||||
destination: "/api/v1"
|
||||
},
|
||||
{
|
||||
source: "/codex/:path*",
|
||||
destination: "/api/v1/responses"
|
||||
},
|
||||
{
|
||||
source: "/v1/:path*",
|
||||
destination: "/api/v1/:path*"
|
||||
},
|
||||
{
|
||||
source: "/v1",
|
||||
destination: "/api/v1"
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.2.13",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build:standalone": "next build && node scripts/prepare-standalone.js",
|
||||
"start": "next start",
|
||||
"start:cli": "node bin/cli.js",
|
||||
"lint": "eslint",
|
||||
"prepublishOnly": "npm run build:standalone"
|
||||
},
|
||||
"dependencies": {
|
||||
"fs": "^0.0.1-security",
|
||||
"lowdb": "^7.0.1",
|
||||
"next": "^15.2.0",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"open": "^10.1.0",
|
||||
"open-sse": "^1.0.0",
|
||||
"ora": "^5.4.1",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"overrides": {
|
||||
"open-sse": "file:../open-sse"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"tailwindcss": "^4"
|
||||
}
|
||||
}
|
||||
6
postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
11
public/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="6" fill="url(#gradient)"/>
|
||||
<text x="16" y="24" font-family="system-ui, -apple-system, sans-serif" font-size="20" font-weight="700" fill="white" text-anchor="middle">9</text>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#f97815"/>
|
||||
<stop offset="1" stop-color="#c2590a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 533 B |
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/providers/antigravity.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/providers/claude.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/providers/cline.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/providers/codex.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/providers/continue.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/providers/copilot.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/providers/cursor.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/providers/gemini-cli.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/providers/github.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/providers/iflow.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/providers/qwen.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/providers/roo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
65
scripts/prepare-standalone.js
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const standaloneDir = path.join(projectRoot, ".next/standalone");
|
||||
const staticSrc = path.join(projectRoot, ".next/static");
|
||||
const staticDest = path.join(standaloneDir, ".next/static");
|
||||
const publicSrc = path.join(projectRoot, "public");
|
||||
const publicDest = path.join(standaloneDir, "public");
|
||||
|
||||
function copyRecursive(src, dest) {
|
||||
if (!fs.existsSync(src)) return;
|
||||
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyRecursive(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Preparing standalone build...");
|
||||
|
||||
// Copy static files
|
||||
if (fs.existsSync(staticSrc)) {
|
||||
copyRecursive(staticSrc, staticDest);
|
||||
console.log("✓ Copied .next/static");
|
||||
}
|
||||
|
||||
// Copy public folder
|
||||
if (fs.existsSync(publicSrc)) {
|
||||
copyRecursive(publicSrc, publicDest);
|
||||
console.log("✓ Copied public");
|
||||
}
|
||||
|
||||
console.log("✓ Standalone build ready");
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
193
src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardSkeleton } from "@/shared/components";
|
||||
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
||||
import { PROVIDER_MODELS, getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { ClaudeToolCard, CodexToolCard, DefaultToolCard } from "./components";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
export default function CLIToolsPageClient({ machineId }) {
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedTool, setExpandedTool] = useState(null);
|
||||
const [modelMappings, setModelMappings] = useState({});
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
loadCloudSettings();
|
||||
fetchApiKeys();
|
||||
}, []);
|
||||
|
||||
const loadCloudSettings = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCloudEnabled(data.cloudEnabled || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading cloud settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchApiKeys = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/keys");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setApiKeys(data.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching API keys:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConnections = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setConnections(data.connections || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveProviders = () => {
|
||||
return connections.filter(c => c.isActive !== false);
|
||||
};
|
||||
|
||||
const getAllAvailableModels = () => {
|
||||
const activeProviders = getActiveProviders();
|
||||
const models = [];
|
||||
const seenModels = new Set();
|
||||
|
||||
activeProviders.forEach(conn => {
|
||||
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
|
||||
const providerModels = getModelsByProviderId(conn.provider);
|
||||
providerModels.forEach(m => {
|
||||
const modelValue = `${alias}/${m.id}`;
|
||||
if (!seenModels.has(modelValue)) {
|
||||
seenModels.add(modelValue);
|
||||
models.push({
|
||||
value: modelValue,
|
||||
label: `${alias}/${m.id}`,
|
||||
provider: conn.provider,
|
||||
alias: alias,
|
||||
connectionName: conn.name,
|
||||
modelId: m.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (models.length === 0) {
|
||||
Object.entries(PROVIDER_MODELS).forEach(([alias, providerModels]) => {
|
||||
providerModels.forEach(m => {
|
||||
const modelValue = `${alias}/${m.id}`;
|
||||
models.push({
|
||||
value: modelValue,
|
||||
label: `${alias}/${m.id}`,
|
||||
provider: alias,
|
||||
alias: alias,
|
||||
connectionName: alias,
|
||||
modelId: m.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return models;
|
||||
};
|
||||
|
||||
const handleModelMappingChange = (toolId, modelAlias, targetModel) => {
|
||||
setModelMappings(prev => ({
|
||||
...prev,
|
||||
[toolId]: {
|
||||
...prev[toolId],
|
||||
[modelAlias]: targetModel,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const getBaseUrl = () => {
|
||||
if (cloudEnabled && CLOUD_URL) {
|
||||
return CLOUD_URL;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
return "http://localhost:3000";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const availableModels = getAllAvailableModels();
|
||||
const hasActiveProviders = availableModels.length > 0;
|
||||
|
||||
const renderToolCard = (toolId, tool) => {
|
||||
const commonProps = {
|
||||
tool,
|
||||
isExpanded: expandedTool === toolId,
|
||||
onToggle: () => setExpandedTool(expandedTool === toolId ? null : toolId),
|
||||
baseUrl: getBaseUrl(),
|
||||
apiKeys,
|
||||
};
|
||||
|
||||
switch (toolId) {
|
||||
case "claude":
|
||||
return (
|
||||
<ClaudeToolCard
|
||||
key={toolId}
|
||||
{...commonProps}
|
||||
activeProviders={getActiveProviders()}
|
||||
modelMappings={modelMappings[toolId] || {}}
|
||||
onModelMappingChange={(alias, target) => handleModelMappingChange(toolId, alias, target)}
|
||||
hasActiveProviders={hasActiveProviders}
|
||||
cloudEnabled={cloudEnabled}
|
||||
/>
|
||||
);
|
||||
case "codex":
|
||||
return <CodexToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
|
||||
default:
|
||||
return <DefaultToolCard key={toolId} toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{!hasActiveProviders && (
|
||||
<Card className="border-yellow-500/50 bg-yellow-500/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||
<div>
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">No active providers</p>
|
||||
<p className="text-sm text-text-muted">Please add and connect providers first to configure CLI tools.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{Object.entries(CLI_TOOLS).map(([toolId, tool]) => renderToolCard(toolId, tool))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, ModelSelectModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
export default function ClaudeToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
activeProviders,
|
||||
modelMappings,
|
||||
onModelMappingChange,
|
||||
baseUrl,
|
||||
hasActiveProviders,
|
||||
apiKeys,
|
||||
cloudEnabled,
|
||||
}) {
|
||||
const [claudeStatus, setClaudeStatus] = useState(null);
|
||||
const [checkingClaude, setCheckingClaude] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [showInstallGuide, setShowInstallGuide] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [copiedConfig, setCopiedConfig] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!claudeStatus?.installed) return null;
|
||||
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
|
||||
if (!currentUrl) return "not_configured";
|
||||
const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1");
|
||||
const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL);
|
||||
if (localMatch || cloudMatch) return "configured";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !claudeStatus) {
|
||||
checkClaudeStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (claudeStatus?.installed) {
|
||||
const env = claudeStatus.settings?.env || {};
|
||||
tool.defaultModels.forEach((model) => {
|
||||
if (model.envKey) {
|
||||
const value = env[model.envKey] || model.defaultValue || "";
|
||||
if (value) onModelMappingChange(model.alias, value);
|
||||
}
|
||||
});
|
||||
// Only set selectedApiKey if it exists in apiKeys list
|
||||
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
|
||||
if (tokenFromFile && apiKeys?.some(k => k.key === tokenFromFile)) {
|
||||
setSelectedApiKey(tokenFromFile);
|
||||
}
|
||||
}
|
||||
}, [claudeStatus, apiKeys]);
|
||||
|
||||
const checkClaudeStatus = async () => {
|
||||
setCheckingClaude(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/claude-settings");
|
||||
const data = await res.json();
|
||||
setClaudeStatus(data);
|
||||
} catch (error) {
|
||||
setClaudeStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingClaude(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const env = { ANTHROPIC_BASE_URL: baseUrl };
|
||||
|
||||
// Get key from dropdown, fallback to first key or sk_9router for localhost
|
||||
const keyToUse = selectedApiKey?.trim()
|
||||
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|
||||
|| (!cloudEnabled ? "sk_9router" : null);
|
||||
|
||||
if (keyToUse) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = keyToUse;
|
||||
}
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
const targetModel = modelMappings[model.alias];
|
||||
if (targetModel && model.envKey) env[model.envKey] = targetModel;
|
||||
});
|
||||
const res = await fetch("/api/cli-tools/claude-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env } }));
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSettings = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/claude-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || ""));
|
||||
setSelectedApiKey("");
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openModelSelector = (alias) => {
|
||||
setCurrentEditingAlias(alias);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModelSelect = (model) => {
|
||||
if (currentEditingAlias) onModelMappingChange(currentEditingAlias, model.value);
|
||||
};
|
||||
|
||||
// Generate settings.json content for manual copy
|
||||
const getSettingsContent = () => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
const env = { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: keyToUse };
|
||||
tool.defaultModels.forEach((model) => {
|
||||
const targetModel = modelMappings[model.alias];
|
||||
if (targetModel && model.envKey) env[model.envKey] = targetModel;
|
||||
});
|
||||
return JSON.stringify({ env }, null, 2);
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedConfig(true);
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
} catch (err) {
|
||||
console.log("Failed to copy:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-12 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${tool.color}15` }}>
|
||||
<Image src="/providers/claude.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-lg">{tool.name}</h3>
|
||||
{configStatus === "configured" && <span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
|
||||
{configStatus === "not_configured" && <span className="px-2 py-0.5 text-xs font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
|
||||
{configStatus === "other" && <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other endpoint</span>}
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">{tool.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`material-symbols-outlined text-text-muted transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
|
||||
{checkingClaude && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking Claude CLI...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingClaude && claudeStatus && !claudeStatus.installed && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">Claude CLI not installed</p>
|
||||
<p className="text-sm text-text-muted">Please install Claude CLI to use this feature.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
|
||||
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
|
||||
{showInstallGuide ? "Hide" : "How to Install"}
|
||||
</Button>
|
||||
</div>
|
||||
{showInstallGuide && (
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<h4 className="font-medium mb-3">Installation Guide</h4>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
|
||||
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g @anthropic-ai/claude-code</code>
|
||||
</div>
|
||||
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">claude</code> to verify.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingClaude && claudeStatus?.installed && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-green-500 text-[16px]">check_circle</span>
|
||||
<span className="text-xs text-text-muted shrink-0">URL:</span>
|
||||
<code className="text-xs font-mono text-text-main truncate">{baseUrl}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted shrink-0">Key:</span>
|
||||
{apiKeys.length > 0 ? (
|
||||
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50">
|
||||
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">
|
||||
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{tool.defaultModels.map((model) => (
|
||||
<div key={model.alias} className="flex items-center gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">{model.name}</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||
<input type="text" value={modelMappings[model.alias] || ""} onChange={(e) => onModelMappingChange(model.alias, e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
|
||||
<button onClick={() => openModelSelector(model.alias)} disabled={!hasActiveProviders} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
|
||||
{modelMappings[model.alias] && <button onClick={() => onModelMappingChange(model.alias, "")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
|
||||
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
|
||||
<span>{message.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!hasActiveProviders} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!claudeStatus?.has9Router} loading={restoring}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={checkClaudeStatus}>
|
||||
<span className="material-symbols-outlined text-[14px]">refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Manual Config Section */}
|
||||
<div className="pt-4 border-t border-border flex flex-col gap-3">
|
||||
<p className="text-xs text-text-muted">Or copy config manually:</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text-main">~/.claude/settings.json</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(getSettingsContent())}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">{copiedConfig ? "check" : "content_copy"}</span>
|
||||
{copiedConfig ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-40 overflow-y-auto">{getSettingsContent()}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, ModelSelectModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled }) {
|
||||
const [codexStatus, setCodexStatus] = useState(null);
|
||||
const [checkingCodex, setCheckingCodex] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [showInstallGuide, setShowInstallGuide] = useState(false);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [copiedConfig, setCopiedConfig] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !codexStatus) {
|
||||
checkCodexStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse model from config content
|
||||
useEffect(() => {
|
||||
if (codexStatus?.config) {
|
||||
const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m);
|
||||
if (modelMatch) setSelectedModel(modelMatch[1]);
|
||||
}
|
||||
}, [codexStatus]);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
if (!codexStatus?.installed) return null;
|
||||
if (!codexStatus.config) return "not_configured";
|
||||
const hasBaseUrl = codexStatus.config.includes(baseUrl) || codexStatus.config.includes("localhost") || codexStatus.config.includes("127.0.0.1");
|
||||
return hasBaseUrl ? "configured" : "other";
|
||||
};
|
||||
|
||||
const configStatus = getConfigStatus();
|
||||
|
||||
const checkCodexStatus = async () => {
|
||||
setCheckingCodex(true);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/codex-settings");
|
||||
const data = await res.json();
|
||||
setCodexStatus(data);
|
||||
} catch (error) {
|
||||
setCodexStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
setCheckingCodex(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
setApplying(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
// Use sk_9router for localhost if no key, otherwise use selected key
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
|
||||
|
||||
const res = await fetch("/api/cli-tools/codex-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl, apiKey: keyToUse, model: selectedModel }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
checkCodexStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSettings = async () => {
|
||||
setRestoring(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/codex-settings", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
setSelectedModel("");
|
||||
checkCodexStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = (model) => {
|
||||
setSelectedModel(model.value);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const configContent = `# 9Router Configuration for Codex CLI
|
||||
model = "${selectedModel}"
|
||||
model_provider = "9router"
|
||||
|
||||
[model_providers.9router]
|
||||
name = "9Router"
|
||||
base_url = "${baseUrl}/v1"
|
||||
wire_api = "responses"
|
||||
`;
|
||||
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const authContent = JSON.stringify({
|
||||
OPENAI_API_KEY: keyToUse
|
||||
}, null, 2);
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedConfig(true);
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
} catch (err) {
|
||||
console.log("Failed to copy:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-12 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${tool.color}15` }}>
|
||||
<Image src="/providers/codex.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-lg">{tool.name}</h3>
|
||||
{configStatus === "configured" && <span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
|
||||
{configStatus === "not_configured" && <span className="px-2 py-0.5 text-xs font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
|
||||
{configStatus === "other" && <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other endpoint</span>}
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">{tool.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`material-symbols-outlined text-text-muted transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
|
||||
{checkingCodex && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>Checking Codex CLI...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingCodex && codexStatus && !codexStatus.installed && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">Codex CLI not installed</p>
|
||||
<p className="text-sm text-text-muted">Please install Codex CLI to use auto-apply feature.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
|
||||
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
|
||||
{showInstallGuide ? "Hide" : "How to Install"}
|
||||
</Button>
|
||||
</div>
|
||||
{showInstallGuide && (
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<h4 className="font-medium mb-3">Installation Guide</h4>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
|
||||
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g @openai/codex</code>
|
||||
</div>
|
||||
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">codex</code> to verify.</p>
|
||||
<div className="pt-2 border-t border-border">
|
||||
<p className="text-text-muted text-xs">
|
||||
Codex uses <code className="px-1 bg-black/5 dark:bg-white/5 rounded">~/.codex/auth.json</code> with <code className="px-1 bg-black/5 dark:bg-white/5 rounded">OPENAI_API_KEY</code>.
|
||||
Click "Apply" to auto-configure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingCodex && codexStatus?.installed && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-green-500 text-[16px]">check_circle</span>
|
||||
<span className="text-xs text-text-muted shrink-0">URL:</span>
|
||||
<code className="text-xs font-mono text-text-main truncate">{baseUrl}/v1</code>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted shrink-0">Key:</span>
|
||||
{apiKeys.length > 0 ? (
|
||||
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50">
|
||||
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">
|
||||
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-sm font-semibold text-text-main text-right">Model</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
|
||||
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
|
||||
{selectedModel && <button onClick={() => setSelectedModel("")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
|
||||
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
|
||||
<span>{message.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!selectedApiKey || !selectedModel} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!codexStatus.has9Router} loading={restoring}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={checkCodexStatus}>
|
||||
<span className="material-symbols-outlined text-[14px]">refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Manual Config Section */}
|
||||
<div className="pt-4 border-t border-border flex flex-col gap-3">
|
||||
<p className="text-xs text-text-muted">Or copy config manually:</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text-main">~/.codex/config.toml</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(configContent)}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">{copiedConfig ? "check" : "content_copy"}</span>
|
||||
{copiedConfig ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-32 overflow-y-auto">{configContent}</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text-main">~/.codex/auth.json</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(authContent)}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">{copiedConfig ? "check" : "content_copy"}</span>
|
||||
{copiedConfig ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-32 overflow-y-auto">{authContent}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Codex"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, ModelSelectModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false }) {
|
||||
const [copiedField, setCopiedField] = useState(null);
|
||||
const [showModelModal, setShowModelModal] = useState(false);
|
||||
const [modelValue, setModelValue] = useState("");
|
||||
|
||||
// Initialize state directly with computed value - no need for useEffect
|
||||
const [selectedApiKey, setSelectedApiKey] = useState(() =>
|
||||
apiKeys?.length > 0 ? apiKeys[0].key : ""
|
||||
);
|
||||
|
||||
const replaceVars = (text) => {
|
||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "your-api-key");
|
||||
|
||||
return text
|
||||
.replace(/\{\{baseUrl\}\}/g, baseUrl || "http://localhost:3000")
|
||||
.replace(/\{\{apiKey\}\}/g, keyToUse)
|
||||
.replace(/\{\{model\}\}/g, modelValue || "provider/model-id");
|
||||
};
|
||||
|
||||
const handleCopy = async (text, field) => {
|
||||
await navigator.clipboard.writeText(replaceVars(text));
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
};
|
||||
|
||||
const handleSelectModel = (model) => {
|
||||
setModelValue(model.value);
|
||||
};
|
||||
|
||||
const hasActiveProviders = activeProviders.length > 0;
|
||||
|
||||
const renderApiKeySelector = () => {
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{apiKeys && apiKeys.length > 0 ? (
|
||||
<>
|
||||
<select
|
||||
value={selectedApiKey}
|
||||
onChange={(e) => setSelectedApiKey(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
{apiKeys.map((key) => (
|
||||
<option key={key.id} value={key.key}>{key.key}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleCopy(selectedApiKey, "apiKey")}
|
||||
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">
|
||||
{copiedField === "apiKey" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-text-muted">
|
||||
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderModelSelector = () => {
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={modelValue}
|
||||
onChange={(e) => setModelValue(e.target.value)}
|
||||
placeholder="provider/model-id"
|
||||
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowModelModal(true)}
|
||||
disabled={!hasActiveProviders}
|
||||
className={`shrink-0 px-3 py-2 rounded-lg border text-sm transition-colors ${
|
||||
hasActiveProviders
|
||||
? "bg-bg-secondary border-border text-text-main hover:border-primary cursor-pointer"
|
||||
: "opacity-50 cursor-not-allowed border-border"
|
||||
}`}
|
||||
>
|
||||
Select Model
|
||||
</button>
|
||||
{modelValue && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleCopy(modelValue, "model")}
|
||||
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">
|
||||
{copiedField === "model" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModelValue("")}
|
||||
className="p-2 text-text-muted hover:text-red-500 rounded transition-colors"
|
||||
title="Clear"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">close</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNotes = () => {
|
||||
if (!tool.notes || tool.notes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
{tool.notes.map((note, index) => {
|
||||
// Skip cloudCheck note if cloud is enabled
|
||||
if (note.type === "cloudCheck" && cloudEnabled) return null;
|
||||
|
||||
const isWarning = note.type === "warning";
|
||||
const isError = note.type === "cloudCheck" && !cloudEnabled;
|
||||
|
||||
let bgClass = "bg-blue-500/10 border-blue-500/30";
|
||||
let textClass = "text-blue-600 dark:text-blue-400";
|
||||
let iconClass = "text-blue-500";
|
||||
let icon = "info";
|
||||
|
||||
if (isWarning) {
|
||||
bgClass = "bg-yellow-500/10 border-yellow-500/30";
|
||||
textClass = "text-yellow-600 dark:text-yellow-400";
|
||||
iconClass = "text-yellow-500";
|
||||
icon = "warning";
|
||||
} else if (isError) {
|
||||
bgClass = "bg-red-500/10 border-red-500/30";
|
||||
textClass = "text-red-600 dark:text-red-400";
|
||||
iconClass = "text-red-500";
|
||||
icon = "error";
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className={`flex items-start gap-3 p-3 rounded-lg border ${bgClass}`}>
|
||||
<span className={`material-symbols-outlined text-lg ${iconClass}`}>{icon}</span>
|
||||
<p className={`text-sm ${textClass}`}>{note.text}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const canShowGuide = () => {
|
||||
if (tool.requiresCloud && !cloudEnabled) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const renderGuideSteps = () => {
|
||||
if (!tool.guideSteps) return <p className="text-text-muted text-sm">Coming soon...</p>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{renderNotes()}
|
||||
{canShowGuide() && tool.guideSteps.map((item) => (
|
||||
<div key={item.step} className="flex items-start gap-4">
|
||||
<div
|
||||
className="size-8 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold text-white"
|
||||
style={{ backgroundColor: tool.color }}
|
||||
>
|
||||
{item.step}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-text">{item.title}</p>
|
||||
{item.desc && <p className="text-sm text-text-muted mt-0.5">{item.desc}</p>}
|
||||
{item.type === "apiKeySelector" && renderApiKeySelector()}
|
||||
{item.type === "modelSelector" && renderModelSelector()}
|
||||
{item.value && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm font-mono border border-border truncate">
|
||||
{replaceVars(item.value)}
|
||||
</code>
|
||||
{item.copyable && (
|
||||
<button
|
||||
onClick={() => handleCopy(item.value, `${item.step}-${item.title}`)}
|
||||
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">
|
||||
{copiedField === `${item.step}-${item.title}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canShowGuide() && tool.codeBlock && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-text-muted uppercase tracking-wide">{tool.codeBlock.language}</span>
|
||||
<button
|
||||
onClick={() => handleCopy(tool.codeBlock.code, "codeblock")}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-bg-secondary hover:bg-bg-tertiary rounded border border-border transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copiedField === "codeblock" ? "check" : "content_copy"}
|
||||
</span>
|
||||
{copiedField === "codeblock" ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-4 bg-bg-secondary rounded-lg border border-border overflow-x-auto">
|
||||
<code className="text-sm font-mono whitespace-pre">{replaceVars(tool.codeBlock.code)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
if (tool.image) {
|
||||
return (
|
||||
<Image
|
||||
src={tool.image}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tool.icon) {
|
||||
return <span className="material-symbols-outlined text-2xl" style={{ color: tool.color }}>{tool.icon}</span>;
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
src={`/providers/${toolId}.png`}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-12 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${tool.color}15` }}>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{tool.name}</h3>
|
||||
<p className="text-sm text-text-muted">{tool.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`material-symbols-outlined text-text-muted transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
{renderGuideSteps()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={showModelModal}
|
||||
onClose={() => setShowModelModal(false)}
|
||||
onSelect={handleSelectModel}
|
||||
selectedModel={modelValue}
|
||||
activeProviders={activeProviders}
|
||||
title="Select Model"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as ClaudeToolCard } from "./ClaudeToolCard";
|
||||
export { default as CodexToolCard } from "./CodexToolCard";
|
||||
export { default as DefaultToolCard } from "./DefaultToolCard";
|
||||
|
||||
7
src/app/(dashboard)/dashboard/cli-tools/page.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import CLIToolsPageClient from "./CLIToolsPageClient";
|
||||
|
||||
export default async function CLIToolsPage() {
|
||||
const machineId = await getMachineId();
|
||||
return <CLIToolsPageClient machineId={machineId} />;
|
||||
}
|
||||
434
src/app/(dashboard)/dashboard/combos/page.js
Normal file
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export default function CombosPage() {
|
||||
const [combos, setCombos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingCombo, setEditingCombo] = useState(null);
|
||||
const [activeProviders, setActiveProviders] = useState([]);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [combosRes, providersRes] = await Promise.all([
|
||||
fetch("/api/combos"),
|
||||
fetch("/api/providers"),
|
||||
]);
|
||||
const combosData = await combosRes.json();
|
||||
const providersData = await providersRes.json();
|
||||
|
||||
if (combosRes.ok) setCombos(combosData.combos || []);
|
||||
if (providersRes.ok) {
|
||||
const active = (providersData.connections || []).filter(
|
||||
c => c.testStatus === "active" || c.testStatus === "success"
|
||||
);
|
||||
setActiveProviders(active);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (data) => {
|
||||
try {
|
||||
const res = await fetch("/api/combos", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
setShowCreateModal(false);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || "Failed to create combo");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error creating combo:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id, data) => {
|
||||
try {
|
||||
const res = await fetch(`/api/combos/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
setEditingCombo(null);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || "Failed to update combo");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error updating combo:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm("Delete this combo?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setCombos(combos.filter(c => c.id !== id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error deleting combo:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Combos</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Create model combos with fallback support
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="add" onClick={() => setShowCreateModal(true)}>
|
||||
Create Combo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Combos List */}
|
||||
{combos.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<span className="material-symbols-outlined text-5xl text-text-muted mb-3 block">
|
||||
layers
|
||||
</span>
|
||||
<p className="text-text-muted mb-4">No combos yet</p>
|
||||
<Button icon="add" onClick={() => setShowCreateModal(true)}>
|
||||
Create your first combo
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{combos.map((combo) => (
|
||||
<ComboCard
|
||||
key={combo.id}
|
||||
combo={combo}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onEdit={() => setEditingCombo(combo)}
|
||||
onDelete={() => handleDelete(combo.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal - Use key to force remount and reset state */}
|
||||
<ComboFormModal
|
||||
key="create"
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSave={handleCreate}
|
||||
activeProviders={activeProviders}
|
||||
/>
|
||||
|
||||
{/* Edit Modal - Use key to force remount and reset state */}
|
||||
<ComboFormModal
|
||||
key={editingCombo?.id || "new"}
|
||||
isOpen={!!editingCombo}
|
||||
combo={editingCombo}
|
||||
onClose={() => setEditingCombo(null)}
|
||||
onSave={(data) => handleUpdate(editingCombo.id, data)}
|
||||
activeProviders={activeProviders}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboCard({ combo, copied, onCopy, onEdit, onDelete }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
{/* Name + Copy */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-primary">layers</span>
|
||||
<code className="text-lg font-semibold font-mono">{combo.name}</code>
|
||||
<button
|
||||
onClick={() => onCopy(combo.name, `combo-${combo.id}`)}
|
||||
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||
title="Copy combo name"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `combo-${combo.id}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Models list */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{combo.models.length === 0 ? (
|
||||
<p className="text-sm text-text-muted italic">No models added</p>
|
||||
) : (
|
||||
combo.models.map((model, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted w-5">{index + 1}.</span>
|
||||
<code className="text-sm font-mono bg-sidebar px-2 py-0.5 rounded">
|
||||
{model}
|
||||
</code>
|
||||
{index === 0 && (
|
||||
<span className="text-xs text-primary font-medium">Primary</span>
|
||||
)}
|
||||
{index > 0 && (
|
||||
<span className="text-xs text-text-muted">Fallback</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||
title="Edit"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 hover:bg-red-50 rounded text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
// Initialize state with combo values - key prop on parent handles reset on remount
|
||||
const [name, setName] = useState(combo?.name || "");
|
||||
const [models, setModels] = useState(combo?.models || []);
|
||||
const [showModelSelect, setShowModelSelect] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
|
||||
// Fetch model aliases when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const fetchModelAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) setModelAliases(data.aliases || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching model aliases:", error);
|
||||
}
|
||||
};
|
||||
fetchModelAliases();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const validateName = (value) => {
|
||||
if (!value.trim()) {
|
||||
setNameError("Name is required");
|
||||
return false;
|
||||
}
|
||||
if (!VALID_NAME_REGEX.test(value)) {
|
||||
setNameError("Only letters, numbers, - and _ allowed");
|
||||
return false;
|
||||
}
|
||||
setNameError("");
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setName(value);
|
||||
if (value) validateName(value);
|
||||
else setNameError("");
|
||||
};
|
||||
|
||||
const handleAddModel = (model) => {
|
||||
if (!models.includes(model.value)) {
|
||||
setModels([...models, model.value]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveModel = (index) => {
|
||||
setModels(models.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleModelChange = (index, value) => {
|
||||
const newModels = [...models];
|
||||
newModels[index] = value;
|
||||
setModels(newModels);
|
||||
};
|
||||
|
||||
const handleMoveUp = (index) => {
|
||||
if (index === 0) return;
|
||||
const newModels = [...models];
|
||||
[newModels[index - 1], newModels[index]] = [newModels[index], newModels[index - 1]];
|
||||
setModels(newModels);
|
||||
};
|
||||
|
||||
const handleMoveDown = (index) => {
|
||||
if (index === models.length - 1) return;
|
||||
const newModels = [...models];
|
||||
[newModels[index], newModels[index + 1]] = [newModels[index + 1], newModels[index]];
|
||||
setModels(newModels);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateName(name)) return;
|
||||
setSaving(true);
|
||||
await onSave({ name: name.trim(), models });
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const isEdit = !!combo;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEdit ? "Edit Combo" : "Create Combo"}
|
||||
size="md"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<Input
|
||||
label="Combo Name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="my-combo"
|
||||
error={nameError}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Only letters, numbers, - and _ allowed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium">Models</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="add"
|
||||
onClick={() => setShowModelSelect(true)}
|
||||
>
|
||||
Add Model
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{models.length === 0 ? (
|
||||
<div className="text-center py-6 border border-dashed border-border rounded-lg">
|
||||
<p className="text-sm text-text-muted">No models added</p>
|
||||
<p className="text-xs text-text-muted mt-1">Click "Add Model" to add</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 max-h-[240px] overflow-y-auto">
|
||||
{models.map((model, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{/* Priority arrows */}
|
||||
<div className="flex flex-col gap-0">
|
||||
<button
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
className={`p-0.5 rounded ${index === 0 ? "text-text-muted/30" : "hover:bg-surface text-text-muted hover:text-primary"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm leading-none">keyboard_arrow_up</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === models.length - 1}
|
||||
className={`p-0.5 rounded ${index === models.length - 1 ? "text-text-muted/30" : "hover:bg-surface text-text-muted hover:text-primary"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm leading-none">keyboard_arrow_down</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model Input */}
|
||||
<Input
|
||||
value={model}
|
||||
onChange={(e) => handleModelChange(index, e.target.value)}
|
||||
placeholder="model-name"
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
onClick={() => handleRemoveModel(index)}
|
||||
className="p-2 hover:bg-red-50 rounded text-red-500"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
fullWidth
|
||||
disabled={!name.trim() || !!nameError || saving}
|
||||
>
|
||||
{saving ? "Saving..." : "Apply"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Model Select Modal */}
|
||||
<ModelSelectModal
|
||||
isOpen={showModelSelect}
|
||||
onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Add Model to Combo"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
605
src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
Normal file
@@ -0,0 +1,605 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
export default function APIPageClient({ machineId }) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [createdKey, setCreatedKey] = useState(null);
|
||||
|
||||
// Cloud sync state
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
const [showCloudModal, setShowCloudModal] = useState(false);
|
||||
const [showDisableModal, setShowDisableModal] = useState(false);
|
||||
const [cloudSyncing, setCloudSyncing] = useState(false);
|
||||
const [cloudStatus, setCloudStatus] = useState(null);
|
||||
const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | ""
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
loadCloudSettings();
|
||||
}, []);
|
||||
|
||||
const loadCloudSettings = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCloudEnabled(data.cloudEnabled || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading cloud settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const keysRes = await fetch("/api/keys");
|
||||
const keysData = await keysRes.json();
|
||||
if (keysRes.ok) {
|
||||
setKeys(keysData.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloudToggle = (checked) => {
|
||||
if (checked) {
|
||||
setShowCloudModal(true);
|
||||
} else {
|
||||
setShowDisableModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnableCloud = async () => {
|
||||
setCloudSyncing(true);
|
||||
setSyncStep("syncing");
|
||||
try {
|
||||
const res = await fetch("/api/sync/cloud", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "enable" })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSyncStep("verifying");
|
||||
|
||||
if (data.verified) {
|
||||
setCloudEnabled(true);
|
||||
setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" });
|
||||
setShowCloudModal(false);
|
||||
} else {
|
||||
setCloudEnabled(true);
|
||||
setCloudStatus({
|
||||
type: "warning",
|
||||
message: data.verifyError || "Connected but verification failed"
|
||||
});
|
||||
setShowCloudModal(false);
|
||||
}
|
||||
|
||||
// Refresh keys list if new key was created
|
||||
if (data.createdKey) {
|
||||
await fetchData();
|
||||
}
|
||||
} else {
|
||||
setCloudStatus({ type: "error", message: data.error || "Failed to enable cloud" });
|
||||
}
|
||||
} catch (error) {
|
||||
setCloudStatus({ type: "error", message: error.message });
|
||||
} finally {
|
||||
setCloudSyncing(false);
|
||||
setSyncStep("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDisable = async () => {
|
||||
setCloudSyncing(true);
|
||||
setSyncStep("syncing");
|
||||
|
||||
try {
|
||||
// Step 1: Sync latest data from cloud
|
||||
const syncRes = await fetch("/api/sync/cloud", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "sync" })
|
||||
});
|
||||
|
||||
setSyncStep("disabling");
|
||||
|
||||
// Step 2: Disable cloud
|
||||
const disableRes = await fetch("/api/sync/cloud", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "disable" })
|
||||
});
|
||||
|
||||
if (disableRes.ok) {
|
||||
setCloudEnabled(false);
|
||||
setCloudStatus({ type: "success", message: "Cloud disabled" });
|
||||
setShowDisableModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error disabling cloud:", error);
|
||||
setCloudStatus({ type: "error", message: "Failed to disable cloud" });
|
||||
} finally {
|
||||
setCloudSyncing(false);
|
||||
setSyncStep("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncCloud = async () => {
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
setCloudSyncing(true);
|
||||
try {
|
||||
const res = await fetch("/api/sync/cloud", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "sync" })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setCloudStatus({ type: "success", message: "Synced successfully" });
|
||||
} else {
|
||||
setCloudStatus({ type: "error", message: data.error });
|
||||
}
|
||||
} catch (error) {
|
||||
setCloudStatus({ type: "error", message: error.message });
|
||||
} finally {
|
||||
setCloudSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newKeyName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setCreatedKey(data.key);
|
||||
await fetchData();
|
||||
setNewKeyName("");
|
||||
setShowAddModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error creating key:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (id) => {
|
||||
if (!confirm("Delete this API key?")) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setKeys(keys.filter((k) => k.id !== id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error deleting key:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const isLocalhost = typeof window !== "undefined" &&
|
||||
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.origin}/v1` : "/v1";
|
||||
const localApiKey = "HELLO";
|
||||
// New format: /v1 (machineId in key), Old format: /{machineId}/v1
|
||||
const cloudEndpointNew = `${CLOUD_URL}/v1`;
|
||||
const cloudEndpointOld = `${CLOUD_URL}/${machineId}/v1`;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Use new format endpoint (machineId embedded in key)
|
||||
const currentEndpoint = cloudEnabled ? cloudEndpointNew : baseUrl;
|
||||
|
||||
const cloudBenefits = [
|
||||
{ icon: "public", title: "Access Anywhere", desc: "No port forwarding needed" },
|
||||
{ icon: "group", title: "Share Endpoint", desc: "Easy team collaboration" },
|
||||
{ icon: "schedule", title: "Always Online", desc: "24/7 availability" },
|
||||
{ icon: "speed", title: "Global Edge", desc: "Fast worldwide access" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Endpoint Card */}
|
||||
<Card className={cloudEnabled ? "" : ""}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">API Endpoint</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{cloudEnabled ? "Using Cloud Proxy" : "Using Local Server"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{cloudEnabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="cloud_off"
|
||||
onClick={() => handleCloudToggle(false)}
|
||||
disabled={cloudSyncing}
|
||||
className="!bg-red-500/10 !text-red-500 hover:!bg-red-500/20 !border-red-500/30"
|
||||
>
|
||||
Disable Cloud
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="cloud_upload"
|
||||
onClick={() => handleCloudToggle(true)}
|
||||
disabled={cloudSyncing}
|
||||
className="bg-gradient-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600"
|
||||
>
|
||||
Enable Cloud
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endpoint URL */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Input
|
||||
value={currentEndpoint}
|
||||
readOnly
|
||||
className={`flex-1 font-mono text-sm ${cloudEnabled ? "animate-border-glow" : ""}`}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "endpoint_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(currentEndpoint, "endpoint_url")}
|
||||
>
|
||||
{copied === "endpoint_url" ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<span className="material-symbols-outlined text-4xl text-text-muted mb-2">
|
||||
vpn_key
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">No API keys yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-3 font-medium">Name</th>
|
||||
<th className="text-left p-3 font-medium">Key</th>
|
||||
<th className="text-left p-3 font-medium">Created</th>
|
||||
<th className="text-left p-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((key) => (
|
||||
<tr key={key.id} className="border-b border-border hover:bg-sidebar/30">
|
||||
<td className="p-3 text-sm">{key.name}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-text-muted">
|
||||
{key.key}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === key.id ? "check" : "content_copy"}
|
||||
onClick={() => copy(key.key, key.id)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-text-muted">
|
||||
{new Date(key.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="delete"
|
||||
className="text-red-500"
|
||||
onClick={() => handleDeleteKey(key.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Cloud Proxy Card - Hidden */}
|
||||
{false && (
|
||||
<Card className={cloudEnabled ? "bg-primary/5" : ""}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-lg ${cloudEnabled ? "bg-primary text-white" : "bg-sidebar text-text-muted"}`}>
|
||||
<span className="material-symbols-outlined text-xl">cloud</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Cloud Proxy</h2>
|
||||
<p className="text-xs text-text-muted">
|
||||
{cloudEnabled ? "Connected & Ready" : "Access your API from anywhere"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{cloudEnabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="cloud_off"
|
||||
onClick={() => handleCloudToggle(false)}
|
||||
disabled={cloudSyncing}
|
||||
className="!bg-red-500/10 !text-red-500 hover:!bg-red-500/20 !border-red-500/30"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="cloud_upload"
|
||||
onClick={() => handleCloudToggle(true)}
|
||||
disabled={cloudSyncing}
|
||||
className="bg-gradient-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 px-6"
|
||||
>
|
||||
Enable Cloud
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benefits Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{cloudBenefits.map((benefit) => (
|
||||
<div key={benefit.title} className="flex flex-col items-center text-center p-3 rounded-lg bg-sidebar/50">
|
||||
<span className="material-symbols-outlined text-xl text-primary mb-1">{benefit.icon}</span>
|
||||
<p className="text-xs font-semibold">{benefit.title}</p>
|
||||
<p className="text-xs text-text-muted">{benefit.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cloud Enable Modal */}
|
||||
<Modal
|
||||
isOpen={showCloudModal}
|
||||
title="Enable Cloud Proxy"
|
||||
onClose={() => setShowCloudModal(false)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium mb-2">
|
||||
What you will get
|
||||
</p>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• Access your API from anywhere in the world</li>
|
||||
<li>• Share endpoint with your team easily</li>
|
||||
<li>• No need to open ports or configure firewall</li>
|
||||
<li>• Fast global edge network</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-1">
|
||||
Note
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
|
||||
<li>• Cloud will keep your auth session for 1 day. If not used, it will be automatically deleted.</li>
|
||||
<li>• Cloud is currently unstable with Claude Code OAuth in some cases.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Sync Progress */}
|
||||
{cloudSyncing && (
|
||||
<div className="flex items-center gap-3 p-3 bg-primary/10 border border-primary/30 rounded-lg">
|
||||
<span className="material-symbols-outlined animate-spin text-primary">progress_activity</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{syncStep === "syncing" && "Syncing data to cloud..."}
|
||||
{syncStep === "verifying" && "Verifying connection..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleEnableCloud}
|
||||
fullWidth
|
||||
disabled={cloudSyncing}
|
||||
>
|
||||
{cloudSyncing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{syncStep === "syncing" ? "Syncing..." : "Verifying..."}
|
||||
</span>
|
||||
) : "Enable Cloud"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowCloudModal(false)}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
disabled={cloudSyncing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Add Key Modal */}
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
title="Create API Key"
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Key Name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Production Key"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateKey} fullWidth disabled={!newKeyName.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
}}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Created Key Modal */}
|
||||
<Modal
|
||||
isOpen={!!createdKey}
|
||||
title="API Key Created"
|
||||
onClose={() => setCreatedKey(null)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
|
||||
Save this key now!
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
This is the only time you will see this key. Store it securely.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={createdKey || ""}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "created_key" ? "check" : "content_copy"}
|
||||
onClick={() => copy(createdKey, "created_key")}
|
||||
>
|
||||
{copied === "created_key" ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setCreatedKey(null)} fullWidth>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Disable Cloud Modal */}
|
||||
<Modal
|
||||
isOpen={showDisableModal}
|
||||
title="Disable Cloud Proxy"
|
||||
onClose={() => !cloudSyncing && setShowDisableModal(false)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-red-600 dark:text-red-400">warning</span>
|
||||
<div>
|
||||
<p className="text-sm text-red-800 dark:text-red-200 font-medium mb-1">
|
||||
Warning
|
||||
</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">
|
||||
All auth sessions will be deleted from cloud.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Progress */}
|
||||
{cloudSyncing && (
|
||||
<div className="flex items-center gap-3 p-3 bg-primary/10 border border-primary/30 rounded-lg">
|
||||
<span className="material-symbols-outlined animate-spin text-primary">progress_activity</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{syncStep === "syncing" && "Syncing latest data..."}
|
||||
{syncStep === "disabling" && "Disabling cloud..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-text-muted">Are you sure you want to disable cloud proxy?</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleConfirmDisable}
|
||||
fullWidth
|
||||
disabled={cloudSyncing}
|
||||
className="!bg-red-500 hover:!bg-red-600 !text-white"
|
||||
>
|
||||
{cloudSyncing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{syncStep === "syncing" ? "Syncing..." : "Disabling..."}
|
||||
</span>
|
||||
) : "Disable Cloud"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowDisableModal(false)}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
disabled={cloudSyncing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/app/(dashboard)/dashboard/endpoint/page.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import EndpointPageClient from "./EndpointPageClient";
|
||||
|
||||
export default async function EndpointPage() {
|
||||
const machineId = await getMachineId();
|
||||
return <EndpointPageClient machineId={machineId} />;
|
||||
}
|
||||
7
src/app/(dashboard)/dashboard/page.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import EndpointPageClient from "./endpoint/EndpointPageClient";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const machineId = await getMachineId();
|
||||
return <EndpointPageClient machineId={machineId} />;
|
||||
}
|
||||
95
src/app/(dashboard)/dashboard/profile/page.js
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { Card, Button, Badge, Toggle } from "@/shared/components";
|
||||
import { useTheme } from "@/shared/hooks/useTheme";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { theme, setTheme, isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Local Mode Info */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="size-12 rounded-lg bg-green-500/10 text-green-500 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-2xl">computer</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Local Mode</h2>
|
||||
<p className="text-text-muted">Running on your machine</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border">
|
||||
<p className="text-sm text-text-muted">
|
||||
All data is stored locally in the <code className="bg-sidebar px-1 rounded">data/db.json</code> file.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Theme Preferences */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold mb-4">Appearance</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Dark Mode</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Switch between light and dark themes
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={isDark}
|
||||
onChange={() => setTheme(isDark ? "light" : "dark")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Theme Options */}
|
||||
<div className="flex gap-3 pt-4 border-t border-border">
|
||||
{["light", "dark", "system"].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setTheme(option)}
|
||||
className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border transition-all ${
|
||||
theme === option
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-2xl">
|
||||
{option === "light"
|
||||
? "light_mode"
|
||||
: option === "dark"
|
||||
? "dark_mode"
|
||||
: "contrast"}
|
||||
</span>
|
||||
<span className="text-sm font-medium capitalize">{option}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Data Management */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold mb-4">Data</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bg border border-border">
|
||||
<div>
|
||||
<p className="font-medium">Database Location</p>
|
||||
<p className="text-sm text-text-muted font-mono">~/9router/data/db.json</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* App Info */}
|
||||
<div className="text-center text-sm text-text-muted py-4">
|
||||
<p>{APP_CONFIG.name} v{APP_CONFIG.version}</p>
|
||||
<p className="mt-1">Local Mode - All data stored on your machine</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
764
src/app/(dashboard)/dashboard/providers/[id]/page.js
Normal file
@@ -0,0 +1,764 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal } from "@/shared/components";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { PROVIDER_ENDPOINTS } from "@/shared/constants/config";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
export default function ProviderDetailPage() {
|
||||
const params = useParams();
|
||||
const providerId = params.id;
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false);
|
||||
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedConnection, setSelectedConnection] = useState(null);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const providerInfo = OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId];
|
||||
const isOAuth = !!OAUTH_PROVIDERS[providerId];
|
||||
const models = getModelsByProviderId(providerId);
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
fetchAliases();
|
||||
}, [providerId]);
|
||||
|
||||
const fetchAliases = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/alias");
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setModelAliases(data.aliases || {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching aliases:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetAlias = async (modelId, alias) => {
|
||||
const fullModel = `${providerAlias}/${modelId}`;
|
||||
try {
|
||||
const res = await fetch("/api/models/alias", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: fullModel, alias }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchAliases();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || "Failed to set alias");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error setting alias:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAlias = async (alias) => {
|
||||
try {
|
||||
const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchAliases();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error deleting alias:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConnections = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const filtered = (data.connections || []).filter(c => c.provider === providerId);
|
||||
setConnections(filtered);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm("Delete this connection?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setConnections(connections.filter(c => c.id !== id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error deleting connection:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthSuccess = () => {
|
||||
fetchConnections();
|
||||
setShowOAuthModal(false);
|
||||
};
|
||||
|
||||
const handleSaveApiKey = async (formData) => {
|
||||
try {
|
||||
const res = await fetch("/api/providers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider: providerId, ...formData }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchConnections();
|
||||
setShowAddApiKeyModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error saving connection:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateConnection = async (formData) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${selectedConnection.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchConnections();
|
||||
setShowEditModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error updating connection:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwapPriority = async (conn1, conn2) => {
|
||||
if (!conn1 || !conn2) return;
|
||||
try {
|
||||
// Swap priorities
|
||||
await Promise.all([
|
||||
fetch(`/api/providers/${conn1.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ priority: conn2.priority }),
|
||||
}),
|
||||
fetch(`/api/providers/${conn2.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ priority: conn1.priority }),
|
||||
}),
|
||||
]);
|
||||
await fetchConnections();
|
||||
} catch (error) {
|
||||
console.log("Error swapping priority:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!providerInfo) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-muted">Provider not found</p>
|
||||
<Link href="/dashboard/providers" className="text-primary mt-4 inline-block">
|
||||
Back to Providers
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard/providers"
|
||||
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">arrow_back</span>
|
||||
Back to Providers
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${providerInfo.color}15` }}
|
||||
>
|
||||
<img
|
||||
src={`/providers/${providerInfo.id}.png`}
|
||||
alt={providerInfo.name}
|
||||
className="size-12 object-contain rounded-lg"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{providerInfo.name}</h1>
|
||||
<p className="text-text-muted">
|
||||
{connections.length} connection{connections.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Connections</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<span className="material-symbols-outlined text-4xl text-text-muted mb-2">
|
||||
{isOAuth ? "lock" : "key"}
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">No connections yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{connections
|
||||
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
|
||||
.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={isOAuth}
|
||||
isFirst={index === 0}
|
||||
isLast={index === connections.length - 1}
|
||||
onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
|
||||
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
|
||||
onEdit={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Models */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
|
||||
</h2>
|
||||
{providerInfo.passthroughModels ? (
|
||||
<PassthroughModelsSection
|
||||
providerAlias={providerAlias}
|
||||
modelAliases={modelAliases}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={handleSetAlias}
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
/>
|
||||
) : models.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">No models configured</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{models.map((model) => {
|
||||
const fullModel = `${providerAlias}/${model.id}`;
|
||||
// Also check for old format (providerId/model) for backward compatibility
|
||||
const oldFormatModel = `${providerId}/${model.id}`;
|
||||
const existingAlias = Object.entries(modelAliases).find(
|
||||
([, m]) => m === fullModel || m === oldFormatModel
|
||||
)?.[0];
|
||||
return (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
fullModel={fullModel}
|
||||
alias={existingAlias}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={(alias) => handleSetAlias(model.id, alias)}
|
||||
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</Card>
|
||||
|
||||
{/* Modals */}
|
||||
<OAuthModal
|
||||
isOpen={showOAuthModal}
|
||||
provider={providerId}
|
||||
providerInfo={providerInfo}
|
||||
onSuccess={handleOAuthSuccess}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
/>
|
||||
<AddApiKeyModal
|
||||
isOpen={showAddApiKeyModal}
|
||||
provider={providerId}
|
||||
onSave={handleSaveApiKey}
|
||||
onClose={() => setShowAddApiKeyModal(false)}
|
||||
/>
|
||||
<EditConnectionModal
|
||||
isOpen={showEditModal}
|
||||
connection={selectedConnection}
|
||||
onSave={handleUpdateConnection}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({ model, fullModel, alias, copied, onCopy }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border hover:bg-sidebar/50">
|
||||
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
|
||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${model.id}`)}
|
||||
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||
title="Copy model"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `model-${model.id}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) {
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
// Filter aliases for this provider - models are persisted via alias
|
||||
const providerAliases = Object.entries(modelAliases).filter(
|
||||
([, model]) => model.startsWith(`${providerAlias}/`)
|
||||
);
|
||||
|
||||
const allModels = providerAliases.map(([alias, fullModel]) => ({
|
||||
modelId: fullModel.replace(`${providerAlias}/`, ""),
|
||||
fullModel,
|
||||
alias,
|
||||
}));
|
||||
|
||||
// Generate default alias from modelId (last part after /)
|
||||
const generateDefaultAlias = (modelId) => {
|
||||
const parts = modelId.split("/");
|
||||
return parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newModel.trim() || adding) return;
|
||||
const modelId = newModel.trim();
|
||||
const defaultAlias = generateDefaultAlias(modelId);
|
||||
|
||||
// Check if alias already exists
|
||||
if (modelAliases[defaultAlias]) {
|
||||
alert(`Alias "${defaultAlias}" already exists. Please use a different model or edit existing alias.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setAdding(true);
|
||||
try {
|
||||
await onSetAlias(modelId, defaultAlias);
|
||||
setNewModel("");
|
||||
} catch (error) {
|
||||
console.log("Error adding model:", error);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
OpenRouter supports any model. Add models and create aliases for quick access.
|
||||
</p>
|
||||
|
||||
{/* Add new model */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-text-muted mb-1 block">Model ID (from OpenRouter)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder="anthropic/claude-3-opus"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModel.trim() || adding}>
|
||||
{adding ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Models list */}
|
||||
{allModels.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{allModels.map(({ modelId, fullModel, alias }) => (
|
||||
<PassthroughModelRow
|
||||
key={fullModel}
|
||||
modelId={modelId}
|
||||
fullModel={fullModel}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onDeleteAlias={() => onDeleteAlias(alias)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-sidebar/50">
|
||||
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{modelId}</p>
|
||||
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${modelId}`)}
|
||||
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||
title="Copy model"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `model-${modelId}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={onDeleteAlias}
|
||||
className="p-1 hover:bg-red-50 rounded text-red-500"
|
||||
title="Remove model"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CooldownTimer({ until }) {
|
||||
const [remaining, setRemaining] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const updateRemaining = () => {
|
||||
const diff = new Date(until).getTime() - Date.now();
|
||||
if (diff <= 0) {
|
||||
setRemaining("");
|
||||
return;
|
||||
}
|
||||
const secs = Math.floor(diff / 1000);
|
||||
if (secs < 60) {
|
||||
setRemaining(`${secs}s`);
|
||||
} else if (secs < 3600) {
|
||||
setRemaining(`${Math.floor(secs / 60)}m ${secs % 60}s`);
|
||||
} else {
|
||||
const hrs = Math.floor(secs / 3600);
|
||||
const mins = Math.floor((secs % 3600) / 60);
|
||||
setRemaining(`${hrs}h ${mins}m`);
|
||||
}
|
||||
};
|
||||
|
||||
updateRemaining();
|
||||
const interval = setInterval(updateRemaining, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [until]);
|
||||
|
||||
if (!remaining) return null;
|
||||
|
||||
return (
|
||||
<span className="text-xs text-orange-500 font-mono">
|
||||
⏱ {remaining}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onEdit, onDelete }) {
|
||||
const displayName = isOAuth
|
||||
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
||||
: connection.name;
|
||||
|
||||
// Use useState + useEffect for impure Date.now() to avoid calling during render
|
||||
const [isCooldown, setIsCooldown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkCooldown = () => {
|
||||
const cooldown = connection.rateLimitedUntil &&
|
||||
new Date(connection.rateLimitedUntil).getTime() > Date.now();
|
||||
setIsCooldown(cooldown);
|
||||
};
|
||||
|
||||
checkCooldown();
|
||||
// Update every second while in cooldown
|
||||
const interval = connection.rateLimitedUntil ? setInterval(checkCooldown, 1000) : null;
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [connection.rateLimitedUntil]);
|
||||
|
||||
// Determine effective status (override unavailable if cooldown expired)
|
||||
const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown)
|
||||
? "active" // Cooldown expired → treat as active
|
||||
: connection.testStatus;
|
||||
|
||||
const getStatusVariant = () => {
|
||||
if (effectiveStatus === "active" || effectiveStatus === "success") return "success";
|
||||
if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error";
|
||||
return "default";
|
||||
};
|
||||
|
||||
const hasError = effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border border-border hover:bg-sidebar/50 hover:cursor-pointer">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Priority arrows */}
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
className={`p-0.5 rounded ${isFirst ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">keyboard_arrow_up</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
className={`p-0.5 rounded ${isLast ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">keyboard_arrow_down</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-base text-text-muted">
|
||||
{isOAuth ? "lock" : "key"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{displayName}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant={getStatusVariant()} size="sm" dot>
|
||||
{effectiveStatus || "Unknown"}
|
||||
</Badge>
|
||||
{isCooldown && <CooldownTimer until={connection.rateLimitedUntil} />}
|
||||
{connection.lastError && (
|
||||
<span className="text-xs text-red-500 truncate max-w-[300px]" title={connection.lastError}>
|
||||
{connection.lastError}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-muted">#{connection.priority}</span>
|
||||
{connection.globalPriority && (
|
||||
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={onEdit} className="p-2 hover:bg-sidebar rounded">
|
||||
<span className="material-symbols-outlined text-base">edit</span>
|
||||
</button>
|
||||
<button onClick={onDelete} className="p-2 hover:bg-red-50 rounded text-red-500">
|
||||
<span className="material-symbols-outlined text-base">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
apiKey: "",
|
||||
priority: 1,
|
||||
});
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
|
||||
const handleValidate = async () => {
|
||||
setValidating(true);
|
||||
try {
|
||||
const res = await fetch("/api/providers/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, apiKey: formData.apiKey }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setValidationResult(data.valid ? "success" : "failed");
|
||||
} catch {
|
||||
setValidationResult("failed");
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave({
|
||||
name: formData.name,
|
||||
apiKey: formData.apiKey,
|
||||
priority: formData.priority,
|
||||
testStatus: validationResult === "success" ? "active" : "unknown",
|
||||
});
|
||||
};
|
||||
|
||||
if (!provider) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={`Add ${provider} API Key`} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Production Key"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="API Key"
|
||||
type="password"
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button onClick={handleValidate} disabled={!formData.apiKey || validating} variant="secondary">
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
)}
|
||||
<Input
|
||||
label="Priority"
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
priority: 1,
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
setFormData({
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
});
|
||||
setTestResult(null);
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!connection?.provider) return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
setTestResult(data.valid ? "success" : "failed");
|
||||
if (data.valid) {
|
||||
onSave({ testStatus: "active", lastError: null, lastErrorAt: null });
|
||||
} else {
|
||||
onSave({ testStatus: "error", lastError: data.error, lastErrorAt: new Date().toISOString() });
|
||||
}
|
||||
} catch {
|
||||
setTestResult("failed");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const updates = { name: formData.name, priority: formData.priority };
|
||||
onSave(updates);
|
||||
};
|
||||
|
||||
if (!connection) return null;
|
||||
|
||||
const isOAuth = connection.authType === "oauth";
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={isOAuth ? "Account name" : "Production Key"}
|
||||
/>
|
||||
{isOAuth && connection.email && (
|
||||
<div className="bg-sidebar/50 p-3 rounded-lg">
|
||||
<p className="text-sm text-text-muted mb-1">Email</p>
|
||||
<p className="font-medium">{connection.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Priority"
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
|
||||
{/* Test Connection */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleTest} variant="secondary" disabled={testing}>
|
||||
{testing ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<Badge variant={testResult === "success" ? "success" : "error"}>
|
||||
{testResult === "success" ? "Valid" : "Failed"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth>Save</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
220
src/app/(dashboard)/dashboard/providers/new/page.js
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Card, Button, Input, Select, Toggle } from "@/shared/components";
|
||||
import { AI_PROVIDERS, AUTH_METHODS } from "@/shared/constants/config";
|
||||
|
||||
const providerOptions = Object.values(AI_PROVIDERS).map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}));
|
||||
|
||||
const authMethodOptions = Object.values(AUTH_METHODS).map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
}));
|
||||
|
||||
export default function NewProviderPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
provider: "",
|
||||
authMethod: "api_key",
|
||||
apiKey: "",
|
||||
displayName: "",
|
||||
isActive: true,
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.provider) newErrors.provider = "Please select a provider";
|
||||
if (formData.authMethod === "api_key" && !formData.apiKey) {
|
||||
newErrors.apiKey = "API Key is required";
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/providers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push("/dashboard/providers");
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setErrors({ submit: data.error || "Failed to create provider" });
|
||||
}
|
||||
} catch (error) {
|
||||
setErrors({ submit: "An error occurred. Please try again." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedProvider = AI_PROVIDERS[formData.provider];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/dashboard/providers"
|
||||
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">arrow_back</span>
|
||||
Back to Providers
|
||||
</Link>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Add New Provider</h1>
|
||||
<p className="text-text-muted mt-2">
|
||||
Configure a new AI provider to use with your applications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
{/* Provider Selection */}
|
||||
<Select
|
||||
label="Provider"
|
||||
options={providerOptions}
|
||||
value={formData.provider}
|
||||
onChange={(e) => handleChange("provider", e.target.value)}
|
||||
placeholder="Select a provider"
|
||||
error={errors.provider}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Provider Info */}
|
||||
{selectedProvider && (
|
||||
<Card.Section className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-10 rounded-lg flex items-center justify-center bg-bg border border-border"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-xl"
|
||||
style={{ color: selectedProvider.color }}
|
||||
>
|
||||
{selectedProvider.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{selectedProvider.name}</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Selected provider
|
||||
</p>
|
||||
</div>
|
||||
</Card.Section>
|
||||
)}
|
||||
|
||||
{/* Auth Method */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-sm font-medium">
|
||||
Authentication Method <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{authMethodOptions.map((method) => (
|
||||
<button
|
||||
key={method.value}
|
||||
type="button"
|
||||
onClick={() => handleChange("authMethod", method.value)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 p-4 rounded-lg border transition-all ${
|
||||
formData.authMethod === method.value
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{method.value === "api_key" ? "key" : "lock"}
|
||||
</span>
|
||||
<span className="font-medium">{method.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Input */}
|
||||
{formData.authMethod === "api_key" && (
|
||||
<Input
|
||||
label="API Key"
|
||||
type="password"
|
||||
placeholder="Enter your API key"
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => handleChange("apiKey", e.target.value)}
|
||||
error={errors.apiKey}
|
||||
hint="Your API key will be encrypted and stored securely."
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* OAuth2 Button */}
|
||||
{formData.authMethod === "oauth2" && (
|
||||
<Card.Section>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Connect your account using OAuth2 authentication.
|
||||
</p>
|
||||
<Button type="button" variant="secondary" icon="link">
|
||||
Connect with OAuth2
|
||||
</Button>
|
||||
</Card.Section>
|
||||
)}
|
||||
|
||||
{/* Display Name */}
|
||||
<Input
|
||||
label="Display Name"
|
||||
placeholder="e.g., Production API, Dev Environment"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => handleChange("displayName", e.target.value)}
|
||||
hint="Optional. A friendly name to identify this configuration."
|
||||
/>
|
||||
|
||||
{/* Active Toggle */}
|
||||
<Toggle
|
||||
checked={formData.isActive}
|
||||
onChange={(checked) => handleChange("isActive", checked)}
|
||||
label="Active"
|
||||
description="Enable this provider for use in your applications"
|
||||
/>
|
||||
|
||||
{/* Error Message */}
|
||||
{errors.submit && (
|
||||
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">
|
||||
{errors.submit}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t border-border">
|
||||
<Link href="/dashboard/providers" className="flex-1">
|
||||
<Button type="button" variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" loading={loading} fullWidth className="flex-1">
|
||||
Create Provider
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
235
src/app/(dashboard)/dashboard/providers/page.js
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardSkeleton, Badge } from "@/shared/components";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
const data = await res.json();
|
||||
if (res.ok) setConnections(data.connections || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderStats = (providerId, authType) => {
|
||||
const providerConnections = connections.filter(
|
||||
c => c.provider === providerId && c.authType === authType
|
||||
);
|
||||
|
||||
// Helper: check if connection is effectively active (cooldown expired)
|
||||
const getEffectiveStatus = (conn) => {
|
||||
const isCooldown = conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now();
|
||||
return (conn.testStatus === "unavailable" && !isCooldown) ? "active" : conn.testStatus;
|
||||
};
|
||||
|
||||
const connected = providerConnections.filter(c => {
|
||||
const status = getEffectiveStatus(c);
|
||||
return status === "active" || status === "success";
|
||||
}).length;
|
||||
|
||||
const errorConns = providerConnections.filter(c => {
|
||||
const status = getEffectiveStatus(c);
|
||||
return status === "error" || status === "expired" || status === "unavailable";
|
||||
});
|
||||
|
||||
const error = errorConns.length;
|
||||
const total = providerConnections.length;
|
||||
|
||||
// Get latest error info
|
||||
const latestError = errorConns.sort((a, b) =>
|
||||
new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0)
|
||||
)[0];
|
||||
const errorCode = latestError ? getErrorCode(latestError.lastError) : null;
|
||||
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
|
||||
|
||||
return { connected, error, total, errorCode, errorTime };
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* OAuth Providers */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">OAuth Providers</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
|
||||
<ProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "oauth")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Providers */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">API Key Providers</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({ providerId, provider, stats }) {
|
||||
const { connected, error, errorCode, errorTime } = stats;
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
const parts = [];
|
||||
if (connected > 0) {
|
||||
parts.push(
|
||||
<Badge key="connected" variant="success" size="sm" dot>
|
||||
{connected} Connected
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (error > 0) {
|
||||
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
|
||||
parts.push(
|
||||
<Badge key="error" variant="error" size="sm" dot>
|
||||
{errText}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return <span className="text-text-muted">No connections</span>;
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`}>
|
||||
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-10 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{!imgError ? (
|
||||
<img
|
||||
src={`/providers/${provider.id}.png`}
|
||||
alt={provider.name}
|
||||
className="size-10 object-contain rounded-lg"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-bold"
|
||||
style={{ color: provider.color }}
|
||||
>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
{getStatusDisplay()}
|
||||
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-text-muted">
|
||||
chevron_right
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// API Key providers - only use textIcon, no image
|
||||
function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||
const { connected, error, errorCode, errorTime } = stats;
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
const parts = [];
|
||||
if (connected > 0) {
|
||||
parts.push(
|
||||
<Badge key="connected" variant="success" size="sm" dot>
|
||||
{connected} Connected
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (error > 0) {
|
||||
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
|
||||
parts.push(
|
||||
<Badge key="error" variant="error" size="sm" dot>
|
||||
{errText}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return <span className="text-text-muted">No connections</span>;
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`}>
|
||||
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-10 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-bold"
|
||||
style={{ color: provider.color }}
|
||||
>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
{getStatusDisplay()}
|
||||
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-text-muted">
|
||||
chevron_right
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
6
src/app/(dashboard)/layout.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { DashboardLayout } from "@/shared/components";
|
||||
|
||||
export default function DashboardRootLayout({ children }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
|
||||
187
src/app/api/cli-tools/claude-settings/route.js
Normal file
@@ -0,0 +1,187 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Get claude settings path based on OS
|
||||
const getClaudeSettingsPath = () => {
|
||||
const homeDir = os.homedir();
|
||||
return path.join(homeDir, ".claude", "settings.json");
|
||||
};
|
||||
|
||||
|
||||
// Check if claude CLI is installed
|
||||
const checkClaudeInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where claude" : "which claude";
|
||||
await execAsync(command);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Read current settings
|
||||
const readSettings = async () => {
|
||||
try {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// GET - Check claude CLI and read current settings
|
||||
export async function GET() {
|
||||
try {
|
||||
const isInstalled = await checkClaudeInstalled();
|
||||
|
||||
if (!isInstalled) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
settings: null,
|
||||
message: "Claude CLI is not installed",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await readSettings();
|
||||
const has9Router = !!(settings?.env?.ANTHROPIC_BASE_URL);
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
settings: settings,
|
||||
has9Router: has9Router,
|
||||
settingsPath: getClaudeSettingsPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking claude settings:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to check claude settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Backup old fields and write new settings
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { env } = await request.json();
|
||||
|
||||
if (!env || typeof env !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid env object" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const claudeDir = path.dirname(settingsPath);
|
||||
|
||||
// Ensure .claude directory exists
|
||||
await fs.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
// Read current settings
|
||||
let currentSettings = {};
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
currentSettings = JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge new env with existing settings
|
||||
const newSettings = {
|
||||
...currentSettings,
|
||||
env: {
|
||||
...(currentSettings.env || {}),
|
||||
...env,
|
||||
},
|
||||
};
|
||||
|
||||
// Write new settings
|
||||
await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Settings updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating claude settings:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update claude settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fields to remove when resetting
|
||||
const RESET_ENV_KEYS = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"API_TIMEOUT_MS",
|
||||
];
|
||||
|
||||
// DELETE - Reset settings (remove env fields)
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
|
||||
// Read current settings
|
||||
let currentSettings = {};
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
currentSettings = JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "No settings file to reset",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Remove specified env fields
|
||||
if (currentSettings.env) {
|
||||
RESET_ENV_KEYS.forEach((key) => {
|
||||
delete currentSettings.env[key];
|
||||
});
|
||||
|
||||
// Clean up empty env object
|
||||
if (Object.keys(currentSettings.env).length === 0) {
|
||||
delete currentSettings.env;
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated settings
|
||||
await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Settings reset successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error resetting claude settings:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reset claude settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
246
src/app/api/cli-tools/codex-settings/route.js
Normal file
@@ -0,0 +1,246 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const getCodexDir = () => path.join(os.homedir(), ".codex");
|
||||
const getCodexConfigPath = () => path.join(getCodexDir(), "config.toml");
|
||||
const getCodexAuthPath = () => path.join(getCodexDir(), "auth.json");
|
||||
|
||||
// Parse TOML config to object (simple parser for codex config)
|
||||
const parseToml = (content) => {
|
||||
const result = { _root: {}, _sections: {} };
|
||||
let currentSection = "_root";
|
||||
|
||||
content.split("\n").forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) return;
|
||||
|
||||
// Section header like [model_providers.9router]
|
||||
const sectionMatch = trimmed.match(/^\[(.+)\]$/);
|
||||
if (sectionMatch) {
|
||||
currentSection = sectionMatch[1];
|
||||
result._sections[currentSection] = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// Key = value
|
||||
const kvMatch = trimmed.match(/^([^=]+)\s*=\s*(.+)$/);
|
||||
if (kvMatch) {
|
||||
const key = kvMatch[1].trim();
|
||||
let value = kvMatch[2].trim();
|
||||
// Remove quotes
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (currentSection === "_root") {
|
||||
result._root[key] = value;
|
||||
} else {
|
||||
result._sections[currentSection][key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Convert parsed object back to TOML string
|
||||
const toToml = (parsed) => {
|
||||
let lines = [];
|
||||
|
||||
// Root level keys
|
||||
Object.entries(parsed._root).forEach(([key, value]) => {
|
||||
lines.push(`${key} = "${value}"`);
|
||||
});
|
||||
|
||||
// Sections
|
||||
Object.entries(parsed._sections).forEach(([section, values]) => {
|
||||
lines.push("");
|
||||
lines.push(`[${section}]`);
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
lines.push(`${key} = "${value}"`);
|
||||
});
|
||||
});
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
};
|
||||
|
||||
// Check if codex CLI is installed
|
||||
const checkCodexInstalled = async () => {
|
||||
try {
|
||||
const isWindows = os.platform() === "win32";
|
||||
const command = isWindows ? "where codex" : "which codex";
|
||||
await execAsync(command);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Read current config.toml
|
||||
const readConfig = async () => {
|
||||
try {
|
||||
const configPath = getCodexConfigPath();
|
||||
const content = await fs.readFile(configPath, "utf-8");
|
||||
return content;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if config has 9Router settings
|
||||
const has9RouterConfig = (config) => {
|
||||
if (!config) return false;
|
||||
return config.includes("model_provider = \"9router\"") || config.includes("[model_providers.9router]");
|
||||
};
|
||||
|
||||
// GET - Check codex CLI and read current settings
|
||||
export async function GET() {
|
||||
try {
|
||||
const isInstalled = await checkCodexInstalled();
|
||||
|
||||
if (!isInstalled) {
|
||||
return NextResponse.json({
|
||||
installed: false,
|
||||
config: null,
|
||||
message: "Codex CLI is not installed",
|
||||
});
|
||||
}
|
||||
|
||||
const config = await readConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
installed: true,
|
||||
config,
|
||||
has9Router: has9RouterConfig(config),
|
||||
configPath: getCodexConfigPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking codex settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check codex settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Update 9Router settings (merge with existing config)
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
|
||||
if (!baseUrl || !apiKey || !model) {
|
||||
return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const codexDir = getCodexDir();
|
||||
const configPath = getCodexConfigPath();
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(codexDir, { recursive: true });
|
||||
|
||||
// Read and parse existing config
|
||||
let parsed = { _root: {}, _sections: {} };
|
||||
try {
|
||||
const existingConfig = await fs.readFile(configPath, "utf-8");
|
||||
parsed = parseToml(existingConfig);
|
||||
} catch { /* No existing config */ }
|
||||
|
||||
// Update only 9Router related fields (api_key goes to auth.json, not config.toml)
|
||||
parsed._root.model = model;
|
||||
parsed._root.model_provider = "9router";
|
||||
|
||||
// Update or create 9router provider section (no api_key - Codex reads from auth.json)
|
||||
parsed._sections["model_providers.9router"] = {
|
||||
name: "9Router",
|
||||
base_url: `${baseUrl}/v1`,
|
||||
wire_api: "responses",
|
||||
};
|
||||
|
||||
// Write merged config
|
||||
const configContent = toToml(parsed);
|
||||
await fs.writeFile(configPath, configContent);
|
||||
|
||||
// Update auth.json with OPENAI_API_KEY (Codex reads this first)
|
||||
const authPath = getCodexAuthPath();
|
||||
let authData = {};
|
||||
try {
|
||||
const existingAuth = await fs.readFile(authPath, "utf-8");
|
||||
authData = JSON.parse(existingAuth);
|
||||
} catch { /* No existing auth */ }
|
||||
|
||||
authData.OPENAI_API_KEY = apiKey;
|
||||
await fs.writeFile(authPath, JSON.stringify(authData, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Codex settings applied successfully!",
|
||||
configPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating codex settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update codex settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Remove 9Router settings only (keep other settings)
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const configPath = getCodexConfigPath();
|
||||
|
||||
// Read and parse existing config
|
||||
let parsed = { _root: {}, _sections: {} };
|
||||
try {
|
||||
const existingConfig = await fs.readFile(configPath, "utf-8");
|
||||
parsed = parseToml(existingConfig);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "No config file to reset",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Remove 9Router related root fields only if they point to 9router
|
||||
if (parsed._root.model_provider === "9router") {
|
||||
delete parsed._root.model;
|
||||
delete parsed._root.model_provider;
|
||||
}
|
||||
|
||||
// Remove 9router provider section
|
||||
delete parsed._sections["model_providers.9router"];
|
||||
|
||||
// Write updated config
|
||||
const configContent = toToml(parsed);
|
||||
await fs.writeFile(configPath, configContent);
|
||||
|
||||
// Remove OPENAI_API_KEY from auth.json
|
||||
const authPath = getCodexAuthPath();
|
||||
try {
|
||||
const existingAuth = await fs.readFile(authPath, "utf-8");
|
||||
const authData = JSON.parse(existingAuth);
|
||||
delete authData.OPENAI_API_KEY;
|
||||
|
||||
// Write back or delete if empty
|
||||
if (Object.keys(authData).length === 0) {
|
||||
await fs.unlink(authPath);
|
||||
} else {
|
||||
await fs.writeFile(authPath, JSON.stringify(authData, null, 2));
|
||||
}
|
||||
} catch { /* No auth file */ }
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "9Router settings removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error resetting codex settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset codex settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
src/app/api/cloud/auth/route.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateApiKey, getProviderConnections, getModelAliases } from "@/models";
|
||||
|
||||
// Verify API key and return provider credentials
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
// return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
|
||||
// Validate API key
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
// return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get active provider connections
|
||||
const connections = await getProviderConnections({ isActive: true });
|
||||
|
||||
// Map connections
|
||||
const mappedConnections = connections.map(conn => ({
|
||||
provider: conn.provider,
|
||||
authType: conn.authType,
|
||||
apiKey: conn.apiKey || null,
|
||||
accessToken: conn.accessToken || null,
|
||||
refreshToken: conn.refreshToken || null,
|
||||
projectId: conn.projectId || null,
|
||||
expiresAt: conn.expiresAt,
|
||||
priority: conn.priority,
|
||||
globalPriority: conn.globalPriority,
|
||||
defaultModel: conn.defaultModel,
|
||||
isActive: conn.isActive
|
||||
}));
|
||||
|
||||
// Get model aliases
|
||||
const modelAliases = await getModelAliases();
|
||||
|
||||
return NextResponse.json({
|
||||
connections: mappedConnections,
|
||||
modelAliases
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log("Cloud auth error:", error);
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
57
src/app/api/cloud/credentials/update/route.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateApiKey, getProviderConnections, updateProviderConnection } from "@/models";
|
||||
|
||||
// Update provider credentials (for cloud token refresh)
|
||||
export async function PUT(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
const body = await request.json();
|
||||
const { provider, credentials } = body;
|
||||
|
||||
if (!provider || !credentials) {
|
||||
return NextResponse.json({ error: "Provider and credentials required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Find active connection for provider
|
||||
const connections = await getProviderConnections({ provider, isActive: true });
|
||||
const connection = connections[0];
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: `No active connection found for provider: ${provider}` }, { status: 404 });
|
||||
}
|
||||
|
||||
// Update credentials
|
||||
const updateData = {};
|
||||
if (credentials.accessToken) {
|
||||
updateData.accessToken = credentials.accessToken;
|
||||
}
|
||||
if (credentials.refreshToken) {
|
||||
updateData.refreshToken = credentials.refreshToken;
|
||||
}
|
||||
if (credentials.expiresIn) {
|
||||
updateData.expiresAt = new Date(Date.now() + credentials.expiresIn * 1000).toISOString();
|
||||
}
|
||||
|
||||
await updateProviderConnection(connection.id, updateData);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Credentials updated for provider: ${provider}`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log("Update credentials error:", error);
|
||||
return NextResponse.json({ error: "Failed to update credentials" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
src/app/api/cloud/model/resolve/route.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateApiKey, getModelAliases } from "@/models";
|
||||
|
||||
// Resolve model alias to provider/model
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
|
||||
const body = await request.json();
|
||||
const { alias } = body;
|
||||
|
||||
if (!alias) {
|
||||
return NextResponse.json({ error: "Missing alias" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get model aliases
|
||||
const modelAliases = await getModelAliases();
|
||||
const resolved = modelAliases[alias];
|
||||
|
||||
if (resolved) {
|
||||
// Parse provider/model
|
||||
const firstSlash = resolved.indexOf("/");
|
||||
if (firstSlash > 0) {
|
||||
return NextResponse.json({
|
||||
alias,
|
||||
provider: resolved.slice(0, firstSlash),
|
||||
model: resolved.slice(firstSlash + 1)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Not found
|
||||
return NextResponse.json({ error: "Alias not found" }, { status: 404 });
|
||||
|
||||
} catch (error) {
|
||||
console.log("Model resolve error:", error);
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
92
src/app/api/cloud/models/alias/route.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateApiKey, getModelAliases, setModelAlias, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
// PUT /api/cloud/models/alias - Set model alias (for cloud/CLI)
|
||||
export async function PUT(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const apiKey = authHeader?.replace("Bearer ", "");
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { model, alias } = body;
|
||||
|
||||
if (!model || !alias) {
|
||||
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if alias already exists for different model
|
||||
const aliases = await getModelAliases();
|
||||
const existingModel = aliases[alias];
|
||||
if (existingModel && existingModel !== model) {
|
||||
return NextResponse.json({
|
||||
error: `Alias '${alias}' already in use for model '${existingModel}'`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Update alias
|
||||
await setModelAlias(alias, model);
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
model,
|
||||
alias,
|
||||
message: `Alias '${alias}' set for model '${model}'`
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating alias:", error);
|
||||
return NextResponse.json({ error: "Failed to update alias" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing aliases to cloud:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/cloud/models/alias - Get all aliases
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const apiKey = authHeader?.replace("Bearer ", "");
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const aliases = await getModelAliases();
|
||||
|
||||
return NextResponse.json({ aliases });
|
||||
} catch (error) {
|
||||
console.log("Error fetching aliases:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
94
src/app/api/combos/[id]/route.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getComboById, updateCombo, deleteCombo, getComboByName, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
// GET /api/combos/[id] - Get combo by ID
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const combo = await getComboById(id);
|
||||
|
||||
if (!combo) {
|
||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(combo);
|
||||
} catch (error) {
|
||||
console.log("Error fetching combo:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch combo" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/combos/[id] - Update combo
|
||||
export async function PUT(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Validate name format if provided
|
||||
if (body.name) {
|
||||
if (!VALID_NAME_REGEX.test(body.name)) {
|
||||
return NextResponse.json({ error: "Name can only contain letters, numbers, - and _" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if name already exists (exclude current combo)
|
||||
const existing = await getComboByName(body.name);
|
||||
if (existing && existing.id !== id) {
|
||||
return NextResponse.json({ error: "Combo name already exists" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const combo = await updateCombo(id, body);
|
||||
|
||||
if (!combo) {
|
||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json(combo);
|
||||
} catch (error) {
|
||||
console.log("Error updating combo:", error);
|
||||
return NextResponse.json({ error: "Failed to update combo" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/combos/[id] - Delete combo
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const success = await deleteCombo(id);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.log("Error deleting combo:", error);
|
||||
return NextResponse.json({ error: "Failed to delete combo" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing to cloud:", error);
|
||||
}
|
||||
}
|
||||
66
src/app/api/combos/route.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCombos, createCombo, getComboByName, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
// GET /api/combos - Get all combos
|
||||
export async function GET() {
|
||||
try {
|
||||
const combos = await getCombos();
|
||||
return NextResponse.json({ combos });
|
||||
} catch (error) {
|
||||
console.log("Error fetching combos:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch combos" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/combos - Create new combo
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, models } = body;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!VALID_NAME_REGEX.test(name)) {
|
||||
return NextResponse.json({ error: "Name can only contain letters, numbers, - and _" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if name already exists
|
||||
const existing = await getComboByName(name);
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Combo name already exists" }, { status: 400 });
|
||||
}
|
||||
|
||||
const combo = await createCombo({ name, models: models || [] });
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json(combo, { status: 201 });
|
||||
} catch (error) {
|
||||
console.log("Error creating combo:", error);
|
||||
return NextResponse.json({ error: "Failed to create combo" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing to cloud:", error);
|
||||
}
|
||||
}
|
||||
7
src/app/api/init/route.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Auto-initialize cloud sync when server starts
|
||||
import "@/lib/initCloudSync";
|
||||
|
||||
// This API route is called automatically to initialize sync
|
||||
export async function GET() {
|
||||
return new Response("Initialized", { status: 200 });
|
||||
}
|
||||
39
src/app/api/keys/[id]/route.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { deleteApiKey, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
// DELETE /api/keys/[id] - Delete API key
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const deleted = await deleteApiKey(id);
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ error: "Key not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncKeysToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ message: "Key deleted successfully" });
|
||||
} catch (error) {
|
||||
console.log("Error deleting key:", error);
|
||||
return NextResponse.json({ error: "Failed to delete key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync API keys to Cloud if enabled
|
||||
*/
|
||||
async function syncKeysToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing keys to cloud:", error);
|
||||
}
|
||||
}
|
||||
59
src/app/api/keys/route.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeys, createApiKey, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
// GET /api/keys - List API keys
|
||||
export async function GET() {
|
||||
try {
|
||||
const keys = await getApiKeys();
|
||||
return NextResponse.json({ keys });
|
||||
} catch (error) {
|
||||
console.log("Error fetching keys:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/keys - Create new API key
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name } = body;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Always get machineId from server
|
||||
const machineId = await getConsistentMachineId();
|
||||
const apiKey = await createApiKey(name, machineId);
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncKeysToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
key: apiKey.key,
|
||||
name: apiKey.name,
|
||||
id: apiKey.id,
|
||||
machineId: apiKey.machineId,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.log("Error creating key:", error);
|
||||
return NextResponse.json({ error: "Failed to create key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync API keys to Cloud if enabled
|
||||
*/
|
||||
async function syncKeysToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing keys to cloud:", error);
|
||||
}
|
||||
}
|
||||
83
src/app/api/models/alias/route.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getModelAliases, setModelAlias, deleteModelAlias, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
// GET /api/models/alias - Get all aliases
|
||||
export async function GET() {
|
||||
try {
|
||||
const aliases = await getModelAliases();
|
||||
return NextResponse.json({ aliases });
|
||||
} catch (error) {
|
||||
console.log("Error fetching aliases:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/models/alias - Set model alias
|
||||
export async function PUT(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { model, alias } = body;
|
||||
|
||||
if (!model || !alias) {
|
||||
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const aliases = await getModelAliases();
|
||||
|
||||
// Check if alias already used by different model
|
||||
const existingModel = aliases[alias];
|
||||
if (existingModel && existingModel !== model) {
|
||||
return NextResponse.json({
|
||||
error: `Alias '${alias}' already in use for model '${existingModel}'`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Delete old alias for this model (if exists and different from new alias)
|
||||
const oldAlias = Object.entries(aliases).find(([a, m]) => m === model && a !== alias)?.[0];
|
||||
if (oldAlias) {
|
||||
await deleteModelAlias(oldAlias);
|
||||
}
|
||||
|
||||
await setModelAlias(alias, model);
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ success: true, model, alias });
|
||||
} catch (error) {
|
||||
console.log("Error updating alias:", error);
|
||||
return NextResponse.json({ error: "Failed to update alias" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/models/alias?alias=xxx - Delete alias
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const alias = searchParams.get("alias");
|
||||
|
||||
if (!alias) {
|
||||
return NextResponse.json({ error: "Alias required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await deleteModelAlias(alias);
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.log("Error deleting alias:", error);
|
||||
return NextResponse.json({ error: "Failed to delete alias" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing aliases to cloud:", error);
|
||||
}
|
||||
}
|
||||
55
src/app/api/models/route.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getModelAliases, setModelAlias } from "@/models";
|
||||
import { AI_MODELS } from "@/shared/constants/config";
|
||||
|
||||
// GET /api/models - Get models with aliases
|
||||
export async function GET() {
|
||||
try {
|
||||
const modelAliases = await getModelAliases();
|
||||
|
||||
const models = AI_MODELS.map((m) => {
|
||||
const fullModel = `${m.provider}/${m.model}`;
|
||||
return {
|
||||
...m,
|
||||
fullModel,
|
||||
alias: modelAliases[fullModel] || m.model,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (error) {
|
||||
console.log("Error fetching models:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/models - Update model alias
|
||||
export async function PUT(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { model, alias } = body;
|
||||
|
||||
if (!model || !alias) {
|
||||
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const modelAliases = await getModelAliases();
|
||||
|
||||
// Check if alias already exists for different model
|
||||
const existingModel = Object.entries(modelAliases).find(
|
||||
([key, val]) => val === alias && key !== model
|
||||
);
|
||||
|
||||
if (existingModel) {
|
||||
return NextResponse.json({ error: "Alias already in use" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Update alias
|
||||
await setModelAlias(model, alias);
|
||||
|
||||
return NextResponse.json({ success: true, model, alias });
|
||||
} catch (error) {
|
||||
console.log("Error updating alias:", error);
|
||||
return NextResponse.json({ error: "Failed to update alias" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
187
src/app/api/oauth/[provider]/[action]/route.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getProvider,
|
||||
generateAuthData,
|
||||
exchangeTokens,
|
||||
requestDeviceCode,
|
||||
pollForToken
|
||||
} from "@/lib/oauth/providers";
|
||||
import { createProviderConnection, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
/**
|
||||
* Dynamic OAuth API Route
|
||||
* Handles: authorize, exchange, device-code, poll
|
||||
*/
|
||||
|
||||
// GET /api/oauth/[provider]/authorize - Generate auth URL
|
||||
// GET /api/oauth/[provider]/device-code - Request device code (for device_code flow)
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { provider, action } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
if (action === "authorize") {
|
||||
const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback";
|
||||
const authData = generateAuthData(provider, redirectUri);
|
||||
return NextResponse.json(authData);
|
||||
}
|
||||
|
||||
if (action === "device-code") {
|
||||
const providerData = getProvider(provider);
|
||||
if (providerData.flowType !== "device_code") {
|
||||
return NextResponse.json({ error: "Provider does not support device code flow" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authData = generateAuthData(provider, null);
|
||||
|
||||
// For providers that don't use PKCE (like GitHub), don't pass codeChallenge
|
||||
let deviceData;
|
||||
if (provider === "github") {
|
||||
deviceData = await requestDeviceCode(provider);
|
||||
} else {
|
||||
// Qwen and other providers use PKCE
|
||||
deviceData = await requestDeviceCode(provider, authData.codeChallenge);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...deviceData,
|
||||
codeVerifier: authData.codeVerifier,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.log("OAuth GET error:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/oauth/[provider]/exchange - Exchange code for tokens and save
|
||||
// POST /api/oauth/[provider]/poll - Poll for token (device_code flow)
|
||||
export async function POST(request, { params }) {
|
||||
try {
|
||||
const { provider, action } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
if (action === "exchange") {
|
||||
const { code, redirectUri, codeVerifier, state } = body;
|
||||
|
||||
if (!code || !redirectUri || !codeVerifier) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state);
|
||||
|
||||
// Save to database
|
||||
const connection = await createProviderConnection({
|
||||
provider,
|
||||
authType: "oauth",
|
||||
...tokenData,
|
||||
expiresAt: tokenData.expiresIn
|
||||
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
|
||||
: null,
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connection: {
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
email: connection.email,
|
||||
displayName: connection.displayName,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "poll") {
|
||||
const { deviceCode, codeVerifier } = body;
|
||||
|
||||
if (!deviceCode) {
|
||||
return NextResponse.json({ error: "Missing device code" }, { status: 400 });
|
||||
}
|
||||
|
||||
// For providers that don't use PKCE (like GitHub), don't pass codeVerifier
|
||||
let result;
|
||||
if (provider === "github") {
|
||||
result = await pollForToken(provider, deviceCode);
|
||||
} else {
|
||||
// Qwen and other providers use PKCE
|
||||
if (!codeVerifier) {
|
||||
return NextResponse.json({ error: "Missing code verifier" }, { status: 400 });
|
||||
}
|
||||
result = await pollForToken(provider, deviceCode, codeVerifier);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
// Save to database
|
||||
const connection = await createProviderConnection({
|
||||
provider,
|
||||
authType: "oauth",
|
||||
...result.tokens,
|
||||
expiresAt: result.tokens.expiresIn
|
||||
? new Date(Date.now() + result.tokens.expiresIn * 1000).toISOString()
|
||||
: null,
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connection: {
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Still pending or error
|
||||
if (!result.pending) {
|
||||
// Save error to database for actual errors (not pending)
|
||||
await createProviderConnection({
|
||||
provider,
|
||||
authType: "oauth",
|
||||
testStatus: "error",
|
||||
lastError: result.errorDescription,
|
||||
errorCode: result.error,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
errorDescription: result.errorDescription,
|
||||
pending: result.pending || result.error === "authorization_pending",
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.log("OAuth POST error:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing to cloud after OAuth:", error);
|
||||
}
|
||||
}
|
||||
148
src/app/api/providers/[id]/models/route.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById } from "@/models";
|
||||
|
||||
// Provider models endpoints configuration
|
||||
const PROVIDER_MODELS_CONFIG = {
|
||||
claude: {
|
||||
url: "https://api.anthropic.com/v1/models",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
authHeader: "x-api-key",
|
||||
parseResponse: (data) => data.data || []
|
||||
},
|
||||
gemini: {
|
||||
url: "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authQuery: "key", // Use query param for API key
|
||||
parseResponse: (data) => data.models || []
|
||||
},
|
||||
"gemini-cli": {
|
||||
url: "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.models || []
|
||||
},
|
||||
qwen: {
|
||||
url: "https://portal.qwen.ai/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || []
|
||||
},
|
||||
antigravity: {
|
||||
url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models",
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
body: {},
|
||||
parseResponse: (data) => data.models || []
|
||||
},
|
||||
openai: {
|
||||
url: "https://api.openai.com/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || []
|
||||
},
|
||||
openrouter: {
|
||||
url: "https://openrouter.ai/api/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || []
|
||||
},
|
||||
anthropic: {
|
||||
url: "https://api.anthropic.com/v1/models",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
authHeader: "x-api-key",
|
||||
parseResponse: (data) => data.data || []
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/providers/[id]/models - Get models list from provider
|
||||
*/
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const connection = await getProviderConnectionById(id);
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const config = PROVIDER_MODELS_CONFIG[connection.provider];
|
||||
if (!config) {
|
||||
return NextResponse.json(
|
||||
{ error: `Provider ${connection.provider} does not support models listing` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get auth token
|
||||
const token = connection.accessToken || connection.apiKey;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "No valid token found" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
let url = config.url;
|
||||
if (config.authQuery) {
|
||||
url += `?${config.authQuery}=${token}`;
|
||||
}
|
||||
|
||||
// Build headers
|
||||
const headers = { ...config.headers };
|
||||
if (config.authHeader && !config.authQuery) {
|
||||
headers[config.authHeader] = (config.authPrefix || "") + token;
|
||||
}
|
||||
|
||||
// Make request
|
||||
const fetchOptions = {
|
||||
method: config.method,
|
||||
headers
|
||||
};
|
||||
|
||||
if (config.body && config.method === "POST") {
|
||||
fetchOptions.body = JSON.stringify(config.body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.log(`Error fetching models from ${connection.provider}:`, errorText);
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = config.parseResponse(data);
|
||||
|
||||
return NextResponse.json({
|
||||
provider: connection.provider,
|
||||
connectionId: connection.id,
|
||||
models
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error fetching provider models:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
102
src/app/api/providers/[id]/route.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById, updateProviderConnection, deleteProviderConnection, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
// GET /api/providers/[id] - Get single connection
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const connection = await getProviderConnectionById(id);
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Hide sensitive fields
|
||||
const result = { ...connection };
|
||||
delete result.apiKey;
|
||||
delete result.accessToken;
|
||||
delete result.refreshToken;
|
||||
delete result.idToken;
|
||||
|
||||
return NextResponse.json({ connection: result });
|
||||
} catch (error) {
|
||||
console.log("Error fetching connection:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch connection" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/providers/[id] - Update connection
|
||||
export async function PUT(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { name, priority, globalPriority, defaultModel, isActive, apiKey } = body;
|
||||
|
||||
const existing = await getProviderConnectionById(id);
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (priority !== undefined) updateData.priority = priority;
|
||||
if (globalPriority !== undefined) updateData.globalPriority = globalPriority;
|
||||
if (defaultModel !== undefined) updateData.defaultModel = defaultModel;
|
||||
if (isActive !== undefined) updateData.isActive = isActive;
|
||||
if (apiKey && existing.authType === "apikey") updateData.apiKey = apiKey;
|
||||
|
||||
const updated = await updateProviderConnection(id, updateData);
|
||||
|
||||
// Hide sensitive fields
|
||||
const result = { ...updated };
|
||||
delete result.apiKey;
|
||||
delete result.accessToken;
|
||||
delete result.refreshToken;
|
||||
delete result.idToken;
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ connection: result });
|
||||
} catch (error) {
|
||||
console.log("Error updating connection:", error);
|
||||
return NextResponse.json({ error: "Failed to update connection" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/providers/[id] - Delete connection
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const deleted = await deleteProviderConnection(id);
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ message: "Connection deleted successfully" });
|
||||
} catch (error) {
|
||||
console.log("Error deleting connection:", error);
|
||||
return NextResponse.json({ error: "Failed to delete connection" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing providers to cloud:", error);
|
||||
}
|
||||
}
|
||||
95
src/app/api/providers/[id]/test/route.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
|
||||
|
||||
// POST /api/providers/[id]/test - Test connection
|
||||
export async function POST(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const connection = await getProviderConnectionById(id);
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
if (connection.authType === "apikey") {
|
||||
// Test API key
|
||||
switch (connection.provider) {
|
||||
case "openai":
|
||||
const openaiRes = await fetch("https://api.openai.com/v1/models", {
|
||||
headers: { "Authorization": `Bearer ${connection.apiKey}` },
|
||||
});
|
||||
isValid = openaiRes.ok;
|
||||
break;
|
||||
|
||||
case "anthropic":
|
||||
const anthropicRes = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": connection.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-3-haiku-20240307",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
}),
|
||||
});
|
||||
isValid = anthropicRes.status !== 401;
|
||||
break;
|
||||
|
||||
case "gemini":
|
||||
const geminiRes = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`);
|
||||
isValid = geminiRes.ok;
|
||||
break;
|
||||
|
||||
case "openrouter":
|
||||
const openrouterRes = await fetch("https://openrouter.ai/api/v1/models", {
|
||||
headers: { "Authorization": `Bearer ${connection.apiKey}` },
|
||||
});
|
||||
isValid = openrouterRes.ok;
|
||||
break;
|
||||
|
||||
default:
|
||||
error = "Provider test not supported";
|
||||
}
|
||||
} else {
|
||||
// OAuth - check if token exists and not expired
|
||||
if (connection.accessToken) {
|
||||
if (connection.expiresAt) {
|
||||
const expiresAt = new Date(connection.expiresAt).getTime();
|
||||
isValid = expiresAt > Date.now();
|
||||
if (!isValid) error = "Token expired";
|
||||
} else {
|
||||
isValid = true;
|
||||
}
|
||||
} else {
|
||||
error = "No access token";
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Update status in db
|
||||
await updateProviderConnection(id, {
|
||||
testStatus: isValid ? "active" : "error",
|
||||
lastError: isValid ? null : error,
|
||||
lastErrorAt: isValid ? null : new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
valid: isValid,
|
||||
error: isValid ? null : error,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error testing connection:", error);
|
||||
return NextResponse.json({ error: "Test failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
20
src/app/api/providers/client/route.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections } from "@/lib/localDb";
|
||||
|
||||
// GET /api/providers/client - List all connections for client (includes sensitive fields for sync)
|
||||
export async function GET() {
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
|
||||
// Include sensitive fields for sync to cloud (only accessible from same origin)
|
||||
const clientConnections = connections.map(c => ({
|
||||
...c,
|
||||
// Don't hide sensitive fields here since this is for internal sync
|
||||
}));
|
||||
|
||||
return NextResponse.json({ connections: clientConnections });
|
||||
} catch (error) {
|
||||
console.log("Error fetching providers for client:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
84
src/app/api/providers/route.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections, createProviderConnection, isCloudEnabled } from "@/models";
|
||||
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
// GET /api/providers - List all connections
|
||||
export async function GET() {
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
|
||||
// Hide sensitive fields
|
||||
const safeConnections = connections.map(c => ({
|
||||
...c,
|
||||
apiKey: undefined,
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
idToken: undefined,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ connections: safeConnections });
|
||||
} catch (error) {
|
||||
console.log("Error fetching providers:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/providers - Create new connection (API Key only, OAuth via separate flow)
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body;
|
||||
|
||||
// Validation
|
||||
if (!provider || !APIKEY_PROVIDERS[provider]) {
|
||||
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
|
||||
}
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "API Key is required" }, { status: 400 });
|
||||
}
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const newConnection = await createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name,
|
||||
apiKey,
|
||||
priority: priority || 1,
|
||||
globalPriority: globalPriority || null,
|
||||
defaultModel: defaultModel || null,
|
||||
isActive: true,
|
||||
testStatus: testStatus || "unknown",
|
||||
});
|
||||
|
||||
// Hide sensitive fields
|
||||
const result = { ...newConnection };
|
||||
delete result.apiKey;
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ connection: result }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.log("Error creating provider:", error);
|
||||
return NextResponse.json({ error: "Failed to create provider" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing providers to cloud:", error);
|
||||
}
|
||||
}
|
||||
96
src/app/api/providers/validate/route.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// POST /api/providers/validate - Validate API key with provider
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { provider, apiKey } = body;
|
||||
|
||||
if (!provider || !apiKey) {
|
||||
return NextResponse.json({ error: "Provider and API key required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
let error = null;
|
||||
|
||||
// Validate with each provider
|
||||
try {
|
||||
switch (provider) {
|
||||
case "openai":
|
||||
const openaiRes = await fetch("https://api.openai.com/v1/models", {
|
||||
headers: { "Authorization": `Bearer ${apiKey}` },
|
||||
});
|
||||
isValid = openaiRes.ok;
|
||||
break;
|
||||
|
||||
case "anthropic":
|
||||
const anthropicRes = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-3-haiku-20240307",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
}),
|
||||
});
|
||||
isValid = anthropicRes.status !== 401;
|
||||
break;
|
||||
|
||||
case "gemini":
|
||||
const geminiRes = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${apiKey}`);
|
||||
isValid = geminiRes.ok;
|
||||
break;
|
||||
|
||||
case "openrouter":
|
||||
const openrouterRes = await fetch("https://openrouter.ai/api/v1/models", {
|
||||
headers: { "Authorization": `Bearer ${apiKey}` },
|
||||
});
|
||||
isValid = openrouterRes.ok;
|
||||
break;
|
||||
|
||||
case "glm":
|
||||
case "kimi":
|
||||
case "minimax": {
|
||||
const claudeBaseUrls = {
|
||||
glm: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
kimi: "https://api.kimi.com/coding/v1/messages",
|
||||
minimax: "https://api.minimax.io/anthropic/v1/messages",
|
||||
};
|
||||
const claudeRes = await fetch(claudeBaseUrls[provider], {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
}),
|
||||
});
|
||||
isValid = claudeRes.status !== 401;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: isValid,
|
||||
error: isValid ? null : (error || "Invalid API key"),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error validating API key:", error);
|
||||
return NextResponse.json({ error: "Validation failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/settings/route.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
return NextResponse.json(settings);
|
||||
} catch (error) {
|
||||
console.log("Error getting settings:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/shutdown/route.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true, message: "Shutting down..." });
|
||||
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
255
src/app/api/sync/cloud/route.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections, getModelAliases, getCombos, getApiKeys, createApiKey, updateProviderConnection, updateSettings } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
/**
|
||||
* POST /api/sync/cloud
|
||||
* Sync data with Cloud
|
||||
*/
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
// Always get machineId from server, don't trust client
|
||||
const machineId = await getConsistentMachineId();
|
||||
|
||||
switch (action) {
|
||||
case "enable":
|
||||
await updateSettings({ cloudEnabled: true });
|
||||
// Auto create key if none exists
|
||||
const keys = await getApiKeys();
|
||||
let createdKey = null;
|
||||
if (keys.length === 0) {
|
||||
createdKey = await createApiKey("Default Key", machineId);
|
||||
}
|
||||
return syncAndVerify(machineId, createdKey?.key, keys);
|
||||
case "sync": {
|
||||
const syncResult = await syncToCloud(machineId);
|
||||
if (syncResult.error) {
|
||||
return NextResponse.json(syncResult, { status: 502 });
|
||||
}
|
||||
return NextResponse.json(syncResult);
|
||||
}
|
||||
case "disable":
|
||||
await updateSettings({ cloudEnabled: false });
|
||||
return handleDisable(machineId, request);
|
||||
default:
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Cloud sync error:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync data to Cloud (exported for reuse)
|
||||
* @param {string} machineId
|
||||
* @param {string|null} createdKey - Key created during enable
|
||||
*/
|
||||
export async function syncToCloud(machineId, createdKey = null) {
|
||||
// Get current data from db
|
||||
const providers = await getProviderConnections();
|
||||
const modelAliases = await getModelAliases();
|
||||
const combos = await getCombos();
|
||||
const apiKeys = await getApiKeys();
|
||||
|
||||
// Send to Cloud
|
||||
const response = await fetch(`${CLOUD_URL}/sync/${machineId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providers,
|
||||
modelAliases,
|
||||
combos,
|
||||
apiKeys
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.log("Cloud sync failed:", errorText);
|
||||
return NextResponse.json({ error: "Cloud sync failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update local db with tokens from Cloud (providers stored by ID)
|
||||
if (result.data && result.data.providers) {
|
||||
await updateLocalTokens(result.data.providers);
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
success: true,
|
||||
message: "Synced successfully",
|
||||
changes: result.changes
|
||||
};
|
||||
|
||||
if (createdKey) {
|
||||
responseData.createdKey = createdKey;
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync and verify connection with ping
|
||||
*/
|
||||
async function syncAndVerify(machineId, createdKey, existingKeys) {
|
||||
// Step 1: Sync data to cloud
|
||||
const syncResult = await syncToCloud(machineId, createdKey);
|
||||
if (syncResult.error) {
|
||||
return NextResponse.json(syncResult, { status: 502 });
|
||||
}
|
||||
|
||||
// Step 2: Verify connection by pinging the cloud
|
||||
const apiKey = createdKey || existingKeys[0]?.key;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: "No API key available"
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const pingResponse = await fetch(`${CLOUD_URL}/${machineId}/v1/verify`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (pingResponse.ok) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: true
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: `Ping failed: ${pingResponse.status}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable Cloud - delete cache and update Claude CLI settings
|
||||
*/
|
||||
async function handleDisable(machineId, request) {
|
||||
const response = await fetch(`${CLOUD_URL}/sync/${machineId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.log("Cloud disable failed:", errorText);
|
||||
return NextResponse.json({ error: "Failed to disable cloud" }, { status: 502 });
|
||||
}
|
||||
|
||||
// Update Claude CLI settings to use local endpoint
|
||||
const host = request.headers.get("host") || "localhost:3000";
|
||||
await updateClaudeSettingsToLocal(machineId, host);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Cloud disabled"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Claude CLI settings to use local endpoint (only if currently using cloud)
|
||||
*/
|
||||
async function updateClaudeSettingsToLocal(machineId, host) {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
||||
const cloudUrl = `${CLOUD_URL}/${machineId}`;
|
||||
const localUrl = `http://${host}`;
|
||||
|
||||
// Read current settings
|
||||
let settings;
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
settings = JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return; // No settings file, nothing to update
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if ANTHROPIC_BASE_URL matches cloud URL
|
||||
const currentUrl = settings.env?.ANTHROPIC_BASE_URL;
|
||||
if (!currentUrl || currentUrl !== cloudUrl) {
|
||||
return; // Not using cloud URL, don't modify
|
||||
}
|
||||
|
||||
// Update to local URL
|
||||
settings.env.ANTHROPIC_BASE_URL = localUrl;
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
console.log(`Updated Claude CLI settings: ${cloudUrl} → ${localUrl}`);
|
||||
} catch (error) {
|
||||
console.log("Failed to update Claude CLI settings:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update local db with data from Cloud
|
||||
* Simple logic: if Cloud is newer, sync entire provider
|
||||
* cloudProviders is object keyed by provider ID
|
||||
*/
|
||||
async function updateLocalTokens(cloudProviders) {
|
||||
const localProviders = await getProviderConnections();
|
||||
|
||||
for (const localProvider of localProviders) {
|
||||
const cloudProvider = cloudProviders[localProvider.id];
|
||||
if (!cloudProvider) continue;
|
||||
|
||||
const cloudUpdatedAt = new Date(cloudProvider.updatedAt || 0).getTime();
|
||||
const localUpdatedAt = new Date(localProvider.updatedAt || 0).getTime();
|
||||
|
||||
// Simple logic: if Cloud is newer, sync entire provider
|
||||
if (cloudUpdatedAt > localUpdatedAt) {
|
||||
const updates = {
|
||||
// Tokens
|
||||
accessToken: cloudProvider.accessToken,
|
||||
refreshToken: cloudProvider.refreshToken,
|
||||
expiresAt: cloudProvider.expiresAt,
|
||||
expiresIn: cloudProvider.expiresIn,
|
||||
|
||||
// Provider specific data
|
||||
providerSpecificData: cloudProvider.providerSpecificData || localProvider.providerSpecificData,
|
||||
|
||||
// Status fields
|
||||
testStatus: cloudProvider.status || "active",
|
||||
lastError: cloudProvider.lastError,
|
||||
lastErrorAt: cloudProvider.lastErrorAt,
|
||||
errorCode: cloudProvider.errorCode,
|
||||
rateLimitedUntil: cloudProvider.rateLimitedUntil,
|
||||
|
||||
// Metadata
|
||||
updatedAt: cloudProvider.updatedAt
|
||||
};
|
||||
|
||||
await updateProviderConnection(localProvider.id, updates);
|
||||
console.log(`Updated ${localProvider.provider} (${localProvider.id}) from Cloud (newer: ${new Date(cloudUpdatedAt).toISOString()})`);
|
||||
} else {
|
||||
console.log(`Skipped ${localProvider.provider} (${localProvider.id}) - Local is newer or equal`);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/app/api/sync/initialize/route.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import initializeCloudSync from "@/shared/services/initializeCloudSync";
|
||||
|
||||
let syncInitialized = false;
|
||||
|
||||
// POST /api/sync/initialize - Initialize cloud sync scheduler
|
||||
export async function POST(request) {
|
||||
try {
|
||||
if (syncInitialized) {
|
||||
return NextResponse.json({
|
||||
message: "Cloud sync already initialized"
|
||||
});
|
||||
}
|
||||
|
||||
await initializeCloudSync();
|
||||
syncInitialized = true;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Cloud sync initialized successfully"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error initializing cloud sync:", error);
|
||||
return NextResponse.json({
|
||||
error: "Failed to initialize cloud sync"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/sync/status - Check sync initialization status
|
||||
export async function GET(request) {
|
||||
return NextResponse.json({
|
||||
initialized: syncInitialized,
|
||||
message: syncInitialized ? "Cloud sync is running" : "Cloud sync not initialized"
|
||||
});
|
||||
}
|
||||
18
src/app/api/tags/route.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ollamaModels } from "open-sse/config/ollamaModels.js";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return new Response(JSON.stringify(ollamaModels), {
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS }
|
||||
});
|
||||
}
|
||||
|
||||
30
src/app/api/usage/[connectionId]/route.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getProviderConnectionById } from "@/lib/localDb";
|
||||
import { getUsageForProvider } from "open-sse/services/usage.js";
|
||||
|
||||
/**
|
||||
* GET /api/usage/[connectionId] - Get usage data for a specific connection
|
||||
*/
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { connectionId } = await params;
|
||||
|
||||
// Get connection from database
|
||||
const connection = await getProviderConnectionById(connectionId);
|
||||
if (!connection) {
|
||||
return Response.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only OAuth connections have usage APIs
|
||||
if (connection.authType !== "oauth") {
|
||||
return Response.json({ message: "Usage not available for API key connections" });
|
||||
}
|
||||
|
||||
// Fetch usage from provider API
|
||||
const usage = await getUsageForProvider(connection);
|
||||
return Response.json(usage);
|
||||
} catch (error) {
|
||||
console.log("Error fetching usage:", error);
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
38
src/app/api/v1/api/chat/route.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { handleChat } from "@/sse/handlers/chat.js";
|
||||
import { initTranslators } from "open-sse/translator/index.js";
|
||||
import { transformToOllama } from "open-sse/utils/ollamaTransform.js";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
async function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
await initTranslators();
|
||||
initialized = true;
|
||||
console.log("[SSE] Translators initialized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
await ensureInitialized();
|
||||
|
||||
const clonedReq = request.clone();
|
||||
let modelName = "llama3.2";
|
||||
try {
|
||||
const body = await clonedReq.json();
|
||||
modelName = body.model || "llama3.2";
|
||||
} catch {}
|
||||
|
||||
const response = await handleChat(request);
|
||||
return transformToOllama(response, modelName);
|
||||
}
|
||||
|
||||
37
src/app/api/v1/chat/completions/route.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { callCloudWithMachineId } from "@/shared/utils/cloud.js";
|
||||
import { handleChat } from "@/sse/handlers/chat.js";
|
||||
import { initTranslators } from "open-sse/translator/index.js";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize translators once
|
||||
*/
|
||||
async function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
await initTranslators();
|
||||
initialized = true;
|
||||
console.log("[SSE] Translators initialized");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
// Fallback to local handling
|
||||
await ensureInitialized();
|
||||
|
||||
return await handleChat(request);
|
||||
}
|
||||
|
||||
52
src/app/api/v1/messages/count_tokens/route.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/messages/count_tokens - Mock token count response
|
||||
*/
|
||||
export async function POST(request) {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS }
|
||||
});
|
||||
}
|
||||
|
||||
// Estimate token count based on content length
|
||||
const messages = body.messages || [];
|
||||
let totalChars = 0;
|
||||
for (const msg of messages) {
|
||||
if (typeof msg.content === "string") {
|
||||
totalChars += msg.content.length;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text" && part.text) {
|
||||
totalChars += part.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rough estimate: ~4 chars per token
|
||||
const inputTokens = Math.ceil(totalChars / 4);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
input_tokens: inputTokens
|
||||
}), {
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS }
|
||||
});
|
||||
}
|
||||
|
||||
37
src/app/api/v1/messages/route.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { handleChat } from "@/sse/handlers/chat.js";
|
||||
import { initTranslators } from "open-sse/translator/index.js";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize translators once
|
||||
*/
|
||||
async function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
await initTranslators();
|
||||
initialized = true;
|
||||
console.log("[SSE] Translators initialized for /v1/messages");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/messages - Claude format (auto convert via handleChat)
|
||||
*/
|
||||
export async function POST(request) {
|
||||
await ensureInitialized();
|
||||
return await handleChat(request);
|
||||
}
|
||||
|
||||
31
src/app/api/v1/responses/route.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { handleChat } from "@/sse/handlers/chat.js";
|
||||
import { initTranslators } from "open-sse/translator/index.js";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
async function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
await initTranslators();
|
||||
initialized = true;
|
||||
console.log("[SSE] Translators initialized for /v1/responses");
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/responses - OpenAI Responses API format
|
||||
* Now handled by translator pattern (openai-responses format auto-detected)
|
||||
*/
|
||||
export async function POST(request) {
|
||||
await ensureInitialized();
|
||||
return await handleChat(request);
|
||||
}
|
||||
32
src/app/api/v1/route.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1 - Return models list (OpenAI compatible)
|
||||
*/
|
||||
export async function GET() {
|
||||
const models = [
|
||||
{ id: "claude-sonnet-4-20250514", object: "model", owned_by: "anthropic" },
|
||||
{ id: "claude-3-5-sonnet-20241022", object: "model", owned_by: "anthropic" },
|
||||
{ id: "gpt-4o", object: "model", owned_by: "openai" },
|
||||
{ id: "gemini-2.5-pro", object: "model", owned_by: "google" }
|
||||
];
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
object: "list",
|
||||
data: models
|
||||
}), {
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS }
|
||||
});
|
||||
}
|
||||
|
||||
113
src/app/api/v1beta/models/[...path]/route.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { handleChat } from "@/sse/handlers/chat.js";
|
||||
import { initTranslators } from "open-sse/translator/index.js";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize translators once
|
||||
*/
|
||||
async function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
await initTranslators();
|
||||
initialized = true;
|
||||
console.log("[SSE] Translators initialized for /v1beta/models");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1beta/models/{model}:generateContent - Gemini compatible endpoint
|
||||
* Converts Gemini format to internal format and handles via handleChat
|
||||
*/
|
||||
export async function POST(request, { params }) {
|
||||
await ensureInitialized();
|
||||
|
||||
try {
|
||||
const { path } = await params;
|
||||
// path = ["provider", "model:generateContent"] or ["model:generateContent"]
|
||||
|
||||
let model;
|
||||
if (path.length >= 2) {
|
||||
// Format: /v1beta/models/provider/model:generateContent
|
||||
const provider = path[0];
|
||||
const modelAction = path[1];
|
||||
const modelName = modelAction.replace(":generateContent", "").replace(":streamGenerateContent", "");
|
||||
model = `${provider}/${modelName}`;
|
||||
} else {
|
||||
// Format: /v1beta/models/model:generateContent
|
||||
const modelAction = path[0];
|
||||
model = modelAction.replace(":generateContent", "").replace(":streamGenerateContent", "");
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Convert Gemini format to OpenAI/internal format
|
||||
const convertedBody = convertGeminiToInternal(body, model);
|
||||
|
||||
// Create new request with converted body
|
||||
const newRequest = new Request(request.url, {
|
||||
method: "POST",
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(convertedBody),
|
||||
});
|
||||
|
||||
return await handleChat(newRequest);
|
||||
} catch (error) {
|
||||
console.log("Error handling Gemini request:", error);
|
||||
return Response.json(
|
||||
{ error: { message: error.message, code: 500 } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Gemini request format to internal format
|
||||
*/
|
||||
function convertGeminiToInternal(geminiBody, model) {
|
||||
const messages = [];
|
||||
|
||||
// Convert system instruction
|
||||
if (geminiBody.systemInstruction) {
|
||||
const systemText = geminiBody.systemInstruction.parts
|
||||
?.map(p => p.text)
|
||||
.join("\n") || "";
|
||||
if (systemText) {
|
||||
messages.push({ role: "system", content: systemText });
|
||||
}
|
||||
}
|
||||
|
||||
// Convert contents to messages
|
||||
if (geminiBody.contents) {
|
||||
for (const content of geminiBody.contents) {
|
||||
const role = content.role === "model" ? "assistant" : "user";
|
||||
const text = content.parts?.map(p => p.text).join("\n") || "";
|
||||
messages.push({ role, content: text });
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if streaming
|
||||
const stream = geminiBody.generationConfig?.stream !== false;
|
||||
|
||||
return {
|
||||
model,
|
||||
messages,
|
||||
stream,
|
||||
max_tokens: geminiBody.generationConfig?.maxOutputTokens,
|
||||
temperature: geminiBody.generationConfig?.temperature,
|
||||
top_p: geminiBody.generationConfig?.topP,
|
||||
};
|
||||
}
|
||||
|
||||
44
src/app/api/v1beta/models/route.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { PROVIDER_MODELS } from "@/shared/constants/models";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1beta/models - Gemini compatible models list
|
||||
* Returns models in Gemini API format
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Collect all models from all providers
|
||||
const models = [];
|
||||
|
||||
for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) {
|
||||
for (const model of providerModels) {
|
||||
models.push({
|
||||
name: `models/${provider}/${model.id}`,
|
||||
displayName: model.name || model.id,
|
||||
description: `${provider} model: ${model.name || model.id}`,
|
||||
supportedGenerationMethods: ["generateContent"],
|
||||
inputTokenLimit: 128000,
|
||||
outputTokenLimit: 8192,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ models });
|
||||
} catch (error) {
|
||||
console.log("Error fetching models:", error);
|
||||
return Response.json({ error: { message: error.message } }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
142
src/app/callback/page.js
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
/**
|
||||
* OAuth Callback Page Content
|
||||
*/
|
||||
function CallbackContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = useState("processing");
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const error = searchParams.get("error");
|
||||
const errorDescription = searchParams.get("error_description");
|
||||
|
||||
const callbackData = {
|
||||
code,
|
||||
state,
|
||||
error,
|
||||
errorDescription,
|
||||
fullUrl: window.location.href,
|
||||
};
|
||||
|
||||
let sent = false;
|
||||
|
||||
// Check if this callback is from expected origin/port
|
||||
const expectedOrigins = [
|
||||
window.location.origin, // Same origin (for most providers)
|
||||
"http://localhost:1455", // Codex specific port
|
||||
];
|
||||
|
||||
// Method 1: postMessage to opener (popup mode)
|
||||
if (window.opener) {
|
||||
try {
|
||||
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); // Allow any origin for local dev
|
||||
sent = true;
|
||||
} catch (e) {
|
||||
console.log("postMessage failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: BroadcastChannel (same origin tabs)
|
||||
try {
|
||||
const channel = new BroadcastChannel("oauth_callback");
|
||||
channel.postMessage(callbackData);
|
||||
channel.close();
|
||||
sent = true;
|
||||
} catch (e) {
|
||||
console.log("BroadcastChannel failed:", e);
|
||||
}
|
||||
|
||||
// Method 3: localStorage event (fallback)
|
||||
try {
|
||||
localStorage.setItem("oauth_callback", JSON.stringify({ ...callbackData, timestamp: Date.now() }));
|
||||
sent = true;
|
||||
} catch (e) {
|
||||
console.log("localStorage failed:", e);
|
||||
}
|
||||
|
||||
if (sent && (code || error)) {
|
||||
// Use setTimeout to avoid synchronous setState in effect
|
||||
setTimeout(() => {
|
||||
setStatus("success");
|
||||
// Auto close after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
// If can't close (not a popup), show success message
|
||||
setTimeout(() => setStatus("done"), 500);
|
||||
}, 1500);
|
||||
}, 0);
|
||||
} else {
|
||||
setTimeout(() => setStatus("manual"), 0);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg">
|
||||
<div className="text-center p-8 max-w-md">
|
||||
{status === "processing" && (
|
||||
<>
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">progress_activity</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold mb-2">Processing...</h1>
|
||||
<p className="text-text-muted">Please wait while we complete the authorization.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(status === "success" || status === "done") && (
|
||||
<>
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-green-600">check_circle</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold mb-2">Authorization Successful!</h1>
|
||||
<p className="text-text-muted">
|
||||
{status === "success" ? "This window will close automatically..." : "You can close this tab now."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "manual" && (
|
||||
<>
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-yellow-600">info</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold mb-2">Copy This URL</h1>
|
||||
<p className="text-text-muted mb-4">
|
||||
Please copy the URL from the address bar and paste it in the application.
|
||||
</p>
|
||||
<div className="bg-surface border border-border rounded-lg p-3 text-left">
|
||||
<code className="text-xs break-all">{typeof window !== "undefined" ? window.location.href : ""}</code>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth Callback Page
|
||||
* Receives callback from OAuth providers and sends data back via multiple methods
|
||||
*/
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg">
|
||||
<div className="text-center p-8">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">progress_activity</span>
|
||||
</div>
|
||||
<p className="text-text-muted">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
169
src/app/globals.css
Normal file
@@ -0,0 +1,169 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Claude-inspired Color Palette */
|
||||
:root {
|
||||
/* Primary - Warm Coral/Terracotta */
|
||||
--color-primary: #D97757;
|
||||
--color-primary-hover: #C56243;
|
||||
|
||||
/* Light theme */
|
||||
--color-bg: #FBF9F6;
|
||||
--color-bg-alt: #F5F1ED;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-sidebar: #F0EFEC;
|
||||
--color-border: #E6E4DD;
|
||||
--color-text-main: #383733;
|
||||
--color-text-muted: #75736E;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-soft: 0 2px 10px rgba(0, 0, 0, 0.03), 0 10px 25px rgba(0, 0, 0, 0.02);
|
||||
--shadow-warm: 0 4px 20px -2px rgba(217, 119, 87, 0.15);
|
||||
--shadow-elevated: 0 20px 40px -4px rgba(60, 50, 45, 0.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme */
|
||||
--color-bg: #191918;
|
||||
--color-bg-alt: #1F1F1E;
|
||||
--color-surface: #242423;
|
||||
--color-sidebar: #1F1F1E;
|
||||
--color-border: #333331;
|
||||
--color-text-main: #ECEBE8;
|
||||
--color-text-muted: #9E9D99;
|
||||
|
||||
/* Dark shadows */
|
||||
--shadow-soft: 0 2px 10px rgba(0, 0, 0, 0.2), 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
--shadow-warm: 0 4px 20px -2px rgba(217, 119, 87, 0.2);
|
||||
--shadow-elevated: 0 20px 40px -4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Primary */
|
||||
--color-primary: var(--color-primary);
|
||||
--color-primary-hover: var(--color-primary-hover);
|
||||
|
||||
/* Auto-switch colors (use CSS variables from :root/.dark) */
|
||||
--color-bg: var(--color-bg);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-sidebar: var(--color-sidebar);
|
||||
--color-border: var(--color-border);
|
||||
--color-text-main: var(--color-text-main);
|
||||
--color-text-muted: var(--color-text-muted);
|
||||
|
||||
/* Static colors (for explicit light/dark usage) */
|
||||
--color-bg-light: #FBF9F6;
|
||||
--color-bg-dark: #191918;
|
||||
--color-surface-light: #FFFFFF;
|
||||
--color-surface-dark: #242423;
|
||||
--color-sidebar-light: #F0EFEC;
|
||||
--color-sidebar-dark: #1F1F1E;
|
||||
--color-border-light: #E6E4DD;
|
||||
--color-border-dark: #333331;
|
||||
--color-text-main-light: #383733;
|
||||
--color-text-main-dark: #ECEBE8;
|
||||
--color-text-muted-light: #75736E;
|
||||
--color-text-muted-dark: #9E9D99;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-soft: var(--shadow-soft);
|
||||
--shadow-warm: var(--shadow-warm);
|
||||
--shadow-elevated: var(--shadow-elevated);
|
||||
|
||||
/* Font */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text-main);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: rgba(217, 119, 87, 0.2);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
|
||||
/* Hero gradient */
|
||||
.bg-hero-gradient {
|
||||
background: linear-gradient(180deg, #F5F1ED 0%, #FEFCFB 100%);
|
||||
}
|
||||
|
||||
.dark .bg-hero-gradient {
|
||||
background: linear-gradient(180deg, #1F1F1E 0%, #191918 100%);
|
||||
}
|
||||
|
||||
/* Material Symbols */
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
|
||||
.material-symbols-outlined.fill-1 {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes border-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(217, 119, 87, 0.3), 0 0 10px rgba(217, 119, 87, 0.2);
|
||||
border-color: rgba(217, 119, 87, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 10px rgba(217, 119, 87, 0.5), 0 0 20px rgba(217, 119, 87, 0.3);
|
||||
border-color: rgba(217, 119, 87, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-border-glow {
|
||||
animation: border-glow 2s ease-in-out infinite;
|
||||
}
|
||||
57
src/app/landing/components/AnimatedBackground.js
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
export default function AnimatedBackground() {
|
||||
return (
|
||||
<>
|
||||
{/* Animated Background */}
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none">
|
||||
{/* Grid pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, #f97815 1px, transparent 1px), linear-gradient(to bottom, #f97815 1px, transparent 1px)`,
|
||||
backgroundSize: '50px 50px'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Animated gradient orbs */}
|
||||
<div className="absolute -top-20 left-1/4 w-[600px] h-[600px] bg-[#f97815]/20 rounded-full blur-[120px] animate-blob" />
|
||||
<div className="absolute top-1/3 -right-20 w-[500px] h-[500px] bg-purple-500/15 rounded-full blur-[120px] animate-blob-delayed-1" />
|
||||
<div className="absolute -bottom-20 left-1/2 w-[550px] h-[550px] bg-blue-500/12 rounded-full blur-[120px] animate-blob-delayed-2" />
|
||||
|
||||
{/* Vignette effect */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'radial-gradient(circle at center, transparent 0%, rgba(24, 20, 17, 0.4) 100%)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CSS Animations */}
|
||||
<style jsx global>{`
|
||||
@keyframes blob {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 20s ease-in-out infinite;
|
||||
}
|
||||
.animate-blob-delayed-1 {
|
||||
animation: blob 22s ease-in-out 2s infinite;
|
||||
}
|
||||
.animate-blob-delayed-2 {
|
||||
animation: blob 25s ease-in-out 4s infinite;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
133
src/app/landing/components/Features.js
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: "link",
|
||||
title: "Unified Endpoint",
|
||||
desc: "Access all providers via a single standard API URL.",
|
||||
colors: {
|
||||
border: "hover:border-blue-500/50",
|
||||
bg: "hover:bg-blue-500/5",
|
||||
iconBg: "bg-blue-500/10",
|
||||
iconText: "text-blue-500",
|
||||
titleHover: "group-hover:text-blue-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: "bolt",
|
||||
title: "Easy Setup",
|
||||
desc: "Get up and running in minutes with npx command.",
|
||||
colors: {
|
||||
border: "hover:border-orange-500/50",
|
||||
bg: "hover:bg-orange-500/5",
|
||||
iconBg: "bg-orange-500/10",
|
||||
iconText: "text-orange-500",
|
||||
titleHover: "group-hover:text-orange-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: "shield_with_heart",
|
||||
title: "Model Fallback",
|
||||
desc: "Automatically switch providers on failure or high latency.",
|
||||
colors: {
|
||||
border: "hover:border-rose-500/50",
|
||||
bg: "hover:bg-rose-500/5",
|
||||
iconBg: "bg-rose-500/10",
|
||||
iconText: "text-rose-500",
|
||||
titleHover: "group-hover:text-rose-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: "monitoring",
|
||||
title: "Usage Tracking",
|
||||
desc: "Detailed analytics and cost monitoring across all models.",
|
||||
colors: {
|
||||
border: "hover:border-purple-500/50",
|
||||
bg: "hover:bg-purple-500/5",
|
||||
iconBg: "bg-purple-500/10",
|
||||
iconText: "text-purple-500",
|
||||
titleHover: "group-hover:text-purple-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: "key",
|
||||
title: "OAuth & API Keys",
|
||||
desc: "Securely manage credentials in one vault.",
|
||||
colors: {
|
||||
border: "hover:border-amber-500/50",
|
||||
bg: "hover:bg-amber-500/5",
|
||||
iconBg: "bg-amber-500/10",
|
||||
iconText: "text-amber-500",
|
||||
titleHover: "group-hover:text-amber-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: "cloud_sync",
|
||||
title: "Cloud Sync",
|
||||
desc: "Sync your configurations across devices instantly.",
|
||||
colors: {
|
||||
border: "hover:border-sky-500/50",
|
||||
bg: "hover:bg-sky-500/5",
|
||||
iconBg: "bg-sky-500/10",
|
||||
iconText: "text-sky-500",
|
||||
titleHover: "group-hover:text-sky-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: "terminal",
|
||||
title: "CLI Support",
|
||||
desc: "Works with Claude Code, Codex, Cline, Cursor, and more.",
|
||||
colors: {
|
||||
border: "hover:border-emerald-500/50",
|
||||
bg: "hover:bg-emerald-500/5",
|
||||
iconBg: "bg-emerald-500/10",
|
||||
iconText: "text-emerald-500",
|
||||
titleHover: "group-hover:text-emerald-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: "dashboard",
|
||||
title: "Dashboard",
|
||||
desc: "Visual dashboard for real-time traffic analysis.",
|
||||
colors: {
|
||||
border: "hover:border-fuchsia-500/50",
|
||||
bg: "hover:bg-fuchsia-500/5",
|
||||
iconBg: "bg-fuchsia-500/10",
|
||||
iconText: "text-fuchsia-500",
|
||||
titleHover: "group-hover:text-fuchsia-400"
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
export default function Features() {
|
||||
return (
|
||||
<section className="py-24 px-6" id="features">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">Powerful Features</h2>
|
||||
<p className="text-gray-400 max-w-xl text-lg">
|
||||
Everything you need to manage your AI infrastructure in one place, built for scale.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{FEATURES.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className={`p-6 rounded-xl bg-[#23180f] border border-[#3a2f27] ${feature.colors.border} ${feature.colors.bg} transition-all duration-300 group`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg ${feature.colors.iconBg} flex items-center justify-center mb-4 ${feature.colors.iconText} group-hover:scale-110 transition-transform duration-300`}>
|
||||
<span className="material-symbols-outlined">{feature.icon}</span>
|
||||
</div>
|
||||
<h3 className={`text-lg font-bold mb-2 ${feature.colors.titleHover} transition-colors`}>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 leading-relaxed">{feature.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
120
src/app/landing/components/FlowAnimation.js
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
const CLI_TOOLS = [
|
||||
{ id: "claude", name: "Claude Code", image: "/providers/claude.png" },
|
||||
{ id: "codex", name: "OpenAI Codex", image: "/providers/codex.png" },
|
||||
{ id: "cline", name: "Cline", image: "/providers/cline.png" },
|
||||
{ id: "cursor", name: "Cursor", image: "/providers/cursor.png" },
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: "openai", name: "OpenAI", color: "bg-emerald-500", textColor: "text-white" },
|
||||
{ id: "anthropic", name: "Anthropic", color: "bg-orange-400", textColor: "text-white" },
|
||||
{ id: "gemini", name: "Gemini", color: "bg-blue-500", textColor: "text-white" },
|
||||
{ id: "github", name: "GitHub Copilot", color: "bg-gray-700", textColor: "text-white" },
|
||||
];
|
||||
|
||||
export default function FlowAnimation() {
|
||||
const [activeFlow, setActiveFlow] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActiveFlow((prev) => (prev + 1) % PROVIDERS.length);
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-16 w-full max-w-4xl relative h-[360px] hidden md:flex items-center justify-center animate-[float_6s_ease-in-out_infinite]">
|
||||
{/* 9Router Hub - Center */}
|
||||
<div className="relative z-20 w-32 h-32 rounded-full bg-[#23180f] border-2 border-[#f97815] shadow-[0_0_40px_rgba(249,120,21,0.3)] flex flex-col items-center justify-center gap-1 group cursor-pointer hover:scale-105 transition-transform duration-500">
|
||||
<span className="material-symbols-outlined text-4xl text-[#f97815]">hub</span>
|
||||
<span className="text-xs font-bold text-white tracking-widest uppercase">9Router</span>
|
||||
<div className="absolute inset-0 rounded-full border border-[#f97815]/30 animate-ping opacity-20"></div>
|
||||
</div>
|
||||
|
||||
{/* CLI Tools - Left side */}
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 flex flex-col gap-7">
|
||||
{CLI_TOOLS.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-3 opacity-70 hover:opacity-100 transition-opacity group"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-[#23180f] border border-[#3a2f27] flex items-center justify-center overflow-hidden p-2 hover:border-[#f97815]/50 transition-all hover:scale-105">
|
||||
<Image
|
||||
src={tool.image}
|
||||
alt={tool.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="object-contain rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SVG Lines from CLI to 9Router */}
|
||||
<svg className="absolute inset-0 w-full h-full z-10 pointer-events-none stroke-yellow-700" xmlns="http://www.w3.org/2000/svg">
|
||||
<path className="animate-[dash_2s_linear_infinite]" d="M 60 50 C 250 70, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
|
||||
<path className="animate-[dash_2s_linear_infinite]" d="M 60 140 C 250 140, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
|
||||
<path className="animate-[dash_2s_linear_infinite]" d="M 60 210 C 250 210, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
|
||||
<path className="animate-[dash_2s_linear_infinite]" d="M 60 300 C 250 280, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
|
||||
</svg>
|
||||
|
||||
{/* SVG Lines from 9Router to Providers */}
|
||||
<svg className="absolute inset-0 w-full h-full z-10 pointer-events-none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 50, 740 50"
|
||||
fill="none"
|
||||
stroke={activeFlow === 0 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
strokeWidth={activeFlow === 0 ? "3" : "2"}
|
||||
className={activeFlow === 0 ? "animate-pulse" : ""}
|
||||
></path>
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 130, 740 130"
|
||||
fill="none"
|
||||
stroke={activeFlow === 1 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
strokeWidth={activeFlow === 1 ? "3" : "2"}
|
||||
className={activeFlow === 1 ? "animate-pulse" : ""}
|
||||
></path>
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 230, 740 230"
|
||||
fill="none"
|
||||
stroke={activeFlow === 2 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
strokeWidth={activeFlow === 2 ? "3" : "2"}
|
||||
className={activeFlow === 2 ? "animate-pulse" : ""}
|
||||
></path>
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 310, 740 310"
|
||||
fill="none"
|
||||
stroke={activeFlow === 3 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
strokeWidth={activeFlow === 3 ? "3" : "2"}
|
||||
className={activeFlow === 3 ? "animate-pulse" : ""}
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
{/* AI Providers - Right side */}
|
||||
<div className="absolute right-0 top-0 bottom-0 flex flex-col justify-between py-6">
|
||||
{PROVIDERS.map((provider, idx) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`px-4 py-2 rounded-lg ${provider.color} ${provider.textColor} flex items-center justify-center font-bold text-xs shadow-lg hover:scale-110 transition-all cursor-help min-w-[140px] ${
|
||||
activeFlow === idx ? "ring-4 ring-[#f97815]/50 scale-110" : ""
|
||||
}`}
|
||||
title={provider.name}
|
||||
>
|
||||
{provider.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile fallback */}
|
||||
<div className="md:hidden mt-8 w-full p-4 rounded-lg bg-[#23180f] border border-[#3a2f27]">
|
||||
<p className="text-sm text-center text-gray-400">Interactive diagram visible on desktop</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
61
src/app/landing/components/Footer.js
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-[#3a2f27] bg-[#120f0d] pt-16 pb-8 px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-8 mb-16">
|
||||
{/* Brand */}
|
||||
<div className="col-span-2 lg:col-span-2">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="size-6 rounded bg-[#f97815] flex items-center justify-center text-white">
|
||||
<span className="material-symbols-outlined text-[16px]">hub</span>
|
||||
</div>
|
||||
<h3 className="text-white text-lg font-bold">9Router</h3>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm max-w-xs mb-6">
|
||||
The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<a className="text-gray-400 hover:text-white transition-colors" href="https://github.com/decolua/9router" target="_blank" rel="noopener noreferrer">
|
||||
<span className="material-symbols-outlined">code</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="font-bold text-white">Product</h4>
|
||||
<a className="text-gray-400 hover:text-[#f97815] text-sm transition-colors" href="#features">Features</a>
|
||||
<a className="text-gray-400 hover:text-[#f97815] text-sm transition-colors" href="/dashboard">Dashboard</a>
|
||||
<a className="text-gray-400 hover:text-[#f97815] text-sm transition-colors" href="https://github.com/decolua/9router" target="_blank" rel="noopener noreferrer">Changelog</a>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="font-bold text-white">Resources</h4>
|
||||
<a className="text-gray-400 hover:text-[#f97815] text-sm transition-colors" href="https://github.com/decolua/9router#readme" target="_blank" rel="noopener noreferrer">Documentation</a>
|
||||
<a className="text-gray-400 hover:text-[#f97815] text-sm transition-colors" href="https://github.com/decolua/9router" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
<a className="text-gray-400 hover:text-[#f97815] text-sm transition-colors" href="https://www.npmjs.com/package/9router" target="_blank" rel="noopener noreferrer">NPM</a>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="font-bold text-white">Legal</h4>
|
||||
<a className="text-gray-400 hover:text-[#f97815] text-sm transition-colors" href="https://github.com/decolua/9router/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">MIT License</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom */}
|
||||
<div className="border-t border-[#3a2f27] pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p className="text-gray-600 text-sm">© 2025 9Router. All rights reserved.</p>
|
||||
<div className="flex gap-6">
|
||||
<a className="text-gray-600 hover:text-white text-sm transition-colors" href="https://github.com/decolua/9router" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
<a className="text-gray-600 hover:text-white text-sm transition-colors" href="https://www.npmjs.com/package/9router" target="_blank" rel="noopener noreferrer">NPM</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
99
src/app/landing/components/GetStarted.js
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function GetStarted() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-24 px-6 bg-[#120f0d]">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col lg:flex-row gap-16 items-start">
|
||||
{/* Left: Steps */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">Get Started in 30 Seconds</h2>
|
||||
<p className="text-gray-400 text-lg mb-8">
|
||||
Install 9Router, configure your providers via web dashboard, and start routing AI requests.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-none w-8 h-8 rounded-full bg-[#f97815]/20 text-[#f97815] flex items-center justify-center font-bold">1</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg">Install 9Router</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">Run npx command to start the server instantly</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-none w-8 h-8 rounded-full bg-[#f97815]/20 text-[#f97815] flex items-center justify-center font-bold">2</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg">Open Dashboard</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure providers and API keys via web interface</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-none w-8 h-8 rounded-full bg-[#f97815]/20 text-[#f97815] flex items-center justify-center font-bold">3</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg">Route Requests</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">Point your CLI tools to http://localhost:20128</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Code block */}
|
||||
<div className="flex-1 w-full">
|
||||
<div className="rounded-xl overflow-hidden bg-[#1e1e1e] border border-[#3a2f27] shadow-2xl">
|
||||
{/* Terminal header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-[#252526] border-b border-gray-700">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<div className="ml-2 text-xs text-gray-500 font-mono">terminal</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content */}
|
||||
<div className="p-6 font-mono text-sm leading-relaxed overflow-x-auto">
|
||||
<div
|
||||
className="flex items-center gap-2 mb-4 group cursor-pointer"
|
||||
onClick={() => handleCopy("npx 9router")}
|
||||
>
|
||||
<span className="text-green-400">$</span>
|
||||
<span className="text-white">npx 9router</span>
|
||||
<span className="ml-auto text-gray-500 text-xs opacity-0 group-hover:opacity-100">
|
||||
{copied ? "✓ Copied" : "Copy"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-gray-400 mb-6">
|
||||
<span className="text-[#f97815]">></span> Starting 9Router...<br/>
|
||||
<span className="text-[#f97815]">></span> Server running on <span className="text-blue-400">http://localhost:20128</span><br/>
|
||||
<span className="text-[#f97815]">></span> Dashboard: <span className="text-blue-400">http://localhost:20128/dashboard</span><br/>
|
||||
<span className="text-green-400">></span> Ready to route! ✓
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 mb-2 border-t border-gray-700 pt-4">
|
||||
📝 Configure providers in dashboard or use environment variables
|
||||
</div>
|
||||
|
||||
<div className="text-gray-400 text-xs">
|
||||
<span className="text-purple-400">Data Location:</span><br/>
|
||||
<span className="text-gray-500"> macOS/Linux:</span> ~/.9router/db.json<br/>
|
||||
<span className="text-gray-500"> Windows:</span> %APPDATA%/9router/db.json
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/app/landing/components/HeroSection.js
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
export default function HeroSection() {
|
||||
return (
|
||||
<section className="relative pt-32 pb-20 px-6 min-h-[90vh] flex flex-col items-center justify-center overflow-hidden">
|
||||
{/* Glow effect */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[500px] bg-[#f97815]/10 rounded-full blur-[120px] pointer-events-none"></div>
|
||||
|
||||
<div className="relative z-10 max-w-4xl w-full text-center flex flex-col items-center gap-8">
|
||||
{/* Version badge */}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-[#3a2f27] bg-[#23180f]/50 px-3 py-1 text-xs font-medium text-[#f97815]">
|
||||
<span className="flex h-2 w-2 rounded-full bg-[#f97815] animate-pulse"></span>
|
||||
v1.0 is now live
|
||||
</div>
|
||||
|
||||
{/* Main heading */}
|
||||
<h1 className="text-5xl md:text-7xl font-black leading-[1.1] tracking-tight">
|
||||
One Endpoint for <br/>
|
||||
<span className="text-[#f97815]">All AI Providers</span>
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-lg md:text-xl text-gray-400 max-w-2xl mx-auto font-light">
|
||||
AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.
|
||||
</p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 w-full">
|
||||
<button className="h-12 px-8 rounded-lg bg-[#f97815] hover:bg-[#e0650a] text-[#181411] text-base font-bold transition-all shadow-[0_0_15px_rgba(249,120,21,0.4)] flex items-center gap-2">
|
||||
<span className="material-symbols-outlined">rocket_launch</span>
|
||||
Get Started
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/decolua/9router"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-12 px-8 rounded-lg border border-[#3a2f27] bg-[#23180f] hover:bg-[#3a2f27] text-white text-base font-bold transition-all flex items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined">code</span>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
66
src/app/landing/components/HowItWorks.js
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
export default function HowItWorks() {
|
||||
return (
|
||||
<section className="py-24 border-y border-[#3a2f27] bg-[#23180f]/30" id="how-it-works">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">How 9Router Works</h2>
|
||||
<p className="text-gray-400 max-w-xl text-lg">
|
||||
Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 relative">
|
||||
{/* Connection line */}
|
||||
<div className="hidden md:block absolute top-12 left-[16%] right-[16%] h-[2px] bg-gradient-to-r from-gray-700 via-[#f97815] to-gray-700 -z-10"></div>
|
||||
|
||||
{/* Step 1: CLI & SDKs */}
|
||||
<div className="flex flex-col gap-6 relative group">
|
||||
<div className="w-24 h-24 rounded-2xl bg-[#181411] border border-[#3a2f27] flex items-center justify-center shadow-xl group-hover:border-gray-500 transition-colors z-10 mx-auto md:mx-0">
|
||||
<span className="material-symbols-outlined text-4xl text-gray-300">terminal</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-2">1. CLI & SDKs</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Your requests start from your favorite tools or our unified SDK. Just change the base URL.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: 9Router Hub */}
|
||||
<div className="flex flex-col gap-6 relative group md:items-center md:text-center">
|
||||
<div className="w-24 h-24 rounded-2xl bg-[#181411] border-2 border-[#f97815] flex items-center justify-center shadow-[0_0_30px_rgba(249,120,21,0.2)] z-10 mx-auto">
|
||||
<span className="material-symbols-outlined text-4xl text-[#f97815] animate-pulse">hub</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-2 text-[#f97815]">2. 9Router Hub</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3: AI Providers */}
|
||||
<div className="flex flex-col gap-6 relative group md:items-end md:text-right">
|
||||
<div className="w-24 h-24 rounded-2xl bg-[#181411] border border-[#3a2f27] flex items-center justify-center shadow-xl group-hover:border-gray-500 transition-colors z-10 mx-auto md:mx-0">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="w-6 h-6 rounded bg-white/10"></div>
|
||||
<div className="w-6 h-6 rounded bg-white/10"></div>
|
||||
<div className="w-6 h-6 rounded bg-white/10"></div>
|
||||
<div className="w-6 h-6 rounded bg-white/10"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-2">3. AI Providers</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
67
src/app/landing/components/Navigation.js
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function Navigation() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 z-50 w-full bg-[#181411]/80 backdrop-blur-md border-b border-[#3a2f27]">
|
||||
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 cursor-pointer" onClick={() => router.push("/")}>
|
||||
<div className="size-8 rounded bg-gradient-to-br from-[#f97815] to-orange-700 flex items-center justify-center text-white">
|
||||
<span className="material-symbols-outlined text-[20px]">hub</span>
|
||||
</div>
|
||||
<h2 className="text-white text-xl font-bold tracking-tight">9Router</h2>
|
||||
</div>
|
||||
|
||||
{/* Desktop menu */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<a className="text-gray-300 hover:text-white text-sm font-medium transition-colors" href="#features">Features</a>
|
||||
<a className="text-gray-300 hover:text-white text-sm font-medium transition-colors" href="#how-it-works">How it Works</a>
|
||||
<a className="text-gray-300 hover:text-white text-sm font-medium transition-colors" href="https://github.com/decolua/9router#readme" target="_blank" rel="noopener noreferrer">Docs</a>
|
||||
<a className="text-gray-300 hover:text-white text-sm font-medium transition-colors flex items-center gap-1" href="https://github.com/decolua/9router" target="_blank" rel="noopener noreferrer">
|
||||
GitHub <span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* CTA + Mobile menu */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="hidden sm:flex h-9 items-center justify-center rounded-lg px-4 bg-[#f97815] hover:bg-[#e0650a] transition-all text-[#181411] text-sm font-bold shadow-[0_0_15px_rgba(249,120,21,0.4)] hover:shadow-[0_0_20px_rgba(249,120,21,0.6)]"
|
||||
>
|
||||
Get Started
|
||||
</button>
|
||||
<button
|
||||
className="md:hidden text-white"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
<span className="material-symbols-outlined">{mobileMenuOpen ? "close" : "menu"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu dropdown */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-[#3a2f27] bg-[#181411]/95 backdrop-blur-md">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<a className="text-gray-300 hover:text-white text-sm font-medium transition-colors" href="#features" onClick={() => setMobileMenuOpen(false)}>Features</a>
|
||||
<a className="text-gray-300 hover:text-white text-sm font-medium transition-colors" href="#how-it-works" onClick={() => setMobileMenuOpen(false)}>How it Works</a>
|
||||
<a className="text-gray-300 hover:text-white text-sm font-medium transition-colors" href="https://github.com/decolua/9router#readme" target="_blank" rel="noopener noreferrer">Docs</a>
|
||||
<a className="text-gray-300 hover:text-white text-sm font-medium transition-colors" href="https://github.com/decolua/9router" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="h-9 rounded-lg bg-[#f97815] hover:bg-[#e0650a] text-[#181411] text-sm font-bold"
|
||||
>
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
104
src/app/landing/page.js
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
import Navigation from "./components/Navigation";
|
||||
import HeroSection from "./components/HeroSection";
|
||||
import FlowAnimation from "./components/FlowAnimation";
|
||||
import HowItWorks from "./components/HowItWorks";
|
||||
import Features from "./components/Features";
|
||||
import GetStarted from "./components/GetStarted";
|
||||
import Footer from "./components/Footer";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="relative text-white font-sans overflow-x-hidden antialiased selection:bg-[#f97815] selection:text-white">
|
||||
{/* Animated Background */}
|
||||
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[#181411]">
|
||||
{/* Grid pattern */}
|
||||
<div className="absolute inset-0 opacity-[0.06]" style={{
|
||||
backgroundImage: `linear-gradient(to right, #f97815 1px, transparent 1px), linear-gradient(to bottom, #f97815 1px, transparent 1px)`,
|
||||
backgroundSize: '50px 50px'
|
||||
}}></div>
|
||||
|
||||
{/* Animated gradient orbs */}
|
||||
<div className="absolute top-0 left-1/4 w-[700px] h-[700px] bg-[#f97815]/12 rounded-full blur-[130px] animate-blob"></div>
|
||||
<div className="absolute top-1/3 right-1/4 w-[600px] h-[600px] bg-purple-500/10 rounded-full blur-[130px] animate-blob" style={{ animationDelay: '2s', animationDuration: '22s' }}></div>
|
||||
<div className="absolute bottom-0 left-1/2 w-[650px] h-[650px] bg-blue-500/8 rounded-full blur-[130px] animate-blob" style={{ animationDelay: '4s', animationDuration: '25s' }}></div>
|
||||
|
||||
{/* Vignette effect */}
|
||||
<div className="absolute inset-0" style={{
|
||||
background: 'radial-gradient(circle at center, transparent 0%, rgba(24, 20, 17, 0.4) 100%)'
|
||||
}}></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<Navigation />
|
||||
|
||||
<main>
|
||||
{/* Hero with Flow Animation */}
|
||||
<div className="relative">
|
||||
<HeroSection />
|
||||
<div className="flex justify-center pb-20">
|
||||
<FlowAnimation />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GetStarted />
|
||||
<HowItWorks />
|
||||
<Features />
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-32 px-6 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#f97815]/5 to-transparent pointer-events-none"></div>
|
||||
<div className="max-w-4xl mx-auto text-center relative z-10">
|
||||
<h2 className="text-4xl md:text-5xl font-black mb-6">Ready to Simplify Your AI Infrastructure?</h2>
|
||||
<p className="text-xl text-gray-400 mb-10 max-w-2xl mx-auto">
|
||||
Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => window.location.href = "/dashboard"}
|
||||
className="w-full sm:w-auto h-14 px-10 rounded-lg bg-[#f97815] hover:bg-[#e0650a] text-[#181411] text-lg font-bold transition-all shadow-[0_0_20px_rgba(249,120,21,0.5)]"
|
||||
>
|
||||
Start Free
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open("https://github.com/decolua/9router#readme", "_blank")}
|
||||
className="w-full sm:w-auto h-14 px-10 rounded-lg border border-[#3a2f27] hover:bg-[#23180f] text-white text-lg font-bold transition-all"
|
||||
>
|
||||
Read Documentation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
{/* Global styles for keyframes */}
|
||||
<style jsx global>{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
@keyframes dash {
|
||||
to { stroke-dashoffset: -20; }
|
||||
}
|
||||
@keyframes blob {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 20s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
33
src/app/layout.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/shared/components/ThemeProvider";
|
||||
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "9Router - AI Infrastructure Management",
|
||||
description: "One endpoint for all your AI providers. Manage keys, monitor usage, and scale effortlessly.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
7
src/app/page.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Auto-initialize cloud sync when server starts
|
||||
import "@/lib/initCloudSync";
|
||||
import LandingPage from "./landing/page";
|
||||
|
||||
export default function InitPage() {
|
||||
return <LandingPage />;
|
||||
}
|
||||
22
src/lib/initCloudSync.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import initializeCloudSync from "@/shared/services/initializeCloudSync";
|
||||
|
||||
// Initialize cloud sync when this module is imported
|
||||
let initialized = false;
|
||||
|
||||
export async function ensureCloudSyncInitialized() {
|
||||
if (!initialized) {
|
||||
try {
|
||||
await initializeCloudSync();
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error("[ServerInit] Error initializing cloud sync:", error);
|
||||
}
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
|
||||
// Auto-initialize when module loads
|
||||
ensureCloudSyncInitialized().catch(console.log);
|
||||
|
||||
export default ensureCloudSyncInitialized;
|
||||
|
||||
496
src/lib/localDb.js
Normal file
@@ -0,0 +1,496 @@
|
||||
import { Low } from "lowdb";
|
||||
import { JSONFile } from "lowdb/node";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Get app name from root package.json config
|
||||
function getAppName() {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Look for root package.json (monorepo root)
|
||||
const rootPkgPath = path.resolve(__dirname, "../../../package.json");
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf-8"));
|
||||
return pkg.config?.appName || "9router";
|
||||
} catch {
|
||||
return "9router";
|
||||
}
|
||||
}
|
||||
|
||||
// Get user data directory based on platform
|
||||
function getUserDataDir() {
|
||||
const platform = process.platform;
|
||||
const homeDir = os.homedir();
|
||||
const appName = getAppName();
|
||||
|
||||
if (platform === "win32") {
|
||||
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName);
|
||||
} else {
|
||||
// macOS & Linux: ~/.{appName}
|
||||
return path.join(homeDir, `.${appName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Data file path - stored in user home directory
|
||||
const DATA_DIR = getUserDataDir();
|
||||
const DB_FILE = path.join(DATA_DIR, "db.json");
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Default data structure
|
||||
const defaultData = {
|
||||
providerConnections: [],
|
||||
modelAliases: {},
|
||||
combos: [],
|
||||
apiKeys: [],
|
||||
settings: {
|
||||
cloudEnabled: false
|
||||
}
|
||||
};
|
||||
|
||||
// Singleton instance
|
||||
let dbInstance = null;
|
||||
|
||||
/**
|
||||
* Get database instance (singleton)
|
||||
*/
|
||||
export async function getDb() {
|
||||
if (!dbInstance) {
|
||||
const adapter = new JSONFile(DB_FILE);
|
||||
dbInstance = new Low(adapter, defaultData);
|
||||
|
||||
// Try to read DB with error recovery for corrupt JSON
|
||||
try {
|
||||
await dbInstance.read();
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
console.warn('[DB] Corrupt JSON detected, resetting to defaults...');
|
||||
dbInstance.data = defaultData;
|
||||
await dbInstance.write();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize with default data if empty
|
||||
if (!dbInstance.data) {
|
||||
dbInstance.data = defaultData;
|
||||
await dbInstance.write();
|
||||
}
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
// ============ Provider Connections ============
|
||||
|
||||
/**
|
||||
* Get all provider connections
|
||||
*/
|
||||
export async function getProviderConnections(filter = {}) {
|
||||
const db = await getDb();
|
||||
let connections = db.data.providerConnections || [];
|
||||
|
||||
if (filter.provider) {
|
||||
connections = connections.filter(c => c.provider === filter.provider);
|
||||
}
|
||||
if (filter.isActive !== undefined) {
|
||||
connections = connections.filter(c => c.isActive === filter.isActive);
|
||||
}
|
||||
|
||||
// Sort by priority (lower = higher priority)
|
||||
connections.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
||||
|
||||
return connections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider connection by ID
|
||||
*/
|
||||
export async function getProviderConnectionById(id) {
|
||||
const db = await getDb();
|
||||
return db.data.providerConnections.find(c => c.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update provider connection (upsert by provider + email/name)
|
||||
*/
|
||||
export async function createProviderConnection(data) {
|
||||
const db = await getDb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Check for existing connection with same provider and email (for OAuth)
|
||||
// or same provider and name (for API key)
|
||||
let existingIndex = -1;
|
||||
if (data.authType === "oauth" && data.email) {
|
||||
existingIndex = db.data.providerConnections.findIndex(
|
||||
c => c.provider === data.provider && c.authType === "oauth" && c.email === data.email
|
||||
);
|
||||
} else if (data.authType === "apikey" && data.name) {
|
||||
existingIndex = db.data.providerConnections.findIndex(
|
||||
c => c.provider === data.provider && c.authType === "apikey" && c.name === data.name
|
||||
);
|
||||
}
|
||||
|
||||
// If exists, update instead of create
|
||||
if (existingIndex !== -1) {
|
||||
db.data.providerConnections[existingIndex] = {
|
||||
...db.data.providerConnections[existingIndex],
|
||||
...data,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db.write();
|
||||
return db.data.providerConnections[existingIndex];
|
||||
}
|
||||
|
||||
// Generate name for OAuth if not provided
|
||||
let connectionName = data.name || null;
|
||||
if (!connectionName && data.authType === "oauth") {
|
||||
if (data.email) {
|
||||
connectionName = data.email;
|
||||
} else {
|
||||
// Count existing connections for this provider to generate index
|
||||
const existingCount = db.data.providerConnections.filter(
|
||||
c => c.provider === data.provider
|
||||
).length;
|
||||
connectionName = `Account ${existingCount + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-increment priority if not provided
|
||||
let connectionPriority = data.priority;
|
||||
if (!connectionPriority) {
|
||||
const providerConnections = db.data.providerConnections.filter(
|
||||
c => c.provider === data.provider
|
||||
);
|
||||
const maxPriority = providerConnections.reduce((max, c) => Math.max(max, c.priority || 0), 0);
|
||||
connectionPriority = maxPriority + 1;
|
||||
}
|
||||
|
||||
// Create new connection - only save fields with actual values
|
||||
const connection = {
|
||||
id: uuidv4(),
|
||||
provider: data.provider,
|
||||
authType: data.authType || "oauth",
|
||||
name: connectionName,
|
||||
priority: connectionPriority,
|
||||
isActive: data.isActive !== undefined ? data.isActive : true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Only add optional fields if they have values
|
||||
const optionalFields = [
|
||||
"displayName", "email", "globalPriority", "defaultModel",
|
||||
"accessToken", "refreshToken", "expiresAt", "tokenType",
|
||||
"scope", "idToken", "projectId", "apiKey", "testStatus",
|
||||
"lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "errorCode"
|
||||
];
|
||||
|
||||
for (const field of optionalFields) {
|
||||
if (data[field] !== undefined && data[field] !== null) {
|
||||
connection[field] = data[field];
|
||||
}
|
||||
}
|
||||
|
||||
// Only add providerSpecificData if it has content
|
||||
if (data.providerSpecificData && Object.keys(data.providerSpecificData).length > 0) {
|
||||
connection.providerSpecificData = data.providerSpecificData;
|
||||
}
|
||||
|
||||
db.data.providerConnections.push(connection);
|
||||
await db.write();
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider connection
|
||||
*/
|
||||
export async function updateProviderConnection(id, data) {
|
||||
const db = await getDb();
|
||||
const index = db.data.providerConnections.findIndex(c => c.id === id);
|
||||
|
||||
if (index === -1) return null;
|
||||
|
||||
db.data.providerConnections[index] = {
|
||||
...db.data.providerConnections[index],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await db.write();
|
||||
return db.data.providerConnections[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete provider connection
|
||||
*/
|
||||
export async function deleteProviderConnection(id) {
|
||||
const db = await getDb();
|
||||
const index = db.data.providerConnections.findIndex(c => c.id === id);
|
||||
|
||||
if (index === -1) return false;
|
||||
|
||||
db.data.providerConnections.splice(index, 1);
|
||||
await db.write();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ Model Aliases ============
|
||||
|
||||
/**
|
||||
* Get all model aliases
|
||||
*/
|
||||
export async function getModelAliases() {
|
||||
const db = await getDb();
|
||||
return db.data.modelAliases || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model alias
|
||||
*/
|
||||
export async function setModelAlias(alias, model) {
|
||||
const db = await getDb();
|
||||
db.data.modelAliases[alias] = model;
|
||||
await db.write();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete model alias
|
||||
*/
|
||||
export async function deleteModelAlias(alias) {
|
||||
const db = await getDb();
|
||||
delete db.data.modelAliases[alias];
|
||||
await db.write();
|
||||
}
|
||||
|
||||
// ============ Combos ============
|
||||
|
||||
/**
|
||||
* Get all combos
|
||||
*/
|
||||
export async function getCombos() {
|
||||
const db = await getDb();
|
||||
return db.data.combos || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get combo by ID
|
||||
*/
|
||||
export async function getComboById(id) {
|
||||
const db = await getDb();
|
||||
return (db.data.combos || []).find(c => c.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get combo by name
|
||||
*/
|
||||
export async function getComboByName(name) {
|
||||
const db = await getDb();
|
||||
return (db.data.combos || []).find(c => c.name === name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create combo
|
||||
*/
|
||||
export async function createCombo(data) {
|
||||
const db = await getDb();
|
||||
if (!db.data.combos) db.data.combos = [];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const combo = {
|
||||
id: uuidv4(),
|
||||
name: data.name,
|
||||
models: data.models || [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
db.data.combos.push(combo);
|
||||
await db.write();
|
||||
return combo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update combo
|
||||
*/
|
||||
export async function updateCombo(id, data) {
|
||||
const db = await getDb();
|
||||
if (!db.data.combos) db.data.combos = [];
|
||||
|
||||
const index = db.data.combos.findIndex(c => c.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
db.data.combos[index] = {
|
||||
...db.data.combos[index],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await db.write();
|
||||
return db.data.combos[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete combo
|
||||
*/
|
||||
export async function deleteCombo(id) {
|
||||
const db = await getDb();
|
||||
if (!db.data.combos) return false;
|
||||
|
||||
const index = db.data.combos.findIndex(c => c.id === id);
|
||||
if (index === -1) return false;
|
||||
|
||||
db.data.combos.splice(index, 1);
|
||||
await db.write();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ API Keys ============
|
||||
|
||||
/**
|
||||
* Get all API keys
|
||||
*/
|
||||
export async function getApiKeys() {
|
||||
const db = await getDb();
|
||||
return db.data.apiKeys || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate short random key (8 chars)
|
||||
*/
|
||||
function generateShortKey() {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API key
|
||||
* @param {string} name - Key name
|
||||
* @param {string} machineId - MachineId (required)
|
||||
*/
|
||||
export async function createApiKey(name, machineId) {
|
||||
if (!machineId) {
|
||||
throw new Error("machineId is required");
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Always use new format: sk-{machineId}-{keyId}-{crc8}
|
||||
const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey");
|
||||
const result = generateApiKeyWithMachine(machineId);
|
||||
|
||||
const apiKey = {
|
||||
id: uuidv4(),
|
||||
name: name,
|
||||
key: result.key,
|
||||
machineId: machineId,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
db.data.apiKeys.push(apiKey);
|
||||
await db.write();
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete API key
|
||||
*/
|
||||
export async function deleteApiKey(id) {
|
||||
const db = await getDb();
|
||||
const index = db.data.apiKeys.findIndex(k => k.id === id);
|
||||
|
||||
if (index === -1) return false;
|
||||
|
||||
db.data.apiKeys.splice(index, 1);
|
||||
await db.write();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API key
|
||||
*/
|
||||
export async function validateApiKey(key) {
|
||||
const db = await getDb();
|
||||
return db.data.apiKeys.some(k => k.key === key);
|
||||
}
|
||||
|
||||
// ============ Data Cleanup ============
|
||||
|
||||
/**
|
||||
* Remove null/empty fields from all provider connections to reduce db size
|
||||
*/
|
||||
export async function cleanupProviderConnections() {
|
||||
const db = await getDb();
|
||||
const fieldsToCheck = [
|
||||
"displayName", "email", "globalPriority", "defaultModel",
|
||||
"accessToken", "refreshToken", "expiresAt", "tokenType",
|
||||
"scope", "idToken", "projectId", "apiKey", "testStatus",
|
||||
"lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn"
|
||||
];
|
||||
|
||||
let cleaned = 0;
|
||||
for (const connection of db.data.providerConnections) {
|
||||
for (const field of fieldsToCheck) {
|
||||
if (connection[field] === null || connection[field] === undefined) {
|
||||
delete connection[field];
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
// Remove empty providerSpecificData
|
||||
if (connection.providerSpecificData && Object.keys(connection.providerSpecificData).length === 0) {
|
||||
delete connection.providerSpecificData;
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
await db.write();
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// ============ Settings ============
|
||||
|
||||
/**
|
||||
* Get settings
|
||||
*/
|
||||
export async function getSettings() {
|
||||
const db = await getDb();
|
||||
return db.data.settings || { cloudEnabled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings
|
||||
*/
|
||||
export async function updateSettings(updates) {
|
||||
const db = await getDb();
|
||||
db.data.settings = {
|
||||
...db.data.settings,
|
||||
...updates
|
||||
};
|
||||
await db.write();
|
||||
return db.data.settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cloud is enabled
|
||||
*/
|
||||
export async function isCloudEnabled() {
|
||||
const settings = await getSettings();
|
||||
return settings.cloudEnabled === true;
|
||||
}
|
||||
|
||||
126
src/lib/oauth/constants/oauth.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* OAuth Configuration Constants
|
||||
*/
|
||||
|
||||
// Claude OAuth Configuration (Authorization Code Flow with PKCE)
|
||||
export const CLAUDE_CONFIG = {
|
||||
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
authorizeUrl: "https://claude.ai/oauth/authorize",
|
||||
tokenUrl: "https://console.anthropic.com/v1/oauth/token",
|
||||
scopes: ["org:create_api_key", "user:profile", "user:inference"],
|
||||
codeChallengeMethod: "S256",
|
||||
};
|
||||
|
||||
// Codex (OpenAI) OAuth Configuration (Authorization Code Flow with PKCE)
|
||||
export const CODEX_CONFIG = {
|
||||
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
authorizeUrl: "https://auth.openai.com/oauth/authorize",
|
||||
tokenUrl: "https://auth.openai.com/oauth/token",
|
||||
scope: "openid profile email offline_access",
|
||||
codeChallengeMethod: "S256",
|
||||
// Additional OpenAI-specific params
|
||||
extraParams: {
|
||||
id_token_add_organizations: "true",
|
||||
codex_cli_simplified_flow: "true",
|
||||
originator: "codex_cli_rs",
|
||||
},
|
||||
};
|
||||
|
||||
// Gemini (Google) OAuth Configuration (Standard OAuth2)
|
||||
export const GEMINI_CONFIG = {
|
||||
clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
|
||||
clientSecret: "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",
|
||||
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo",
|
||||
scopes: [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
],
|
||||
};
|
||||
|
||||
// Qwen OAuth Configuration (Device Code Flow with PKCE)
|
||||
export const QWEN_CONFIG = {
|
||||
clientId: "f0304373b74a44d2b584a3fb70ca9e56",
|
||||
deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code",
|
||||
tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
scope: "openid profile email model.completion",
|
||||
codeChallengeMethod: "S256",
|
||||
};
|
||||
|
||||
// iFlow OAuth Configuration (Authorization Code)
|
||||
export const IFLOW_CONFIG = {
|
||||
clientId: "10009311001",
|
||||
clientSecret: "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW",
|
||||
authorizeUrl: "https://iflow.cn/oauth",
|
||||
tokenUrl: "https://iflow.cn/oauth/token",
|
||||
userInfoUrl: "https://iflow.cn/api/oauth/getUserInfo",
|
||||
extraParams: {
|
||||
loginMethod: "phone",
|
||||
type: "phone",
|
||||
},
|
||||
};
|
||||
|
||||
// Antigravity OAuth Configuration (Standard OAuth2 with Google)
|
||||
export const ANTIGRAVITY_CONFIG = {
|
||||
clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||
clientSecret: "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",
|
||||
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo",
|
||||
scopes: [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
],
|
||||
// Antigravity specific
|
||||
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1",
|
||||
loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
loadCodeAssistClientMetadata: `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}`,
|
||||
};
|
||||
|
||||
// OpenAI OAuth Configuration (Authorization Code Flow with PKCE)
|
||||
export const OPENAI_CONFIG = {
|
||||
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
authorizeUrl: "https://auth.openai.com/oauth/authorize",
|
||||
tokenUrl: "https://auth.openai.com/oauth/token",
|
||||
scope: "openid profile email offline_access",
|
||||
codeChallengeMethod: "S256",
|
||||
extraParams: {
|
||||
id_token_add_organizations: "true",
|
||||
originator: "openai_native",
|
||||
},
|
||||
};
|
||||
|
||||
// GitHub Copilot OAuth Configuration (Device Code Flow)
|
||||
export const GITHUB_CONFIG = {
|
||||
clientId: "Iv1.b507a08c87ecfe98",
|
||||
deviceCodeUrl: "https://github.com/login/device/code",
|
||||
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||
userInfoUrl: "https://api.github.com/user",
|
||||
scopes: "read:user",
|
||||
apiVersion: "2022-11-28", // Updated to supported version
|
||||
copilotTokenUrl: "https://api.github.com/copilot_internal/v2/token",
|
||||
userAgent: "GitHubCopilotChat/0.26.7",
|
||||
editorVersion: "vscode/1.85.0",
|
||||
editorPluginVersion: "copilot-chat/0.26.7",
|
||||
};
|
||||
|
||||
// OAuth timeout (5 minutes)
|
||||
export const OAUTH_TIMEOUT = 300000;
|
||||
|
||||
// Provider list
|
||||
export const PROVIDERS = {
|
||||
CLAUDE: "claude",
|
||||
CODEX: "codex",
|
||||
GEMINI: "gemini-cli",
|
||||
QWEN: "qwen",
|
||||
IFLOW: "iflow",
|
||||
ANTIGRAVITY: "antigravity",
|
||||
OPENAI: "openai",
|
||||
GITHUB: "github",
|
||||
};
|
||||
619
src/lib/oauth/providers.js
Normal file
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* OAuth Provider Configurations and Handlers
|
||||
* Centralized DRY approach for all OAuth providers
|
||||
*/
|
||||
|
||||
import { generatePKCE, generateState } from "./utils/pkce";
|
||||
import {
|
||||
CLAUDE_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
GEMINI_CONFIG,
|
||||
QWEN_CONFIG,
|
||||
IFLOW_CONFIG,
|
||||
ANTIGRAVITY_CONFIG,
|
||||
GITHUB_CONFIG,
|
||||
} from "./constants/oauth";
|
||||
|
||||
// Provider configurations
|
||||
const PROVIDERS = {
|
||||
claude: {
|
||||
config: CLAUDE_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
const params = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
state: state,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
|
||||
// Parse code - may contain state after #
|
||||
let authCode = code;
|
||||
let codeState = "";
|
||||
if (authCode.includes("#")) {
|
||||
const parts = authCode.split("#");
|
||||
authCode = parts[0];
|
||||
codeState = parts[1] || "";
|
||||
}
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: authCode,
|
||||
state: codeState || state,
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
}),
|
||||
},
|
||||
|
||||
codex: {
|
||||
config: CODEX_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
fixedPort: 1455,
|
||||
callbackPath: "/auth/callback",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
...config.extraParams,
|
||||
state: state,
|
||||
};
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
return `${config.authorizeUrl}?${queryString}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
}),
|
||||
},
|
||||
|
||||
"gemini-cli": {
|
||||
config: GEMINI_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
state: state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Fetch user info
|
||||
const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
|
||||
// Fetch project ID
|
||||
let projectId = "";
|
||||
try {
|
||||
const projectRes = await fetch(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" },
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (projectRes.ok) {
|
||||
const data = await projectRes.json();
|
||||
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to fetch project ID:", e);
|
||||
}
|
||||
|
||||
return { userInfo, projectId };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
email: extra?.userInfo?.email,
|
||||
projectId: extra?.projectId,
|
||||
}),
|
||||
},
|
||||
|
||||
antigravity: {
|
||||
config: ANTIGRAVITY_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
state: state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Fetch user info
|
||||
const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
|
||||
// Fetch project ID from loadCodeAssist
|
||||
let projectId = "";
|
||||
try {
|
||||
const projectRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
|
||||
"X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
|
||||
"Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" },
|
||||
}),
|
||||
});
|
||||
if (projectRes.ok) {
|
||||
const data = await projectRes.json();
|
||||
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to fetch project ID:", e);
|
||||
}
|
||||
|
||||
return { userInfo, projectId };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
email: extra?.userInfo?.email,
|
||||
projectId: extra?.projectId,
|
||||
}),
|
||||
},
|
||||
|
||||
iflow: {
|
||||
config: IFLOW_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
loginMethod: config.extraParams.loginMethod,
|
||||
type: config.extraParams.type,
|
||||
redirect: redirectUri,
|
||||
state: state,
|
||||
client_id: config.clientId,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
// Create Basic Auth header
|
||||
const basicAuth = Buffer.from(
|
||||
`${config.clientId}:${config.clientSecret}`
|
||||
).toString("base64");
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Fetch user info
|
||||
const userInfoRes = await fetch(
|
||||
`${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
const result = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
const userInfo = result.success ? result.data : {};
|
||||
return { userInfo };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
apiKey: extra?.userInfo?.apiKey,
|
||||
email: extra?.userInfo?.email || extra?.userInfo?.phone,
|
||||
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
|
||||
}),
|
||||
},
|
||||
|
||||
qwen: {
|
||||
config: QWEN_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config, codeChallenge) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
scope: config.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
pollToken: async (config, deviceCode, codeVerifier) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: await response.json(),
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: { resourceUrl: tokens.resource_url },
|
||||
}),
|
||||
},
|
||||
|
||||
github: {
|
||||
config: GITHUB_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
scope: config.scopes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
});
|
||||
|
||||
// Handle response properly - if not ok, try to get error as text first
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
// If response is not JSON, get as text
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: data,
|
||||
};
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Get Copilot token using GitHub access token
|
||||
const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
Accept: "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
const copilotToken = copilotRes.ok ? await copilotRes.json() : {};
|
||||
|
||||
// Get user info from GitHub
|
||||
const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
Accept: "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
const userInfo = userRes.ok ? await userRes.json() : {};
|
||||
|
||||
return { copilotToken, userInfo };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: {
|
||||
copilotToken: extra?.copilotToken?.token,
|
||||
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
|
||||
githubUserId: extra?.userInfo?.id,
|
||||
githubLogin: extra?.userInfo?.login,
|
||||
githubName: extra?.userInfo?.name,
|
||||
githubEmail: extra?.userInfo?.email,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get provider handler
|
||||
*/
|
||||
export function getProvider(name) {
|
||||
const provider = PROVIDERS[name];
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown provider: ${name}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all provider names
|
||||
*/
|
||||
export function getProviderNames() {
|
||||
return Object.keys(PROVIDERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate auth data for a provider
|
||||
*/
|
||||
export function generateAuthData(providerName, redirectUri) {
|
||||
const provider = getProvider(providerName);
|
||||
const { codeVerifier, codeChallenge, state } = generatePKCE();
|
||||
|
||||
let authUrl;
|
||||
if (provider.flowType === "device_code") {
|
||||
// Device code flow doesn't have auth URL upfront
|
||||
authUrl = null;
|
||||
} else if (provider.flowType === "authorization_code_pkce") {
|
||||
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge);
|
||||
} else {
|
||||
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state);
|
||||
}
|
||||
|
||||
return {
|
||||
authUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
codeChallenge,
|
||||
redirectUri,
|
||||
flowType: provider.flowType,
|
||||
fixedPort: provider.fixedPort,
|
||||
callbackPath: provider.callbackPath || "/callback",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange code for tokens
|
||||
*/
|
||||
export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state) {
|
||||
const provider = getProvider(providerName);
|
||||
|
||||
const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state);
|
||||
|
||||
let extra = null;
|
||||
if (provider.postExchange) {
|
||||
extra = await provider.postExchange(tokens);
|
||||
}
|
||||
|
||||
return provider.mapTokens(tokens, extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request device code (for device_code flow)
|
||||
*/
|
||||
export async function requestDeviceCode(providerName, codeChallenge) {
|
||||
const provider = getProvider(providerName);
|
||||
if (provider.flowType !== "device_code") {
|
||||
throw new Error(`Provider ${providerName} does not support device code flow`);
|
||||
}
|
||||
return await provider.requestDeviceCode(provider.config, codeChallenge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for token (for device_code flow)
|
||||
*/
|
||||
export async function pollForToken(providerName, deviceCode, codeVerifier) {
|
||||
const provider = getProvider(providerName);
|
||||
if (provider.flowType !== "device_code") {
|
||||
throw new Error(`Provider ${providerName} does not support device code flow`);
|
||||
}
|
||||
|
||||
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier);
|
||||
|
||||
if (result.ok) {
|
||||
// For device code flows, success is only when we have an access token
|
||||
if (result.data.access_token) {
|
||||
// Call postExchange to get additional data (copilotToken, userInfo, etc.)
|
||||
let extra = null;
|
||||
if (provider.postExchange) {
|
||||
extra = await provider.postExchange(result.data);
|
||||
}
|
||||
return { success: true, tokens: provider.mapTokens(result.data, extra) };
|
||||
} else {
|
||||
// Check if it's still pending authorization
|
||||
if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') {
|
||||
// This is not a failure, just still waiting
|
||||
return {
|
||||
success: false,
|
||||
error: result.data.error,
|
||||
errorDescription: result.data.error_description || result.data.message,
|
||||
pending: result.data.error === 'authorization_pending'
|
||||
};
|
||||
} else {
|
||||
// Actual error
|
||||
return {
|
||||
success: false,
|
||||
error: result.data.error || 'no_access_token',
|
||||
errorDescription: result.data.error_description || result.data.message || 'No access token received'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: result.data.error, errorDescription: result.data.error_description };
|
||||
}
|
||||
|
||||
239
src/lib/oauth/services/antigravity.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import crypto from "crypto";
|
||||
import open from "open";
|
||||
import { ANTIGRAVITY_CONFIG } from "../constants/oauth.js";
|
||||
import { getServerCredentials } from "../config/index.js";
|
||||
import { startLocalServer } from "../utils/server.js";
|
||||
import { spinner as createSpinner } from "../utils/ui.js";
|
||||
|
||||
/**
|
||||
* Antigravity OAuth Service
|
||||
* Uses standard OAuth2 Authorization Code flow (similar to Gemini)
|
||||
*/
|
||||
export class AntigravityService {
|
||||
constructor() {
|
||||
this.config = ANTIGRAVITY_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Antigravity authorization URL
|
||||
*/
|
||||
buildAuthUrl(redirectUri, state) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: this.config.scopes.join(" "),
|
||||
state: state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
|
||||
return `${this.config.authorizeUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
async exchangeCode(code, redirectUri) {
|
||||
const response = await fetch(this.config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info from Google
|
||||
*/
|
||||
async getUserInfo(accessToken) {
|
||||
const response = await fetch(`${this.config.userInfoUrl}?alt=json`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to get user info: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Project ID from loadCodeAssist API
|
||||
*/
|
||||
async fetchProjectId(accessToken) {
|
||||
const loadReqBody = {
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(this.config.loadCodeAssistEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": this.config.loadCodeAssistUserAgent,
|
||||
"X-Goog-Api-Client": this.config.loadCodeAssistApiClient,
|
||||
"Client-Metadata": this.config.loadCodeAssistClientMetadata,
|
||||
},
|
||||
body: JSON.stringify(loadReqBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to fetch project ID: ${errorText}`);
|
||||
}
|
||||
|
||||
const loadResp = await response.json();
|
||||
let projectId = loadResp.cloudaicompanionProject;
|
||||
|
||||
if (typeof projectId === 'object' && projectId !== null && projectId.id) {
|
||||
projectId = projectId.id;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error("No cloudaicompanionProject found in response");
|
||||
}
|
||||
|
||||
return projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Antigravity tokens to server
|
||||
*/
|
||||
async saveTokens(tokens, userInfo, projectId) {
|
||||
const { server, token, userId } = getServerCredentials();
|
||||
|
||||
const response = await fetch(`${server}/api/cli/providers/antigravity`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-User-Id": userId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
email: userInfo.email,
|
||||
projectId: projectId, // Send projectId to server
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to save tokens");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete Antigravity OAuth flow
|
||||
*/
|
||||
async connect() {
|
||||
const spinner = createSpinner("Starting Antigravity OAuth...").start();
|
||||
|
||||
try {
|
||||
spinner.text = "Starting local server...";
|
||||
|
||||
// Start local server for callback
|
||||
let callbackParams = null;
|
||||
const { port, close } = await startLocalServer((params) => {
|
||||
callbackParams = params;
|
||||
});
|
||||
|
||||
const redirectUri = `http://localhost:${port}/callback`;
|
||||
spinner.succeed(`Local server started on port ${port}`);
|
||||
|
||||
// Generate state
|
||||
const state = crypto.randomBytes(32).toString("base64url");
|
||||
|
||||
// Build authorization URL
|
||||
const authUrl = this.buildAuthUrl(redirectUri, state);
|
||||
|
||||
console.log("\nOpening browser for Antigravity authentication...");
|
||||
console.log(`If browser doesn't open, visit:\n${authUrl}\n`);
|
||||
|
||||
// Open browser
|
||||
await open(authUrl);
|
||||
|
||||
// Wait for callback
|
||||
spinner.start("Waiting for Antigravity authorization...");
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Authentication timeout (5 minutes)"));
|
||||
}, 300000);
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
if (callbackParams) {
|
||||
clearInterval(checkInterval);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
close();
|
||||
|
||||
if (callbackParams.error) {
|
||||
throw new Error(callbackParams.error_description || callbackParams.error);
|
||||
}
|
||||
|
||||
if (!callbackParams.code) {
|
||||
throw new Error("No authorization code received");
|
||||
}
|
||||
|
||||
spinner.start("Exchanging code for tokens...");
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await this.exchangeCode(callbackParams.code, redirectUri);
|
||||
|
||||
spinner.text = "Fetching user info...";
|
||||
|
||||
// Get user info
|
||||
const userInfo = await this.getUserInfo(tokens.access_token);
|
||||
|
||||
spinner.text = "Fetching Google Cloud Project ID...";
|
||||
|
||||
// Fetch Project ID
|
||||
const projectId = await this.fetchProjectId(tokens.access_token);
|
||||
|
||||
spinner.text = "Saving tokens to server...";
|
||||
|
||||
// Save tokens to server
|
||||
await this.saveTokens(tokens, userInfo, projectId);
|
||||
|
||||
spinner.succeed(`Antigravity connected successfully! (${userInfo.email}, Project: ${projectId})`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
136
src/lib/oauth/services/claude.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { OAuthService } from "./oauth.js";
|
||||
import { CLAUDE_CONFIG } from "../constants/oauth.js";
|
||||
import { getServerCredentials } from "../config/index.js";
|
||||
import { spinner as createSpinner } from "../utils/ui.js";
|
||||
|
||||
/**
|
||||
* Claude OAuth Service
|
||||
*/
|
||||
export class ClaudeService extends OAuthService {
|
||||
constructor() {
|
||||
super(CLAUDE_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Claude authorization URL
|
||||
*/
|
||||
buildClaudeAuthUrl(redirectUri, state, codeChallenge) {
|
||||
const scopeStr = CLAUDE_CONFIG.scopes.join(" ");
|
||||
const params = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: CLAUDE_CONFIG.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopeStr,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: CLAUDE_CONFIG.codeChallengeMethod,
|
||||
state: state,
|
||||
});
|
||||
|
||||
return `${CLAUDE_CONFIG.authorizeUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange Claude authorization code (with special handling)
|
||||
*/
|
||||
async exchangeClaudeCode(code, redirectUri, codeVerifier, state) {
|
||||
// Parse code - may contain state after #
|
||||
let authCode = code;
|
||||
let codeState = "";
|
||||
if (authCode.includes("#")) {
|
||||
const parts = authCode.split("#");
|
||||
authCode = parts[0];
|
||||
codeState = parts[1] || "";
|
||||
}
|
||||
|
||||
// Claude uses JSON format (not form-urlencoded)
|
||||
const tokenPayload = {
|
||||
code: authCode,
|
||||
state: codeState || state,
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLAUDE_CONFIG.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
};
|
||||
|
||||
const response = await fetch(CLAUDE_CONFIG.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(tokenPayload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Claude tokens to server
|
||||
*/
|
||||
async saveTokens(tokens) {
|
||||
const { server, token, userId } = getServerCredentials();
|
||||
|
||||
// Server will auto-generate displayName based on existing account count
|
||||
const response = await fetch(`${server}/api/cli/providers/claude`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-User-Id": userId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to save tokens");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete Claude OAuth flow
|
||||
*/
|
||||
async connect() {
|
||||
const spinner = createSpinner("Starting Claude OAuth...").start();
|
||||
|
||||
try {
|
||||
spinner.text = "Starting local server...";
|
||||
|
||||
// Authenticate and get authorization code
|
||||
const { code, state, codeVerifier, redirectUri } = await this.authenticate(
|
||||
"Claude",
|
||||
this.buildClaudeAuthUrl.bind(this)
|
||||
);
|
||||
|
||||
spinner.start("Exchanging code for tokens...");
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await this.exchangeClaudeCode(code, redirectUri, codeVerifier, state);
|
||||
|
||||
spinner.text = "Saving tokens to server...";
|
||||
|
||||
// Save tokens to server
|
||||
await this.saveTokens(tokens);
|
||||
|
||||
spinner.succeed("Claude connected successfully!");
|
||||
return true;
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
145
src/lib/oauth/services/codex.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import open from "open";
|
||||
import { OAuthService } from "./oauth.js";
|
||||
import { CODEX_CONFIG } from "../constants/oauth.js";
|
||||
import { getServerCredentials } from "../config/index.js";
|
||||
import { startLocalServer } from "../utils/server.js";
|
||||
import { generatePKCE } from "../utils/pkce.js";
|
||||
import { spinner as createSpinner } from "../utils/ui.js";
|
||||
|
||||
/**
|
||||
* Codex (OpenAI) OAuth Service
|
||||
*/
|
||||
export class CodexService extends OAuthService {
|
||||
constructor() {
|
||||
super(CODEX_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Codex authorization URL
|
||||
*/
|
||||
buildCodexAuthUrl(redirectUri, state, codeChallenge) {
|
||||
// Build URL manually to ensure space encoding as %20 instead of +
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id: CODEX_CONFIG.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: CODEX_CONFIG.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: CODEX_CONFIG.codeChallengeMethod,
|
||||
...CODEX_CONFIG.extraParams,
|
||||
state: state,
|
||||
};
|
||||
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
|
||||
return `${CODEX_CONFIG.authorizeUrl}?${queryString}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Codex tokens to server
|
||||
*/
|
||||
async saveTokens(tokens) {
|
||||
const { server, token, userId } = getServerCredentials();
|
||||
|
||||
const response = await fetch(`${server}/api/cli/providers/codex`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-User-Id": userId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to save tokens");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete Codex OAuth flow
|
||||
*/
|
||||
async connect() {
|
||||
const spinner = createSpinner("Starting Codex OAuth...").start();
|
||||
|
||||
try {
|
||||
spinner.text = "Starting local server...";
|
||||
|
||||
// Start local server for callback (use fixed port 1455 like real Codex CLI)
|
||||
const fixedPort = 1455;
|
||||
let callbackParams = null;
|
||||
const { port, close } = await startLocalServer((params) => {
|
||||
callbackParams = params;
|
||||
}, fixedPort);
|
||||
|
||||
const redirectUri = `http://localhost:${port}/auth/callback`;
|
||||
spinner.succeed(`Local server started on port ${port}`);
|
||||
|
||||
// Generate PKCE
|
||||
const { codeVerifier, codeChallenge, state } = generatePKCE();
|
||||
|
||||
// Build authorization URL
|
||||
const authUrl = this.buildCodexAuthUrl(redirectUri, state, codeChallenge);
|
||||
|
||||
console.log("\nOpening browser for OpenAI authentication...");
|
||||
console.log(`If browser doesn't open, visit:\n${authUrl}\n`);
|
||||
|
||||
// Open browser
|
||||
await open(authUrl);
|
||||
|
||||
// Wait for callback
|
||||
spinner.start("Waiting for OpenAI authorization...");
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Authentication timeout (5 minutes)"));
|
||||
}, 300000);
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
if (callbackParams) {
|
||||
clearInterval(checkInterval);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
close();
|
||||
|
||||
if (callbackParams.error) {
|
||||
throw new Error(callbackParams.error_description || callbackParams.error);
|
||||
}
|
||||
|
||||
if (!callbackParams.code) {
|
||||
throw new Error("No authorization code received");
|
||||
}
|
||||
|
||||
spinner.start("Exchanging code for tokens...");
|
||||
|
||||
// Exchange code for tokens (Codex uses form-urlencoded)
|
||||
const tokens = await this.exchangeCode(callbackParams.code, redirectUri, codeVerifier, "application/x-www-form-urlencoded");
|
||||
|
||||
spinner.text = "Saving tokens to server...";
|
||||
|
||||
// Save tokens to server
|
||||
await this.saveTokens(tokens);
|
||||
|
||||
spinner.succeed("Codex connected successfully!");
|
||||
return true;
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
247
src/lib/oauth/services/gemini.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import crypto from "crypto";
|
||||
import open from "open";
|
||||
import { GEMINI_CONFIG } from "../constants/oauth.js";
|
||||
import { getServerCredentials } from "../config/index.js";
|
||||
import { startLocalServer } from "../utils/server.js";
|
||||
import { spinner as createSpinner } from "../utils/ui.js";
|
||||
|
||||
/**
|
||||
* Gemini CLI (Google Cloud Code Assist) OAuth Service
|
||||
* Uses standard OAuth2 Authorization Code flow (no PKCE)
|
||||
*/
|
||||
export class GeminiCLIService {
|
||||
constructor() {
|
||||
this.config = GEMINI_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Gemini CLI authorization URL
|
||||
*/
|
||||
buildAuthUrl(redirectUri, state) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: this.config.scopes.join(" "),
|
||||
state: state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
|
||||
return `${this.config.authorizeUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
async exchangeCode(code, redirectUri) {
|
||||
const response = await fetch(this.config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch project ID from Google Cloud Code Assist
|
||||
*/
|
||||
async fetchProjectId(accessToken) {
|
||||
const response = await fetch(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI"
|
||||
})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI"
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to fetch project ID: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Extract project ID
|
||||
let projectId = "";
|
||||
if (typeof data.cloudaicompanionProject === "string") {
|
||||
projectId = data.cloudaicompanionProject.trim();
|
||||
} else if (data.cloudaicompanionProject?.id) {
|
||||
projectId = data.cloudaicompanionProject.id.trim();
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error("No project ID found in response");
|
||||
}
|
||||
|
||||
return projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info from Google
|
||||
*/
|
||||
async getUserInfo(accessToken) {
|
||||
const response = await fetch(`${this.config.userInfoUrl}?alt=json`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to get user info: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Gemini CLI tokens to server
|
||||
*/
|
||||
async saveTokens(tokens, userInfo, projectId) {
|
||||
const { server, token, userId } = getServerCredentials();
|
||||
|
||||
const response = await fetch(`${server}/api/cli/providers/gemini-cli`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-User-Id": userId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
email: userInfo.email,
|
||||
projectId: projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to save tokens");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete Gemini OAuth flow
|
||||
*/
|
||||
async connect() {
|
||||
const spinner = createSpinner("Starting Gemini OAuth...").start();
|
||||
|
||||
try {
|
||||
spinner.text = "Starting local server...";
|
||||
|
||||
// Start local server for callback
|
||||
let callbackParams = null;
|
||||
const { port, close } = await startLocalServer((params) => {
|
||||
callbackParams = params;
|
||||
});
|
||||
|
||||
const redirectUri = `http://localhost:${port}/callback`;
|
||||
spinner.succeed(`Local server started on port ${port}`);
|
||||
|
||||
// Generate state
|
||||
const state = crypto.randomBytes(32).toString("base64url");
|
||||
|
||||
// Build authorization URL
|
||||
const authUrl = this.buildAuthUrl(redirectUri, state);
|
||||
|
||||
console.log("\nOpening browser for Google authentication...");
|
||||
console.log(`If browser doesn't open, visit:\n${authUrl}\n`);
|
||||
|
||||
// Open browser
|
||||
await open(authUrl);
|
||||
|
||||
// Wait for callback
|
||||
spinner.start("Waiting for Google authorization...");
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Authentication timeout (5 minutes)"));
|
||||
}, 300000);
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
if (callbackParams) {
|
||||
clearInterval(checkInterval);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
close();
|
||||
|
||||
if (callbackParams.error) {
|
||||
throw new Error(callbackParams.error_description || callbackParams.error);
|
||||
}
|
||||
|
||||
if (!callbackParams.code) {
|
||||
throw new Error("No authorization code received");
|
||||
}
|
||||
|
||||
spinner.start("Exchanging code for tokens...");
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await this.exchangeCode(callbackParams.code, redirectUri);
|
||||
|
||||
spinner.text = "Fetching user info...";
|
||||
|
||||
// Get user info
|
||||
const userInfo = await this.getUserInfo(tokens.access_token);
|
||||
|
||||
spinner.text = "Fetching project ID...";
|
||||
|
||||
// Fetch project ID
|
||||
const projectId = await this.fetchProjectId(tokens.access_token);
|
||||
|
||||
spinner.text = "Saving tokens to server...";
|
||||
|
||||
// Save tokens to server
|
||||
await this.saveTokens(tokens, userInfo, projectId);
|
||||
|
||||
spinner.succeed(`Gemini CLI connected successfully! (${userInfo.email}, Project: ${projectId})`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
225
src/lib/oauth/services/github.js
Normal file
@@ -0,0 +1,225 @@
|
||||
import { OAuthService } from "./oauth.js";
|
||||
import { GITHUB_CONFIG } from "../constants/oauth.js";
|
||||
import { spinner as createSpinner } from "../utils/ui.js";
|
||||
|
||||
/**
|
||||
* GitHub Copilot OAuth Service
|
||||
* Uses Device Code Flow for authentication
|
||||
*/
|
||||
export class GitHubService extends OAuthService {
|
||||
constructor() {
|
||||
super(GITHUB_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device code for GitHub authentication
|
||||
*/
|
||||
async getDeviceCode() {
|
||||
const response = await fetch(`${GITHUB_CONFIG.deviceCodeUrl}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: GITHUB_CONFIG.clientId,
|
||||
scope: GITHUB_CONFIG.scopes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to get device code: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for access token using device code
|
||||
*/
|
||||
async pollAccessToken(deviceCode, verificationUri, userCode, interval = 5000) {
|
||||
const spinner = createSpinner("Waiting for GitHub authentication...").start();
|
||||
|
||||
// Show user code and verification URL
|
||||
console.log(`\nPlease visit: ${verificationUri}`);
|
||||
console.log(`Enter code: ${userCode}\n`);
|
||||
|
||||
// Open browser automatically
|
||||
try {
|
||||
const open = (await import("open")).default;
|
||||
await open(verificationUri);
|
||||
} catch (error) {
|
||||
console.log("Could not open browser automatically. Please visit the URL above manually.");
|
||||
}
|
||||
|
||||
// Poll for access token
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
|
||||
const response = await fetch(`${GITHUB_CONFIG.tokenUrl}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: GITHUB_CONFIG.clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.access_token) {
|
||||
spinner.succeed("GitHub authentication successful!");
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
token_type: data.token_type,
|
||||
scope: data.scope,
|
||||
};
|
||||
} else if (data.error === "authorization_pending") {
|
||||
// Continue polling
|
||||
continue;
|
||||
} else if (data.error === "slow_down") {
|
||||
// Increase polling interval
|
||||
interval += 5000;
|
||||
continue;
|
||||
} else if (data.error === "expired_token") {
|
||||
spinner.fail("Device code expired. Please try again.");
|
||||
throw new Error("Device code expired");
|
||||
} else if (data.error === "access_denied") {
|
||||
spinner.fail("Access denied by user.");
|
||||
throw new Error("Access denied");
|
||||
} else {
|
||||
spinner.fail("Failed to get access token.");
|
||||
throw new Error(data.error_description || data.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Copilot token using GitHub access token
|
||||
*/
|
||||
async getCopilotToken(accessToken) {
|
||||
const response = await fetch(`${GITHUB_CONFIG.copilotTokenUrl}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`, // GitHub API typically uses Bearer
|
||||
Accept: "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to get Copilot token: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info using GitHub access token
|
||||
*/
|
||||
async getUserInfo(accessToken) {
|
||||
const response = await fetch(`${GITHUB_CONFIG.userInfoUrl}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`, // GitHub API typically uses Bearer
|
||||
Accept: "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to get user info: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete GitHub Copilot authentication flow
|
||||
*/
|
||||
async authenticate() {
|
||||
try {
|
||||
// Get device code
|
||||
const deviceResponse = await this.getDeviceCode();
|
||||
|
||||
// Poll for access token
|
||||
const tokenResponse = await this.pollAccessToken(
|
||||
deviceResponse.device_code,
|
||||
deviceResponse.verification_uri,
|
||||
deviceResponse.user_code
|
||||
);
|
||||
|
||||
// Get Copilot token
|
||||
const copilotToken = await this.getCopilotToken(tokenResponse.access_token);
|
||||
|
||||
// Get user info
|
||||
const userInfo = await this.getUserInfo(tokenResponse.access_token);
|
||||
|
||||
console.log(`\n✅ Successfully authenticated as ${userInfo.login}`);
|
||||
|
||||
return {
|
||||
accessToken: tokenResponse.access_token,
|
||||
copilotToken: copilotToken.token,
|
||||
refreshToken: null, // GitHub device flow doesn't return refresh token
|
||||
expiresIn: copilotToken.expires_at,
|
||||
userInfo: {
|
||||
id: userInfo.id,
|
||||
login: userInfo.login,
|
||||
name: userInfo.name,
|
||||
email: userInfo.email,
|
||||
},
|
||||
copilotTokenInfo: copilotToken,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`GitHub authentication failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to server with GitHub credentials
|
||||
*/
|
||||
async connect() {
|
||||
try {
|
||||
// Authenticate with GitHub
|
||||
const authResult = await this.authenticate();
|
||||
|
||||
// Send credentials to server
|
||||
const { server, token, userId } = await import("../config/index.js").then(m => m.getServerCredentials());
|
||||
const spinner = (await import("../utils/ui.js")).spinner("Connecting to server...").start();
|
||||
|
||||
const response = await fetch(`${server}/api/cli/providers/github`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-User-Id": userId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: authResult.accessToken,
|
||||
copilotToken: authResult.copilotToken,
|
||||
userInfo: authResult.userInfo,
|
||||
copilotTokenInfo: authResult.copilotTokenInfo,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to connect to server");
|
||||
}
|
||||
|
||||
spinner.succeed("GitHub Copilot connected successfully!");
|
||||
console.log(`\nConnected as: ${authResult.userInfo.login}`);
|
||||
} catch (error) {
|
||||
const { error: showError } = await import("../utils/ui.js");
|
||||
showError(`GitHub connection failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||