106 Commits

Author SHA1 Message Date
musistudio
7faf20e0c8 feat: add JSON editor for config
- Add a JSON editor using Monaco Editor to allow raw editing of the configuration.
- The editor is presented as a full-screen dialog that slides up from the bottom.
- Includes 'Save' and 'Save and Restart' functionality with internationalized labels and toast notifications for success/failure.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 14:20:20 +08:00
musistudio
ad17b27c3d feat: get all transformers 2025-07-30 11:55:55 +08:00
musistudio
112d7ef8f9 feat: add UI build to build process
- Created separate build script to handle both CLI and UI building
- Added automatic UI dependency installation
- Copy built UI artifacts to dist directory

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 11:15:05 +08:00
musistudio
31db041084 release v1.0.28 2025-07-27 17:36:17 +08:00
musistudio
882efb5284 update readme 2025-07-27 17:27:06 +08:00
musistudio
604cc8e1dc fix the stuttering issue on windows 2025-07-27 16:10:48 +08:00
musi
a778755492 Merge pull request #330 from JoeChen2me/main
Feature: 添加可选的长上下文模型阈值配置
2025-07-26 23:03:47 +08:00
JoeChen
3bbfebb5e3 Feature: 添加可选的长上下文模型阈值配置 2025-07-26 12:13:55 +08:00
musistudio
6883fff352 update qwen3-coder example 2025-07-25 17:19:52 +08:00
musistudio
179bab605e release v1.0.27 to fix some bugs and enhance tools 2025-07-25 17:17:14 +08:00
jinhui.li
202402a123 update readme 2025-07-25 14:27:34 +08:00
jinhui.li
c6969fdd90 release v1.0.26 2025-07-23 12:20:52 +08:00
musi
b9d556ff1b add release command 2025-07-21 16:48:30 +08:00
musi
194a664f20 update cli help text 2025-07-21 15:34:39 +08:00
musi
09c3f0ccc6 add custom router doc 2025-07-21 15:31:55 +08:00
musi
5e14b9b0e1 release v1.0.24 to support custom router 2025-07-21 15:13:58 +08:00
jinhui.li
7165953b50 release v1.0.23 to support websearch 2025-07-21 10:46:53 +08:00
musi
e362feaa82 Merge pull request #264 from Linde7777/main
docs: add official Gemini api example in README.md
2025-07-21 10:37:12 +08:00
Linde7777
66054dccb0 docs: add official Gemini api example in README.md 2025-07-21 09:52:52 +08:00
jinhui.li
7efd7183d8 update readme 2025-07-20 16:20:12 +08:00
musi
5b27e797b3 Merge pull request #255 from Thlnking/feature/docs-fix 2025-07-20 15:31:46 +08:00
Thlnking
269a87da74 fix: 更新 pnpm-lock.yaml 和 README_zh.md,删除冲突标志信息 2025-07-20 14:06:30 +08:00
Thlnking
88fbf5e400 doc: 删除文档中出现的 git 冲突标志信息
Signed-off-by: Thlnking <2927874032@qq.com>
2025-07-20 09:23:46 +08:00
jinhui.li
f45316904b Merge branch 'main' of github.com:musistudio/claude-code-router 2025-07-20 01:27:43 +08:00
jinhui.li
d528a8df4c release v1.0.22 to support image 2025-07-20 01:26:52 +08:00
jinhui.li
d0de78eaf0 support configuring the port 2025-07-20 01:00:33 +08:00
musi
2fc79dcf37 release 1.0.21 2025-07-18 23:22:37 +08:00
musi
174c9a740f add groq transformer doc 2025-07-17 23:12:52 +08:00
musi
445908f8ae release v1.0.20 to support groq and fix messges transform bug 2025-07-17 23:10:10 +08:00
jinhui.li
18803469de update doc 2025-07-17 13:25:27 +08:00
jinhui.li
49502e1534 support authentication via APIKEY configuration 2025-07-17 12:49:14 +08:00
jinhui.li
f7f6943d31 update doc 2025-07-17 12:06:22 +08:00
jinhui.li
df21270a7e add config.example.json 2025-07-17 08:45:29 +08:00
musi
0a6d06b7a6 update sponsors 2025-07-16 21:59:00 +08:00
musi
84ac5b62cb fix openrouter stream response parse error 2025-07-16 21:14:27 +08:00
jinhui.li
5174ddacfc fix demo config 2025-07-16 09:15:05 +08:00
musi
5d7681cb62 Merge pull request #194 from weipengzou/feat_support_siliconflow 2025-07-16 08:01:39 +08:00
weipengzou
82d6d420b3 feat: support siliconflow config 2025-07-16 05:33:35 +08:00
musi
4d81734ceb update doc 2025-07-15 22:37:29 +08:00
musi
fb65bb8a95 update sponsors 2025-07-14 22:32:14 +08:00
musi
68e06b0d53 release v1.0.18 2025-07-14 19:48:33 +08:00
musi
3235ff4ed0 release v1.0.17 to support windows 2025-07-14 16:13:45 +08:00
musi
ea6438311f Merge pull request #156 from LinHayaii/fix/windows-support
adapt path and commands for Windows environment
2025-07-14 16:09:14 +08:00
musi
37ae7a99d8 release v1.0.16 2025-07-14 11:54:22 +08:00
Hayaii
20e8a8f197 adapt path and commands for Windows environment 2025-07-13 15:36:59 +08:00
musi
8f8eeacc3d fix fallback to the default model when no router configuration is provided 2025-07-10 20:51:30 +08:00
musi
50a679278d update sponsors 2025-07-08 21:58:10 +08:00
musi
d16fc16de6 update Sponsors 2025-07-07 20:16:17 +08:00
musi
3260d1b6b8 release v1.0.15 2025-07-07 15:03:48 +08:00
jinhui.li
c5ddf2ddae release v1.0.14 2025-07-04 13:35:29 +08:00
jinhui.li
9b5fa3c7e1 add openrouter transformer 2025-07-04 12:37:35 +08:00
jinhui.li
426262e62a release v1.0.13 2025-07-04 09:10:40 +08:00
jinhui.li
3b39677d46 fix config file error 2025-07-04 09:10:07 +08:00
jinhui.li
3ef82991fb support json config 2025-07-03 17:19:26 +08:00
jinhui.li
f9ba3805a6 fix gemini auth error 2025-07-03 09:08:31 +08:00
jinhui.li
936f697110 update readme 2025-07-02 13:33:47 +08:00
jinhui.li
b07bbd7d8c release v1.0.11 2025-07-02 13:31:42 +08:00
jinhui.li
42f7d2da60 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	README.md
#	src/utils/index.ts
2025-07-02 13:31:10 +08:00
jinhui.li
30c764828a switch to llms 2025-07-02 13:24:37 +08:00
musi
802bde2d76 Merge pull request #67 from stonega/main 2025-06-25 17:43:47 +08:00
stone
b2db0307eb Add support for lowercase HTTPS_PROXY environment variable 2025-06-24 15:19:05 +08:00
musi
391cbd8334 Update readme 2025-06-23 22:19:05 +08:00
jinhui.li
dba8b1e6c8 update Sponsors 2025-06-20 22:25:56 +08:00
jinhui.li
6bdcf4ccc2 release v1.0.9 2025-06-20 22:17:45 +08:00
musi
add5cfb6c2 Merge pull request #37 from Evyatar108/patch-1
Fix (1) multi/parallel-tool invocation and (2) `API Error: A.map` permanently
2025-06-20 21:54:59 +08:00
Evyatar108
c984b57585 Fix multi/parallel-tool invocation 2025-06-20 00:16:47 -07:00
musi
258ef787c7 update features 2025-06-19 22:14:48 +08:00
musi
2c3f89cf53 Merge branch 'main' of github.com:musistudio/claude-code-router 2025-06-19 21:15:19 +08:00
musi
a67fce3991 update sponsors 2025-06-19 21:14:52 +08:00
jinhui.li
d3856c0cf9 Merge remote-tracking branch 'origin/main' 2025-06-19 12:07:42 +08:00
jinhui.li
2cad9e93b8 update README 2025-06-19 12:06:55 +08:00
musi
d6be620cec update sponsors 2025-06-18 21:19:38 +08:00
jinhui.li
57a7da14a3 add plugins 2025-06-18 12:20:07 +08:00
jinhui.li
84cb9a2009 release v1.0.8 2025-06-17 12:38:33 +08:00
jinhui.li
ac51db990c save request errors to a log file 2025-06-17 12:37:52 +08:00
jinhui.li
ae88d63c7c adjust /model command priority 2025-06-17 12:36:59 +08:00
jinhui.li
dd29cf895f fix API Error: A.map is not a function 2025-06-17 09:01:53 +08:00
musi
56ab2ee309 add Sponsors 2025-06-17 06:26:46 +08:00
musi
d0d164e8ea release v1.0.7 2025-06-17 06:12:59 +08:00
jinhui.li
ca1b9a5fba update README 2025-06-16 13:04:43 +08:00
jinhui.li
4482853222 update README 2025-06-16 13:03:20 +08:00
jinhui.li
4dc73a31eb add fisrt article 2025-06-16 13:02:15 +08:00
jinhui.li
329b5d9b9b fix API Error when using proxy 2025-06-16 12:43:44 +08:00
musi
0da9cf156d release v1.0.6 2025-06-16 06:16:07 +08:00
musi
d810e2f57e fix missing tiktoken_bg.wasm 2025-06-16 06:14:40 +08:00
musi
81514b0676 fix doc typo 2025-06-15 22:33:06 +08:00
musi
a3ec2c223d fix not working without router 2025-06-15 20:45:32 +08:00
jinhui.li
ee8b82947d release v1.0.4 2025-06-15 20:28:11 +08:00
jinhui.li
1aa6dbe51a add CLAUDE_PATH env variable 2025-06-15 20:26:35 +08:00
musi
80d9298b34 update README 2025-06-15 20:13:11 +08:00
jinhui.li
5e70bc70c0 Support multiple plugins 2025-06-15 16:58:11 +08:00
jinhui.li
9a89250d79 use the router to dispatch different models: background,longcontext and think 2025-06-14 19:48:29 +08:00
musi
7a5d712444 release v1.0.3 2025-06-13 06:23:45 +08:00
jinhui.li
84e76f24b0 fix the issue of multiple calude using one server by claude code 2025-06-12 09:58:05 +08:00
musi
c9059f146d fix miss api 2025-06-10 21:43:01 +08:00
jinhui.li
9cffebf081 add LICENSE 2025-06-10 16:46:14 +08:00
jinhui.li
111492b908 add screenshot 2025-06-10 13:30:18 +08:00
jinhui.li
edc8ecbcba add screenshot 2025-06-10 13:28:41 +08:00
jinhui.li
ea68b2ea55 fix typo 2025-06-10 13:19:02 +08:00
jinhui.li
3b0d7bac0c add doc 2025-06-10 13:15:36 +08:00
jinhui.li
6912572fbb Merge branch 'feature/cli'
# Conflicts:
#	.gitignore
#	index.mjs
2025-06-10 12:58:00 +08:00
jinhui.li
2cc91ada5c add cli 2025-06-10 12:55:25 +08:00
musi
aa3f72f390 Merge pull request #8 from sbtobb/feature-docker-config
Feature add docker config
2025-05-06 08:47:49 +08:00
TOBB
6e4022b6f1 config(gitignore): Update .gitignore to exclude common files 2025-05-05 23:38:05 +08:00
TOBB
30bf711a2a config(docker): Add Docker configuration files 2025-05-05 23:37:00 +08:00
TOBB
2ade113c2a refactor(index.mjs): change listen ip to 0/32
Make server listen on all network interfaces

Allow external connections by binding to 0.0.0.0 instead of localhost only
2025-05-05 23:36:06 +08:00
96 changed files with 18238 additions and 2336 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
npm-debug.log

View File

@@ -1,31 +0,0 @@
## If you don't want to use multi-model routing
## set ENABLE_ROUTER to false, and define the following variables
## the model needs to support function calling
ENABLE_ROUTER=false
OPENAI_API_KEY=""
OPENAI_BASE_URL=""
OPENAI_MODEL=""
## If you want to use multi-model routing, set ENABLE_ROUTER to true
# ENABLE_ROUTER=true
## Define the model for the tool agent, the model needs to support function calling
# TOOL_AGENT_API_KEY=""
# TOOL_AGENT_BASE_URL=""
# TOOL_AGENT_MODEL=""
## Define the model for the coder agent
# CODER_AGENT_API_KEY=""
# CODER_AGENT_BASE_URL=""
# CODER_AGENT_MODEL=""
## Define the model for the thinker agent, using a model that supports reasoning will yield better results
# THINK_AGENT_API_KEY=""
# THINK_AGENT_BASE_URL=""
# THINK_AGENT_MODEL=""
## Define the model for the router agent, this model is the entry point for each request, it will consume a lot of tokens, please choose a small model to reduce costs
# ROUTER_AGENT_API_KEY=""
# ROUTER_AGENT_BASE_URL=""
# ROUTER_AGENT_MODEL=""

14
.npmignore Normal file
View File

@@ -0,0 +1,14 @@
src
node_modules
.claude
CLAUDE.md
screenshoots
.DS_Store
.vscode
.idea
.env
.blog
docs
.log
blog
config.json

43
CLAUDE.md Normal file
View File

@@ -0,0 +1,43 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
- **Build the project**:
```bash
npm run build
```
- **Start the router server**:
```bash
ccr start
```
- **Stop the router server**:
```bash
ccr stop
```
- **Check the server status**:
```bash
ccr status
```
- **Run Claude Code through the router**:
```bash
ccr code "<your prompt>"
```
- **Release a new version**:
```bash
npm run release
```
## Architecture
This project is a TypeScript-based router for Claude Code requests. It allows routing requests to different large language models (LLMs) from various providers based on custom rules.
- **Entry Point**: The main command-line interface logic is in `src/cli.ts`. It handles parsing commands like `start`, `stop`, and `code`.
- **Server**: The `ccr start` command launches a server that listens for requests from Claude Code. The server logic is initiated from `src/index.ts`.
- **Configuration**: The router is configured via a JSON file located at `~/.claude-code-router/config.json`. This file defines API providers, routing rules, and custom transformers. An example can be found in `config.example.json`.
- **Routing**: The core routing logic determines which LLM provider and model to use for a given request. It supports default routes for different scenarios (`default`, `background`, `think`, `longContext`, `webSearch`) and can be extended with a custom JavaScript router file. The router logic is likely in `src/utils/router.ts`.
- **Providers and Transformers**: The application supports multiple LLM providers. Transformers adapt the request and response formats for different provider APIs.
- **Claude Code Integration**: When a user runs `ccr code`, the command is forwarded to the running router service. The service then processes the request, applies routing rules, and sends it to the configured LLM. If the service isn't running, `ccr code` will attempt to start it automatically.
- **Dependencies**: The project is built with `esbuild`. It has a key local dependency `@musistudio/llms`, which probably contains the core logic for interacting with different LLM APIs.
- `@musistudio/llms` is implemented based on `fastify` and exposes `fastify`'s hook and middleware interfaces, allowing direct use of `server.addHook`.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 musistudio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

438
README.md
View File

@@ -1,60 +1,432 @@
# Claude Code Router
> This is a repository for testing routing Claude Code requests to different models.
[中文版](README_zh.md)
![demo.png](https://github.com/musistudio/claude-code-router/blob/main/screenshoots/demo.png)
> A powerful tool to route Claude Code requests to different models and customize any request.
## Implemented
![](blog/images/claude-code.png)
- [x] Support writing custom plugins for rewriting prompts.
## ✨ Features
- [x] Support writing custom plugins for implementing routers.
- **Model Routing**: Route requests to different models based on your needs (e.g., background tasks, thinking, long context).
- **Multi-Provider Support**: Supports various model providers like OpenRouter, DeepSeek, Ollama, Gemini, Volcengine, and SiliconFlow.
- **Request/Response Transformation**: Customize requests and responses for different providers using transformers.
- **Dynamic Model Switching**: Switch models on-the-fly within Claude Code using the `/model` command.
- **GitHub Actions Integration**: Trigger Claude Code tasks in your GitHub workflows.
- **Plugin System**: Extend functionality with custom transformers.
## Usage
## 🚀 Getting Started
0. Install Claude Code
### 1. Installation
First, ensure you have [Claude Code](https://docs.anthropic.com/en/docs/claude-code/quickstart) installed:
```shell
npm install -g @anthropic-ai/claude-code
```
1. Clone this repo and install dependencies
Then, install Claude Code Router:
```shell
git clone https://github.com/musistudio/claude-code-router
cd claude-code-router && pnpm i
npm run build
npm install -g @musistudio/claude-code-router
```
2. Start claude-code-router server
### 2. Configuration
```shell
node dist/cli.js
```
Create and configure your `~/.claude-code-router/config.json` file. For more details, you can refer to `config.example.json`.
3. Set environment variable to start claude code
The `config.json` file has several key sections:
```shell
export DISABLE_PROMPT_CACHING=1
export ANTHROPIC_BASE_URL="http://127.0.0.1:3456"
export API_TIMEOUT_MS=600000
claude
```
- **`PROXY_URL`** (optional): You can set a proxy for API requests, for example: `"PROXY_URL": "http://127.0.0.1:7890"`.
- **`LOG`** (optional): You can enable logging by setting it to `true`. The log file will be located at `$HOME/.claude-code-router.log`.
- **`APIKEY`** (optional): You can set a secret key to authenticate requests. When set, clients must provide this key in the `Authorization` header (e.g., `Bearer your-secret-key`) or the `x-api-key` header. Example: `"APIKEY": "your-secret-key"`.
- **`HOST`** (optional): You can set the host address for the server. If `APIKEY` is not set, the host will be forced to `127.0.0.1` for security reasons to prevent unauthorized access. Example: `"HOST": "0.0.0.0"`.
## Plugin
- **`Providers`**: Used to configure different model providers.
- **`Router`**: Used to set up routing rules. `default` specifies the default model, which will be used for all requests if no other route is configured.
The plugin allows users to rewrite Claude Code prompt and custom router. The plugin path is in `$HOME/.claude-code-router/plugins`. Currently, there are two demos available:
1. [custom router](https://github.com/musistudio/claude-code-router/blob/dev/custom-prompt/plugins/deepseek.js)
2. [rewrite prompt](https://github.com/musistudio/claude-code-router/blob/dev/custom-prompt/plugins/gemini.js)
You need to move them to the `$HOME/.claude-code-router/plugins` directory and configure 'usePlugin' in `$HOME/.claude-code-router/config.json`like this:
Here is a comprehensive example:
```json
{
"usePlugin": "gemini",
"LOG": true,
"OPENAI_API_KEY": "",
"OPENAI_BASE_URL": "",
"OPENAI_MODEL": ""
"APIKEY": "your-secret-key",
"PROXY_URL": "http://127.0.0.1:7890",
"LOG": true,
"Providers": [
{
"name": "openrouter",
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
"api_key": "sk-xxx",
"models": [
"google/gemini-2.5-pro-preview",
"anthropic/claude-sonnet-4",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.7-sonnet:thinking"
],
"transformer": {
"use": ["openrouter"]
}
},
{
"name": "deepseek",
"api_base_url": "https://api.deepseek.com/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-chat", "deepseek-reasoner"],
"transformer": {
"use": ["deepseek"],
"deepseek-chat": {
"use": ["tooluse"]
}
}
},
{
"name": "ollama",
"api_base_url": "http://localhost:11434/v1/chat/completions",
"api_key": "ollama",
"models": ["qwen2.5-coder:latest"]
},
{
"name": "gemini",
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
"api_key": "sk-xxx",
"models": ["gemini-2.5-flash", "gemini-2.5-pro"],
"transformer": {
"use": ["gemini"]
}
},
{
"name": "volcengine",
"api_base_url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-v3-250324", "deepseek-r1-250528"],
"transformer": {
"use": ["deepseek"]
}
},
{
"name": "modelscope",
"api_base_url": "https://api-inference.modelscope.cn/v1/chat/completions",
"api_key": "",
"models": ["Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-235B-A22B-Thinking-2507"],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 65536
}
],
"enhancetool"
],
"Qwen/Qwen3-235B-A22B-Thinking-2507": {
"use": ["reasoning"]
}
}
},
{
"name": "dashscope",
"api_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
"api_key": "",
"models": ["qwen3-coder-plus"],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 65536
}
],
"enhancetool"
]
}
}
],
"Router": {
"default": "deepseek,deepseek-chat",
"background": "ollama,qwen2.5-coder:latest",
"think": "deepseek,deepseek-reasoner",
"longContext": "openrouter,google/gemini-2.5-pro-preview",
"longContextThreshold": 60000,
"webSearch": "gemini,gemini-2.5-flash"
}
}
```
### 3. Running Claude Code with the Router
Start Claude Code using the router:
```shell
ccr code
```
> **Note**: After modifying the configuration file, you need to restart the service for the changes to take effect:
>
> ```shell
> ccr restart
> ```
#### Providers
The `Providers` array is where you define the different model providers you want to use. Each provider object requires:
- `name`: A unique name for the provider.
- `api_base_url`: The full API endpoint for chat completions.
- `api_key`: Your API key for the provider.
- `models`: A list of model names available from this provider.
- `transformer` (optional): Specifies transformers to process requests and responses.
#### Transformers
Transformers allow you to modify the request and response payloads to ensure compatibility with different provider APIs.
- **Global Transformer**: Apply a transformer to all models from a provider. In this example, the `openrouter` transformer is applied to all models under the `openrouter` provider.
```json
{
"name": "openrouter",
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
"api_key": "sk-xxx",
"models": [
"google/gemini-2.5-pro-preview",
"anthropic/claude-sonnet-4",
"anthropic/claude-3.5-sonnet"
],
"transformer": { "use": ["openrouter"] }
}
```
- **Model-Specific Transformer**: Apply a transformer to a specific model. In this example, the `deepseek` transformer is applied to all models, and an additional `tooluse` transformer is applied only to the `deepseek-chat` model.
```json
{
"name": "deepseek",
"api_base_url": "https://api.deepseek.com/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-chat", "deepseek-reasoner"],
"transformer": {
"use": ["deepseek"],
"deepseek-chat": { "use": ["tooluse"] }
}
}
```
- **Passing Options to a Transformer**: Some transformers, like `maxtoken`, accept options. To pass options, use a nested array where the first element is the transformer name and the second is an options object.
```json
{
"name": "siliconflow",
"api_base_url": "https://api.siliconflow.cn/v1/chat/completions",
"api_key": "sk-xxx",
"models": ["moonshotai/Kimi-K2-Instruct"],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 16384
}
]
]
}
}
```
**Available Built-in Transformers:**
- `deepseek`: Adapts requests/responses for DeepSeek API.
- `gemini`: Adapts requests/responses for Gemini API.
- `openrouter`: Adapts requests/responses for OpenRouter API.
- `groq`: Adapts requests/responses for groq API.
- `maxtoken`: Sets a specific `max_tokens` value.
- `tooluse`: Optimizes tool usage for certain models via `tool_choice`.
- `gemini-cli` (experimental): Unofficial support for Gemini via Gemini CLI [gemini-cli.js](https://gist.github.com/musistudio/1c13a65f35916a7ab690649d3df8d1cd).
**Custom Transformers:**
You can also create your own transformers and load them via the `transformers` field in `config.json`.
```json
{
"transformers": [
{
"path": "$HOME/.claude-code-router/plugins/gemini-cli.js",
"options": {
"project": "xxx"
}
}
]
}
```
#### Router
The `Router` object defines which model to use for different scenarios:
- `default`: The default model for general tasks.
- `background`: A model for background tasks. This can be a smaller, local model to save costs.
- `think`: A model for reasoning-heavy tasks, like Plan Mode.
- `longContext`: A model for handling long contexts (e.g., > 60K tokens).
- `longContextThreshold` (optional): The token count threshold for triggering the long context model. Defaults to 60000 if not specified.
- `webSearch`: Used for handling web search tasks and this requires the model itself to support the feature. If you're using openrouter, you need to add the `:online` suffix after the model name.
You can also switch models dynamically in Claude Code with the `/model` command:
`/model provider_name,model_name`
Example: `/model openrouter,anthropic/claude-3.5-sonnet`
#### Custom Router
For more advanced routing logic, you can specify a custom router script via the `CUSTOM_ROUTER_PATH` in your `config.json`. This allows you to implement complex routing rules beyond the default scenarios.
In your `config.json`:
```json
{
"CUSTOM_ROUTER_PATH": "$HOME/.claude-code-router/custom-router.js"
}
```
The custom router file must be a JavaScript module that exports an `async` function. This function receives the request object and the config object as arguments and should return the provider and model name as a string (e.g., `"provider_name,model_name"`), or `null` to fall back to the default router.
Here is an example of a `custom-router.js` based on `custom-router.example.js`:
```javascript
// $HOME/.claude-code-router/custom-router.js
/**
* A custom router function to determine which model to use based on the request.
*
* @param {object} req - The request object from Claude Code, containing the request body.
* @param {object} config - The application's config object.
* @returns {Promise<string|null>} - A promise that resolves to the "provider,model_name" string, or null to use the default router.
*/
module.exports = async function router(req, config) {
const userMessage = req.body.messages.find((m) => m.role === "user")?.content;
if (userMessage && userMessage.includes("explain this code")) {
// Use a powerful model for code explanation
return "openrouter,anthropic/claude-3.5-sonnet";
}
// Fallback to the default router configuration
return null;
};
```
## 🤖 GitHub Actions
Integrate Claude Code Router into your CI/CD pipeline. After setting up [Claude Code Actions](https://docs.anthropic.com/en/docs/claude-code/github-actions), modify your `.github/workflows/claude.yaml` to use the router:
```yaml
name: Claude Code
on:
issue_comment:
types: [created]
# ... other triggers
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
# ... other conditions
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Prepare Environment
run: |
curl -fsSL https://bun.sh/install | bash
mkdir -p $HOME/.claude-code-router
cat << 'EOF' > $HOME/.claude-code-router/config.json
{
"log": true,
"OPENAI_API_KEY": "${{ secrets.OPENAI_API_KEY }}",
"OPENAI_BASE_URL": "https://api.deepseek.com",
"OPENAI_MODEL": "deepseek-chat"
}
EOF
shell: bash
- name: Start Claude Code Router
run: |
nohup ~/.bun/bin/bunx @musistudio/claude-code-router@1.0.8 start &
shell: bash
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
env:
ANTHROPIC_BASE_URL: http://localhost:3456
with:
anthropic_api_key: "any-string-is-ok"
```
This setup allows for interesting automations, like running tasks during off-peak hours to reduce API costs.
## 📝 Further Reading
- [Project Motivation and How It Works](blog/en/project-motivation-and-how-it-works.md)
- [Maybe We Can Do More with the Router](blog/en/maybe-we-can-do-more-with-the-route.md)
## ❤️ Support & Sponsoring
If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated!
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F31GN2GM)
<table>
<tr>
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
<td><img src="/blog/images/wechat.jpg" width="200" alt="WeChat Pay" /></td>
</tr>
</table>
### Our Sponsors
A huge thank you to all our sponsors for their generous support!
- @Simon Leischnig
- [@duanshuaimin](https://github.com/duanshuaimin)
- [@vrgitadmin](https://github.com/vrgitadmin)
- @\*o
- [@ceilwoo](https://github.com/ceilwoo)
- @\*说
- @\*更
- @K\*g
- @R\*R
- [@bobleer](https://github.com/bobleer)
- @\*苗
- @\*划
- [@Clarence-pan](https://github.com/Clarence-pan)
- [@carter003](https://github.com/carter003)
- @S\*r
- @\*晖
- @\*敏
- @Z\*z
- @\*然
- [@cluic](https://github.com/cluic)
- @\*苗
- [@PromptExpert](https://github.com/PromptExpert)
- @\*应
- [@yusnake](https://github.com/yusnake)
- @\*飞
- @董\*
- @\*汀
- @\*涯
- @\*:-
- @\*\*磊
- @\*琢
- @\*成
- @Z\*o
- @\*琨
- [@congzhangzh](https://github.com/congzhangzh)
- @\*\_
- @Z\*m
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)

431
README_zh.md Normal file
View File

@@ -0,0 +1,431 @@
# Claude Code Router
> 一款强大的工具,可将 Claude Code 请求路由到不同的模型,并自定义任何请求。
![](blog/images/claude-code.png)
## ✨ 功能
- **模型路由**: 根据您的需求将请求路由到不同的模型(例如,后台任务、思考、长上下文)。
- **多提供商支持**: 支持 OpenRouter、DeepSeek、Ollama、Gemini、Volcengine 和 SiliconFlow 等各种模型提供商。
- **请求/响应转换**: 使用转换器为不同的提供商自定义请求和响应。
- **动态模型切换**: 在 Claude Code 中使用 `/model` 命令动态切换模型。
- **GitHub Actions 集成**: 在您的 GitHub 工作流程中触发 Claude Code 任务。
- **插件系统**: 使用自定义转换器扩展功能。
## 🚀 快速入门
### 1. 安装
首先,请确保您已安装 [Claude Code](https://docs.anthropic.com/en/docs/claude-code/quickstart)
```shell
npm install -g @anthropic-ai/claude-code
```
然后,安装 Claude Code Router
```shell
npm install -g @musistudio/claude-code-router
```
### 2. 配置
创建并配置您的 `~/.claude-code-router/config.json` 文件。有关更多详细信息,您可以参考 `config.example.json`
`config.json` 文件有几个关键部分:
- **`PROXY_URL`** (可选): 您可以为 API 请求设置代理,例如:`"PROXY_URL": "http://127.0.0.1:7890"`
- **`LOG`** (可选): 您可以通过将其设置为 `true` 来启用日志记录。日志文件将位于 `$HOME/.claude-code-router.log`
- **`APIKEY`** (可选): 您可以设置一个密钥来进行身份验证。设置后,客户端请求必须在 `Authorization` 请求头 (例如, `Bearer your-secret-key`) 或 `x-api-key` 请求头中提供此密钥。例如:`"APIKEY": "your-secret-key"`
- **`HOST`** (可选): 您可以设置服务的主机地址。如果未设置 `APIKEY`,出于安全考虑,主机地址将强制设置为 `127.0.0.1`,以防止未经授权的访问。例如:`"HOST": "0.0.0.0"`
- **`Providers`**: 用于配置不同的模型提供商。
- **`Router`**: 用于设置路由规则。`default` 指定默认模型,如果未配置其他路由,则该模型将用于所有请求。
这是一个综合示例:
```json
{
"APIKEY": "your-secret-key",
"PROXY_URL": "http://127.0.0.1:7890",
"LOG": true,
"Providers": [
{
"name": "openrouter",
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
"api_key": "sk-xxx",
"models": [
"google/gemini-2.5-pro-preview",
"anthropic/claude-sonnet-4",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.7-sonnet:thinking"
],
"transformer": {
"use": ["openrouter"]
}
},
{
"name": "deepseek",
"api_base_url": "https://api.deepseek.com/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-chat", "deepseek-reasoner"],
"transformer": {
"use": ["deepseek"],
"deepseek-chat": {
"use": ["tooluse"]
}
}
},
{
"name": "ollama",
"api_base_url": "http://localhost:11434/v1/chat/completions",
"api_key": "ollama",
"models": ["qwen2.5-coder:latest"]
},
{
"name": "gemini",
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
"api_key": "sk-xxx",
"models": ["gemini-2.5-flash", "gemini-2.5-pro"],
"transformer": {
"use": ["gemini"]
}
},
{
"name": "volcengine",
"api_base_url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-v3-250324", "deepseek-r1-250528"],
"transformer": {
"use": ["deepseek"]
}
},
{
"name": "modelscope",
"api_base_url": "https://api-inference.modelscope.cn/v1/chat/completions",
"api_key": "",
"models": ["Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-235B-A22B-Thinking-2507"],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 65536
}
],
"enhancetool"
],
"Qwen/Qwen3-235B-A22B-Thinking-2507": {
"use": ["reasoning"]
}
}
},
{
"name": "dashscope",
"api_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
"api_key": "",
"models": ["qwen3-coder-plus"],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 65536
}
],
"enhancetool"
]
}
}
],
"Router": {
"default": "deepseek,deepseek-chat",
"background": "ollama,qwen2.5-coder:latest",
"think": "deepseek,deepseek-reasoner",
"longContext": "openrouter,google/gemini-2.5-pro-preview",
"longContextThreshold": 60000,
"webSearch": "gemini,gemini-2.5-flash"
}
}
```
### 3. 使用 Router 运行 Claude Code
使用 router 启动 Claude Code
```shell
ccr code
```
> **注意**: 修改配置文件后,需要重启服务使配置生效:
> ```shell
> ccr restart
> ```
#### Providers
`Providers` 数组是您定义要使用的不同模型提供商的地方。每个提供商对象都需要:
- `name`: 提供商的唯一名称。
- `api_base_url`: 聊天补全的完整 API 端点。
- `api_key`: 您提供商的 API 密钥。
- `models`: 此提供商可用的模型名称列表。
- `transformer` (可选): 指定用于处理请求和响应的转换器。
#### Transformers
Transformers 允许您修改请求和响应负载,以确保与不同提供商 API 的兼容性。
- **全局 Transformer**: 将转换器应用于提供商的所有模型。在此示例中,`openrouter` 转换器将应用于 `openrouter` 提供商下的所有模型。
```json
{
"name": "openrouter",
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
"api_key": "sk-xxx",
"models": [
"google/gemini-2.5-pro-preview",
"anthropic/claude-sonnet-4",
"anthropic/claude-3.5-sonnet"
],
"transformer": { "use": ["openrouter"] }
}
```
- **特定于模型的 Transformer**: 将转换器应用于特定模型。在此示例中,`deepseek` 转换器应用于所有模型,而额外的 `tooluse` 转换器仅应用于 `deepseek-chat` 模型。
```json
{
"name": "deepseek",
"api_base_url": "https://api.deepseek.com/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-chat", "deepseek-reasoner"],
"transformer": {
"use": ["deepseek"],
"deepseek-chat": { "use": ["tooluse"] }
}
}
```
- **向 Transformer 传递选项**: 某些转换器(如 `maxtoken`)接受选项。要传递选项,请使用嵌套数组,其中第一个元素是转换器名称,第二个元素是选项对象。
```json
{
"name": "siliconflow",
"api_base_url": "https://api.siliconflow.cn/v1/chat/completions",
"api_key": "sk-xxx",
"models": ["moonshotai/Kimi-K2-Instruct"],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 16384
}
]
]
}
}
```
**可用的内置 Transformer**
- `deepseek`: 适配 DeepSeek API 的请求/响应。
- `gemini`: 适配 Gemini API 的请求/响应。
- `openrouter`: 适配 OpenRouter API 的请求/响应。
- `groq`: 适配 groq API 的请求/响应
- `maxtoken`: 设置特定的 `max_tokens` 值。
- `tooluse`: 优化某些模型的工具使用(通过`tool_choice`参数)。
- `gemini-cli` (实验性): 通过 Gemini CLI [gemini-cli.js](https://gist.github.com/musistudio/1c13a65f35916a7ab690649d3df8d1cd) 对 Gemini 的非官方支持。
**自定义 Transformer:**
您还可以创建自己的转换器,并通过 `config.json` 中的 `transformers` 字段加载它们。
```json
{
"transformers": [
{
"path": "$HOME/.claude-code-router/plugins/gemini-cli.js",
"options": {
"project": "xxx"
}
}
]
}
```
#### Router
`Router` 对象定义了在不同场景下使用哪个模型:
- `default`: 用于常规任务的默认模型。
- `background`: 用于后台任务的模型。这可以是一个较小的本地模型以节省成本。
- `think`: 用于推理密集型任务(如计划模式)的模型。
- `longContext`: 用于处理长上下文(例如,> 60K 令牌)的模型。
- `longContextThreshold` (可选): 触发长上下文模型的令牌数阈值。如果未指定,默认为 60000。
- `webSearch`: 用于处理网络搜索任务,需要模型本身支持。如果使用`openrouter`需要在模型后面加上`:online`后缀。
您还可以使用 `/model` 命令在 Claude Code 中动态切换模型:
`/model provider_name,model_name`
示例: `/model openrouter,anthropic/claude-3.5-sonnet`
#### 自定义路由器
对于更高级的路由逻辑,您可以在 `config.json` 中通过 `CUSTOM_ROUTER_PATH` 字段指定一个自定义路由器脚本。这允许您实现超出默认场景的复杂路由规则。
在您的 `config.json` 中配置:
```json
{
"CUSTOM_ROUTER_PATH": "$HOME/.claude-code-router/custom-router.js"
}
```
自定义路由器文件必须是一个导出 `async` 函数的 JavaScript 模块。该函数接收请求对象和配置对象作为参数,并应返回提供商和模型名称的字符串(例如 `"provider_name,model_name"`),如果返回 `null` 则回退到默认路由。
这是一个基于 `custom-router.example.js` 的 `custom-router.js` 示例:
```javascript
// $HOME/.claude-code-router/custom-router.js
/**
* 一个自定义路由函数,用于根据请求确定使用哪个模型。
*
* @param {object} req - 来自 Claude Code 的请求对象,包含请求体。
* @param {object} config - 应用程序的配置对象。
* @returns {Promise<string|null>} - 一个解析为 "provider,model_name" 字符串的 Promise如果返回 null则使用默认路由。
*/
module.exports = async function router(req, config) {
const userMessage = req.body.messages.find(m => m.role === 'user')?.content;
if (userMessage && userMessage.includes('解释这段代码')) {
// 为代码解释任务使用更强大的模型
return 'openrouter,anthropic/claude-3.5-sonnet';
}
// 回退到默认的路由配置
return null;
};
```
## 🤖 GitHub Actions
将 Claude Code Router 集成到您的 CI/CD 管道中。在设置 [Claude Code Actions](https://docs.anthropic.com/en/docs/claude-code/github-actions) 后,修改您的 `.github/workflows/claude.yaml` 以使用路由器:
```yaml
name: Claude Code
on:
issue_comment:
types: [created]
# ... other triggers
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
# ... other conditions
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Prepare Environment
run: |
curl -fsSL https://bun.sh/install | bash
mkdir -p $HOME/.claude-code-router
cat << 'EOF' > $HOME/.claude-code-router/config.json
{
"log": true,
"OPENAI_API_KEY": "${{ secrets.OPENAI_API_KEY }}",
"OPENAI_BASE_URL": "https://api.deepseek.com",
"OPENAI_MODEL": "deepseek-chat"
}
EOF
shell: bash
- name: Start Claude Code Router
run: |
nohup ~/.bun/bin/bunx @musistudio/claude-code-router@1.0.8 start &
shell: bash
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
env:
ANTHROPIC_BASE_URL: http://localhost:3456
with:
anthropic_api_key: "any-string-is-ok"
```
这种设置可以实现有趣的自动化,例如在非高峰时段运行任务以降低 API 成本。
## 📝 深入阅读
- [项目动机和工作原理](blog/zh/项目初衷及原理.md)
- [也许我们可以用路由器做更多事情](blog/zh/或许我们能在Router中做更多事情.md)
## ❤️ 支持与赞助
如果您觉得这个项目有帮助,请考虑赞助它的开发。非常感谢您的支持!
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F31GN2GM)
<table>
<tr>
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
<td><img src="/blog/images/wechat.jpg" width="200" alt="WeChat Pay" /></td>
</tr>
</table>
### 我们的赞助商
非常感谢所有赞助商的慷慨支持!
- @Simon Leischnig
- [@duanshuaimin](https://github.com/duanshuaimin)
- [@vrgitadmin](https://github.com/vrgitadmin)
- @*o
- [@ceilwoo](https://github.com/ceilwoo)
- @*说
- @*更
- @K*g
- @R*R
- [@bobleer](https://github.com/bobleer)
- @*苗
- @*划
- [@Clarence-pan](https://github.com/Clarence-pan)
- [@carter003](https://github.com/carter003)
- @S*r
- @*晖
- @*敏
- @Z*z
- @*然
- [@cluic](https://github.com/cluic)
- @*苗
- [@PromptExpert](https://github.com/PromptExpert)
- @*应
- [@yusnake](https://github.com/yusnake)
- @*飞
- @董*
- @*汀
- @*涯
- @*:-
- @**磊
- @*琢
- @*成
- @Z*o
- [@congzhangzh](https://github.com/congzhangzh)
- @*_
- @Z\*m
(如果您的名字被屏蔽,请通过我的主页电子邮件与我联系,以便使用您的 GitHub 用户名进行更新。)
## 交流群
<img src="/blog/images/wechat_group.jpg" width="200" alt="wechat_group" />

View File

@@ -0,0 +1,105 @@
# Maybe We Can Do More with the Router
Since the release of `claude-code-router`, Ive received a lot of user feedback, and quite a few issues are still open. Most of them are related to support for different providers and the lack of tool usage from the deepseek model.
Originally, I created this project for personal use, mainly to access claude code at a lower cost. So, multi-provider support wasnt part of the initial design. But during troubleshooting, I discovered that even though most providers claim to be compatible with the OpenAI-style `/chat/completions` interface, there are many subtle differences. For example:
1. When Gemini's tool parameter type is string, the `format` field only supports `date` and `date-time`, and theres no tool call ID.
2. OpenRouter requires `cache_control` for caching.
3. The official DeepSeek API has a `max_output` of 8192, but Volcano Engines limit is even higher.
Aside from these, smaller providers often have quirks in their parameter handling. So I decided to create a new project, [musistudio/llms](https://github.com/musistudio/llms), to deal with these compatibility issues. It uses the OpenAI format as a base and introduces a generic Transformer interface for transforming both requests and responses.
Once a `Transformer` is implemented for each provider, it becomes possible to mix-and-match requests between them. For example, I implemented bidirectional conversion between Anthropic and OpenAI formats in `AnthropicTransformer`, which listens to the `/v1/messages` endpoint. Similarly, `GeminiTransformer` handles Gemini <-> OpenAI format conversions and listens to `/v1beta/models/:modelAndAction`.
When both requests and responses are transformed into a common format, they can interoperate seamlessly:
```
AnthropicRequest -> AnthropicTransformer -> OpenAIRequest -> GeminiTransformer -> GeminiRequest -> GeminiServer
```
```
GeminiResponse -> GeminiTransformer -> OpenAIResponse -> AnthropicTransformer -> AnthropicResponse
```
Using a middleware layer to smooth out differences may introduce some performance overhead, but the main goal here is to enable `claude-code-router` to support multiple providers.
As for the issue of DeepSeeks lackluster tool usage — I found that it stems from poor instruction adherence in long conversations. Initially, the model actively calls tools, but after several rounds, it starts responding with plain text instead. My first workaround was injecting a system prompt to remind the model to use tools proactively. But in long contexts, the model tends to forget this instruction.
After reading the DeepSeek documentation, I noticed it supports the `tool_choice` parameter, which can be set to `"required"` to force the model to use at least one tool. I tested this by enabling the parameter, and it significantly improved the models tool usage. We can remove the setting when it's no longer necessary. With the help of the `Transformer` interface in [musistudio/llms](https://github.com/musistudio/llms), we can modify the request before its sent and adjust the response after its received.
Inspired by the Plan Mode in `claude code`, I implemented a similar Tool Mode for DeepSeek:
```typescript
export class TooluseTransformer implements Transformer {
name = "tooluse";
transformRequestIn(request: UnifiedChatRequest): UnifiedChatRequest {
if (request.tools?.length) {
request.messages.push({
role: "system",
content: `<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task.
Before invoking a tool, you must carefully evaluate whether it matches the current task. If no available tool is appropriate for the task, you MUST call the \`ExitTool\` to exit tool mode — this is the only valid way to terminate tool mode.
Always prioritize completing the user's task effectively and efficiently by using tools whenever appropriate.</system-reminder>`,
});
request.tool_choice = "required";
request.tools.unshift({
type: "function",
function: {
name: "ExitTool",
description: `Use this tool when you are in tool mode and have completed the task. This is the only valid way to exit tool mode.
IMPORTANT: Before using this tool, ensure that none of the available tools are applicable to the current task. You must evaluate all available options — only if no suitable tool can help you complete the task should you use ExitTool to terminate tool mode.
Examples:
1. Task: "Use a tool to summarize this document" — Do not use ExitTool if a summarization tool is available.
2. Task: "Whats the weather today?" — If no tool is available to answer, use ExitTool after reasoning that none can fulfill the task.`,
parameters: {
type: "object",
properties: {
response: {
type: "string",
description:
"Your response will be forwarded to the user exactly as returned — the tool will not modify or post-process it in any way.",
},
},
required: ["response"],
},
},
});
}
return request;
}
async transformResponseOut(response: Response): Promise<Response> {
if (response.headers.get("Content-Type")?.includes("application/json")) {
const jsonResponse = await response.json();
if (
jsonResponse?.choices[0]?.message.tool_calls?.length &&
jsonResponse?.choices[0]?.message.tool_calls[0]?.function?.name ===
"ExitTool"
) {
const toolArguments = JSON.parse(toolCall.function.arguments || "{}");
jsonResponse.choices[0].message.content = toolArguments.response || "";
delete jsonResponse.choices[0].message.tool_calls;
}
// Handle non-streaming response if needed
return new Response(JSON.stringify(jsonResponse), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} else if (response.headers.get("Content-Type")?.includes("stream")) {
// ...
}
return response;
}
}
```
This transformer ensures the model calls at least one tool. If no tools are appropriate or the task is finished, it can exit using `ExitTool`. Since this relies on the `tool_choice` parameter, it only works with models that support it.
In practice, this approach noticeably improves tool usage for DeepSeek. The tradeoff is that sometimes the model may invoke irrelevant or unnecessary tools, which could increase latency and token usage.
This update is just a small experiment — adding an `“agent”` to the router. Maybe there are more interesting things we can explore from here.

View File

@@ -0,0 +1,103 @@
# Project Motivation and Principles
As early as the day after Claude Code was released (2025-02-25), I began and completed a reverse engineering attempt of the project. At that time, using Claude Code required registering for an Anthropic account, applying for a waitlist, and waiting for approval. However, due to well-known reasons, Anthropic blocks users from mainland China, making it impossible for me to use the service through normal means. Based on known information, I discovered the following:
1. Claude Code is installed via npm, so it's very likely developed with Node.js.
2. Node.js offers various debugging methods: simple `console.log` usage, launching with `--inspect` to hook into Chrome DevTools, or even debugging obfuscated code using `d8`.
My goal was to use Claude Code without an Anthropic account. I didnt need the full source code—just a way to intercept and reroute requests made by Claude Code to Anthropics models to my own custom endpoint. So I started the reverse engineering process:
1. First, install Claude Code:
```bash
npm install -g @anthropic-ai/claude-code
```
2. After installation, the project is located at `~/.nvm/versions/node/v20.10.0/lib/node_modules/@anthropic-ai/claude-code`(this may vary depending on your Node version manager and version).
3. Open the package.json to analyze the entry point:
```package.json
{
"name": "@anthropic-ai/claude-code",
"version": "1.0.24",
"main": "sdk.mjs",
"types": "sdk.d.ts",
"bin": {
"claude": "cli.js"
},
"engines": {
"node": ">=18.0.0"
},
"type": "module",
"author": "Boris Cherny <boris@anthropic.com>",
"license": "SEE LICENSE IN README.md",
"description": "Use Claude, Anthropic's AI assistant, right from your terminal. Claude can understand your codebase, edit files, run terminal commands, and handle entire workflows for you.",
"homepage": "https://github.com/anthropics/claude-code",
"bugs": {
"url": "https://github.com/anthropics/claude-code/issues"
},
"scripts": {
"prepare": "node -e \"if (!process.env.AUTHORIZED) { console.error('ERROR: Direct publishing is not allowed.\\nPlease use the publish-external.sh script to publish this package.'); process.exit(1); }\"",
"preinstall": "node scripts/preinstall.js"
},
"dependencies": {},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "^0.33.5",
"@img/sharp-darwin-x64": "^0.33.5",
"@img/sharp-linux-arm": "^0.33.5",
"@img/sharp-linux-arm64": "^0.33.5",
"@img/sharp-linux-x64": "^0.33.5",
"@img/sharp-win32-x64": "^0.33.5"
}
}
```
The key entry is `"claude": "cli.js"`. Opening cli.js, you'll see the code is minified and obfuscated. But using WebStorms `Format File` feature, you can reformat it for better readability:
![webstorm-formate-file](../images/webstorm-formate-file.png)
Now you can begin understanding Claude Codes internal logic and prompt structure by reading the code. To dig deeper, you can insert console.log statements or launch in debug mode with Chrome DevTools using:
```bash
NODE_OPTIONS="--inspect-brk=9229" claude
```
This command starts Claude Code in debug mode and opens port 9229. Visit chrome://inspect/ in Chrome and click inspect to begin debugging:
![chrome-devtools](../images/chrome-inspect.png)
![chrome-devtools](../images/chrome-devtools.png)
By searching for the keyword api.anthropic.com, you can easily locate where Claude Code makes its API calls. From the surrounding code, it's clear that baseURL can be overridden with the `ANTHROPIC_BASE_URL` environment variable, and `apiKey` and `authToken` can be configured similarly:
![search](../images/search.png)
So far, weve discovered some key information:
1. Environment variables can override Claude Code's `baseURL` and `apiKey`.
2. Claude Code adheres to the Anthropic API specification.
Therefore, we need:
1. A service to convert OpenAI APIcompatible requests into Anthropic API format.
2. Set the environment variables before launching Claude Code to redirect requests to this service.
Thus, `claude-code-router` was born. This project uses `Express.js` to implement the `/v1/messages` endpoint. It leverages middlewares to transform request/response formats and supports request rewriting (useful for prompt tuning per model).
Back in February, the full DeepSeek model series had poor support for Function Calling, so I initially used `qwen-max`. It worked well—but without KV cache support, it consumed a large number of tokens and couldnt provide the native `Claude Code` experience.
So I experimented with a Router-based mode using a lightweight model to dispatch tasks. The architecture included four roles: `router`, `tool`, `think`, and `coder`. Each request passed through a free lightweight model that would decide whether the task involved reasoning, coding, or tool usage. Reasoning and coding tasks looped until a tool was invoked to apply changes. However, the lightweight model lacked the capability to route tasks accurately, and architectural issues prevented it from effectively driving Claude Code.
Everything changed at the end of May when the official Claude Code was launched, and `DeepSeek-R1` model (released 2025-05-28) added Function Call support. I redesigned the system. With the help of AI pair programming, I fixed earlier request/response transformation issues—especially the handling of models that return JSON instead of Function Call outputs.
This time, I used the `DeepSeek-V3` model. It performed better than expected: supporting most tool calls, handling task decomposition and stepwise planning, and—most importantly—costing less than one-tenth the price of Claude 3.5 Sonnet.
The official Claude Code organizes agents differently from the beta version, so I restructured my Router mode to include four roles: the default model, `background`, `think`, and `longContext`.
- The default model handles general tasks and acts as a fallback.
- The `background` model manages lightweight background tasks. According to Anthropic, Claude Haiku 3.5 is often used here, so I routed this to a local `ollama` service.
- The `think` model is responsible for reasoning and planning mode tasks. I use `DeepSeek-R1` here, though it doesnt support cost control, so `Think` and `UltraThink` behave identically.
- The `longContext` model handles long-context scenarios. The router uses `tiktoken` to calculate token lengths in real time, and if the context exceeds 32K, it switches to this model to compensate for DeepSeek's long-context limitations.
This describes the evolution and reasoning behind the project. By cleverly overriding environment variables, we can forward and modify requests without altering Claude Codes source—allowing us to benefit from official updates while using our own models and custom prompts.
This project offers a practical approach to running Claude Code under Anthropics regional restrictions, balancing `cost`, `performance`, and `customizability`. That said, the official `Max Plan` still offers the best experience if available.

BIN
blog/images/alipay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

BIN
blog/images/claude-code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
blog/images/search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

BIN
blog/images/wechat.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

View File

@@ -0,0 +1,95 @@
# 或许我们能在 Router 中做更多事情
自从`claude-code-router`发布以来,我收到了很多用户的反馈,至今还有不少的 issues 未处理。其中大多都是关于不同的供应商的支持和`deepseek`模型调用工具不积极的问题。
之前开发这个项目主要是为了我自己能以较低成本使用上`claude code`,所以一开始的设计并没有考虑到多供应商的情况。在实际的排查问题中,我发现尽管市面上所有的供应商几乎都宣称兼容`OpenAI`格式调用,即通过`/chat/compeletions`接口调用,但是其中的细节差异非常多。例如:
1. Gemini 的工具参数类型是 string 时,`format`参数只支持`date``date-time`,并且没有工具调用 ID。
2. OpenRouter 需要使用`cache_control`进行缓存。
3. DeepSeek 官方 API 的 `max_output` 为 8192而火山引擎的会更大。
除了这些问题之外,还有一些其他的小的供应商,他们或多或少参数都有点问题。于是,我打算开发一个新的项目[musistudio/llms](https://github.com/musistudio/llms)来处理这种不同服务商的兼容问题。该项目使用 OpenAI 格式为基础的通用格式,提供了一个`Transformer`接口,该接口用于处理转换请求和响应。当我们给不同的服务商都实现了`Transformer`后,我们可以实现不同服务商的混合调用。比如我在`AnthropicTransformer`中实现了`Anthropic`<->`OpenAI`格式的互相转换,并监听了`/v1/messages`端点,在`GeminiTransformer`中实现了`Gemini`<->`OpenAI`格式的互相转换,并监听了`/v1beta/models/:modelAndAction`端点,当他们的请求和响应都被转换成一个通用格式的时候,就可以实现他们的互相调用。
```
AnthropicRequest -> AnthropicTransformer -> OpenAIRequest -> GeminiTransformer -> GeminiRequest -> GeminiServer
```
```
GeminiReseponse -> GeminiTransformer -> OpenAIResponse -> AnthropicTransformer -> AnthropicResponse
```
虽然使用中间层抹平差异可能会带来一些性能问题,但是该项目最初的目的是为了让`claude-code-router`支持不同的供应商。
至于`deepseek`模型调用工具不积极的问题,我发现这是由于`deepseek`在长上下文中的指令遵循不佳导致的。现象就是刚开始模型会主动调用工具,但是在经过几轮对话后模型只会返回文本。一开始的解决方案是通过注入一个系统提示词告知模型需要积极去使用工具以解决用户的问题,但是后面测试发现在长上下文中模型会遗忘该指令。
查看`deepseek`文档后发现模型支持`tool_choice`参数,可以强制让模型最少调用 1 个工具,我尝试将该值设置为`required`,发现模型调用工具的积极性大大增加,现在我们只需要在合适的时候取消这个参数即可。借助[musistudio/llms](https://github.com/musistudio/llms)的`Transformer`可以让我们在发送请求前和收到响应后做点什么,所以我参考`claude code``Plan Mode`,实现了一个使用与`deepseek``Tool Mode`
```typescript
export class TooluseTransformer implements Transformer {
name = "tooluse";
transformRequestIn(request: UnifiedChatRequest): UnifiedChatRequest {
if (request.tools?.length) {
request.messages.push({
role: "system",
content: `<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task.
Before invoking a tool, you must carefully evaluate whether it matches the current task. If no available tool is appropriate for the task, you MUST call the \`ExitTool\` to exit tool mode — this is the only valid way to terminate tool mode.
Always prioritize completing the user's task effectively and efficiently by using tools whenever appropriate.</system-reminder>`,
});
request.tool_choice = "required";
request.tools.unshift({
type: "function",
function: {
name: "ExitTool",
description: `Use this tool when you are in tool mode and have completed the task. This is the only valid way to exit tool mode.
IMPORTANT: Before using this tool, ensure that none of the available tools are applicable to the current task. You must evaluate all available options — only if no suitable tool can help you complete the task should you use ExitTool to terminate tool mode.
Examples:
1. Task: "Use a tool to summarize this document" — Do not use ExitTool if a summarization tool is available.
2. Task: "Whats the weather today?" — If no tool is available to answer, use ExitTool after reasoning that none can fulfill the task.`,
parameters: {
type: "object",
properties: {
response: {
type: "string",
description:
"Your response will be forwarded to the user exactly as returned — the tool will not modify or post-process it in any way.",
},
},
required: ["response"],
},
},
});
}
return request;
}
async transformResponseOut(response: Response): Promise<Response> {
if (response.headers.get("Content-Type")?.includes("application/json")) {
const jsonResponse = await response.json();
if (
jsonResponse?.choices[0]?.message.tool_calls?.length &&
jsonResponse?.choices[0]?.message.tool_calls[0]?.function?.name ===
"ExitTool"
) {
const toolArguments = JSON.parse(toolCall.function.arguments || "{}");
jsonResponse.choices[0].message.content = toolArguments.response || "";
delete jsonResponse.choices[0].message.tool_calls;
}
// Handle non-streaming response if needed
return new Response(JSON.stringify(jsonResponse), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} else if (response.headers.get("Content-Type")?.includes("stream")) {
// ...
}
return response;
}
}
```
该工具将始终让模型至少调用一个工具,如果没有合适的工具或者任务已完成可以调用`ExitTool`来退出工具模式,因为是依靠`tool_choice`参数实现的,所以仅适用于支持该参数的模型。经过测试,该工具能显著增加`deepseek`的工具调用次数,弊端是可能会有跟任务无关或者没有必要的工具调用导致增加任务执行事件和消耗的 `token` 数。
这次更新仅仅是在 Router 中实现一个`agent`的一次小探索,或许还能做更多其他有趣的事也说不定...

View File

@@ -0,0 +1,96 @@
# 项目初衷及原理
早在 Claude Code 发布的第二天(2025-02-25),我就尝试并完成了对该项目的逆向。当时要使用 Claude Code 你需要注册一个 Anthropic 账号,然后申请 waitlist等待通过后才能使用。但是因为众所周知的原因Anthropic 屏蔽了中国区的用户,所以通过正常手段我无法使用,通过已知的信息,我发现:
1. Claude Code 使用 npm 进行安装,所以很大可能其使用 Node.js 进行开发。
2. Node.js 调试手段众多,可以简单使用`console.log`获取想要的信息,也可以使用`--inspect`将其接入`Chrome Devtools`,甚至你可以使用`d8`去调试某些加密混淆的代码。
由于我的目标是让我在没有 Anthropic 账号的情况下使用`Claude Code`,我并不需要获得完整的源代码,只需要将`Claude Code`请求 Anthropic 模型时将其转发到我自定义的接口即可。接下来我就开启了我的逆向过程:
1. 首先安装`Claude Code`
```bash
npm install -g @anthropic-ai/claude-code
```
2. 安装后该项目被放在了`~/.nvm/versions/node/v20.10.0/lib/node_modules/@anthropic-ai/claude-code`中,因为我使用了`nvm`作为我的 node 版本控制器,当前使用`node-v20.10.0`,所以该路径会因人而异。
3. 找到项目路径之后可通过 package.json 分析包入口,内容如下:
```package.json
{
"name": "@anthropic-ai/claude-code",
"version": "1.0.24",
"main": "sdk.mjs",
"types": "sdk.d.ts",
"bin": {
"claude": "cli.js"
},
"engines": {
"node": ">=18.0.0"
},
"type": "module",
"author": "Boris Cherny <boris@anthropic.com>",
"license": "SEE LICENSE IN README.md",
"description": "Use Claude, Anthropic's AI assistant, right from your terminal. Claude can understand your codebase, edit files, run terminal commands, and handle entire workflows for you.",
"homepage": "https://github.com/anthropics/claude-code",
"bugs": {
"url": "https://github.com/anthropics/claude-code/issues"
},
"scripts": {
"prepare": "node -e \"if (!process.env.AUTHORIZED) { console.error('ERROR: Direct publishing is not allowed.\\nPlease use the publish-external.sh script to publish this package.'); process.exit(1); }\"",
"preinstall": "node scripts/preinstall.js"
},
"dependencies": {},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "^0.33.5",
"@img/sharp-darwin-x64": "^0.33.5",
"@img/sharp-linux-arm": "^0.33.5",
"@img/sharp-linux-arm64": "^0.33.5",
"@img/sharp-linux-x64": "^0.33.5",
"@img/sharp-win32-x64": "^0.33.5"
}
}
```
其中`"claude": "cli.js"`就是我们要找的入口,打开 cli.js发现代码被压缩混淆过了。没关系借助`webstorm`的`Formate File`功能可以重新格式化,让代码变得稍微好看一点。就像这样:
![webstorm-formate-file](../images/webstorm-formate-file.png)
现在,你可以通过阅读部分代码来了解`Claude Code`的内容工具原理与提示词。你也可以在关键地方使用`console.log`来获得更多信息,当然,也可以使用`Chrome Devtools`来进行断点调试,使用以下命令启动`Claude Code`:
```bash
NODE_OPTIONS="--inspect-brk=9229" claude
```
该命令会以调试模式启动`Claude Code`,并将调试的端口设置为`9229`。这时候通过 Chrome 访问`chrome://inspect/`即可看到当前的`Claude Code`进程,点击`inspect`即可进行调试。
![chrome-devtools](../images/chrome-inspect.png)
![chrome-devtools](../images/chrome-devtools.png)
通过搜索关键字符`api.anthropic.com`很容易能找到`Claude Code`用来发请求的地方,根据上下文的查看,很容易发现这里的`baseURL`可以通过环境变量`ANTHROPIC_BASE_URL`进行覆盖,`apiKey`和`authToken`也同理。
![search](../images/search.png)
到目前为止,我们获得关键信息:
1. 可以使用环境变量覆盖`Claude Code`的`BaseURL`和`apiKey`的配置
2. `Claude Code`使用[Anthropic API](https://docs.anthropic.com/en/api/overview)的规范
所以我们需要:
1. 实现一个服务用来将`OpenAI API`的规范转换成`Anthropic API`格式。
2. 启动`Claude Code`之前写入环境变量将`baseURL`指向到该服务。
于是,`claude-code-router`就诞生了,该项目使用`Express.js`作为 HTTP 服务,实现`/v1/messages`端点,使用`middlewares`处理请求/响应的格式转换以及请求重写功能(可以用来重写 Claude Code 的提示词以针对单个模型进行调优)。
在 2 月份由于`DeepSeek`全系列模型对`Function Call`的支持不佳导致无法直接使用`DeepSeek`模型,所以在当时我选择了`qwen-max`模型,一切表现的都很好,但是`qwen-max`不支持`KV Cache`,意味着我要消耗大量的 token但是却无法获取`Claude Code`原生的体验。
所以我又尝试了`Router`模式,即使用一个小模型对任务进行分发,一共分为四个模型:`router`、`tool`、`think`和`coder`,所有的请求先经过一个免费的小模型,由小模型去判断应该是进行思考还是编码还是调用工具,再进行任务的分发,如果是思考和编码任务将会进行循环调用,直到最终使用工具写入或修改文件。但是实践下来发现免费的小模型不足以很好的完成任务的分发,再加上整个 Agnet 的设计存在缺陷,导致并不能很好的驱动`Claude Code`。
直到 5 月底,`Claude Code`被正式推出,这时`DeepSeek`全系列模型(R1 于 05-28)均支持`Function Call`,我开始重新设计该项目。在与 AI 的结对编程中我修复了之前的请求和响应转换问题,在某些场景下模型输出 JSON 响应而不是`Function Call`。这次直接使用`DeepSeek-v3`模型,它工作的比我想象中要好:能完成绝大多数工具调用,还支持用步骤规划解决任务,最关键的是`DeepSeek`的价格不到`claude Sonnet 3.5`的十分之一。正式发布的`Claude Code`对 Agent 的组织也不同于测试版,于是在分析了`Claude Code`的请求调用之后,我重新组织了`Router`模式:现在它还是四个模型:默认模型、`background`、`think`和`longContext`。
- 默认模型作为最终的兜底和日常处理
- `background`是用来处理一些后台任务,据 Anthropic 官方说主要用`Claude Haiku 3.5`模型去处理一些小任务,如俳句生成和对话摘要,于是我将其路由到了本地的`ollama`服务。
- `think`模型用于让`Claude Code`进行思考或者在`Plan Mode`下使用,这里我使用的是`DeepSeek-R1`,由于其不支持推理成本控制,所以`Think`和`UltraThink`是一样的逻辑。
- `longContext`是用于处理长下上文的场景该项目会对每次请求使用tiktoken实时计算上下文长度如果上下文大于32K则使用该模型旨在弥补`DeepSeek`在长上下文处理不佳的情况。
以上就是该项目的发展历程以及我的一些思考,通过巧妙的使用环境变量覆盖的手段在不修改`Claude Code`源码的情况下完成请求的转发和修改,这就使得在可以得到 Anthropic 更新的同时使用自己的模型,自定义自己的提示词。该项目只是在 Anthropic 封禁中国区用户的情况下使用`Claude Code`并且达到成本和性能平衡的一种手段。如果可以的话还是官方的Max Plan体验最好。

117
config.example.json Normal file
View File

@@ -0,0 +1,117 @@
{
"Providers": [
{
"name": "openrouter",
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
"api_key": "sk-xxx",
"models": [
"google/gemini-2.5-pro-preview",
"anthropic/claude-sonnet-4",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.7-sonnet:thinking"
],
"transformer": {
"use": ["openrouter"]
}
},
{
"name": "deepseek",
"api_base_url": "https://api.deepseek.com/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-chat", "deepseek-reasoner"],
"transformer": {
"use": ["deepseek"],
"deepseek-chat": {
"use": ["tooluse"]
}
}
},
{
"name": "ollama",
"api_base_url": "http://localhost:11434/v1/chat/completions",
"api_key": "ollama",
"models": ["qwen2.5-coder:latest"]
},
{
"name": "gemini",
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
"api_key": "sk-xxx",
"models": ["gemini-2.5-flash", "gemini-2.5-pro"],
"transformer": {
"use": ["gemini"]
}
},
{
"name": "volcengine",
"api_base_url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-v3-250324", "deepseek-r1-250528"],
"transformer": {
"use": ["deepseek"]
}
},
{
"name": "siliconflow",
"api_base_url": "https://api.siliconflow.cn/v1/chat/completions",
"api_key": "sk-xxx",
"models": ["moonshotai/Kimi-K2-Instruct"],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 16384
}
]
]
}
},
{
"name": "modelscope",
"api_base_url": "https://api-inference.modelscope.cn/v1/chat/completions",
"api_key": "",
"models": ["Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-235B-A22B-Thinking-2507"],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 65536
}
],
"enhancetool"
],
"Qwen/Qwen3-235B-A22B-Thinking-2507": {
"use": ["reasoning"]
}
}
},
{
"name": "dashscope",
"api_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
"api_key": "",
"models": ["qwen3-coder-plus"],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 65536
}
],
"enhancetool"
]
}
}
],
"Router": {
"default": "deepseek,deepseek-chat",
"background": "ollama,qwen2.5-coder:latest",
"think": "deepseek,deepseek-reasoner",
"longContext": "openrouter,google/gemini-2.5-pro-preview",
"longContextThreshold": 60000,
"webSearch": "gemini,gemini-2.5-flash"
},
"APIKEY": "your-secret-key",
"HOST": "0.0.0.0"
}

3
custom-router.example.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = async function router(req, config) {
return "deepseek,deepseek-chat";
};

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
version: "3.8"
services:
claude-code-reverse:
build: .
ports:
- "3456:3456"
environment:
- ENABLE_ROUTER=${ENABLE_ROUTER}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
- OPENAI_MODEL=${OPENAI_MODEL}
restart: unless-stopped

12
dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm i
COPY . .
EXPOSE 3456
CMD ["node", "index.mjs"]

2484
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,37 @@
{
"name": "claude-code-router",
"version": "1.0.0",
"name": "@musistudio/claude-code-router",
"version": "1.0.28",
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
"bin": {
"claude-code-router": "./dist/cli.js"
"ccr": "./dist/cli.js"
},
"scripts": {
"start": "node dist/cli.js",
"build": "tsc && esbuild src/index.ts --bundle --platform=node --outfile=dist/cli.js"
"build": "node scripts/build.js",
"release": "npm run build && npm publish"
},
"keywords": ["claude", "code", "router", "llm", "anthropic"],
"keywords": [
"claude",
"code",
"router",
"llm",
"anthropic"
],
"author": "musistudio",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-code": "^0.2.53",
"@anthropic-ai/sdk": "^0.39.0",
"@fastify/static": "^8.2.0",
"@musistudio/llms": "file:../llms",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"https-proxy-agent": "^7.0.6",
"openai": "^4.85.4"
"json5": "^2.2.3",
"openurl": "^1.1.1",
"tiktoken": "^1.0.21",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^24.0.15",
"esbuild": "^0.25.1",
"fastify": "^5.4.0",
"shx": "^0.4.0",
"typescript": "^5.8.2"
},
"publishConfig": {

View File

@@ -1,139 +0,0 @@
const {
log,
streamOpenAIResponse,
createClient,
} = require("claude-code-router");
const thinkRouter = {
name: "think",
description: `This agent is used solely for complex reasoning and thinking tasks. It should not be called for information retrieval or repetitive, frequent requests. Only use this agent for tasks that require deep analysis or problem-solving. If there is an existing result from the Thinker agent, do not call this agent again.你只负责深度思考以拆分任务,不需要进行任何的编码和调用工具。最后讲拆分的步骤按照顺序返回。比如\n1. xxx\n2. xxx\n3. xxx`,
run(args) {
const client = createClient({
apiKey: process.env.THINK_AGENT_API_KEY,
baseURL: process.env.THINK_AGENT_BASE_URL,
});
const messages = JSON.parse(JSON.stringify(args.messages));
messages.forEach((msg) => {
if (Array.isArray(msg.content)) {
msg.content = JSON.stringify(msg.content);
}
});
let startIdx = messages.findIndex((msg) => msg.role !== "system");
if (startIdx === -1) startIdx = messages.length;
for (let i = startIdx; i < messages.length; i++) {
const expectedRole = (i - startIdx) % 2 === 0 ? "user" : "assistant";
messages[i].role = expectedRole;
}
if (
messages.length > 0 &&
messages[messages.length - 1].role === "assistant"
) {
messages.push({
role: "user",
content:
"Please follow the instructions provided above to resolve the issue.",
});
}
delete args.tools;
return client.chat.completions.create({
...args,
messages,
model: process.env.THINK_AGENT_MODEL,
});
},
};
class Router {
constructor() {
this.routers = [thinkRouter];
this.client = createClient({
apiKey: process.env.ROUTER_AGENT_API_KEY,
baseURL: process.env.ROUTER_AGENT_BASE_URL,
});
}
async route(args) {
log(`Request Router: ${JSON.stringify(args, null, 2)}`);
const res = await this.client.chat.completions.create({
...args,
messages: [
...args.messages,
{
role: "system",
content: `## **Guidelines:**
- **Trigger the "think" mode when the user's request involves deep thinking, complex reasoning, or multi-step analysis.**
- **Criteria:**
- Involves multi-layered logical reasoning or causal analysis
- Requires establishing connections or pattern recognition between different pieces of information
- Involves cross-domain knowledge integration or weighing multiple possibilities
- Requires creative thinking or non-direct inference
### **Special Case:**
- **When the user sends "test", respond with "success" only.**
### **Format requirements:**
- When you need to trigger the "think" mode, return the following JSON format:
\`\`\`json
{
"use": "think"
}
\`\`\`
`,
},
],
model: process.env.ROUTER_AGENT_MODEL,
stream: false,
});
let result;
try {
const text = res.choices[0].message.content;
if (!text) {
throw new Error("No text");
}
result = JSON.parse(
text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)
);
} catch (e) {
res.choices[0].delta = res.choices[0].message;
log(`No Router: ${JSON.stringify(res.choices[0].message)}`);
return [res];
}
const router = this.routers.find((item) => item.name === result.use);
if (!router) {
res.choices[0].delta = res.choices[0].message;
log(`No Router: ${JSON.stringify(res.choices[0].message)}`);
return [res];
}
log(`Use Router: ${router.name}`);
if (router.name === "think") {
const agentResult = await router.run({
...args,
stream: false,
});
try {
args.messages.push({
role: "user",
content:
`${router.name} Agent Result: ` +
agentResult.choices[0].message.content,
});
log(
`${router.name} Agent Result: ` +
agentResult.choices[0].message.content
);
return await this.route(args);
} catch (error) {
console.log(agentResult);
throw error;
}
}
return router.run(args);
}
}
const router = new Router();
module.exports = async function handle(req, res, next) {
const completions = await router.route(req.body);
streamOpenAIResponse(res, completions, req.body.model);
};

View File

@@ -1,23 +0,0 @@
module.exports = async function handle(req, res, next) {
if (Array.isArray(req.body.tools)) {
// rewrite tools definition
req.body.tools.forEach((tool) => {
if (tool.function.name === "BatchTool") {
// HACK: Gemini does not support objects with empty properties
tool.function.parameters.properties.invocations.items.properties.input.type =
"number";
return;
}
Object.keys(tool.function.parameters.properties).forEach((key) => {
const prop = tool.function.parameters.properties[key];
if (
prop.type === "string" &&
!["enum", "date-time"].includes(prop.format)
) {
delete prop.format;
}
});
});
}
next();
};

2469
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

35
scripts/build.js Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
console.log('Building Claude Code Router...');
try {
// Build the main CLI application
console.log('Building CLI application...');
execSync('esbuild src/cli.ts --bundle --platform=node --outfile=dist/cli.js', { stdio: 'inherit' });
// Copy the tiktoken WASM file
console.log('Copying tiktoken WASM file...');
execSync('shx cp node_modules/tiktoken/tiktoken_bg.wasm dist/tiktoken_bg.wasm', { stdio: 'inherit' });
// Build the UI
console.log('Building UI...');
// Check if node_modules exists in ui directory, if not install dependencies
if (!fs.existsSync('ui/node_modules')) {
console.log('Installing UI dependencies...');
execSync('cd ui && npm install', { stdio: 'inherit' });
}
execSync('cd ui && npm run build', { stdio: 'inherit' });
// Copy the built UI index.html to dist
console.log('Copying UI build artifacts...');
execSync('shx cp ui/dist/index.html dist/index.html', { stdio: 'inherit' });
console.log('Build completed successfully!');
} catch (error) {
console.error('Build failed:', error.message);
process.exit(1);
}

226
src/cli.ts Normal file
View File

@@ -0,0 +1,226 @@
#!/usr/bin/env node
import { run } from "./index";
import { showStatus } from "./utils/status";
import { executeCodeCommand } from "./utils/codeCommand";
import { cleanupPidFile, isServiceRunning, getServiceInfo } from "./utils/processCheck";
import { version } from "../package.json";
import { spawn, exec } from "child_process";
import { PID_FILE, REFERENCE_COUNT_FILE } from "./constants";
import fs, { existsSync, readFileSync } from "fs";
import {join} from "path";
const command = process.argv[2];
const HELP_TEXT = `
Usage: ccr [command]
Commands:
start Start server
stop Stop server
restart Restart server
status Show server status
code Execute claude command
ui Open the web UI in browser
-v, version Show version information
-h, help Show help information
Example:
ccr start
ccr code "Write a Hello World"
ccr ui
`;
async function waitForService(
timeout = 10000,
initialDelay = 1000
): Promise<boolean> {
// Wait for an initial period to let the service initialize
await new Promise((resolve) => setTimeout(resolve, initialDelay));
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (isServiceRunning()) {
// Wait for an additional short period to ensure service is fully ready
await new Promise((resolve) => setTimeout(resolve, 500));
return true;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
}
async function main() {
switch (command) {
case "start":
run();
break;
case "stop":
try {
const pid = parseInt(readFileSync(PID_FILE, "utf-8"));
process.kill(pid);
cleanupPidFile();
if (existsSync(REFERENCE_COUNT_FILE)) {
try {
fs.unlinkSync(REFERENCE_COUNT_FILE);
} catch (e) {
// Ignore cleanup errors
}
}
console.log(
"claude code router service has been successfully stopped."
);
} catch (e) {
console.log(
"Failed to stop the service. It may have already been stopped."
);
cleanupPidFile();
}
break;
case "status":
await showStatus();
break;
case "code":
if (!isServiceRunning()) {
console.log("Service not running, starting service...");
const cliPath = join(__dirname, "cli.js");
const startProcess = spawn("node", [cliPath, "start"], {
detached: true,
stdio: "ignore",
});
// let errorMessage = "";
// startProcess.stderr?.on("data", (data) => {
// errorMessage += data.toString();
// });
startProcess.on("error", (error) => {
console.error("Failed to start service:", error.message);
process.exit(1);
});
// startProcess.on("close", (code) => {
// if (code !== 0 && errorMessage) {
// console.error("Failed to start service:", errorMessage.trim());
// process.exit(1);
// }
// });
startProcess.unref();
if (await waitForService()) {
executeCodeCommand(process.argv.slice(3));
} else {
console.error(
"Service startup timeout, please manually run `ccr start` to start the service"
);
process.exit(1);
}
} else {
executeCodeCommand(process.argv.slice(3));
}
break;
case "ui":
// Check if service is running
if (!isServiceRunning()) {
console.log("Service not running, starting service...");
const cliPath = join(__dirname, "cli.js");
const startProcess = spawn("node", [cliPath, "start"], {
detached: true,
stdio: "ignore",
});
startProcess.on("error", (error) => {
console.error("Failed to start service:", error.message);
process.exit(1);
});
startProcess.unref();
if (!(await waitForService())) {
console.error(
"Service startup timeout, please manually run `ccr start` to start the service"
);
process.exit(1);
}
}
// Get service info and open UI
const serviceInfo = await getServiceInfo();
const uiUrl = `${serviceInfo.endpoint}/ui/`;
console.log(`Opening UI at ${uiUrl}`);
// Open URL in browser based on platform
const platform = process.platform;
let openCommand = "";
if (platform === "win32") {
// Windows
openCommand = `start ${uiUrl}`;
} else if (platform === "darwin") {
// macOS
openCommand = `open ${uiUrl}`;
} else if (platform === "linux") {
// Linux
openCommand = `xdg-open ${uiUrl}`;
} else {
console.error("Unsupported platform for opening browser");
process.exit(1);
}
exec(openCommand, (error) => {
if (error) {
console.error("Failed to open browser:", error.message);
process.exit(1);
}
});
break;
case "-v":
case "version":
console.log(`claude-code-router version: ${version}`);
break;
case "restart":
// Stop the service if it's running
try {
const pid = parseInt(readFileSync(PID_FILE, "utf-8"));
process.kill(pid);
cleanupPidFile();
if (existsSync(REFERENCE_COUNT_FILE)) {
try {
fs.unlinkSync(REFERENCE_COUNT_FILE);
} catch (e) {
// Ignore cleanup errors
}
}
console.log("claude code router service has been stopped.");
} catch (e) {
console.log("Service was not running or failed to stop.");
cleanupPidFile();
}
// Start the service again in the background
console.log("Starting claude code router service...");
const cliPath = join(__dirname, "cli.js");
const startProcess = spawn("node", [cliPath, "start"], {
detached: true,
stdio: "ignore",
});
startProcess.on("error", (error) => {
console.error("Failed to start service:", error);
process.exit(1);
});
startProcess.unref();
console.log("✅ Service started successfully in the background.");
break;
case "-h":
case "help":
console.log(HELP_TEXT);
break;
default:
console.log(HELP_TEXT);
process.exit(1);
}
}
main().catch(console.error);

View File

@@ -3,13 +3,18 @@ import os from "node:os";
export const HOME_DIR = path.join(os.homedir(), ".claude-code-router");
export const CONFIG_FILE = `${HOME_DIR}/config.json`;
export const CONFIG_FILE = path.join(HOME_DIR, "config.json");
export const PLUGINS_DIR = path.join(HOME_DIR, "plugins");
export const PID_FILE = path.join(HOME_DIR, '.claude-code-router.pid');
export const REFERENCE_COUNT_FILE = path.join(os.tmpdir(), "claude-code-reference-count.txt");
export const PLUGINS_DIR = `${HOME_DIR}/plugins`;
export const DEFAULT_CONFIG = {
log: false,
LOG: false,
OPENAI_API_KEY: "",
OPENAI_BASE_URL: "https://openrouter.ai/api/v1",
OPENAI_MODEL: "openai/o3-mini",
OPENAI_BASE_URL: "",
OPENAI_MODEL: "",
};

View File

@@ -1,15 +1,21 @@
import { existsSync } from "fs";
import { writeFile } from "fs/promises";
import { getOpenAICommonOptions, initConfig, initDir } from "./utils";
import { homedir } from "os";
import { join } from "path";
import { initConfig, initDir } from "./utils";
import { createServer } from "./server";
import { formatRequest } from "./middlewares/formatRequest";
import { rewriteBody } from "./middlewares/rewriteBody";
import OpenAI from "openai";
import { streamOpenAIResponse } from "./utils/stream";
import { router } from "./utils/router";
import { apiKeyAuth } from "./middleware/auth";
import {
cleanupPidFile,
isServiceRunning,
savePid,
} from "./utils/processCheck";
import { CONFIG_FILE } from "./constants";
async function initializeClaudeConfig() {
const homeDir = process.env.HOME;
const configPath = `${homeDir}/.claude.json`;
const homeDir = homedir();
const configPath = join(homeDir, ".claude.json");
if (!existsSync(configPath)) {
const userID = Array.from(
{ length: 64 },
@@ -20,37 +26,81 @@ async function initializeClaudeConfig() {
autoUpdaterStatus: "enabled",
userID,
hasCompletedOnboarding: true,
lastOnboardingVersion: "0.2.9",
lastOnboardingVersion: "1.0.17",
projects: {},
};
await writeFile(configPath, JSON.stringify(configContent, null, 2));
}
}
async function run() {
interface RunOptions {
port?: number;
}
async function run(options: RunOptions = {}) {
// Check if service is already running
if (isServiceRunning()) {
console.log("✅ Service is already running in the background.");
return;
}
await initializeClaudeConfig();
await initDir();
await initConfig();
const server = createServer(3456);
server.useMiddleware(formatRequest);
server.useMiddleware(rewriteBody);
const config = await initConfig();
let HOST = config.HOST;
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
...getOpenAICommonOptions(),
if (config.HOST && !config.APIKEY) {
HOST = "127.0.0.1";
console.warn(
"⚠️ API key is not set. HOST is forced to 127.0.0.1."
);
}
const port = config.PORT || 3456;
// Save the PID of the background process
savePid(process.pid);
// Handle SIGINT (Ctrl+C) to clean up PID file
process.on("SIGINT", () => {
console.log("Received SIGINT, cleaning up...");
cleanupPidFile();
process.exit(0);
});
server.app.post("/v1/messages", async (req, res) => {
try {
if (process.env.OPENAI_MODEL) {
req.body.model = process.env.OPENAI_MODEL;
}
const completion: any = await openai.chat.completions.create(req.body);
await streamOpenAIResponse(res, completion, req.body.model);
} catch (e) {
console.error("Error in OpenAI API call:", e);
// Handle SIGTERM to clean up PID file
process.on("SIGTERM", () => {
cleanupPidFile();
process.exit(0);
});
console.log(HOST)
// Use port from environment variable if set (for background process)
const servicePort = process.env.SERVICE_PORT
? parseInt(process.env.SERVICE_PORT)
: port;
const server = createServer({
jsonPath: CONFIG_FILE,
initialConfig: {
// ...config,
providers: config.Providers || config.providers,
HOST: HOST,
PORT: servicePort,
LOG_FILE: join(
homedir(),
".claude-code-router",
"claude-code-router.log"
),
},
});
server.addHook("preHandler", apiKeyAuth(config));
server.addHook("preHandler", async (req, reply) => {
if(req.url.startsWith("/v1/messages")) {
router(req, reply, config)
}
});
server.start();
}
run();
export { run };
// run();

33
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { FastifyRequest, FastifyReply } from "fastify";
export const apiKeyAuth =
(config: any) =>
(req: FastifyRequest, reply: FastifyReply, done: () => void) => {
if (["/", "/health"].includes(req.url) || req.url.startsWith("/ui")) {
return done();
}
const apiKey = config.APIKEY;
if (!apiKey) {
return done();
}
const authKey: string =
req.headers.authorization || req.headers["x-api-key"];
if (!authKey) {
reply.status(401).send("APIKEY is missing");
return;
}
let token = "";
if (authKey.startsWith("Bearer")) {
token = authKey.split(" ")[1];
} else {
token = authKey;
}
if (token !== apiKey) {
reply.status(401).send("Invalid API key");
return;
}
done();
};

View File

@@ -1,101 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import { MessageCreateParamsBase } from "@anthropic-ai/sdk/resources/messages";
import OpenAI from "openai";
import { streamOpenAIResponse } from "../utils/stream";
export const formatRequest = async (
req: Request,
res: Response,
next: NextFunction
) => {
let {
model,
max_tokens,
messages,
system = [],
temperature,
metadata,
tools,
}: MessageCreateParamsBase = req.body;
try {
const openAIMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =
messages.map((item) => {
if (item.content instanceof Array) {
return {
role: item.role,
content: item.content
.map((it: ContentBlockParam) => {
if (it.type === "text") {
return typeof it.text === "string"
? it.text
: JSON.stringify(it);
}
return JSON.stringify(it);
})
.join(""),
} as OpenAI.Chat.Completions.ChatCompletionMessageParam;
}
return {
role: item.role,
content:
typeof item.content === "string"
? item.content
: JSON.stringify(item.content),
};
});
const systemMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =
Array.isArray(system)
? system.map((item) => ({
role: "system",
content: item.text,
}))
: [{ role: "system", content: system }];
const data: OpenAI.Chat.Completions.ChatCompletionCreateParams = {
model,
messages: [...systemMessages, ...openAIMessages],
temperature,
stream: true,
};
if (tools) {
data.tools = tools
.filter((tool) => !["StickerRequest"].includes(tool.name))
.map((item: any) => ({
type: "function",
function: {
name: item.name,
description: item.description,
parameters: item.input_schema,
},
}));
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
req.body = data;
} catch (error) {
console.error("Error in request processing:", error);
const errorCompletion: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk> =
{
async *[Symbol.asyncIterator]() {
yield {
id: `error_${Date.now()}`,
created: Math.floor(Date.now() / 1000),
model: "gpt-3.5-turbo",
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {
content: `Error: ${(error as Error).message}`,
},
finish_reason: "stop",
},
],
};
},
};
await streamOpenAIResponse(res, errorCompletion, model);
}
next();
};

View File

@@ -1,43 +0,0 @@
import { Request, Response, NextFunction } from "express";
import Module from "node:module";
import { streamOpenAIResponse } from "../utils/stream";
import { log } from "../utils/log";
import { PLUGINS_DIR } from "../constants";
import path from "node:path";
import { access } from "node:fs/promises";
import { OpenAI } from "openai";
import { createClient } from "../utils";
// @ts-ignore
const originalLoad = Module._load;
// @ts-ignore
Module._load = function (request, parent, isMain) {
if (request === "claude-code-router") {
return {
streamOpenAIResponse,
log,
OpenAI,
createClient,
};
}
return originalLoad.call(this, request, parent, isMain);
};
export const rewriteBody = async (
req: Request,
res: Response,
next: NextFunction
) => {
if (!process.env.usePlugin) {
return next();
}
const pluginPath = path.join(PLUGINS_DIR, `${process.env.usePlugin}.js`);
try {
await access(pluginPath);
const rewritePlugin = require(pluginPath);
rewritePlugin(req, res, next);
} catch (e) {
console.error(e);
next();
}
};

View File

@@ -1,23 +1,59 @@
import express, { RequestHandler } from "express";
import Server from "@musistudio/llms";
import { readConfigFile, writeConfigFile } from "./utils";
import { CONFIG_FILE } from "./constants";
import { join } from "path";
import { readFileSync } from "fs";
import fastifyStatic from "@fastify/static";
interface Server {
app: express.Application;
useMiddleware: (middleware: RequestHandler) => void;
start: () => void;
}
export const createServer = (config: any): Server => {
const server = new Server(config);
export const createServer = (port: number): Server => {
const app = express();
app.use(express.json({ limit: "500mb" }));
return {
app,
useMiddleware: (middleware: RequestHandler) => {
app.use("/v1/messages", middleware);
},
start: () => {
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
},
};
// Add endpoint to read config.json
server.app.get("/api/config", async () => {
return await readConfigFile();
});
server.app.get("/api/transformers", async () => {
const transformers =
server.app._server!.transformerService.getAllTransformers();
const transformerList = Array.from(transformers.entries()).map(
([name, transformer]: any) => ({
name,
endpoint: transformer.endPoint || null,
})
);
return { transformers: transformerList };
});
// Add endpoint to save config.json
server.app.post("/api/config", async (req) => {
const newConfig = req.body;
await writeConfigFile(newConfig);
return { success: true, message: "Config saved successfully" };
});
// Add endpoint to restart the service
server.app.post("/api/restart", async (_, reply) => {
reply.send({ success: true, message: "Service restart initiated" });
// Restart the service after a short delay to allow response to be sent
setTimeout(() => {
const { spawn } = require("child_process");
spawn("ccr", ["restart"], { detached: true, stdio: "ignore" });
}, 1000);
});
// Register static file serving with caching
server.app.register(fastifyStatic, {
root: join(__dirname, "..", "dist"),
prefix: "/ui/",
maxAge: "1h",
});
// Redirect /ui to /ui/ for proper static file serving
server.app.get("/ui", async (_, reply) => {
return reply.redirect("/ui/");
});
return server;
};

27
src/utils/close.ts Normal file
View File

@@ -0,0 +1,27 @@
import { isServiceRunning, cleanupPidFile, getReferenceCount } from './processCheck';
import { readFileSync } from 'fs';
import { HOME_DIR } from '../constants';
import { join } from 'path';
export async function closeService() {
const PID_FILE = join(HOME_DIR, '.claude-code-router.pid');
if (!isServiceRunning()) {
console.log("No service is currently running.");
return;
}
if (getReferenceCount() > 0) {
return;
}
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
process.kill(pid);
cleanupPidFile();
console.log("claude code router service has been successfully stopped.");
} catch (e) {
console.log("Failed to stop the service. It may have already been stopped.");
cleanupPidFile();
}
}

49
src/utils/codeCommand.ts Normal file
View File

@@ -0,0 +1,49 @@
import { spawn } from "child_process";
import {
incrementReferenceCount,
decrementReferenceCount,
} from "./processCheck";
import { closeService } from "./close";
import { readConfigFile } from ".";
export async function executeCodeCommand(args: string[] = []) {
// Set environment variables
const config = await readConfigFile();
const env = {
...process.env,
ANTHROPIC_AUTH_TOKEN: "test",
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.PORT || 3456}`,
API_TIMEOUT_MS: "600000",
};
if (config?.APIKEY) {
env.ANTHROPIC_API_KEY = config.APIKEY;
delete env.ANTHROPIC_AUTH_TOKEN;
}
// Increment reference count when command starts
incrementReferenceCount();
// Execute claude command
const claudePath = process.env.CLAUDE_PATH || "claude";
const claudeProcess = spawn(claudePath, args, {
env,
stdio: "inherit",
shell: true,
});
claudeProcess.on("error", (error) => {
console.error("Failed to start claude command:", error.message);
console.log(
"Make sure Claude Code is installed: npm install -g @anthropic-ai/claude-code"
);
decrementReferenceCount();
process.exit(1);
});
claudeProcess.on("close", (code) => {
decrementReferenceCount();
closeService();
process.exit(code || 0);
});
}

View File

@@ -1,7 +1,6 @@
import { HttpsProxyAgent } from "https-proxy-agent";
import OpenAI, { ClientOptions } from "openai";
import fs from "node:fs/promises";
import readline from "node:readline";
import JSON5 from "json5";
import {
CONFIG_FILE,
DEFAULT_CONFIG,
@@ -9,14 +8,6 @@ import {
PLUGINS_DIR,
} from "../constants";
export function getOpenAICommonOptions(): ClientOptions {
const options: ClientOptions = {};
if (process.env.PROXY_URL) {
options.httpAgent = new HttpsProxyAgent(process.env.PROXY_URL);
}
return options;
}
const ensureDir = async (dir_path: string) => {
try {
await fs.access(dir_path);
@@ -55,42 +46,53 @@ const confirm = async (query: string): Promise<boolean> => {
export const readConfigFile = async () => {
try {
const config = await fs.readFile(CONFIG_FILE, "utf-8");
return JSON.parse(config);
} catch {
const useRouter = await confirm(
"No config file found. Enable router mode? (Y/n)"
);
if (!useRouter) {
const apiKey = await question("Enter OPENAI_API_KEY: ");
const baseUrl = await question("Enter OPENAI_BASE_URL: ");
const model = await question("Enter OPENAI_MODEL: ");
try {
// Try to parse with JSON5 first (which also supports standard JSON)
return JSON5.parse(config);
} catch (parseError) {
console.error(`Failed to parse config file at ${CONFIG_FILE}`);
console.error("Error details:", (parseError as Error).message);
console.error("Please check your config file syntax.");
process.exit(1);
}
} catch (readError: any) {
if (readError.code === "ENOENT") {
// Config file doesn't exist, prompt user for initial setup
const name = await question("Enter Provider Name: ");
const APIKEY = await question("Enter Provider API KEY: ");
const baseUrl = await question("Enter Provider URL: ");
const model = await question("Enter MODEL Name: ");
const config = Object.assign({}, DEFAULT_CONFIG, {
OPENAI_API_KEY: apiKey,
OPENAI_BASE_URL: baseUrl,
OPENAI_MODEL: model,
Providers: [
{
name,
api_base_url: baseUrl,
api_key: APIKEY,
models: [model],
},
],
Router: {
default: `${name},${model}`,
},
});
await writeConfigFile(config);
return config;
} else {
const router = await question("Enter OPENAI_API_KEY: ");
return DEFAULT_CONFIG;
console.error(`Failed to read config file at ${CONFIG_FILE}`);
console.error("Error details:", readError.message);
process.exit(1);
}
}
};
export const writeConfigFile = async (config: any) => {
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
await ensureDir(HOME_DIR);
const configWithComment = `${JSON.stringify(config, null, 2)}`;
await fs.writeFile(CONFIG_FILE, configWithComment);
};
export const initConfig = async () => {
const config = await readConfigFile();
Object.assign(process.env, config);
};
export const createClient = (options: ClientOptions) => {
const client = new OpenAI({
...options,
...getOpenAICommonOptions(),
});
return client;
return config;
};

View File

@@ -1,8 +1,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { HOME_DIR } from '../constants';
import fs from "node:fs";
import path from "node:path";
import { HOME_DIR } from "../constants";
const LOG_FILE = path.join(HOME_DIR, 'claude-code-router.log');
const LOG_FILE = path.join(HOME_DIR, "claude-code-router.log");
// Ensure log directory exists
if (!fs.existsSync(HOME_DIR)) {
@@ -11,17 +11,23 @@ if (!fs.existsSync(HOME_DIR)) {
export function log(...args: any[]) {
// Check if logging is enabled via environment variable
const isLogEnabled = process.env.LOG === 'true';
const isLogEnabled = process.env.LOG === "true";
if (!isLogEnabled) {
return;
}
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
).join(' ')}\n`;
const logMessage = `[${timestamp}] ${
Array.isArray(args)
? args
.map((arg) =>
typeof arg === "object" ? JSON.stringify(arg) : String(arg)
)
.join(" ")
: ""
}\n`;
// Append to log file
fs.appendFileSync(LOG_FILE, logMessage, 'utf8');
fs.appendFileSync(LOG_FILE, logMessage, "utf8");
}

87
src/utils/processCheck.ts Normal file
View File

@@ -0,0 +1,87 @@
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
import { readConfigFile } from '.';
export function incrementReferenceCount() {
let count = 0;
if (existsSync(REFERENCE_COUNT_FILE)) {
count = parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
}
count++;
writeFileSync(REFERENCE_COUNT_FILE, count.toString());
}
export function decrementReferenceCount() {
let count = 0;
if (existsSync(REFERENCE_COUNT_FILE)) {
count = parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
}
count = Math.max(0, count - 1);
writeFileSync(REFERENCE_COUNT_FILE, count.toString());
}
export function getReferenceCount(): number {
if (!existsSync(REFERENCE_COUNT_FILE)) {
return 0;
}
return parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
}
export function isServiceRunning(): boolean {
if (!existsSync(PID_FILE)) {
return false;
}
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
process.kill(pid, 0);
return true;
} catch (e) {
// Process not running, clean up pid file
cleanupPidFile();
return false;
}
}
export function savePid(pid: number) {
writeFileSync(PID_FILE, pid.toString());
}
export function cleanupPidFile() {
if (existsSync(PID_FILE)) {
try {
const fs = require('fs');
fs.unlinkSync(PID_FILE);
} catch (e) {
// Ignore cleanup errors
}
}
}
export function getServicePid(): number | null {
if (!existsSync(PID_FILE)) {
return null;
}
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
return isNaN(pid) ? null : pid;
} catch (e) {
return null;
}
}
export async function getServiceInfo() {
const pid = getServicePid();
const running = isServiceRunning();
const config = await readConfigFile();
return {
running,
pid,
port: config.PORT,
endpoint: `http://127.0.0.1:${config.PORT}`,
pidFile: PID_FILE,
referenceCount: getReferenceCount()
};
}

127
src/utils/router.ts Normal file
View File

@@ -0,0 +1,127 @@
import {
MessageCreateParamsBase,
MessageParam,
Tool,
} from "@anthropic-ai/sdk/resources/messages";
import { get_encoding } from "tiktoken";
import { log } from "./log";
const enc = get_encoding("cl100k_base");
const calculateTokenCount = (
messages: MessageParam[],
system: any,
tools: Tool[]
) => {
let tokenCount = 0;
if (Array.isArray(messages)) {
messages.forEach((message) => {
if (typeof message.content === "string") {
tokenCount += enc.encode(message.content).length;
} else if (Array.isArray(message.content)) {
message.content.forEach((contentPart: any) => {
if (contentPart.type === "text") {
tokenCount += enc.encode(contentPart.text).length;
} else if (contentPart.type === "tool_use") {
tokenCount += enc.encode(
JSON.stringify(contentPart.input)
).length;
} else if (contentPart.type === "tool_result") {
tokenCount += enc.encode(
typeof contentPart.content === "string"
? contentPart.content
: JSON.stringify(contentPart.content)
).length;
}
});
}
});
}
if (typeof system === "string") {
tokenCount += enc.encode(system).length;
} else if (Array.isArray(system)) {
system.forEach((item: any) => {
if (item.type !== "text") return;
if (typeof item.text === "string") {
tokenCount += enc.encode(item.text).length;
} else if (Array.isArray(item.text)) {
item.text.forEach((textPart: any) => {
tokenCount += enc.encode(textPart || "").length;
});
}
});
}
if (tools) {
tools.forEach((tool: Tool) => {
if (tool.description) {
tokenCount += enc.encode(tool.name + tool.description).length;
}
if (tool.input_schema) {
tokenCount += enc.encode(JSON.stringify(tool.input_schema)).length;
}
});
}
return tokenCount;
};
const getUseModel = async (req: any, tokenCount: number, config: any) => {
if (req.body.model.includes(",")) {
return req.body.model;
}
// if tokenCount is greater than the configured threshold, use the long context model
const longContextThreshold = config.Router.longContextThreshold || 60000;
if (tokenCount > longContextThreshold && config.Router.longContext) {
log("Using long context model due to token count:", tokenCount, "threshold:", longContextThreshold);
return config.Router.longContext;
}
// If the model is claude-3-5-haiku, use the background model
if (
req.body.model?.startsWith("claude-3-5-haiku") &&
config.Router.background
) {
log("Using background model for ", req.body.model);
return config.Router.background;
}
// if exits thinking, use the think model
if (req.body.thinking && config.Router.think) {
log("Using think model for ", req.body.thinking);
return config.Router.think;
}
if (
Array.isArray(req.body.tools) &&
req.body.tools.some((tool: any) => tool.type?.startsWith("web_search")) &&
config.Router.webSearch
) {
return config.Router.webSearch;
}
return config.Router!.default;
};
export const router = async (req: any, _res: any, config: any) => {
const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
try {
const tokenCount = calculateTokenCount(
messages as MessageParam[],
system,
tools as Tool[]
);
let model;
if (config.CUSTOM_ROUTER_PATH) {
try {
const customRouter = require(config.CUSTOM_ROUTER_PATH);
model = await customRouter(req, config);
} catch (e: any) {
log("failed to load custom router", e.message);
}
}
if (!model) {
model = await getUseModel(req, tokenCount, config);
}
req.body.model = model;
} catch (error: any) {
log("Error in router middleware:", error.message);
req.body.model = config.Router!.default;
}
return;
};

27
src/utils/status.ts Normal file
View File

@@ -0,0 +1,27 @@
import { getServiceInfo } from './processCheck';
export async function showStatus() {
const info = await getServiceInfo();
console.log('\n📊 Claude Code Router Status');
console.log('═'.repeat(40));
if (info.running) {
console.log('✅ Status: Running');
console.log(`🆔 Process ID: ${info.pid}`);
console.log(`🌐 Port: ${info.port}`);
console.log(`📡 API Endpoint: ${info.endpoint}`);
console.log(`📄 PID File: ${info.pidFile}`);
console.log('');
console.log('🚀 Ready to use! Run the following commands:');
console.log(' ccr code # Start coding with Claude');
console.log(' ccr stop # Stop the service');
} else {
console.log('❌ Status: Not Running');
console.log('');
console.log('💡 To start the service:');
console.log(' ccr start');
}
console.log('');
}

View File

@@ -1,268 +0,0 @@
import { Response } from "express";
import { OpenAI } from "openai";
interface ContentBlock {
type: string;
id?: string;
name?: string;
input?: any;
text?: string;
}
interface MessageEvent {
type: string;
message?: {
id: string;
type: string;
role: string;
content: any[];
model: string;
stop_reason: string | null;
stop_sequence: string | null;
usage: {
input_tokens: number;
output_tokens: number;
};
};
delta?: {
stop_reason?: string;
stop_sequence?: string | null;
content?: ContentBlock[];
type?: string;
text?: string;
partial_json?: string;
};
index?: number;
content_block?: ContentBlock;
usage?: {
input_tokens: number;
output_tokens: number;
};
}
export async function streamOpenAIResponse(
res: Response,
completion: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
model: string
) {
const messageId = "msg_" + Date.now();
let contentBlockIndex = 0;
let currentContentBlocks: ContentBlock[] = [];
// Send message_start event
const messageStart: MessageEvent = {
type: "message_start",
message: {
id: messageId,
type: "message",
role: "assistant",
content: [],
model,
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
};
res.write(`event: message_start\ndata: ${JSON.stringify(messageStart)}\n\n`);
let isToolUse = false;
let toolUseJson = "";
let hasStartedTextBlock = false;
try {
for await (const chunk of completion) {
const delta = chunk.choices[0].delta;
if (delta.tool_calls && delta.tool_calls.length > 0) {
const toolCall = delta.tool_calls[0];
if (!isToolUse) {
// Start new tool call block
isToolUse = true;
const toolBlock: ContentBlock = {
type: "tool_use",
id: `toolu_${Date.now()}`,
name: toolCall.function?.name,
input: {},
};
const toolBlockStart: MessageEvent = {
type: "content_block_start",
index: contentBlockIndex,
content_block: toolBlock,
};
currentContentBlocks.push(toolBlock);
res.write(
`event: content_block_start\ndata: ${JSON.stringify(
toolBlockStart
)}\n\n`
);
toolUseJson = "";
}
// Stream tool call JSON
if (toolCall.function?.arguments) {
const jsonDelta: MessageEvent = {
type: "content_block_delta",
index: contentBlockIndex,
delta: {
type: "input_json_delta",
partial_json: toolCall.function?.arguments,
},
};
toolUseJson += toolCall.function.arguments;
try {
const parsedJson = JSON.parse(toolUseJson);
currentContentBlocks[contentBlockIndex].input = parsedJson;
} catch (e) {
// JSON not yet complete, continue accumulating
}
res.write(
`event: content_block_delta\ndata: ${JSON.stringify(jsonDelta)}\n\n`
);
}
} else if (delta.content) {
// Handle regular text content
if (isToolUse) {
// End previous tool call block
const contentBlockStop: MessageEvent = {
type: "content_block_stop",
index: contentBlockIndex,
};
res.write(
`event: content_block_stop\ndata: ${JSON.stringify(
contentBlockStop
)}\n\n`
);
contentBlockIndex++;
isToolUse = false;
}
if (!delta.content) continue;
// If text block not yet started, send content_block_start
if (!hasStartedTextBlock) {
const textBlock: ContentBlock = {
type: "text",
text: "",
};
const textBlockStart: MessageEvent = {
type: "content_block_start",
index: contentBlockIndex,
content_block: textBlock,
};
currentContentBlocks.push(textBlock);
res.write(
`event: content_block_start\ndata: ${JSON.stringify(
textBlockStart
)}\n\n`
);
hasStartedTextBlock = true;
}
// Send regular text content
const contentDelta: MessageEvent = {
type: "content_block_delta",
index: contentBlockIndex,
delta: {
type: "text_delta",
text: delta.content,
},
};
// Update content block text
if (currentContentBlocks[contentBlockIndex]) {
currentContentBlocks[contentBlockIndex].text += delta.content;
}
res.write(
`event: content_block_delta\ndata: ${JSON.stringify(
contentDelta
)}\n\n`
);
}
}
} catch (e: any) {
// If text block not yet started, send content_block_start
if (!hasStartedTextBlock) {
const textBlock: ContentBlock = {
type: "text",
text: "",
};
const textBlockStart: MessageEvent = {
type: "content_block_start",
index: contentBlockIndex,
content_block: textBlock,
};
currentContentBlocks.push(textBlock);
res.write(
`event: content_block_start\ndata: ${JSON.stringify(
textBlockStart
)}\n\n`
);
hasStartedTextBlock = true;
}
// Send regular text content
const contentDelta: MessageEvent = {
type: "content_block_delta",
index: contentBlockIndex,
delta: {
type: "text_delta",
text: JSON.stringify(e),
},
};
// Update content block text
if (currentContentBlocks[contentBlockIndex]) {
currentContentBlocks[contentBlockIndex].text += JSON.stringify(e);
}
res.write(
`event: content_block_delta\ndata: ${JSON.stringify(contentDelta)}\n\n`
);
}
// Close last content block
const contentBlockStop: MessageEvent = {
type: "content_block_stop",
index: contentBlockIndex,
};
res.write(
`event: content_block_stop\ndata: ${JSON.stringify(contentBlockStop)}\n\n`
);
// Send message_delta event with appropriate stop_reason
const messageDelta: MessageEvent = {
type: "message_delta",
delta: {
stop_reason: isToolUse ? "tool_use" : "end_turn",
stop_sequence: null,
content: currentContentBlocks,
},
usage: { input_tokens: 100, output_tokens: 150 },
};
res.write(`event: message_delta\ndata: ${JSON.stringify(messageDelta)}\n\n`);
// Send message_stop event
const messageStop: MessageEvent = {
type: "message_stop",
};
res.write(`event: message_stop\ndata: ${JSON.stringify(messageStop)}\n\n`);
res.end();
}

33
ui/CLAUDE.md Normal file
View File

@@ -0,0 +1,33 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a frontend project for a configuration settings UI. The goal is to produce a single, self-contained HTML file with all JavaScript and CSS inlined. The application should be designed with a clean, modern UI and support both English and Chinese languages.
## Tech Stack
- **Package Manager:** pnpm
- **Build Tool:** Vite.js
- **Framework:** React.js
- **Styling:** Tailwind CSS with shadcn-ui
- **Languages:** TypeScript, English, Chinese
## Key Commands
- **Run development server:** `pnpm dev`
- **Build for production:** `pnpm build` (This produces a single HTML file)
- **Lint files:** `pnpm lint`
- **Preview production build:** `pnpm preview`
## Architecture & Development Notes
- **Configuration:** The application's configuration structure is defined in `config.example.json`. This file should be used as a reference for mocking data, as no backend APIs will be implemented.
- **Build Target:** The final build output must be a single HTML file. This is configured in `vite.config.ts` using `vite-plugin-singlefile`.
- **Internationalization (i18n):** The project uses `i18next` to support both English and Chinese. Locale files are located in `src/locales/`. When adding or changing text, ensure it is properly added to the translation files.
- **UI:** The UI is built with `shadcn-ui` components. Refer to existing components in `src/components/ui/` for styling conventions.
- **API Client:** The project uses a custom `ApiClient` class for handling HTTP requests with baseUrl and API key authentication. The class is defined in `src/lib/api.ts` and provides methods for GET, POST, PUT, and DELETE requests.
## 项目描述
参考`PROJECT.md`文件

23
ui/PROJECT.md Normal file
View File

@@ -0,0 +1,23 @@
# 项目指南
> 这是一个用于设置配置的前端项目配置格式参考config.example.json
## 技术栈
1. 使用pnpm作为包管理工具
2. 使用vite.js作为构建工具
3. 使用react.js + tailwindcss + shadcn-ui构建前端界面
## UI设计
采用现代化的UI风格让界面整体体现出呼吸感。整体配置应该简洁和通俗易懂需要有必要的校验易用的交互体验。
## 接口设计
不需要实现任何接口但你需要根据config.example.json文件的内容mock数据
## 代码指引
在使用任何库之前你都需要使用websearch工具查找最新的文档不要使用你知识库的内容即使是显而易见的你以为的确定性的知识。
## 多语言设计
项目需要同时支持中文和英文
## 构建发布
最后需要构建出一个HTML文件其中所有的js和css采用内联的方式构建产物应该只包含一个html文件。

69
ui/README.md Normal file
View File

@@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

21
ui/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

177
ui/config.example.json Normal file
View File

@@ -0,0 +1,177 @@
{
"LOG": true,
"CLAUDE_PATH": "/Users/jinhuilee/.claude/local/claude",
"HOST": "127.0.0.1",
"PORT": 8080,
"APIKEY": "1",
"transformers": [
{
"path": "/Users/abc/.claude-code-router/plugins/gemini-cli.js",
"options": {
"project": "x"
}
}
],
"Providers": [
{
"name": "siliconflow",
"api_base_url": "https://api.moonshot.cn/v1/chat/completions",
"api_key": "sk-",
"models": [
"kimi-k2-0711-preview"
],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 130000
}
]
]
}
},
{
"name": "kimi",
"api_base_url": "https://api.moonshot.cn/v1/chat/completions",
"api_key": "sk-",
"models": [
"kimi-k2-0711-preview"
]
},
{
"name": "groq",
"api_base_url": "https://api.groq.com/openai/v1/chat/completions",
"api_key": "",
"models": [
"moonshotai/kimi-k2-instruct"
],
"transformer": {
"use": [
[
"maxtoken",
{
"max_tokens": 16384
}
],
"groq"
]
}
},
{
"name": "openrouter",
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
"api_key": "sk-or-v1-",
"models": [
"google/gemini-2.5-pro-preview",
"anthropic/claude-sonnet-4",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.7-sonnet:thinking",
"deepseek/deepseek-chat-v3-0324",
"@preset/kimi"
],
"transformer": {
"use": [
"openrouter"
],
"deepseek/deepseek-chat-v3-0324": {
"use": [
"tooluse"
]
}
}
},
{
"name": "deepseek",
"api_base_url": "https://api.deepseek.com/chat/completions",
"api_key": "sk-",
"models": [
"deepseek-chat",
"deepseek-reasoner"
],
"transformer": {
"use": [
"deepseek"
],
"deepseek-chat": {
"use": [
"tooluse"
]
}
}
},
{
"name": "test",
"api_base_url": "https://tbai.xin/v1/chat/completions",
"api_key": "sk-",
"models": [
"gemini-2.5-pro"
]
},
{
"name": "ollama",
"api_base_url": "http://localhost:11434/v1/chat/completions",
"api_key": "ollama",
"models": [
"qwen2.5-coder:latest"
]
},
{
"name": "gemini",
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
"api_key": "",
"models": [
"gemini-2.5-flash",
"gemini-2.5-pro"
],
"transformer": {
"use": [
"gemini"
]
}
},
{
"name": "volcengine",
"api_base_url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
"api_key": "sk-xxx",
"models": [
"deepseek-v3-250324",
"deepseek-r1-250528"
],
"transformer": {
"use": [
"deepseek"
]
}
},
{
"name": "gemini-cli",
"api_base_url": "https://cloudcode-pa.googleapis.com/v1internal",
"api_key": "sk-xxx",
"models": [
"gemini-2.5-flash",
"gemini-2.5-pro"
],
"transformer": {
"use": [
"gemini-cli"
]
}
},
{
"name": "azure",
"api_base_url": "https://your-resource-name.openai.azure.com/",
"api_key": "",
"models": [
"gpt-4"
]
}
],
"Router": {
"default": "gemini-cli,gemini-2.5-pro",
"background": "gemini-cli,gemini-2.5-flash",
"think": "gemini-cli,gemini-2.5-pro",
"longContext": "gemini-cli,gemini-2.5-pro",
"webSearch": "gemini-cli,gemini-2.5-flash"
}
}

23
ui/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CCR UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5033
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
ui/package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "temp-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@tailwindcss/vite": "^4.1.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.6.1",
"react-router-dom": "^7.7.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@tailwindcss/postcss": "^4.1.11",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4",
"vite-plugin-singlefile": "^2.3.0"
}
}

3459
ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
ui/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

237
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,237 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "@/components/SettingsDialog";
import { Transformers } from "@/components/Transformers";
import { Providers } from "@/components/Providers";
import { Router } from "@/components/Router";
import { JsonEditor } from "@/components/JsonEditor";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/components/ConfigProvider";
import { api } from "@/lib/api";
import { Settings, Languages, Save, RefreshCw, FileJson } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Toast } from "@/components/ui/toast";
import "@/styles/animations.css";
function App() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { config, error } = useConfig();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
useEffect(() => {
const checkAuth = async () => {
// If we already have a config, we're authenticated
if (config) {
setIsCheckingAuth(false);
return;
}
// For empty API key, allow access without checking config
const apiKey = localStorage.getItem('apiKey');
if (!apiKey) {
setIsCheckingAuth(false);
return;
}
// If we don't have a config, try to fetch it
try {
await api.getConfig();
// If successful, we don't need to do anything special
// The ConfigProvider will handle setting the config
} catch (err) {
// If it's a 401, the API client will redirect to login
// For other errors, we still show the app to display the error
console.error('Error checking auth:', err);
// Redirect to login on authentication error
if ((err as Error).message === 'Unauthorized') {
navigate('/login');
}
} finally {
setIsCheckingAuth(false);
}
};
checkAuth();
// Listen for unauthorized events
const handleUnauthorized = () => {
navigate('/login');
};
window.addEventListener('unauthorized', handleUnauthorized);
return () => {
window.removeEventListener('unauthorized', handleUnauthorized);
};
}, [config, navigate]);
const saveConfig = async () => {
if (config) {
try {
// Save to API
const response = await api.updateConfig(config);
// Show success message or handle as needed
console.log('Config saved successfully');
// 根据响应信息进行提示
if (response && typeof response === 'object' && 'success' in response) {
const apiResponse = response as { success: boolean; message?: string };
if (apiResponse.success) {
setToast({ message: apiResponse.message || t('app.config_saved_success'), type: 'success' });
} else {
setToast({ message: apiResponse.message || t('app.config_saved_failed'), type: 'error' });
}
} else {
// 默认成功提示
setToast({ message: t('app.config_saved_success'), type: 'success' });
}
} catch (error) {
console.error('Failed to save config:', error);
// Handle error appropriately
setToast({ message: t('app.config_saved_failed') + ': ' + (error as Error).message, type: 'error' });
}
}
};
const saveConfigAndRestart = async () => {
if (config) {
try {
// Save to API
const response = await api.updateConfig(config);
// Check if save was successful before restarting
let saveSuccessful = true;
if (response && typeof response === 'object' && 'success' in response) {
const apiResponse = response as { success: boolean; message?: string };
if (!apiResponse.success) {
saveSuccessful = false;
setToast({ message: apiResponse.message || t('app.config_saved_failed'), type: 'error' });
}
}
// Only restart if save was successful
if (saveSuccessful) {
// Restart service
const response = await api.restartService();
// Show success message or handle as needed
console.log('Config saved and service restarted successfully');
// 根据响应信息进行提示
if (response && typeof response === 'object' && 'success' in response) {
const apiResponse = response as { success: boolean; message?: string };
if (apiResponse.success) {
setToast({ message: apiResponse.message || t('app.config_saved_restart_success'), type: 'success' });
}
} else {
// 默认成功提示
setToast({ message: t('app.config_saved_restart_success'), type: 'success' });
}
}
} catch (error) {
console.error('Failed to save config and restart:', error);
// Handle error appropriately
setToast({ message: t('app.config_saved_restart_failed') + ': ' + (error as Error).message, type: 'error' });
}
}
};
if (isCheckingAuth) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
if (!config) {
return <div>Loading...</div>;
}
return (
<div className="h-screen bg-gray-50 font-sans">
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<h1 className="text-xl font-semibold text-gray-800">{t('app.title')}</h1>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => setIsSettingsOpen(true)} className="transition-all-ease hover:scale-110">
<Settings className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setIsJsonEditorOpen(true)} className="transition-all-ease hover:scale-110">
<FileJson className="h-5 w-5" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="transition-all-ease hover:scale-110">
<Languages className="h-5 w-5" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-32 p-2">
<div className="space-y-1">
<Button
variant={i18n.language.startsWith('en') ? 'default' : 'ghost'}
className="w-full justify-start transition-all-ease hover:scale-[1.02]"
onClick={() => i18n.changeLanguage('en')}
>
English
</Button>
<Button
variant={i18n.language.startsWith('zh') ? 'default' : 'ghost'}
className="w-full justify-start transition-all-ease hover:scale-[1.02]"
onClick={() => i18n.changeLanguage('zh')}
>
</Button>
</div>
</PopoverContent>
</Popover>
<Button onClick={saveConfig} variant="outline" className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]">
<Save className="mr-2 h-4 w-4" />
{t('app.save')}
</Button>
<Button onClick={saveConfigAndRestart} className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]">
<RefreshCw className="mr-2 h-4 w-4" />
{t('app.save_and_restart')}
</Button>
</div>
</header>
<main className="flex h-[calc(100vh-4rem)] gap-4 p-4">
<div className="w-3/5">
<Providers />
</div>
<div className="flex w-2/5 flex-col gap-4">
<div className="h-3/5">
<Router />
</div>
<div className="flex-1">
<Transformers />
</div>
</div>
</main>
<SettingsDialog isOpen={isSettingsOpen} onOpenChange={setIsSettingsOpen} />
<JsonEditor
open={isJsonEditorOpen}
onOpenChange={setIsJsonEditorOpen}
showToast={(message, type) => setToast({ message, type })}
/>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
);
}
export default App;

1
ui/src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,147 @@
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode, Dispatch, SetStateAction } from 'react';
import { api } from '@/lib/api';
export interface Transformer {
path: string;
options: {
[key: string]: string;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export interface ProviderTransformer {
use: (string | (string | Record<string, unknown> | { max_tokens: number })[])[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any; // for model specific transformers
}
export interface Provider {
name: string;
api_base_url: string;
api_key: string;
models: string[];
transformer?: ProviderTransformer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export interface RouterConfig {
default: string;
background: string;
think: string;
longContext: string;
webSearch: string;
}
export interface Config {
LOG: boolean;
CLAUDE_PATH: string;
HOST: string;
PORT: number;
APIKEY: string;
transformers: Transformer[];
Providers: Provider[];
Router: RouterConfig;
}
interface ConfigContextType {
config: Config | null;
setConfig: Dispatch<SetStateAction<Config | null>>;
error: Error | null;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
// eslint-disable-next-line react-refresh/only-export-components
export function useConfig() {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
}
interface ConfigProviderProps {
children: ReactNode;
}
export function ConfigProvider({ children }: ConfigProviderProps) {
const [config, setConfig] = useState<Config | null>(null);
const [error, setError] = useState<Error | null>(null);
const [hasFetched, setHasFetched] = useState<boolean>(false);
const [apiKey, setApiKey] = useState<string | null>(localStorage.getItem('apiKey'));
// Listen for localStorage changes
useEffect(() => {
const handleStorageChange = () => {
setApiKey(localStorage.getItem('apiKey'));
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
useEffect(() => {
const fetchConfig = async () => {
// Reset fetch state when API key changes
setHasFetched(false);
setConfig(null);
setError(null);
};
fetchConfig();
}, [apiKey]);
useEffect(() => {
const fetchConfig = async () => {
// Prevent duplicate API calls in React StrictMode
// Skip if we've already fetched
if (hasFetched) {
return;
}
setHasFetched(true);
try {
// Try to fetch config regardless of API key presence
const data = await api.getConfig();
setConfig(data);
} catch (err) {
console.error('Failed to fetch config:', err);
// If we get a 401, the API client will redirect to login
// Otherwise, set an empty config or error
if ((err as Error).message !== 'Unauthorized') {
// Set default empty config when fetch fails
setConfig({
LOG: false,
CLAUDE_PATH: '',
HOST: '127.0.0.1',
PORT: 3456,
APIKEY: '',
transformers: [],
Providers: [],
Router: {
default: '',
background: '',
think: '',
longContext: '',
webSearch: ''
}
});
setError(err as Error);
}
}
};
fetchConfig();
}, [hasFetched, apiKey]);
return (
<ConfigContext.Provider value={{ config, setConfig, error }}>
{children}
</ConfigContext.Provider>
);
}

View File

@@ -0,0 +1,220 @@
import { useState, useEffect, useRef } from 'react';
import Editor from '@monaco-editor/react';
import { Button } from '@/components/ui/button';
import { useConfig } from '@/components/ConfigProvider';
import { api } from '@/lib/api';
import { useTranslation } from 'react-i18next';
import { Save, X, RefreshCw } from 'lucide-react';
interface JsonEditorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
showToast?: (message: string, type: 'success' | 'error' | 'warning') => void;
}
export function JsonEditor({ open, onOpenChange, showToast }: JsonEditorProps) {
const { t } = useTranslation();
const { config } = useConfig();
const [jsonValue, setJsonValue] = useState<string>('');
const [isSaving, setIsSaving] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (config && open) {
setJsonValue(JSON.stringify(config, null, 2));
}
}, [config, open]);
// Handle open/close animations
useEffect(() => {
if (open) {
setIsVisible(true);
// Trigger the animation after a small delay to ensure the element is rendered
requestAnimationFrame(() => {
setIsAnimating(true);
});
} else {
setIsAnimating(false);
// Wait for the animation to complete before hiding
const timer = setTimeout(() => {
setIsVisible(false);
}, 300);
return () => clearTimeout(timer);
}
}, [open]);
const handleSaveResponse = (response: unknown, successMessage: string, errorMessage: string) => {
// 根据响应信息进行提示
if (response && typeof response === 'object' && 'success' in response) {
const apiResponse = response as { success: boolean; message?: string };
if (apiResponse.success) {
if (showToast) {
showToast(apiResponse.message || successMessage, 'success');
}
return true;
} else {
if (showToast) {
showToast(apiResponse.message || errorMessage, 'error');
}
return false;
}
} else {
// 默认成功提示
if (showToast) {
showToast(successMessage, 'success');
}
return true;
}
};
const handleSave = async () => {
if (!jsonValue) return;
try {
setIsSaving(true);
const parsedConfig = JSON.parse(jsonValue);
const response = await api.updateConfig(parsedConfig);
const success = handleSaveResponse(
response,
t('app.config_saved_success'),
t('app.config_saved_failed')
);
if (success) {
onOpenChange(false);
}
} catch (error) {
console.error('Failed to save config:', error);
if (showToast) {
showToast(t('app.config_saved_failed') + ': ' + (error as Error).message, 'error');
}
} finally {
setIsSaving(false);
}
};
const handleSaveAndRestart = async () => {
if (!jsonValue) return;
try {
setIsSaving(true);
const parsedConfig = JSON.parse(jsonValue);
// Save config first
const saveResponse = await api.updateConfig(parsedConfig);
const saveSuccessful = handleSaveResponse(
saveResponse,
t('app.config_saved_success'),
t('app.config_saved_failed')
);
// Only restart if save was successful
if (saveSuccessful) {
// Restart service
const restartResponse = await api.restartService();
handleSaveResponse(
restartResponse,
t('app.config_saved_restart_success'),
t('app.config_saved_restart_failed')
);
onOpenChange(false);
}
} catch (error) {
console.error('Failed to save config and restart:', error);
if (showToast) {
showToast(t('app.config_saved_restart_failed') + ': ' + (error as Error).message, 'error');
}
} finally {
setIsSaving(false);
}
};
if (!isVisible && !open) {
return null;
}
return (
<>
{(isVisible || open) && (
<div
className={`fixed inset-0 z-50 transition-all duration-300 ease-out ${
isAnimating && open ? 'bg-black/50 opacity-100' : 'bg-black/0 opacity-0 pointer-events-none'
}`}
onClick={() => onOpenChange(false)}
/>
)}
<div
ref={containerRef}
className={`fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-white shadow-2xl transition-all duration-300 ease-out transform ${
isAnimating && open ? 'translate-y-0' : 'translate-y-full'
}`}
style={{
height: '100vh',
maxHeight: '100vh'
}}
>
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-semibold">{t('json_editor.title')}</h2>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
<X className="h-4 w-4 mr-2" />
{t('json_editor.cancel')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleSave}
disabled={isSaving}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? t('json_editor.saving') : t('json_editor.save')}
</Button>
<Button
variant="default"
size="sm"
onClick={handleSaveAndRestart}
disabled={isSaving}
>
<RefreshCw className="h-4 w-4 mr-2" />
{isSaving ? t('json_editor.saving') : t('json_editor.save_and_restart')}
</Button>
</div>
</div>
<div className="flex-1 min-h-0 bg-gray-50">
<Editor
height="100%"
defaultLanguage="json"
value={jsonValue}
onChange={(value) => setJsonValue(value || '')}
theme="vs"
options={{
minimap: { enabled: true },
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: 'on',
formatOnPaste: true,
formatOnType: true,
suggest: {
showKeywords: true,
showSnippets: true,
},
}}
/>
</div>
</div>
</>
);
}

129
ui/src/components/Login.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
export function Login() {
const { t } = useTranslation();
const navigate = useNavigate();
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Check if user is already authenticated
useEffect(() => {
const checkAuth = async () => {
const apiKey = localStorage.getItem('apiKey');
if (apiKey) {
setIsLoading(true);
// Verify the API key is still valid
try {
await api.getConfig();
navigate('/dashboard');
} catch (err) {
// If verification fails, remove the API key
localStorage.removeItem('apiKey');
} finally {
setIsLoading(false);
}
}
};
checkAuth();
// Listen for unauthorized events
const handleUnauthorized = () => {
navigate('/login');
};
window.addEventListener('unauthorized', handleUnauthorized);
return () => {
window.removeEventListener('unauthorized', handleUnauthorized);
};
}, [navigate]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Set the API key
api.setApiKey(apiKey);
// Dispatch storage event to notify other components of the change
window.dispatchEvent(new StorageEvent('storage', {
key: 'apiKey',
newValue: apiKey,
url: window.location.href
}));
// Test the API key by fetching config (skip if apiKey is empty)
if (apiKey) {
await api.getConfig();
}
// Navigate to dashboard
// The ConfigProvider will handle fetching the config
navigate('/dashboard');
} catch (err) {
// Clear the API key on failure
api.setApiKey('');
setError(t('login.invalidApiKey'));
}
};
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">{t('login.title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
<p className="text-center text-sm text-gray-500">{t('login.validating')}</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">{t('login.title')}</CardTitle>
<CardDescription>
{t('login.description')}
</CardDescription>
</CardHeader>
<form onSubmit={handleLogin}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">{t('login.apiKey')}</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('login.apiKeyPlaceholder')}
/>
</div>
{error && <div className="text-sm text-red-500">{error}</div>}
</CardContent>
<CardFooter>
<Button className="w-full" type="submit">
{t('login.signIn')}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { type Provider } from "./ConfigProvider";
interface ProviderListProps {
providers: Provider[];
onEdit: (index: number) => void;
onRemove: (index: number) => void;
}
export function ProviderList({ providers, onEdit, onRemove }: ProviderListProps) {
return (
<div className="space-y-3">
{providers.map((provider, index) => (
<div key={index} className="flex items-start justify-between rounded-md border bg-white p-4 transition-all hover:shadow-md animate-slide-in hover:scale-[1.01]">
<div className="flex-1 space-y-1.5">
<p className="text-md font-semibold text-gray-800">{provider.name}</p>
<p className="text-sm text-gray-500">{provider.api_base_url}</p>
<div className="flex flex-wrap gap-2 pt-2">
{provider.models.map((model) => (
<Badge key={model} variant="outline" className="font-normal transition-all-ease hover:scale-105">{model}</Badge>
))}
</div>
</div>
<div className="ml-4 flex flex-shrink-0 items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => onEdit(index)} className="transition-all-ease hover:scale-110">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="destructive" size="icon" onClick={() => onRemove(index)} className="transition-all duration-200 hover:scale-110">
<Trash2 className="h-4 w-4 text-current transition-colors duration-200" />
</Button>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,791 @@
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useConfig } from "./ConfigProvider";
import { ProviderList } from "./ProviderList";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { X, Trash2, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { ComboInput } from "@/components/ui/combo-input";
import { api } from "@/lib/api";
export function Providers() {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
const [editingProviderIndex, setEditingProviderIndex] = useState<number | null>(null);
const [deletingProviderIndex, setDeletingProviderIndex] = useState<number | null>(null);
const [hasFetchedModels, setHasFetchedModels] = useState<Record<number, boolean>>({});
const [providerParamInputs, setProviderParamInputs] = useState<Record<string, {name: string, value: string}>>({});
const [modelParamInputs, setModelParamInputs] = useState<Record<string, {name: string, value: string}>>({});
const [availableTransformers, setAvailableTransformers] = useState<{name: string; endpoint: string | null;}[]>([]);
const comboInputRef = useRef<HTMLInputElement>(null);
// Fetch available transformers when component mounts
useEffect(() => {
const fetchTransformers = async () => {
try {
const response = await api.get<{transformers: {name: string; endpoint: string | null;}[]}>('/transformers');
setAvailableTransformers(response.transformers);
} catch (error) {
console.error('Failed to fetch transformers:', error);
}
};
fetchTransformers();
}, []);
if (!config) {
return null;
}
const handleAddProvider = () => {
const newProviders = [...config.Providers, { name: "", api_base_url: "", api_key: "", models: [] }];
setConfig({ ...config, Providers: newProviders });
setEditingProviderIndex(newProviders.length - 1);
};
const handleSaveProvider = () => {
setEditingProviderIndex(null);
};
const handleCancelAddProvider = () => {
// If we're adding a new provider, remove it regardless of content
if (editingProviderIndex !== null && editingProviderIndex === config.Providers.length - 1) {
const newProviders = [...config.Providers];
newProviders.pop();
setConfig({ ...config, Providers: newProviders });
}
// Reset fetched models state for this provider
if (editingProviderIndex !== null) {
setHasFetchedModels(prev => {
const newState = { ...prev };
delete newState[editingProviderIndex];
return newState;
});
}
setEditingProviderIndex(null);
};
const handleRemoveProvider = (index: number) => {
const newProviders = [...config.Providers];
newProviders.splice(index, 1);
setConfig({ ...config, Providers: newProviders });
setDeletingProviderIndex(null);
};
const handleProviderChange = (index: number, field: string, value: string) => {
const newProviders = [...config.Providers];
newProviders[index][field] = value;
setConfig({ ...config, Providers: newProviders });
};
const handleProviderTransformerChange = (index: number, transformerPath: string) => {
if (!transformerPath) return; // Don't add empty transformers
const newProviders = [...config.Providers];
if (!newProviders[index].transformer) {
newProviders[index].transformer = { use: [] };
}
// Add transformer to the use array
newProviders[index].transformer!.use = [...newProviders[index].transformer!.use, transformerPath];
setConfig({ ...config, Providers: newProviders });
};
const removeProviderTransformerAtIndex = (index: number, transformerIndex: number) => {
const newProviders = [...config.Providers];
if (newProviders[index].transformer) {
const newUseArray = [...newProviders[index].transformer!.use];
newUseArray.splice(transformerIndex, 1);
newProviders[index].transformer!.use = newUseArray;
// If use array is now empty and no other properties, remove transformer entirely
if (newUseArray.length === 0 && Object.keys(newProviders[index].transformer!).length === 1) {
delete newProviders[index].transformer;
}
}
setConfig({ ...config, Providers: newProviders });
};
const handleModelTransformerChange = (providerIndex: number, model: string, transformerPath: string) => {
if (!transformerPath) return; // Don't add empty transformers
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer) {
newProviders[providerIndex].transformer = { use: [] };
}
// Initialize model transformer if it doesn't exist
if (!newProviders[providerIndex].transformer![model]) {
newProviders[providerIndex].transformer![model] = { use: [] };
}
// Add transformer to the use array
newProviders[providerIndex].transformer![model].use = [...newProviders[providerIndex].transformer![model].use, transformerPath];
setConfig({ ...config, Providers: newProviders });
};
const removeModelTransformerAtIndex = (providerIndex: number, model: string, transformerIndex: number) => {
const newProviders = [...config.Providers];
if (newProviders[providerIndex].transformer && newProviders[providerIndex].transformer![model]) {
const newUseArray = [...newProviders[providerIndex].transformer![model].use];
newUseArray.splice(transformerIndex, 1);
newProviders[providerIndex].transformer![model].use = newUseArray;
// If use array is now empty and no other properties, remove model transformer entirely
if (newUseArray.length === 0 && Object.keys(newProviders[providerIndex].transformer![model]).length === 1) {
delete newProviders[providerIndex].transformer![model];
}
}
setConfig({ ...config, Providers: newProviders });
};
const addProviderTransformerParameter = (providerIndex: number, transformerIndex: number, paramName: string, paramValue: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer) {
newProviders[providerIndex].transformer = { use: [] };
}
// Add parameter to the specified transformer in use array
if (newProviders[providerIndex].transformer!.use && newProviders[providerIndex].transformer!.use.length > transformerIndex) {
const targetTransformer = newProviders[providerIndex].transformer!.use[transformerIndex];
// If it's already an array with parameters, update it
if (Array.isArray(targetTransformer)) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (transformerArray.length > 1 && typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
// Update the existing parameters object
const existingParams = transformerArray[1] as Record<string, unknown>;
const paramsObj: Record<string, unknown> = { ...existingParams, [paramName]: paramValue };
transformerArray[1] = paramsObj;
} else if (transformerArray.length > 1) {
// If there are other elements, add the parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.splice(1, transformerArray.length - 1, paramsObj);
} else {
// Add a new parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.push(paramsObj);
}
newProviders[providerIndex].transformer!.use[transformerIndex] = transformerArray as any;
} else {
// Convert to array format with parameters
const paramsObj = { [paramName]: paramValue };
newProviders[providerIndex].transformer!.use[transformerIndex] = [targetTransformer as string, paramsObj] as any;
}
}
setConfig({ ...config, Providers: newProviders });
};
const removeProviderTransformerParameterAtIndex = (providerIndex: number, transformerIndex: number, paramName: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer?.use || newProviders[providerIndex].transformer.use.length <= transformerIndex) {
return;
}
const targetTransformer = newProviders[providerIndex].transformer.use[transformerIndex];
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
const paramsObj = { ...(transformerArray[1] as Record<string, unknown>) };
delete paramsObj[paramName];
// If the parameters object is now empty, remove it
if (Object.keys(paramsObj).length === 0) {
transformerArray.splice(1, 1);
} else {
transformerArray[1] = paramsObj;
}
newProviders[providerIndex].transformer!.use[transformerIndex] = transformerArray;
setConfig({ ...config, Providers: newProviders });
}
}
};
const addModelTransformerParameter = (providerIndex: number, model: string, transformerIndex: number, paramName: string, paramValue: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer) {
newProviders[providerIndex].transformer = { use: [] };
}
if (!newProviders[providerIndex].transformer![model]) {
newProviders[providerIndex].transformer![model] = { use: [] };
}
// Add parameter to the specified transformer in use array
if (newProviders[providerIndex].transformer![model].use && newProviders[providerIndex].transformer![model].use.length > transformerIndex) {
const targetTransformer = newProviders[providerIndex].transformer![model].use[transformerIndex];
// If it's already an array with parameters, update it
if (Array.isArray(targetTransformer)) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (transformerArray.length > 1 && typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
// Update the existing parameters object
const existingParams = transformerArray[1] as Record<string, unknown>;
const paramsObj: Record<string, unknown> = { ...existingParams, [paramName]: paramValue };
transformerArray[1] = paramsObj;
} else if (transformerArray.length > 1) {
// If there are other elements, add the parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.splice(1, transformerArray.length - 1, paramsObj);
} else {
// Add a new parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.push(paramsObj);
}
newProviders[providerIndex].transformer![model].use[transformerIndex] = transformerArray as any;
} else {
// Convert to array format with parameters
const paramsObj = { [paramName]: paramValue };
newProviders[providerIndex].transformer![model].use[transformerIndex] = [targetTransformer as string, paramsObj] as any;
}
}
setConfig({ ...config, Providers: newProviders });
};
const removeModelTransformerParameterAtIndex = (providerIndex: number, model: string, transformerIndex: number, paramName: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer?.[model]?.use || newProviders[providerIndex].transformer[model].use.length <= transformerIndex) {
return;
}
const targetTransformer = newProviders[providerIndex].transformer[model].use[transformerIndex];
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
const paramsObj = { ...(transformerArray[1] as Record<string, unknown>) };
delete paramsObj[paramName];
// If the parameters object is now empty, remove it
if (Object.keys(paramsObj).length === 0) {
transformerArray.splice(1, 1);
} else {
transformerArray[1] = paramsObj;
}
newProviders[providerIndex].transformer![model].use[transformerIndex] = transformerArray;
setConfig({ ...config, Providers: newProviders });
}
}
};
const handleAddModel = (index: number, model: string) => {
if (!model.trim()) return;
const newProviders = [...config.Providers];
const models = [...newProviders[index].models];
// Check if model already exists
if (!models.includes(model.trim())) {
models.push(model.trim());
newProviders[index].models = models;
setConfig({ ...config, Providers: newProviders });
}
};
const handleRemoveModel = (providerIndex: number, modelIndex: number) => {
const newProviders = [...config.Providers];
const models = [...newProviders[providerIndex].models];
models.splice(modelIndex, 1);
newProviders[providerIndex].models = models;
setConfig({ ...config, Providers: newProviders });
};
const editingProvider = editingProviderIndex !== null ? config.Providers[editingProviderIndex] : null;
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
<CardTitle className="text-lg">{t("providers.title")} <span className="text-sm font-normal text-gray-500">({config.Providers.length})</span></CardTitle>
<Button onClick={handleAddProvider}>{t("providers.add")}</Button>
</CardHeader>
<CardContent className="flex-grow overflow-y-auto p-4">
<ProviderList
providers={config.Providers}
onEdit={setEditingProviderIndex}
onRemove={setDeletingProviderIndex}
/>
</CardContent>
{/* Edit Dialog */}
<Dialog open={editingProviderIndex !== null} onOpenChange={(open) => {
if (!open) {
handleCancelAddProvider();
}
}}>
<DialogContent className="max-h-[80vh] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("providers.edit")}</DialogTitle>
</DialogHeader>
{editingProvider && editingProviderIndex !== null && (
<div className="space-y-4 p-4 overflow-y-auto flex-grow">
<div className="space-y-2">
<Label htmlFor="name">{t("providers.name")}</Label>
<Input id="name" value={editingProvider.name} onChange={(e) => handleProviderChange(editingProviderIndex, 'name', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="api_base_url">{t("providers.api_base_url")}</Label>
<Input id="api_base_url" value={editingProvider.api_base_url} onChange={(e) => handleProviderChange(editingProviderIndex, 'api_base_url', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="api_key">{t("providers.api_key")}</Label>
<Input id="api_key" type="password" value={editingProvider.api_key} onChange={(e) => handleProviderChange(editingProviderIndex, 'api_key', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="models">{t("providers.models")}</Label>
<div className="space-y-2">
<div className="flex gap-2">
<div className="flex-1">
{hasFetchedModels[editingProviderIndex] ? (
<ComboInput
ref={comboInputRef}
options={editingProvider.models.map(model => ({ label: model, value: model }))}
value=""
onChange={(_) => {
// 只更新输入值,不添加模型
}}
onEnter={(value) => {
if (editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, value);
}
}}
inputPlaceholder={t("providers.models_placeholder")}
/>
) : (
<Input
id="models"
placeholder={t("providers.models_placeholder")}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.currentTarget.value.trim() && editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, e.currentTarget.value);
e.currentTarget.value = '';
}
}}
/>
)}
</div>
<Button
onClick={() => {
if (hasFetchedModels[editingProviderIndex] && comboInputRef.current) {
// 使用ComboInput的逻辑
const comboInput = comboInputRef.current as any;
const currentValue = comboInput.getCurrentValue();
if (currentValue && currentValue.trim() && editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, currentValue.trim());
// 清空ComboInput
comboInput.clearInput();
}
} else {
// 使用普通Input的逻辑
const input = document.getElementById('models') as HTMLInputElement;
if (input && input.value.trim() && editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, input.value);
input.value = '';
}
}
}}
>
{t("providers.add_model")}
</Button>
{/* <Button
onClick={() => editingProvider && fetchAvailableModels(editingProvider)}
disabled={isFetchingModels}
variant="outline"
>
{isFetchingModels ? t("providers.fetching_models") : t("providers.fetch_available_models")}
</Button> */}
</div>
<div className="flex flex-wrap gap-2 pt-2">
{editingProvider.models.map((model, modelIndex) => (
<Badge key={modelIndex} variant="outline" className="font-normal flex items-center gap-1">
{model}
<button
type="button"
className="ml-1 rounded-full hover:bg-gray-200"
onClick={() => editingProviderIndex !== null && handleRemoveModel(editingProviderIndex, modelIndex)}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
</div>
{/* Provider Transformer Selection */}
<div className="space-y-2">
<Label>{t("providers.provider_transformer")}</Label>
{/* Add new transformer */}
<div className="flex gap-2">
<Combobox
options={availableTransformers.map(t => ({
label: t.name,
value: t.name
}))}
value=""
onChange={(value) => {
if (editingProviderIndex !== null) {
handleProviderTransformerChange(editingProviderIndex, value);
}
}}
placeholder={t("providers.select_transformer")}
emptyPlaceholder={t("providers.no_transformers")}
/>
</div>
{/* Display existing transformers */}
{editingProvider.transformer?.use && editingProvider.transformer.use.length > 0 && (
<div className="space-y-2 mt-2">
<div className="text-sm font-medium text-gray-700">{t("providers.selected_transformers")}</div>
{editingProvider.transformer.use.map((transformer: any, transformerIndex: number) => (
<div key={transformerIndex} className="border rounded-md p-3">
<div className="flex gap-2 items-center mb-2">
<div className="flex-1 bg-gray-50 rounded p-2 text-sm">
{typeof transformer === 'string' ? transformer : Array.isArray(transformer) ? String(transformer[0]) : String(transformer)}
</div>
<Button
variant="outline"
size="icon"
onClick={() => {
if (editingProviderIndex !== null) {
removeProviderTransformerAtIndex(editingProviderIndex, transformerIndex);
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Transformer-specific Parameters */}
<div className="mt-2 pl-4 border-l-2 border-gray-200">
<Label className="text-sm">{t("providers.transformer_parameters")}</Label>
<div className="space-y-2 mt-1">
<div className="flex gap-2">
<Input
placeholder={t("providers.parameter_name")}
value={providerParamInputs[`provider-${editingProviderIndex}-transformer-${transformerIndex}`]?.name || ""}
onChange={(e) => {
const key = `provider-${editingProviderIndex}-transformer-${transformerIndex}`;
setProviderParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
name: e.target.value
}
}));
}}
/>
<Input
placeholder={t("providers.parameter_value")}
value={providerParamInputs[`provider-${editingProviderIndex}-transformer-${transformerIndex}`]?.value || ""}
onChange={(e) => {
const key = `provider-${editingProviderIndex}-transformer-${transformerIndex}`;
setProviderParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
value: e.target.value
}
}));
}}
/>
<Button
size="sm"
onClick={() => {
if (editingProviderIndex !== null) {
const key = `provider-${editingProviderIndex}-transformer-${transformerIndex}`;
const paramInput = providerParamInputs[key];
if (paramInput && paramInput.name && paramInput.value) {
addProviderTransformerParameter(editingProviderIndex, transformerIndex, paramInput.name, paramInput.value);
setProviderParamInputs(prev => ({
...prev,
[key]: {name: "", value: ""}
}));
}
}
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Display existing parameters for this transformer */}
{(() => {
// Get parameters for this specific transformer
if (!editingProvider.transformer?.use || editingProvider.transformer.use.length <= transformerIndex) {
return null;
}
const targetTransformer = editingProvider.transformer.use[transformerIndex];
let params = {};
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
// Check if the second element is an object (parameters object)
if (typeof targetTransformer[1] === 'object' && targetTransformer[1] !== null) {
params = targetTransformer[1] as Record<string, unknown>;
}
}
return Object.keys(params).length > 0 ? (
<div className="space-y-1">
{Object.entries(params).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-gray-50 rounded p-2">
<div className="text-sm">
<span className="font-medium">{key}:</span> {String(value)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => {
if (editingProviderIndex !== null) {
// We need a function to remove parameters from a specific transformer
removeProviderTransformerParameterAtIndex(editingProviderIndex, transformerIndex, key);
}
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : null;
})()}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Model-specific Transformers */}
{editingProvider.models.length > 0 && (
<div className="space-y-2">
<Label>{t("providers.model_transformers")}</Label>
<div className="space-y-3">
{editingProvider.models.map((model, modelIndex) => (
<div key={modelIndex} className="border rounded-md p-3">
<div className="font-medium text-sm mb-2">{model}</div>
{/* Add new transformer */}
<div className="flex gap-2">
<div className="flex-1 flex gap-2">
<Combobox
options={availableTransformers.map(t => ({
label: t.name,
value: t.name
}))}
value=""
onChange={(value) => {
if (editingProviderIndex !== null) {
handleModelTransformerChange(editingProviderIndex, model, value);
}
}}
placeholder={t("providers.select_transformer")}
emptyPlaceholder={t("providers.no_transformers")}
/>
</div>
</div>
{/* Display existing transformers */}
{editingProvider.transformer?.[model]?.use && editingProvider.transformer[model].use.length > 0 && (
<div className="space-y-2 mt-2">
<div className="text-sm font-medium text-gray-700">{t("providers.selected_transformers")}</div>
{editingProvider.transformer[model].use.map((transformer: any, transformerIndex: number) => (
<div key={transformerIndex} className="border rounded-md p-3">
<div className="flex gap-2 items-center mb-2">
<div className="flex-1 bg-gray-50 rounded p-2 text-sm">
{typeof transformer === 'string' ? transformer : Array.isArray(transformer) ? String(transformer[0]) : String(transformer)}
</div>
<Button
variant="outline"
size="icon"
onClick={() => {
if (editingProviderIndex !== null) {
removeModelTransformerAtIndex(editingProviderIndex, model, transformerIndex);
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Transformer-specific Parameters */}
<div className="mt-2 pl-4 border-l-2 border-gray-200">
<Label className="text-sm">{t("providers.transformer_parameters")}</Label>
<div className="space-y-2 mt-1">
<div className="flex gap-2">
<Input
placeholder={t("providers.parameter_name")}
value={modelParamInputs[`model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`]?.name || ""}
onChange={(e) => {
const key = `model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`;
setModelParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
name: e.target.value
}
}));
}}
/>
<Input
placeholder={t("providers.parameter_value")}
value={modelParamInputs[`model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`]?.value || ""}
onChange={(e) => {
const key = `model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`;
setModelParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
value: e.target.value
}
}));
}}
/>
<Button
size="sm"
onClick={() => {
if (editingProviderIndex !== null) {
const key = `model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`;
const paramInput = modelParamInputs[key];
if (paramInput && paramInput.name && paramInput.value) {
addModelTransformerParameter(editingProviderIndex, model, transformerIndex, paramInput.name, paramInput.value);
setModelParamInputs(prev => ({
...prev,
[key]: {name: "", value: ""}
}));
}
}
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Display existing parameters for this transformer */}
{(() => {
// Get parameters for this specific transformer
if (!editingProvider.transformer?.[model]?.use || editingProvider.transformer[model].use.length <= transformerIndex) {
return null;
}
const targetTransformer = editingProvider.transformer[model].use[transformerIndex];
let params = {};
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
// Check if the second element is an object (parameters object)
if (typeof targetTransformer[1] === 'object' && targetTransformer[1] !== null) {
params = targetTransformer[1] as Record<string, unknown>;
}
}
return Object.keys(params).length > 0 ? (
<div className="space-y-1">
{Object.entries(params).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-gray-50 rounded p-2">
<div className="text-sm">
<span className="font-medium">{key}:</span> {String(value)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => {
if (editingProviderIndex !== null) {
// We need a function to remove parameters from a specific transformer
removeModelTransformerParameterAtIndex(editingProviderIndex, model, transformerIndex, key);
}
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : null;
})()}
</div>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
<div className="space-y-3 mt-auto">
<div className="flex justify-end gap-2">
{/* <Button
variant="outline"
onClick={() => editingProvider && testConnectivity(editingProvider)}
disabled={isTestingConnectivity || !editingProvider}
>
<Wifi className="mr-2 h-4 w-4" />
{isTestingConnectivity ? t("providers.testing") : t("providers.test_connectivity")}
</Button> */}
<Button onClick={handleSaveProvider}>{t("app.save")}</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deletingProviderIndex !== null} onOpenChange={() => setDeletingProviderIndex(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("providers.delete")}</DialogTitle>
<DialogDescription>
{t("providers.delete_provider_confirm")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingProviderIndex(null)}>{t("providers.cancel")}</Button>
<Button variant="destructive" onClick={() => deletingProviderIndex !== null && handleRemoveProvider(deletingProviderIndex)}>{t("providers.delete")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,91 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { useConfig } from "./ConfigProvider";
import { Combobox } from "./ui/combobox";
export function Router() {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
if (!config) {
return null;
}
const handleRouterChange = (field: string, value: string) => {
const newRouter = { ...config.Router, [field]: value };
setConfig({ ...config, Router: newRouter });
};
const modelOptions = config.Providers.flatMap((provider) =>
provider.models.map((model) => ({
value: `${provider.name},${model}`,
label: `${provider.name}, ${model}`,
}))
);
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="border-b p-4">
<CardTitle className="text-lg">{t("router.title")}</CardTitle>
</CardHeader>
<CardContent className="flex-grow space-y-5 overflow-y-auto p-4">
<div className="space-y-2">
<Label>{t("router.default")}</Label>
<Combobox
options={modelOptions}
value={config.Router.default}
onChange={(value) => handleRouterChange("default", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.background")}</Label>
<Combobox
options={modelOptions}
value={config.Router.background}
onChange={(value) => handleRouterChange("background", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.think")}</Label>
<Combobox
options={modelOptions}
value={config.Router.think}
onChange={(value) => handleRouterChange("think", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.longContext")}</Label>
<Combobox
options={modelOptions}
value={config.Router.longContext}
onChange={(value) => handleRouterChange("longContext", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.webSearch")}</Label>
<Combobox
options={modelOptions}
value={config.Router.webSearch}
onChange={(value) => handleRouterChange("webSearch", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,71 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useConfig } from "./ConfigProvider";
interface SettingsDialogProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
if (!config) {
return null;
}
const handleLogChange = (checked: boolean) => {
setConfig({ ...config, LOG: checked });
};
const handlePathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setConfig({ ...config, CLAUDE_PATH: e.target.value });
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("toplevel.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center space-x-2">
<Switch id="log" checked={config.LOG} onCheckedChange={handleLogChange} />
<Label htmlFor="log" className="transition-all-ease hover:scale-[1.02] cursor-pointer">{t("toplevel.log")}</Label>
</div>
<div className="space-y-2">
<Label htmlFor="claude-path" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.claude_path")}</Label>
<Input id="claude-path" value={config.CLAUDE_PATH} onChange={handlePathChange} className="transition-all-ease focus:scale-[1.01]" />
</div>
<div className="space-y-2">
<Label htmlFor="host" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.host")}</Label>
<Input id="host" value={config.HOST} onChange={(e) => setConfig({ ...config, HOST: e.target.value })} className="transition-all-ease focus:scale-[1.01]" />
</div>
<div className="space-y-2">
<Label htmlFor="port" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.port")}</Label>
<Input id="port" type="number" value={config.PORT} onChange={(e) => setConfig({ ...config, PORT: parseInt(e.target.value, 10) })} className="transition-all-ease focus:scale-[1.01]" />
</div>
<div className="space-y-2">
<Label htmlFor="apikey" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.apikey")}</Label>
<Input id="apikey" type="password" value={config.APIKEY} onChange={(e) => setConfig({ ...config, APIKEY: e.target.value })} className="transition-all-ease focus:scale-[1.01]" />
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)} className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]">{t("app.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,32 @@
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { type Transformer } from "./ConfigProvider";
interface TransformerListProps {
transformers: Transformer[];
onEdit: (index: number) => void;
onRemove: (index: number) => void;
}
export function TransformerList({ transformers, onEdit, onRemove }: TransformerListProps) {
return (
<div className="space-y-3">
{transformers.map((transformer, index) => (
<div key={index} className="flex items-start justify-between rounded-md border bg-white p-4 transition-all hover:shadow-md animate-slide-in hover:scale-[1.01]">
<div className="flex-1 space-y-1.5">
<p className="text-md font-semibold text-gray-800">{transformer.path}</p>
<p className="text-sm text-gray-500">{transformer.options.project}</p>
</div>
<div className="ml-4 flex flex-shrink-0 items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => onEdit(index)} className="transition-all-ease hover:scale-110">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="destructive" size="icon" onClick={() => onRemove(index)} className="transition-all duration-200 hover:scale-110">
<Trash2 className="h-4 w-4 text-current transition-colors duration-200" />
</Button>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,220 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import { useConfig } from "./ConfigProvider";
import { TransformerList } from "./TransformerList";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function Transformers() {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
const [editingTransformerIndex, setEditingTransformerIndex] = useState<number | null>(null);
const [deletingTransformerIndex, setDeletingTransformerIndex] = useState<number | null>(null);
const [newTransformer, setNewTransformer] = useState<{ path: string; options: { [key: string]: string } } | null>(null);
if (!config) {
return null;
}
const handleAddTransformer = () => {
const newTransformer = { path: "", options: {} };
setNewTransformer(newTransformer);
setEditingTransformerIndex(config.transformers.length); // Use the length as index for the new item
};
const handleRemoveTransformer = (index: number) => {
const newTransformers = [...config.transformers];
newTransformers.splice(index, 1);
setConfig({ ...config, transformers: newTransformers });
setDeletingTransformerIndex(null);
};
const handleTransformerChange = (index: number, field: string, value: string, optionKey?: string) => {
if (index < config.transformers.length) {
// Editing an existing transformer
const newTransformers = [...config.transformers];
if (optionKey !== undefined) {
newTransformers[index].options[optionKey] = value;
} else {
(newTransformers[index] as Record<string, unknown>)[field] = value;
}
setConfig({ ...config, transformers: newTransformers });
} else {
// Editing the new transformer
if (newTransformer) {
const updatedTransformer = { ...newTransformer };
if (optionKey !== undefined) {
updatedTransformer.options[optionKey] = value;
} else {
(updatedTransformer as Record<string, unknown>)[field] = value;
}
setNewTransformer(updatedTransformer);
}
}
};
const editingTransformer = editingTransformerIndex !== null ?
(editingTransformerIndex < config.transformers.length ?
config.transformers[editingTransformerIndex] :
newTransformer) :
null;
const handleSaveTransformer = () => {
if (newTransformer && editingTransformerIndex === config.transformers.length) {
// Saving a new transformer
const newTransformers = [...config.transformers, newTransformer];
setConfig({ ...config, transformers: newTransformers });
}
// Close the dialog
setEditingTransformerIndex(null);
setNewTransformer(null);
};
const handleCancelTransformer = () => {
// Close the dialog without saving
setEditingTransformerIndex(null);
setNewTransformer(null);
};
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
<CardTitle className="text-lg">{t("transformers.title")} <span className="text-sm font-normal text-gray-500">({config.transformers.length})</span></CardTitle>
<Button onClick={handleAddTransformer}>{t("transformers.add")}</Button>
</CardHeader>
<CardContent className="flex-grow overflow-y-auto p-4">
<TransformerList
transformers={config.transformers}
onEdit={setEditingTransformerIndex}
onRemove={setDeletingTransformerIndex}
/>
</CardContent>
{/* Edit Dialog */}
<Dialog open={editingTransformerIndex !== null} onOpenChange={handleCancelTransformer}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("transformers.edit")}</DialogTitle>
</DialogHeader>
{editingTransformer && editingTransformerIndex !== null && (
<div className="space-y-4 py-4 px-6 max-h-96 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="transformer-path">{t("transformers.path")}</Label>
<Input
id="transformer-path"
value={editingTransformer.path}
onChange={(e) => handleTransformerChange(editingTransformerIndex, "path", e.target.value)}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("transformers.parameters")}</Label>
<Button
variant="outline"
size="sm"
onClick={() => {
const newKey = `param${Object.keys(editingTransformer.options).length + 1}`;
if (editingTransformerIndex !== null) {
const newOptions = { ...editingTransformer.options, [newKey]: "" };
if (editingTransformerIndex < config.transformers.length) {
const newTransformers = [...config.transformers];
newTransformers[editingTransformerIndex].options = newOptions;
setConfig({ ...config, transformers: newTransformers });
} else if (newTransformer) {
setNewTransformer({ ...newTransformer, options: newOptions });
}
}
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{Object.entries(editingTransformer.options).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<Input
value={key}
onChange={(e) => {
const newOptions = { ...editingTransformer.options };
delete newOptions[key];
newOptions[e.target.value] = value;
if (editingTransformerIndex !== null) {
if (editingTransformerIndex < config.transformers.length) {
const newTransformers = [...config.transformers];
newTransformers[editingTransformerIndex].options = newOptions;
setConfig({ ...config, transformers: newTransformers });
} else if (newTransformer) {
setNewTransformer({ ...newTransformer, options: newOptions });
}
}
}}
className="flex-1"
/>
<Input
value={value}
onChange={(e) => {
if (editingTransformerIndex !== null) {
handleTransformerChange(editingTransformerIndex, "options", e.target.value, key);
}
}}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={() => {
if (editingTransformerIndex !== null) {
const newOptions = { ...editingTransformer.options };
delete newOptions[key];
if (editingTransformerIndex < config.transformers.length) {
const newTransformers = [...config.transformers];
newTransformers[editingTransformerIndex].options = newOptions;
setConfig({ ...config, transformers: newTransformers });
} else if (newTransformer) {
setNewTransformer({ ...newTransformer, options: newOptions });
}
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleCancelTransformer}>{t("app.cancel")}</Button>
<Button onClick={handleSaveTransformer}>{t("app.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deletingTransformerIndex !== null} onOpenChange={() => setDeletingTransformerIndex(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("transformers.delete")}</DialogTitle>
<DialogDescription>
{t("transformers.delete_transformer_confirm")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingTransformerIndex(null)}>{t("app.cancel")}</Button>
<Button variant="destructive" onClick={() => deletingTransformerIndex !== null && handleRemoveTransformer(deletingTransformerIndex)}>{t("app.delete")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
// eslint-disable-next-line react-refresh/only-export-components
export { Badge, badgeVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"border border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface ComboInputProps {
options: { label: string; value: string }[];
value?: string;
onChange: (value: string) => void;
onEnter?: (value: string) => void;
searchPlaceholder?: string;
emptyPlaceholder?: string;
inputPlaceholder?: string;
}
export const ComboInput = React.forwardRef<HTMLInputElement, ComboInputProps>(({
options,
value,
onChange,
onEnter,
searchPlaceholder = "Search...",
emptyPlaceholder = "No options found.",
inputPlaceholder = "Type or select...",
}, ref) => {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState(value || "")
const internalInputRef = React.useRef<HTMLInputElement>(null)
// Forward ref to the internal input
React.useImperativeHandle(ref, () => internalInputRef.current as HTMLInputElement)
React.useEffect(() => {
setInputValue(value || "")
}, [value])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setInputValue(newValue)
onChange(newValue)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputValue.trim() && onEnter) {
onEnter(inputValue.trim())
setInputValue("")
}
}
const handleSelect = (selectedValue: string) => {
setInputValue(selectedValue)
onChange(selectedValue)
if (onEnter) {
onEnter(selectedValue)
setInputValue("")
}
setOpen(false)
}
// Function to get current value for external access
const getCurrentValue = () => inputValue
// Expose methods through the ref
React.useImperativeHandle(ref, () => ({
...internalInputRef.current!,
value: inputValue,
getCurrentValue,
clearInput: () => {
setInputValue("")
onChange("")
}
}))
return (
<div className="relative">
<Input
ref={internalInputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={inputPlaceholder}
className="pr-10"
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
>
<ChevronsUpDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 animate-fade-in">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
className="transition-all-ease hover:bg-accent hover:text-accent-foreground"
>
<Check
className={cn(
"mr-2 h-4 w-4 transition-opacity",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
})

View File

@@ -0,0 +1,87 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface ComboboxProps {
options: { label: string; value: string }[];
value?: string;
onChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyPlaceholder?: string;
}
export function Combobox({
options,
value,
onChange,
placeholder = "Select an option...",
searchPlaceholder = "Search...",
emptyPlaceholder = "No options found.",
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const selectedOption = options.find((option) => option.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
>
{selectedOption ? selectedOption.label : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 animate-fade-in">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue)
setOpen(false)
}}
className="transition-all-ease hover:bg-accent hover:text-accent-foreground"
>
<Check
className={cn(
"mr-2 h-4 w-4 transition-opacity",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,181 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,125 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Overlay>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
)>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Content>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
)>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg animate-scale-in",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground transition-all-ease hover:scale-110">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Title>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
)>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Description>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
)>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,114 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Badge } from "@/components/ui/badge"
interface MultiComboboxProps {
options: { label: string; value: string }[];
value?: string[];
onChange: (value: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyPlaceholder?: string;
}
export function MultiCombobox({
options,
value = [],
onChange,
placeholder = "Select options...",
searchPlaceholder = "Search...",
emptyPlaceholder = "No options found.",
}: MultiComboboxProps) {
const [open, setOpen] = React.useState(false)
const handleSelect = (currentValue: string) => {
if (value.includes(currentValue)) {
onChange(value.filter(v => v !== currentValue))
} else {
onChange([...value, currentValue])
}
}
const removeValue = (val: string, e: React.MouseEvent) => {
e.stopPropagation()
onChange(value.filter(v => v !== val))
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
{value.map((val) => {
const option = options.find(opt => opt.value === val)
return (
<Badge key={val} variant="outline" className="font-normal">
{option?.label || val}
<button
onClick={(e) => removeValue(val, e)}
className="ml-1 rounded-full hover:bg-gray-200"
>
<X className="h-3 w-3" />
</button>
</Badge>
)
})}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
>
{value.length > 0 ? `${value.length} selected` : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 animate-fade-in">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
className="transition-all-ease hover:bg-accent hover:text-accent-foreground"
>
<Check
className={cn(
"mr-2 h-4 w-4 transition-opacity",
value.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden animate-fade-in",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-all-ease focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 transition-all-ease"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react';
interface ToastProps {
message: string;
type: 'success' | 'error' | 'warning';
onClose: () => void;
}
export function Toast({ message, type, onClose }: ToastProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose();
}, 3000);
return () => clearTimeout(timer);
}, [onClose]);
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <XCircle className="h-5 w-5 text-red-500" />;
case 'warning':
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
default:
return null;
}
};
const getBackgroundColor = () => {
switch (type) {
case 'success':
return 'bg-green-100 border-green-200';
case 'error':
return 'bg-red-100 border-red-200';
case 'warning':
return 'bg-yellow-100 border-yellow-200';
default:
return 'bg-gray-100 border-gray-200';
}
};
return (
<div className={`fixed top-4 right-4 z-50 flex items-center justify-between p-4 rounded-lg border shadow-lg ${getBackgroundColor()} transition-all duration-300 ease-in-out`}>
<div className="flex items-center space-x-2">
{getIcon()}
<span className="text-sm font-medium">{message}</span>
</div>
<button
onClick={onClose}
className="ml-4 text-gray-500 hover:text-gray-700 focus:outline-none"
>
<X className="h-4 w-4" />
</button>
</div>
);
}

28
ui/src/i18n.ts Normal file
View File

@@ -0,0 +1,28 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import en from "./locales/en.json";
import zh from "./locales/zh.json";
const resources = {
en: {
translation: en,
},
zh: {
translation: zh,
},
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
export default i18n;

122
ui/src/index.css Normal file
View File

@@ -0,0 +1,122 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

189
ui/src/lib/api.ts Normal file
View File

@@ -0,0 +1,189 @@
import type { Config, Provider, Transformer } from '@/components/ConfigProvider';
// API Client Class for handling requests with baseUrl and apikey authentication
class ApiClient {
private baseUrl: string;
private apiKey: string;
constructor(baseUrl: string = 'http://127.0.0.1:3456/api', apiKey: string = '') {
this.baseUrl = baseUrl;
// Load API key from localStorage if available
this.apiKey = apiKey || localStorage.getItem('apiKey') || '';
}
// Update base URL
setBaseUrl(url: string) {
this.baseUrl = url;
}
// Update API key
setApiKey(apiKey: string) {
this.apiKey = apiKey;
// Save API key to localStorage
if (apiKey) {
localStorage.setItem('apiKey', apiKey);
} else {
localStorage.removeItem('apiKey');
}
}
// Create headers with API key authentication
private createHeaders(contentType: string = 'application/json'): HeadersInit {
const headers: Record<string, string> = {
'X-API-Key': this.apiKey,
'Accept': 'application/json',
};
if (contentType) {
headers['Content-Type'] = contentType;
}
return headers;
}
// Generic fetch wrapper with base URL and authentication
private async apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const config: RequestInit = {
...options,
headers: {
...this.createHeaders(),
...options.headers,
},
};
try {
const response = await fetch(url, config);
// Handle 401 Unauthorized responses
if (response.status === 401) {
// Remove API key when it's invalid
localStorage.removeItem('apiKey');
// Redirect to login page if not already there
// For memory router, we need to use the router instance
// We'll dispatch a custom event that the app can listen to
window.dispatchEvent(new CustomEvent('unauthorized'));
// Return a promise that never resolves to prevent further execution
return new Promise(() => {}) as Promise<T>;
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
if (response.status === 204) {
return {} as T;
}
const text = await response.text();
return text ? JSON.parse(text) : ({} as T);
} catch (error) {
console.error('API request error:', error);
throw error;
}
}
// GET request
async get<T>(endpoint: string): Promise<T> {
return this.apiFetch<T>(endpoint, {
method: 'GET',
});
}
// POST request
async post<T>(endpoint: string, data: unknown): Promise<T> {
return this.apiFetch<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
// PUT request
async put<T>(endpoint: string, data: unknown): Promise<T> {
return this.apiFetch<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// DELETE request
async delete<T>(endpoint: string): Promise<T> {
return this.apiFetch<T>(endpoint, {
method: 'DELETE',
});
}
// API methods for configuration
// Get current configuration
async getConfig(): Promise<Config> {
return this.get<Config>('/config');
}
// Update entire configuration
async updateConfig(config: Config): Promise<Config> {
return this.post<Config>('/config', config);
}
// Get providers
async getProviders(): Promise<Provider[]> {
return this.get<Provider[]>('/api/providers');
}
// Add a new provider
async addProvider(provider: Provider): Promise<Provider> {
return this.post<Provider>('/api/providers', provider);
}
// Update a provider
async updateProvider(index: number, provider: Provider): Promise<Provider> {
return this.post<Provider>(`/api/providers/${index}`, provider);
}
// Delete a provider
async deleteProvider(index: number): Promise<void> {
return this.delete<void>(`/api/providers/${index}`);
}
// Get transformers
async getTransformers(): Promise<Transformer[]> {
return this.get<Transformer[]>('/api/transformers');
}
// Add a new transformer
async addTransformer(transformer: Transformer): Promise<Transformer> {
return this.post<Transformer>('/api/transformers', transformer);
}
// Update a transformer
async updateTransformer(index: number, transformer: Transformer): Promise<Transformer> {
return this.post<Transformer>(`/api/transformers/${index}`, transformer);
}
// Delete a transformer
async deleteTransformer(index: number): Promise<void> {
return this.delete<void>(`/api/transformers/${index}`);
}
// Get configuration (new endpoint)
async getConfigNew(): Promise<Config> {
return this.get<Config>('/config');
}
// Save configuration (new endpoint)
async saveConfig(config: Config): Promise<unknown> {
return this.post<Config>('/config', config);
}
// Restart service
async restartService(): Promise<unknown> {
return this.post<void>('/restart', {});
}
}
// Create a default instance of the API client
export const api = new ApiClient();
// Export the class for creating custom instances
export default ApiClient;

6
ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

99
ui/src/locales/en.json Normal file
View File

@@ -0,0 +1,99 @@
{
"app": {
"title": "Claude Code Router",
"save": "Save",
"save_and_restart": "Save and Restart",
"cancel": "Cancel",
"edit": "Edit",
"remove": "Remove",
"delete": "Delete",
"settings": "Settings",
"selectFile": "Select File",
"config_saved_success": "Config saved successfully",
"config_saved_failed": "Failed to save config",
"config_saved_restart_success": "Config saved and service restarted successfully",
"config_saved_restart_failed": "Failed to save config and restart service"
},
"login": {
"title": "Sign in to your account",
"description": "Enter your API key to access the configuration panel",
"apiKey": "API Key",
"apiKeyPlaceholder": "Enter your API key",
"signIn": "Sign In",
"invalidApiKey": "Invalid API key",
"configError": "Configuration not loaded",
"validating": "Validating API key..."
},
"toplevel": {
"title": "General Settings",
"log": "Enable Logging",
"claude_path": "Claude Path",
"host": "Host",
"port": "Port",
"apikey": "API Key"
},
"transformers": {
"title": "Custom Transformers",
"path": "Path",
"project": "Project",
"remove": "Remove",
"add": "Add Custom Transformer",
"edit": "Edit Custom Transformer",
"delete": "Delete Custom Transformer",
"delete_transformer_confirm": "Are you sure you want to delete this custom transformer?",
"parameters": "Parameters"
},
"providers": {
"title": "Providers",
"name": "Name",
"api_base_url": "API Base URL",
"api_key": "API Key",
"models": "Models",
"models_placeholder": "Enter model name and press Enter to add",
"add_model": "Add Model",
"select_models": "Select Models",
"remove": "Remove",
"add": "Add Provider",
"edit": "Edit Provider",
"delete": "Delete",
"cancel": "Cancel",
"delete_provider_confirm": "Are you sure you want to delete this provider?",
"test_connectivity": "Test Connectivity",
"testing": "Testing...",
"connection_successful": "Connection successful!",
"connection_failed": "Connection failed!",
"missing_credentials": "Missing API base URL or API key",
"fetch_available_models": "Fetch available models",
"fetching_models": "Fetching models...",
"fetch_models_failed": "Failed to fetch models",
"transformers": "Transformers",
"select_transformer": "Select Transformer",
"no_transformers": "No transformers available",
"provider_transformer": "Provider Transformer",
"model_transformers": "Model Transformers",
"transformer_parameters": "Transformer Parameters",
"add_parameter": "Add Parameter",
"parameter_name": "Parameter Name",
"parameter_value": "Parameter Value",
"selected_transformers": "Selected Transformers"
},
"router": {
"title": "Router",
"default": "Default",
"background": "Background",
"think": "Think",
"longContext": "Long Context",
"webSearch": "Web Search",
"selectModel": "Select a model...",
"searchModel": "Search model...",
"noModelFound": "No model found."
},
"json_editor": {
"title": "JSON Editor",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"save_failed": "Failed to save config",
"save_and_restart": "Save & Restart"
}
}

99
ui/src/locales/zh.json Normal file
View File

@@ -0,0 +1,99 @@
{
"app": {
"title": "Claude Code Router",
"save": "保存",
"save_and_restart": "保存并重启",
"cancel": "取消",
"edit": "编辑",
"remove": "移除",
"delete": "删除",
"settings": "设置",
"selectFile": "选择文件",
"config_saved_success": "配置保存成功",
"config_saved_failed": "配置保存失败",
"config_saved_restart_success": "配置保存并服务重启成功",
"config_saved_restart_failed": "配置保存并服务重启失败"
},
"login": {
"title": "登录到您的账户",
"description": "请输入您的API密钥以访问配置面板",
"apiKey": "API密钥",
"apiKeyPlaceholder": "请输入您的API密钥",
"signIn": "登录",
"invalidApiKey": "API密钥无效",
"configError": "配置未加载",
"validating": "正在验证API密钥..."
},
"toplevel": {
"title": "通用设置",
"log": "启用日志",
"claude_path": "Claude 路径",
"host": "主机",
"port": "端口",
"apikey": "API 密钥"
},
"transformers": {
"title": "自定义转换器",
"path": "路径",
"project": "项目",
"remove": "移除",
"add": "添加自定义转换器",
"edit": "编辑自定义转换器",
"delete": "删除自定义转换器",
"delete_transformer_confirm": "您确定要删除此自定义转换器吗?",
"parameters": "参数"
},
"providers": {
"title": "供应商",
"name": "名称",
"api_base_url": "API 基础地址",
"api_key": "API 密钥",
"models": "模型",
"models_placeholder": "输入模型名称并按回车键添加",
"add_model": "添加模型",
"select_models": "选择模型",
"remove": "移除",
"add": "添加供应商",
"edit": "编辑供应商",
"delete": "删除",
"cancel": "取消",
"delete_provider_confirm": "您确定要删除此供应商吗?",
"test_connectivity": "测试连通性",
"testing": "测试中...",
"connection_successful": "连接成功!",
"connection_failed": "连接失败!",
"missing_credentials": "缺少 API 基础地址或 API 密钥",
"fetch_available_models": "获取可用模型",
"fetching_models": "获取模型中...",
"fetch_models_failed": "获取模型失败",
"transformers": "转换器",
"select_transformer": "选择转换器",
"no_transformers": "无可用转换器",
"provider_transformer": "供应商转换器",
"model_transformers": "模型转换器",
"transformer_parameters": "转换器参数",
"add_parameter": "添加参数",
"parameter_name": "参数名称",
"parameter_value": "参数值",
"selected_transformers": "已选转换器"
},
"router": {
"title": "路由",
"default": "默认",
"background": "后台",
"think": "思考",
"longContext": "长上下文",
"webSearch": "网络搜索",
"selectModel": "选择一个模型...",
"searchModel": "搜索模型...",
"noModelFound": "未找到模型."
},
"json_editor": {
"title": "JSON 编辑器",
"save": "保存",
"saving": "保存中...",
"cancel": "取消",
"save_failed": "配置保存失败",
"save_and_restart": "保存并重启"
}
}

15
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import './i18n';
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';
import { ConfigProvider } from '@/components/ConfigProvider';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ConfigProvider>
<RouterProvider router={router} />
</ConfigProvider>
</StrictMode>,
)

32
ui/src/routes.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { createMemoryRouter, Navigate } from 'react-router-dom';
import App from './App';
import { Login } from '@/components/Login';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
// For this application, we allow access without an API key
// The App component will handle loading and error states
return children;
};
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
// Always show login page
// The login page will handle empty API keys appropriately
return children;
};
export const router = createMemoryRouter([
{
path: '/',
element: <Navigate to="/dashboard" replace />,
},
{
path: '/login',
element: <PublicRoute><Login /></PublicRoute>,
},
{
path: '/dashboard',
element: <ProtectedRoute><App /></ProtectedRoute>,
},
], {
initialEntries: ['/dashboard']
});

View File

@@ -0,0 +1,48 @@
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out forwards;
}
.animate-scale-in {
animation: scaleIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.animate-slide-in {
animation: slideIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.transition-all-ease {
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}

1
ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

26
ui/tsconfig.app.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}

26
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}

1
ui/tsconfig.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/jsoneditor.tsx","./src/components/login.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/utils.ts"],"version":"5.8.3"}

16
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import path from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { viteSingleFile } from "vite-plugin-singlefile"
import tailwindcss from "@tailwindcss/vite"
export default defineConfig({
base: './',
plugins: [react(), tailwindcss(), viteSingleFile()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})