Compare commits
178 Commits
feature/re
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cd21c570f | ||
|
|
c5e97709a5 | ||
|
|
f7adb7b28e | ||
|
|
7964fff175 | ||
|
|
fe06b57032 | ||
|
|
1b3a8f8803 | ||
|
|
cec8421dd9 | ||
|
|
1a7e90df39 | ||
|
|
e5741ae470 | ||
|
|
0152af5db9 | ||
|
|
e6b3e2a194 | ||
|
|
f7058dcdb5 | ||
|
|
e670302e9e | ||
|
|
5761e165fd | ||
|
|
8c4fec4f5f | ||
|
|
5d53571fe6 | ||
|
|
35fc4505b2 | ||
|
|
c7303775ad | ||
|
|
f7981b16cd | ||
|
|
b54687c4d5 | ||
|
|
0be4c3753f | ||
|
|
668e855a2d | ||
|
|
41108cea1d | ||
|
|
19522f496b | ||
|
|
3b9e58a823 | ||
|
|
615fe7629e | ||
|
|
656a5f9a97 | ||
|
|
d2a0815cb7 | ||
|
|
7cc41d83cf | ||
|
|
9a5ea191f8 | ||
|
|
6ab608943e | ||
|
|
50c8f6994f | ||
|
|
915495553a | ||
|
|
5ac4e8955d | ||
|
|
6b7d0926c4 | ||
|
|
01cd5d03a3 | ||
|
|
0c14a5c053 | ||
|
|
b72b05eb5c | ||
|
|
21ab7c61ce | ||
|
|
9f82aa2797 | ||
|
|
ac0263b226 | ||
|
|
6a4c1f7591 | ||
|
|
95b2dadd40 | ||
|
|
d6b11e1b60 | ||
|
|
d2969e4332 | ||
|
|
19d0f3b8f5 | ||
|
|
e078127ac6 | ||
|
|
0e509528c2 | ||
|
|
a265cbdce6 | ||
|
|
b8f52ba538 | ||
|
|
a62a025368 | ||
|
|
9d332aa036 | ||
|
|
5d00f519cd | ||
|
|
4fca983e4f | ||
|
|
bc08c4ab48 | ||
|
|
bdf608fffc | ||
|
|
d9b7667c93 | ||
|
|
c3ab30b0b9 | ||
|
|
cce1625534 | ||
|
|
5c1a193f4d | ||
|
|
3ad140d2f5 | ||
|
|
075ec76ec1 | ||
|
|
709b49b0e8 | ||
|
|
b856e1e11b | ||
|
|
6510d3aac9 | ||
|
|
1708c59434 | ||
|
|
9cd5587f52 | ||
|
|
4334f40926 | ||
|
|
38bc747261 | ||
|
|
adfae3263a | ||
|
|
0794151eb5 | ||
|
|
3e1963564a | ||
|
|
023c045821 | ||
|
|
f9b745b621 | ||
|
|
a958f18305 | ||
|
|
89844bcb62 | ||
|
|
37cb0c776f | ||
|
|
d9e8df5c04 | ||
|
|
91e9d43abd | ||
|
|
216ee939fb | ||
|
|
47051bf11b | ||
|
|
355c83a8c1 | ||
|
|
552621f707 | ||
|
|
75ab74957d | ||
|
|
d684319261 | ||
|
|
7bb816ad03 | ||
|
|
38c6cf0c9a | ||
|
|
e51d70caf2 | ||
|
|
5fd78a103b | ||
|
|
a3b2353bca | ||
|
|
99afe0e21a | ||
|
|
7751683365 | ||
|
|
c2edcd145e | ||
|
|
996a05d1d6 | ||
|
|
cd43a74ab5 | ||
|
|
6523255d83 | ||
|
|
a3d1f44908 | ||
|
|
5a11d2f9af | ||
|
|
d929e7cfef | ||
|
|
03c9b0fa58 | ||
|
|
cd65b3605d | ||
|
|
754125e3a3 | ||
|
|
3cb086fc57 | ||
|
|
3a12fdffb1 | ||
|
|
7978f1abae | ||
|
|
aea48239f9 | ||
|
|
7acb443aad | ||
|
|
18cfe6e2f0 | ||
|
|
1d7374067e | ||
|
|
e560db85f4 | ||
|
|
74fa03d3a5 | ||
|
|
c02e314d76 | ||
|
|
27fa655425 | ||
|
|
d013a8a01a | ||
|
|
7faf20e0c8 | ||
|
|
ad17b27c3d | ||
|
|
112d7ef8f9 | ||
|
|
2acfce5b63 | ||
|
|
2c44ea73c7 | ||
|
|
31db041084 | ||
|
|
882efb5284 | ||
|
|
604cc8e1dc | ||
|
|
a778755492 | ||
|
|
3bbfebb5e3 | ||
|
|
6883fff352 | ||
|
|
179bab605e | ||
|
|
202402a123 | ||
|
|
c6969fdd90 | ||
|
|
b9d556ff1b | ||
|
|
194a664f20 | ||
|
|
09c3f0ccc6 | ||
|
|
5e14b9b0e1 | ||
|
|
7165953b50 | ||
|
|
e362feaa82 | ||
|
|
66054dccb0 | ||
|
|
7efd7183d8 | ||
|
|
5b27e797b3 | ||
|
|
269a87da74 | ||
|
|
88fbf5e400 | ||
|
|
f45316904b | ||
|
|
d528a8df4c | ||
|
|
d0de78eaf0 | ||
|
|
2fc79dcf37 | ||
|
|
174c9a740f | ||
|
|
445908f8ae | ||
|
|
18803469de | ||
|
|
49502e1534 | ||
|
|
f7f6943d31 | ||
|
|
df21270a7e | ||
|
|
0a6d06b7a6 | ||
|
|
84ac5b62cb | ||
|
|
5174ddacfc | ||
|
|
5d7681cb62 | ||
|
|
82d6d420b3 | ||
|
|
4d81734ceb | ||
|
|
fb65bb8a95 | ||
|
|
68e06b0d53 | ||
|
|
3235ff4ed0 | ||
|
|
ea6438311f | ||
|
|
37ae7a99d8 | ||
|
|
20e8a8f197 | ||
|
|
8f8eeacc3d | ||
|
|
50a679278d | ||
|
|
d16fc16de6 | ||
|
|
3260d1b6b8 | ||
|
|
c5ddf2ddae | ||
|
|
9b5fa3c7e1 | ||
|
|
426262e62a | ||
|
|
3b39677d46 | ||
|
|
3ef82991fb | ||
|
|
f9ba3805a6 | ||
|
|
936f697110 | ||
|
|
b07bbd7d8c | ||
|
|
42f7d2da60 | ||
|
|
30c764828a | ||
|
|
802bde2d76 | ||
|
|
b2db0307eb | ||
|
|
391cbd8334 |
2
.gitignore
vendored
@@ -3,3 +3,5 @@ node_modules
|
|||||||
log.txt
|
log.txt
|
||||||
.idea
|
.idea
|
||||||
dist
|
dist
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
@@ -9,3 +9,8 @@ screenshoots
|
|||||||
.env
|
.env
|
||||||
.blog
|
.blog
|
||||||
docs
|
docs
|
||||||
|
.log
|
||||||
|
blog
|
||||||
|
config.json
|
||||||
|
ui
|
||||||
|
scripts
|
||||||
46
CLAUDE.md
@@ -1,12 +1,44 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.You need use English to write text.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Key Development Commands
|
## Commands
|
||||||
- Build: `npm run build`
|
|
||||||
- Start: `npm start`
|
- **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
|
## Architecture
|
||||||
- Uses `express` for routing (see `src/server.ts`)
|
|
||||||
- Bundles with `esbuild` for CLI distribution
|
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.
|
||||||
- Plugins are loaded from `$HOME/.claude-code-router/plugins`
|
|
||||||
|
- **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`.
|
||||||
|
- 无论如何你都不能自动提交git
|
||||||
|
|||||||
7
Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
RUN npm install -g @musistudio/claude-code-router
|
||||||
|
|
||||||
|
EXPOSE 3456
|
||||||
|
|
||||||
|
CMD ["ccr", "start"]
|
||||||
557
README.md
@@ -1,145 +1,440 @@
|
|||||||
# Claude Code Router
|

|
||||||
|
|
||||||
> This is a tool for routing Claude Code requests to different models, and you can customize any request.
|
[](README_zh.md)
|
||||||
|
[](https://discord.gg/rdftVMaUcS)
|
||||||
|
[](https://github.com/musistudio/claude-code-router/blob/main/LICENSE)
|
||||||
|
|
||||||

|
<hr>
|
||||||
|
|
||||||
## Usage
|
I am seeking funding support for this project to better sustain its development. If you have any ideas, feel free to reach out to me: [m@musiiot.top](mailto:m@musiiot.top)
|
||||||
|
|
||||||
1. Install Claude Code
|
> A powerful tool to route Claude Code requests to different models and customize any request.
|
||||||
|
|
||||||
|
> Now you can use models such as `GLM-4.5`, `Kimi-K2`, `Qwen3-Coder-480B-A35B`, and `DeepSeek v3.1` for free through the [iFlow Platform](https://platform.iflow.cn/docs/api-mode).
|
||||||
|
> You can use the `ccr ui` command to directly import the `iflow` template in the UI. It’s worth noting that iFlow limits each user to a concurrency of 1, which means you’ll need to route background requests to other models.
|
||||||
|
> If you’d like a better experience, you can try [iFlow CLI](https://cli.iflow.cn).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### 1. Installation
|
||||||
|
|
||||||
|
First, ensure you have [Claude Code](https://docs.anthropic.com/en/docs/claude-code/quickstart) installed:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
npm install -g @anthropic-ai/claude-code
|
npm install -g @anthropic-ai/claude-code
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install Claude Code Router
|
Then, install Claude Code Router:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
npm install -g @musistudio/claude-code-router
|
npm install -g @musistudio/claude-code-router
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start Claude Code by claude-code-router
|
### 2. Configuration
|
||||||
|
|
||||||
```shell
|
Create and configure your `~/.claude-code-router/config.json` file. For more details, you can refer to `config.example.json`.
|
||||||
ccr code
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Configure routing[optional]
|
The `config.json` file has several key sections:
|
||||||
Set up your `~/.claude-code-router/config.json` file like this:
|
|
||||||
|
- **`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`. When set to `false`, no log files will be created. Default is `true`.
|
||||||
|
- **`LOG_LEVEL`** (optional): Set the logging level. Available options are: `"fatal"`, `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"`. Default is `"debug"`.
|
||||||
|
- **Logging Systems**: The Claude Code Router uses two separate logging systems:
|
||||||
|
- **Server-level logs**: HTTP requests, API calls, and server events are logged using pino in the `~/.claude-code-router/logs/` directory with filenames like `ccr-*.log`
|
||||||
|
- **Application-level logs**: Routing decisions and business logic events are logged in `~/.claude-code-router/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"`.
|
||||||
|
- **`NON_INTERACTIVE_MODE`** (optional): When set to `true`, enables compatibility with non-interactive environments like GitHub Actions, Docker containers, or other CI/CD systems. This sets appropriate environment variables (`CI=true`, `FORCE_COLOR=0`, etc.) and configures stdin handling to prevent the process from hanging in automated environments. Example: `"NON_INTERACTIVE_MODE": true`.
|
||||||
|
|
||||||
|
- **`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.
|
||||||
|
- **`API_TIMEOUT_MS`**: Specifies the timeout for API calls in milliseconds.
|
||||||
|
|
||||||
|
#### Environment Variable Interpolation
|
||||||
|
|
||||||
|
Claude Code Router supports environment variable interpolation for secure API key management. You can reference environment variables in your `config.json` using either `$VAR_NAME` or `${VAR_NAME}` syntax:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"OPENAI_API_KEY": "sk-xxx",
|
"OPENAI_API_KEY": "$OPENAI_API_KEY",
|
||||||
"OPENAI_BASE_URL": "https://api.deepseek.com",
|
"GEMINI_API_KEY": "${GEMINI_API_KEY}",
|
||||||
"OPENAI_MODEL": "deepseek-chat",
|
"Providers": [
|
||||||
|
{
|
||||||
|
"name": "openai",
|
||||||
|
"api_base_url": "https://api.openai.com/v1/chat/completions",
|
||||||
|
"api_key": "$OPENAI_API_KEY",
|
||||||
|
"models": ["gpt-5", "gpt-5-mini"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows you to keep sensitive API keys in environment variables instead of hardcoding them in configuration files. The interpolation works recursively through nested objects and arrays.
|
||||||
|
|
||||||
|
Here is a comprehensive example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"APIKEY": "your-secret-key",
|
||||||
|
"PROXY_URL": "http://127.0.0.1:7890",
|
||||||
|
"LOG": true,
|
||||||
|
"API_TIMEOUT_MS": 600000,
|
||||||
|
"NON_INTERACTIVE_MODE": false,
|
||||||
"Providers": [
|
"Providers": [
|
||||||
{
|
{
|
||||||
"name": "openrouter",
|
"name": "openrouter",
|
||||||
"api_base_url": "https://openrouter.ai/api/v1",
|
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
|
||||||
"api_key": "sk-xxx",
|
"api_key": "sk-xxx",
|
||||||
"models": [
|
"models": [
|
||||||
"google/gemini-2.5-pro-preview",
|
"google/gemini-2.5-pro-preview",
|
||||||
"anthropic/claude-sonnet-4",
|
"anthropic/claude-sonnet-4",
|
||||||
"anthropic/claude-3.5-sonnet",
|
"anthropic/claude-3.5-sonnet",
|
||||||
"anthropic/claude-3.7-sonnet:thinking"
|
"anthropic/claude-3.7-sonnet:thinking"
|
||||||
]
|
],
|
||||||
|
"transformer": {
|
||||||
|
"use": ["openrouter"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deepseek",
|
"name": "deepseek",
|
||||||
"api_base_url": "https://api.deepseek.com",
|
"api_base_url": "https://api.deepseek.com/chat/completions",
|
||||||
"api_key": "sk-xxx",
|
"api_key": "sk-xxx",
|
||||||
"models": ["deepseek-reasoner"]
|
"models": ["deepseek-chat", "deepseek-reasoner"],
|
||||||
|
"transformer": {
|
||||||
|
"use": ["deepseek"],
|
||||||
|
"deepseek-chat": {
|
||||||
|
"use": ["tooluse"]
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ollama",
|
"name": "ollama",
|
||||||
"api_base_url": "http://localhost:11434/v1",
|
"api_base_url": "http://localhost:11434/v1/chat/completions",
|
||||||
"api_key": "ollama",
|
"api_key": "ollama",
|
||||||
"models": ["qwen2.5-coder:latest"]
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aihubmix",
|
||||||
|
"api_base_url": "https://aihubmix.com/v1/chat/completions",
|
||||||
|
"api_key": "sk-",
|
||||||
|
"models": [
|
||||||
|
"Z/glm-4.5",
|
||||||
|
"claude-opus-4-20250514",
|
||||||
|
"gemini-2.5-pro"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Router": {
|
"Router": {
|
||||||
|
"default": "deepseek,deepseek-chat",
|
||||||
"background": "ollama,qwen2.5-coder:latest",
|
"background": "ollama,qwen2.5-coder:latest",
|
||||||
"think": "deepseek,deepseek-reasoner",
|
"think": "deepseek,deepseek-reasoner",
|
||||||
"longContext": "openrouter,google/gemini-2.5-pro-preview"
|
"longContext": "openrouter,google/gemini-2.5-pro-preview",
|
||||||
|
"longContextThreshold": 60000,
|
||||||
|
"webSearch": "gemini,gemini-2.5-flash"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `background`
|
### 3. Running Claude Code with the Router
|
||||||
This model will be used to handle some background tasks([background-token-usage](https://docs.anthropic.com/en/docs/claude-code/costs#background-token-usage)). Based on my tests, it doesn’t require high intelligence. I’m using the qwen-coder-2.5:7b model running locally on my MacBook Pro M1 (32GB) via Ollama.
|
|
||||||
If your computer can’t run Ollama, you can also use some free models, such as qwen-coder-2.5:3b.
|
|
||||||
|
|
||||||
- `think`
|
Start Claude Code using the router:
|
||||||
This model will be used when enabling Claude Code to perform reasoning. However, reasoning budget control has not yet been implemented (since the DeepSeek-R1 model does not support it), so there is currently no difference between using UltraThink and Think modes.
|
|
||||||
It is worth noting that Plan Mode also use this model to achieve better planning results.
|
|
||||||
Note: The reasoning process via the official DeepSeek API may be very slow, so you may need to wait for an extended period of time.
|
|
||||||
|
|
||||||
- `longContext`
|
```shell
|
||||||
This model will be used when the context length exceeds 32K (this value may be modified in the future). You can route the request to a model that performs well with long contexts (I’ve chosen google/gemini-2.5-pro-preview). This scenario has not been thoroughly tested yet, so if you encounter any issues, please submit an issue.
|
ccr code
|
||||||
|
```
|
||||||
|
|
||||||
- model command
|
> **Note**: After modifying the configuration file, you need to restart the service for the changes to take effect:
|
||||||
You can also switch models within Claude Code by using the `/model` command. The format is: `provider,model`, like this:
|
>
|
||||||
`/model openrouter,anthropic/claude-3.5-sonnet`
|
> ```shell
|
||||||
This will use the anthropic/claude-3.5-sonnet model provided by OpenRouter to handle all subsequent tasks.
|
> ccr restart
|
||||||
|
> ```
|
||||||
|
|
||||||
## Features
|
### 4. UI Mode
|
||||||
|
|
||||||
- [x] Support change models
|
For a more intuitive experience, you can use the UI mode to manage your configuration:
|
||||||
- [x] Github Actions
|
|
||||||
- [ ] More robust plugin support
|
|
||||||
- [ ] More detailed logs
|
|
||||||
|
|
||||||
## Plugins
|
```shell
|
||||||
You can modify or enhance Claude Code’s functionality by installing plugins. The mechanism works by using middleware to modify request parameters — this allows you to rewrite prompts or add/remove tools.
|
ccr ui
|
||||||
|
```
|
||||||
|
|
||||||
|
This will open a web-based interface where you can easily view and edit your `config.json` file.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 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:**
|
||||||
|
|
||||||
|
- `Anthropic`:If you use only the `Anthropic` transformer, it will preserve the original request and response parameters(you can use it to connect directly to an Anthropic endpoint).
|
||||||
|
- `deepseek`: Adapts requests/responses for DeepSeek API.
|
||||||
|
- `gemini`: Adapts requests/responses for Gemini API.
|
||||||
|
- `openrouter`: Adapts requests/responses for OpenRouter API. It can also accept a `provider` routing parameter to specify which underlying providers OpenRouter should use. For more details, refer to the [OpenRouter documentation](https://openrouter.ai/docs/features/provider-routing). See an example below:
|
||||||
|
```json
|
||||||
|
"transformer": {
|
||||||
|
"use": ["openrouter"],
|
||||||
|
"moonshotai/kimi-k2": {
|
||||||
|
"use": [
|
||||||
|
[
|
||||||
|
"openrouter",
|
||||||
|
{
|
||||||
|
"provider": {
|
||||||
|
"only": ["moonshotai/fp8"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `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).
|
||||||
|
- `reasoning`: Used to process the `reasoning_content` field.
|
||||||
|
- `sampling`: Used to process sampling information fields such as `temperature`, `top_p`, `top_k`, and `repetition_penalty`.
|
||||||
|
- `enhancetool`: Adds a layer of error tolerance to the tool call parameters returned by the LLM (this will cause the tool call information to no longer be streamed).
|
||||||
|
- `cleancache`: Clears the `cache_control` field from requests.
|
||||||
|
- `vertex-gemini`: Handles the Gemini API using Vertex authentication.
|
||||||
|
- `chutes-glm` Unofficial support for GLM 4.5 model via Chutes [chutes-glm-transformer.js](https://gist.github.com/vitobotta/2be3f33722e05e8d4f9d2b0138b8c863).
|
||||||
|
- `qwen-cli` (experimental): Unofficial support for qwen3-coder-plus model via Qwen CLI [qwen-cli.js](https://gist.github.com/musistudio/f5a67841ced39912fd99e42200d5ca8b).
|
||||||
|
- `rovo-cli` (experimental): Unofficial support for gpt-5 via Atlassian Rovo Dev CLI [rovo-cli.js](https://gist.github.com/SaseQ/c2a20a38b11276537ec5332d1f7a5e53).
|
||||||
|
|
||||||
|
**Custom Transformers:**
|
||||||
|
|
||||||
|
You can also create your own transformers and load them via the `transformers` field in `config.json`.
|
||||||
|
|
||||||
To use a plugin, place it in the ~/.claude-code-router/plugins/ directory and specify the plugin name in config.js using the `usePlugins` option.like this
|
|
||||||
```json
|
```json
|
||||||
// ~/.claud-code-router/config.json
|
|
||||||
{
|
{
|
||||||
...,
|
"transformers": [
|
||||||
"usePlugins": ["notebook-tools-filter", "toolcall-improvement"]
|
{
|
||||||
|
"path": "/User/xxx/.claude-code-router/plugins/gemini-cli.js",
|
||||||
|
"options": {
|
||||||
|
"project": "xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Currently, the following plugins are available:
|
#### Router
|
||||||
|
|
||||||
|
The `Router` object defines which model to use for different scenarios:
|
||||||
|
|
||||||
- **notebook-tools-filter**
|
- `default`: The default model for general tasks.
|
||||||
This plugin filters out tool calls related to Jupyter notebooks (.ipynb files). You can use it if your work does not involve Jupyter.
|
- `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.
|
||||||
|
- `image` (beta): Used for handling image-related tasks (supported by CCR’s built-in agent). If the model does not support tool calling, you need to set the `config.forceUseImageAgent` property to `true`.
|
||||||
|
|
||||||
|
- 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`
|
||||||
|
|
||||||
- **toolcall-improvement**
|
#### Custom Router
|
||||||
If your LLM doesn’t handle tool usage well (for example, always returning code as plain text instead of modifying files — such as with deepseek-v3), you can use this plugin.
|
|
||||||
This plugin simply adds the following system prompt. If you have a better prompt, you can modify it.
|
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.
|
||||||
```markdown
|
|
||||||
## **Important Instruction:**
|
In your `config.json`:
|
||||||
You must use tools as frequently and accurately as possible to help the user solve their problem.
|
|
||||||
Prioritize tool usage whenever it can enhance accuracy, efficiency, or the quality of the response.
|
```json
|
||||||
|
{
|
||||||
|
"CUSTOM_ROUTER_PATH": "/User/xxx/.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
|
||||||
|
// /User/xxx/.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;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Subagent Routing
|
||||||
|
|
||||||
|
For routing within subagents, you must specify a particular provider and model by including `<CCR-SUBAGENT-MODEL>provider,model</CCR-SUBAGENT-MODEL>` at the **beginning** of the subagent's prompt. This allows you to direct specific subagent tasks to designated models.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
<CCR-SUBAGENT-MODEL>openrouter,anthropic/claude-3.5-sonnet</CCR-SUBAGENT-MODEL>
|
||||||
|
Please help me analyze this code snippet for potential optimizations...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status Line (Beta)
|
||||||
|
To better monitor the status of claude-code-router at runtime, version v1.0.40 includes a built-in statusline tool, which you can enable in the UI.
|
||||||
|

|
||||||
|
|
||||||
|
The effect is as follows:
|
||||||
|

|
||||||
|
|
||||||
|
## 🤖 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:
|
||||||
|
|
||||||
## Github Actions
|
|
||||||
You just need to install `Claude Code Actions` in your repository according to the [official documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions). For `ANTHROPIC_API_KEY`, you can use any string. Then, modify your `.github/workflows/claude.yaml` file to include claude-code-router, like this:
|
|
||||||
```yaml
|
```yaml
|
||||||
name: Claude Code
|
name: Claude Code
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
types: [created]
|
types: [created]
|
||||||
pull_request_review_comment:
|
# ... other triggers
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
claude:
|
claude:
|
||||||
if: |
|
if: |
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
# ... other conditions
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -159,6 +454,7 @@ jobs:
|
|||||||
cat << 'EOF' > $HOME/.claude-code-router/config.json
|
cat << 'EOF' > $HOME/.claude-code-router/config.json
|
||||||
{
|
{
|
||||||
"log": true,
|
"log": true,
|
||||||
|
"NON_INTERACTIVE_MODE": true,
|
||||||
"OPENAI_API_KEY": "${{ secrets.OPENAI_API_KEY }}",
|
"OPENAI_API_KEY": "${{ secrets.OPENAI_API_KEY }}",
|
||||||
"OPENAI_BASE_URL": "https://api.deepseek.com",
|
"OPENAI_BASE_URL": "https://api.deepseek.com",
|
||||||
"OPENAI_MODEL": "deepseek-chat"
|
"OPENAI_MODEL": "deepseek-chat"
|
||||||
@@ -177,57 +473,112 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ANTHROPIC_BASE_URL: http://localhost:3456
|
ANTHROPIC_BASE_URL: http://localhost:3456
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: "test"
|
anthropic_api_key: "any-string-is-ok"
|
||||||
```
|
```
|
||||||
You can modify the contents of `$HOME/.claude-code-router/config.json` as needed.
|
|
||||||
GitHub Actions support allows you to trigger Claude Code at specific times, which opens up some interesting possibilities.
|
|
||||||
|
|
||||||
For example, between 00:30 and 08:30 Beijing Time, using the official DeepSeek API:
|
> **Note**: When running in GitHub Actions or other automation environments, make sure to set `"NON_INTERACTIVE_MODE": true` in your configuration to prevent the process from hanging due to stdin handling issues.
|
||||||
|
|
||||||
- The cost of the `deepseek-v3` model is only 50% of the normal time.
|
This setup allows for interesting automations, like running tasks during off-peak hours to reduce API costs.
|
||||||
|
|
||||||
- The `deepseek-r1` model is just 25% of the normal time.
|
## 📝 Further Reading
|
||||||
|
|
||||||
So maybe in the future, I’ll describe detailed tasks for Claude Code ahead of time and let it run during these discounted hours to reduce costs?
|
- [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
|
||||||
|
|
||||||
## Some tips:
|
If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated!
|
||||||
|
|
||||||
Now you can use deepseek-v3 models directly without using any plugins.
|
|
||||||
|
|
||||||
If you’re using the DeepSeek API provided by the official website, you might encounter an “exceeding context” error after several rounds of conversation (since the official API only supports a 64K context window). In this case, you’ll need to discard the previous context and start fresh. Alternatively, you can use ByteDance’s DeepSeek API, which offers a 128K context window and supports KV cache.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Note: claude code consumes a huge amount of tokens, but thanks to DeepSeek’s low cost, you can use claude code at a fraction of Claude’s price, and you don’t need to subscribe to the Claude Max plan.
|
|
||||||
|
|
||||||
Some interesting points: Based on my testing, including a lot of context information can help narrow the performance gap between these LLM models. For instance, when I used Claude-4 in VSCode Copilot to handle a Flutter issue, it messed up the files in three rounds of conversation, and I had to roll everything back. However, when I used claude code with DeepSeek, after three or four rounds of conversation, I finally managed to complete my task—and the cost was less than 1 RMB!
|
|
||||||
|
|
||||||
## Some articles:
|
|
||||||
|
|
||||||
1. [Project Motivation and Principles](blog/en/project-motivation-and-how-it-works.md) ([中文版看这里](blog/zh/项目初衷及原理.md))
|
|
||||||
|
|
||||||
## Buy me a coffee
|
|
||||||
|
|
||||||
If you find this project helpful, you can choose to sponsor the author with a cup of coffee. Please provide your GitHub information so I can add you to the sponsor list below.
|
|
||||||
|
|
||||||
[](https://ko-fi.com/F1F31GN2GM)
|
[](https://ko-fi.com/F1F31GN2GM)
|
||||||
|
|
||||||
|
[Paypal](https://paypal.me/musistudio1999)
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="/blog/images/alipay.jpg" width="200" /></td>
|
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
|
||||||
<td><img src="/blog/images/wechat.jpg" width="200" /></td>
|
<td><img src="/blog/images/wechat.jpg" width="200" alt="WeChat Pay" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## Sponsors
|
### Our Sponsors
|
||||||
|
|
||||||
Thanks to the following sponsors:
|
A huge thank you to all our sponsors for their generous support!
|
||||||
|
|
||||||
@Simon Leischnig (If you see this, feel free to contact me and I can update it with your GitHub information)
|
|
||||||
[@duanshuaimin](https://github.com/duanshuaimin)
|
- [AIHubmix](https://aihubmix.com/)
|
||||||
[@vrgitadmin](https://github.com/vrgitadmin)
|
- [BurnCloud](https://ai.burncloud.com)
|
||||||
@*o (可通过主页邮箱联系我修改github用户名)
|
- @Simon Leischnig
|
||||||
@**聪 (可通过主页邮箱联系我修改github用户名)
|
- [@duanshuaimin](https://github.com/duanshuaimin)
|
||||||
@*说 (可通过主页邮箱联系我修改github用户名)
|
- [@vrgitadmin](https://github.com/vrgitadmin)
|
||||||
@*更 (可通过主页邮箱联系我修改github用户名)
|
- @\*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
|
||||||
|
- @*鑫
|
||||||
|
- @c\*y
|
||||||
|
- @\*昕
|
||||||
|
- [@witsice](https://github.com/witsice)
|
||||||
|
- @b\*g
|
||||||
|
- @\*亿
|
||||||
|
- @\*辉
|
||||||
|
- @JACK
|
||||||
|
- @\*光
|
||||||
|
- @W\*l
|
||||||
|
- [@kesku](https://github.com/kesku)
|
||||||
|
- [@biguncle](https://github.com/biguncle)
|
||||||
|
- @二吉吉
|
||||||
|
- @a\*g
|
||||||
|
- @\*林
|
||||||
|
- @\*咸
|
||||||
|
- @\*明
|
||||||
|
- @S\*y
|
||||||
|
- @f\*o
|
||||||
|
- @\*智
|
||||||
|
- @F\*t
|
||||||
|
- @r\*c
|
||||||
|
- [@qierkang](http://github.com/qierkang)
|
||||||
|
- @\*军
|
||||||
|
- [@snrise-z](http://github.com/snrise-z)
|
||||||
|
- @\*王
|
||||||
|
- [@greatheart1000](http://github.com/greatheart1000)
|
||||||
|
- @\*王
|
||||||
|
- @zcutlip
|
||||||
|
- [@Peng-YM](http://github.com/Peng-YM)
|
||||||
|
- @\*更
|
||||||
|
- @\*.
|
||||||
|
- @F\*t
|
||||||
|
- @\*政
|
||||||
|
- @\*铭
|
||||||
|
- @\*叶
|
||||||
|
|
||||||
|
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)
|
||||||
|
|||||||
561
README_zh.md
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|

|
||||||
|
|
||||||
|
[](README.md)
|
||||||
|
[](https://discord.gg/rdftVMaUcS)
|
||||||
|
[](https://github.com/musistudio/claude-code-router/blob/main/LICENSE)
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
我正在为该项目寻求资金支持,以更好地维持其发展。如果您有任何想法,请随时与我联系: [m@musiiot.top](mailto:m@musiiot.top)
|
||||||
|
|
||||||
|
> 一款强大的工具,可将 Claude Code 请求路由到不同的模型,并自定义任何请求。
|
||||||
|
|
||||||
|
> 现在你可以通过[心流平台](https://platform.iflow.cn/docs/api-mode)免费使用`GLM-4.5`、`Kimi-K2`、`Qwen3-Coder-480B-A35B`、`DeepSeek v3.1`等模型。
|
||||||
|
> 你可以使用`ccr ui`命令在UI中直接导入`iflow`模板,值得注意的是心流限制每位用户的并发数为1,意味着你需要将`background`路由到其他模型。
|
||||||
|
> 如果你想获得更好的体验,可以尝试[iFlow CLI](https://cli.iflow.cn)。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## ✨ 功能
|
||||||
|
|
||||||
|
- **模型路由**: 根据您的需求将请求路由到不同的模型(例如,后台任务、思考、长上下文)。
|
||||||
|
- **多提供商支持**: 支持 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` 来启用日志记录。当设置为 `false` 时,将不会创建日志文件。默认值为 `true`。
|
||||||
|
- **`LOG_LEVEL`** (可选): 设置日志级别。可用选项包括:`"fatal"`、`"error"`、`"warn"`、`"info"`、`"debug"`、`"trace"`。默认值为 `"debug"`。
|
||||||
|
- **日志系统**: Claude Code Router 使用两个独立的日志系统:
|
||||||
|
- **服务器级别日志**: HTTP 请求、API 调用和服务器事件使用 pino 记录在 `~/.claude-code-router/logs/` 目录中,文件名类似于 `ccr-*.log`
|
||||||
|
- **应用程序级别日志**: 路由决策和业务逻辑事件记录在 `~/.claude-code-router/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"`。
|
||||||
|
- **`NON_INTERACTIVE_MODE`** (可选): 当设置为 `true` 时,启用与非交互式环境(如 GitHub Actions、Docker 容器或其他 CI/CD 系统)的兼容性。这会设置适当的环境变量(`CI=true`、`FORCE_COLOR=0` 等)并配置 stdin 处理,以防止进程在自动化环境中挂起。例如:`"NON_INTERACTIVE_MODE": true`。
|
||||||
|
- **`Providers`**: 用于配置不同的模型提供商。
|
||||||
|
- **`Router`**: 用于设置路由规则。`default` 指定默认模型,如果未配置其他路由,则该模型将用于所有请求。
|
||||||
|
- **`API_TIMEOUT_MS`**: API 请求超时时间,单位为毫秒。
|
||||||
|
|
||||||
|
这是一个综合示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"APIKEY": "your-secret-key",
|
||||||
|
"PROXY_URL": "http://127.0.0.1:7890",
|
||||||
|
"LOG": true,
|
||||||
|
"API_TIMEOUT_MS": 600000,
|
||||||
|
"NON_INTERACTIVE_MODE": false,
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aihubmix",
|
||||||
|
"api_base_url": "https://aihubmix.com/v1/chat/completions",
|
||||||
|
"api_key": "sk-",
|
||||||
|
"models": [
|
||||||
|
"Z/glm-4.5",
|
||||||
|
"claude-opus-4-20250514",
|
||||||
|
"gemini-2.5-pro"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 4. UI 模式
|
||||||
|
|
||||||
|
为了获得更直观的体验,您可以使用 UI 模式来管理您的配置:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ccr ui
|
||||||
|
```
|
||||||
|
|
||||||
|
这将打开一个基于 Web 的界面,您可以在其中轻松查看和编辑您的 `config.json` 文件。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 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:**
|
||||||
|
|
||||||
|
- `Anthropic`: 如果你只使用这一个转换器,则会直接透传请求和响应(你可以用它来接入其他支持Anthropic端点的服务商)。
|
||||||
|
- `deepseek`: 适配 DeepSeek API 的请求/响应。
|
||||||
|
- `gemini`: 适配 Gemini API 的请求/响应。
|
||||||
|
- `openrouter`: 适配 OpenRouter API 的请求/响应。它还可以接受一个 `provider` 路由参数,以指定 OpenRouter 应使用哪些底层提供商。有关更多详细信息,请参阅 [OpenRouter 文档](https://openrouter.ai/docs/features/provider-routing)。请参阅下面的示例:
|
||||||
|
```json
|
||||||
|
"transformer": {
|
||||||
|
"use": ["openrouter"],
|
||||||
|
"moonshotai/kimi-k2": {
|
||||||
|
"use": [
|
||||||
|
[
|
||||||
|
"openrouter",
|
||||||
|
{
|
||||||
|
"provider": {
|
||||||
|
"only": ["moonshotai/fp8"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `groq`: 适配 groq API 的请求/响应
|
||||||
|
- `maxtoken`: 设置特定的 `max_tokens` 值。
|
||||||
|
- `tooluse`: 优化某些模型的工具使用(通过`tool_choice`参数)。
|
||||||
|
- `gemini-cli` (实验性): 通过 Gemini CLI [gemini-cli.js](https://gist.github.com/musistudio/1c13a65f35916a7ab690649d3df8d1cd) 对 Gemini 的非官方支持。
|
||||||
|
- `reasoning`: 用于处理 `reasoning_content` 字段。
|
||||||
|
- `sampling`: 用于处理采样信息字段,如 `temperature`、`top_p`、`top_k` 和 `repetition_penalty`。
|
||||||
|
- `enhancetool`: 对 LLM 返回的工具调用参数增加一层容错处理(这会导致不再流式返回工具调用信息)。
|
||||||
|
- `cleancache`: 清除请求中的 `cache_control` 字段。
|
||||||
|
- `vertex-gemini`: 处理使用 vertex 鉴权的 gemini api。
|
||||||
|
- `qwen-cli` (实验性): 通过 Qwen CLI [qwen-cli.js](https://gist.github.com/musistudio/f5a67841ced39912fd99e42200d5ca8b) 对 qwen3-coder-plus 的非官方支持。
|
||||||
|
- `rovo-cli` (experimental): 通过 Atlassian Rovo Dev CLI [rovo-cli.js](https://gist.github.com/SaseQ/c2a20a38b11276537ec5332d1f7a5e53) 对 GPT-5 的非官方支持。
|
||||||
|
|
||||||
|
**自定义 Transformer:**
|
||||||
|
|
||||||
|
您还可以创建自己的转换器,并通过 `config.json` 中的 `transformers` 字段加载它们。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transformers": [
|
||||||
|
{
|
||||||
|
"path": "/User/xxx/.claude-code-router/plugins/gemini-cli.js",
|
||||||
|
"options": {
|
||||||
|
"project": "xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Router
|
||||||
|
|
||||||
|
`Router` 对象定义了在不同场景下使用哪个模型:
|
||||||
|
|
||||||
|
- `default`: 用于常规任务的默认模型。
|
||||||
|
- `background`: 用于后台任务的模型。这可以是一个较小的本地模型以节省成本。
|
||||||
|
- `think`: 用于推理密集型任务(如计划模式)的模型。
|
||||||
|
- `longContext`: 用于处理长上下文(例如,> 60K 令牌)的模型。
|
||||||
|
- `longContextThreshold` (可选): 触发长上下文模型的令牌数阈值。如果未指定,默认为 60000。
|
||||||
|
- `webSearch`: 用于处理网络搜索任务,需要模型本身支持。如果使用`openrouter`需要在模型后面加上`:online`后缀。
|
||||||
|
- `image`(测试版): 用于处理图片类任务(采用CCR内置的agent支持),如果该模型不支持工具调用,需要将`config.forceUseImageAgent`属性设置为`true`。
|
||||||
|
|
||||||
|
您还可以使用 `/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": "/User/xxx/.claude-code-router/custom-router.js"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
自定义路由器文件必须是一个导出 `async` 函数的 JavaScript 模块。该函数接收请求对象和配置对象作为参数,并应返回提供商和模型名称的字符串(例如 `"provider_name,model_name"`),如果返回 `null` 则回退到默认路由。
|
||||||
|
|
||||||
|
这是一个基于 `custom-router.example.js` 的 `custom-router.js` 示例:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// /User/xxx/.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;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 子代理路由
|
||||||
|
|
||||||
|
对于子代理内的路由,您必须在子代理提示词的**开头**包含 `<CCR-SUBAGENT-MODEL>provider,model</CCR-SUBAGENT-MODEL>` 来指定特定的提供商和模型。这样可以将特定的子代理任务定向到指定的模型。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```
|
||||||
|
<CCR-SUBAGENT-MODEL>openrouter,anthropic/claude-3.5-sonnet</CCR-SUBAGENT-MODEL>
|
||||||
|
请帮我分析这段代码是否存在潜在的优化空间...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status Line (Beta)
|
||||||
|
为了在运行时更好的查看claude-code-router的状态,claude-code-router在v1.0.40内置了一个statusline工具,你可以在UI中启用它,
|
||||||
|

|
||||||
|
|
||||||
|
效果如下:
|
||||||
|

|
||||||
|
|
||||||
|
## 🤖 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,
|
||||||
|
"NON_INTERACTIVE_MODE": 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)
|
||||||
|
|
||||||
|
## ❤️ 支持与赞助
|
||||||
|
|
||||||
|
如果您觉得这个项目有帮助,请考虑赞助它的开发。非常感谢您的支持!
|
||||||
|
|
||||||
|
[](https://ko-fi.com/F1F31GN2GM)
|
||||||
|
|
||||||
|
[Paypal](https://paypal.me/musistudio1999)
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
### 我们的赞助商
|
||||||
|
|
||||||
|
非常感谢所有赞助商的慷慨支持!
|
||||||
|
|
||||||
|
- [AIHubmix](https://aihubmix.com/)
|
||||||
|
- [BurnCloud](https://ai.burncloud.com)
|
||||||
|
- @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
|
||||||
|
- @*鑫
|
||||||
|
- @c\*y
|
||||||
|
- @\*昕
|
||||||
|
- [@witsice](https://github.com/witsice)
|
||||||
|
- @b\*g
|
||||||
|
- @\*亿
|
||||||
|
- @\*辉
|
||||||
|
- @JACK
|
||||||
|
- @\*光
|
||||||
|
- @W\*l
|
||||||
|
- [@kesku](https://github.com/kesku)
|
||||||
|
- [@biguncle](https://github.com/biguncle)
|
||||||
|
- @二吉吉
|
||||||
|
- @a\*g
|
||||||
|
- @\*林
|
||||||
|
- @\*咸
|
||||||
|
- @\*明
|
||||||
|
- @S\*y
|
||||||
|
- @f\*o
|
||||||
|
- @\*智
|
||||||
|
- @F\*t
|
||||||
|
- @r\*c
|
||||||
|
- [@qierkang](http://github.com/qierkang)
|
||||||
|
- @\*军
|
||||||
|
- [@snrise-z](http://github.com/snrise-z)
|
||||||
|
- @\*王
|
||||||
|
- [@greatheart1000](http://github.com/greatheart1000)
|
||||||
|
- @\*王
|
||||||
|
- @zcutlip
|
||||||
|
- [@Peng-YM](http://github.com/Peng-YM)
|
||||||
|
- @\*更
|
||||||
|
- @\*.
|
||||||
|
- @F\*t
|
||||||
|
- @\*政
|
||||||
|
- @\*铭
|
||||||
|
- @\*叶
|
||||||
|
|
||||||
|
(如果您的名字被屏蔽,请通过我的主页电子邮件与我联系,以便使用您的 GitHub 用户名进行更新。)
|
||||||
|
|
||||||
|
|
||||||
|
## 交流群
|
||||||
|
<img src="/blog/images/wechat_group.jpg" width="200" alt="wechat_group" />
|
||||||
105
blog/en/maybe-we-can-do-more-with-the-route.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Maybe We Can Do More with the Router
|
||||||
|
|
||||||
|
Since the release of `claude-code-router`, I’ve 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 wasn’t 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 there’s no tool call ID.
|
||||||
|
|
||||||
|
2. OpenRouter requires `cache_control` for caching.
|
||||||
|
|
||||||
|
3. The official DeepSeek API has a `max_output` of 8192, but Volcano Engine’s 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 DeepSeek’s 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 model’s 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 it’s sent and adjust the response after it’s 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: "What’s 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.
|
||||||
BIN
blog/images/claude-code-router-img.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
67
blog/images/roadmap.svg
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<svg viewBox="0 0 1200 420" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.road { stroke: #7aa2ff; stroke-width: 6; fill: none; filter: drop-shadow(0 6px 18px rgba(122,162,255,0.25)); }
|
||||||
|
.dash { stroke: rgba(122,162,255,0.25); stroke-width: 6; fill: none; stroke-dasharray: 2 18; }
|
||||||
|
.node { filter: drop-shadow(0 3px 10px rgba(126,240,193,0.35)); }
|
||||||
|
.node-circle { fill: #7ef0c1; }
|
||||||
|
.node-core { fill: #181b22; stroke: white; stroke-width: 1.5; }
|
||||||
|
.label-bg { fill: rgba(24,27,34,0.8); stroke: rgba(255,255,255,0.12); rx: 12; }
|
||||||
|
.label-text { fill: #e8ecf1; font-weight: 700; font-size: 14px; font-family: Arial, sans-serif; }
|
||||||
|
.label-sub { fill: #9aa6b2; font-weight: 500; font-size: 12px; font-family: Arial, sans-serif; }
|
||||||
|
.spark { fill: none; stroke: #ffd36e; stroke-width: 1.6; stroke-linecap: round; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background road with dash -->
|
||||||
|
<path class="dash" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>
|
||||||
|
|
||||||
|
<!-- Main road -->
|
||||||
|
<path class="road" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>
|
||||||
|
|
||||||
|
<!-- New Documentation Node -->
|
||||||
|
<g class="node" transform="translate(200,280)">
|
||||||
|
<circle class="node-circle" r="10"/>
|
||||||
|
<circle class="node-core" r="6"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- New Documentation Label -->
|
||||||
|
<g transform="translate(80,120)">
|
||||||
|
<rect class="label-bg" width="260" height="92"/>
|
||||||
|
<text class="label-text" x="16" y="34">New Documentation</text>
|
||||||
|
<text class="label-sub" x="16" y="58">Clear structure, examples & best practices</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Plugin Marketplace Node -->
|
||||||
|
<g class="node" transform="translate(640,150)">
|
||||||
|
<circle class="node-circle" r="10"/>
|
||||||
|
<circle class="node-core" r="6"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Plugin Marketplace Label -->
|
||||||
|
<g transform="translate(560,20)">
|
||||||
|
<rect class="label-bg" width="320" height="100"/>
|
||||||
|
<text class="label-text" x="16" y="34">Plugin Marketplace</text>
|
||||||
|
<text class="label-sub" x="16" y="58">Community submissions, ratings & version constraints</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- One More Thing Node -->
|
||||||
|
<g class="node" transform="translate(1080,255)">
|
||||||
|
<circle class="node-circle" r="10"/>
|
||||||
|
<circle class="node-core" r="6"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- One More Thing Label -->
|
||||||
|
<g transform="translate(940,300)">
|
||||||
|
<rect class="label-bg" width="250" height="86"/>
|
||||||
|
<text class="label-text" x="16" y="34">One More Thing</text>
|
||||||
|
<text class="label-sub" x="16" y="58">🚀 Confidential project · Revealing soon</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Spark decorations -->
|
||||||
|
<g transform="translate(1125,290)">
|
||||||
|
<path class="spark" d="M0 0 L8 0 M4 -4 L4 4"/>
|
||||||
|
<path class="spark" d="M14 -2 L22 -2 M18 -6 L18 2"/>
|
||||||
|
<path class="spark" d="M-10 6 L-2 6 M-6 2 L-6 10"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
BIN
blog/images/statusline-config.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
blog/images/statusline.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
blog/images/ui.png
Normal file
|
After Width: | Height: | Size: 518 KiB |
BIN
blog/images/wechat_group.jpg
Normal file
|
After Width: | Height: | Size: 237 KiB |
95
blog/zh/或许我们能在Router中做更多事情.md
Normal 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: "What’s 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`的一次小探索,或许还能做更多其他有趣的事也说不定...
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"usePlugin": "",
|
|
||||||
"LOG": true,
|
|
||||||
"OPENAI_API_KEY": "",
|
|
||||||
"OPENAI_BASE_URL": "",
|
|
||||||
"OPENAI_MODEL": "",
|
|
||||||
"modelProviders": {}
|
|
||||||
}
|
|
||||||
3
custom-router.example.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = async function router(req, config) {
|
||||||
|
return "deepseek,deepseek-chat";
|
||||||
|
};
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
claude-code-reverse:
|
claude-code-router:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3456:3456"
|
- "3456:3456"
|
||||||
environment:
|
volumes:
|
||||||
- ENABLE_ROUTER=${ENABLE_ROUTER}
|
- ~/.claude-code-router:/root/.claude-code-router
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
|
||||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
|
|
||||||
- OPENAI_MODEL=${OPENAI_MODEL}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
12
dockerfile
@@ -1,12 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm i
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 3456
|
|
||||||
|
|
||||||
CMD ["node", "index.mjs"]
|
|
||||||
1377
package-lock.json
generated
20
package.json
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@musistudio/claude-code-router",
|
"name": "@musistudio/claude-code-router",
|
||||||
"version": "1.0.9",
|
"version": "1.0.49",
|
||||||
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
|
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ccr": "./dist/cli.js"
|
"ccr": "./dist/cli.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "esbuild src/cli.ts --bundle --platform=node --outfile=dist/cli.js && cp node_modules/tiktoken/tiktoken_bg.wasm dist/tiktoken_bg.wasm"
|
"build": "node scripts/build.js",
|
||||||
|
"release": "npm run build && npm publish"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
@@ -18,18 +19,21 @@
|
|||||||
"author": "musistudio",
|
"author": "musistudio",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.39.0",
|
"@fastify/static": "^8.2.0",
|
||||||
|
"@musistudio/llms": "^1.0.32",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"find-process": "^2.0.0",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"json5": "^2.2.3",
|
||||||
"lru-cache": "^11.1.0",
|
"openurl": "^1.1.1",
|
||||||
"openai": "^4.85.4",
|
"rotating-file-stream": "^3.2.7",
|
||||||
"tiktoken": "^1.0.21",
|
"tiktoken": "^1.0.21",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"@types/node": "^24.0.15",
|
||||||
"esbuild": "^0.25.1",
|
"esbuild": "^0.25.1",
|
||||||
|
"fastify": "^5.4.0",
|
||||||
|
"shx": "^0.4.0",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
module.exports = async function handle(req, res) {
|
|
||||||
if (req?.body?.tools?.length) {
|
|
||||||
req.body.tools = req.body.tools.filter(
|
|
||||||
(tool) => !["NotebookRead", "NotebookEdit", "mcp__ide__executeCode"].includes(tool.name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
module.exports = async function handle(req, res) {
|
|
||||||
if (req?.body?.tools?.length) {
|
|
||||||
req.body.system.push({
|
|
||||||
type: "text",
|
|
||||||
text: `## **Important Instruction:** \nYou must use tools as frequently and accurately as possible to help the user solve their problem.\nPrioritize tool usage whenever it can enhance accuracy, efficiency, or the quality of the response.`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
2381
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 83 KiB |
35
scripts/build.js
Normal 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);
|
||||||
|
}
|
||||||
207
src/agents/image.agent.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import {IAgent, ITool} from "./type";
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
||||||
|
interface ImageCacheEntry {
|
||||||
|
source: any;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageCache {
|
||||||
|
private cache: LRUCache<string, ImageCacheEntry>;
|
||||||
|
|
||||||
|
constructor(maxSize = 100) {
|
||||||
|
this.cache = new LRUCache({
|
||||||
|
max: maxSize,
|
||||||
|
ttl: 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
storeImage(id: string, source: any): void {
|
||||||
|
if (this.hasImage(id)) return;
|
||||||
|
this.cache.set(id, {
|
||||||
|
source,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getImage(id: string): any {
|
||||||
|
const entry = this.cache.get(id);
|
||||||
|
return entry ? entry.source : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasImage(hash: string): boolean {
|
||||||
|
return this.cache.has(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
return this.cache.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageCache = new ImageCache();
|
||||||
|
|
||||||
|
export class ImageAgent implements IAgent {
|
||||||
|
name = "image";
|
||||||
|
tools: Map<string, ITool>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.tools = new Map<string, ITool>();
|
||||||
|
this.appendTools()
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldHandle(req: any, config: any): boolean {
|
||||||
|
if (!config.Router.image || req.body.model === config.Router.image) return false;
|
||||||
|
const lastMessage = req.body.messages[req.body.messages.length - 1]
|
||||||
|
if (!config.forceUseImageAgent && lastMessage.role === 'user' && Array.isArray(lastMessage.content) &&lastMessage.content.find((item: any) => item.type === 'image')) {
|
||||||
|
req.body.model = config.Router.image
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return req.body.messages.some((msg: any) => msg.role === 'user' && Array.isArray(msg.content) && msg.content.some((item: any) => item.type === 'image'))
|
||||||
|
}
|
||||||
|
|
||||||
|
appendTools() {
|
||||||
|
this.tools.set('analyzeImage', {
|
||||||
|
name: "analyzeImage",
|
||||||
|
description: "Analyse image or images by ID and extract information such as OCR text, objects, layout, colors, or safety signals.",
|
||||||
|
input_schema: {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"imageId": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "an array of IDs to analyse",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Details of task to perform on the image.The more detailed, the better",
|
||||||
|
},
|
||||||
|
"regions": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Optional regions of interest within the image",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Optional label for the region"},
|
||||||
|
"x": {"type": "number", "description": "X coordinate"},
|
||||||
|
"y": {"type": "number", "description": "Y coordinate"},
|
||||||
|
"w": {"type": "number", "description": "Width of the region"},
|
||||||
|
"h": {"type": "number", "description": "Height of the region"},
|
||||||
|
"units": {"type": "string", "enum": ["px", "pct"], "description": "Units for coordinates and size"}
|
||||||
|
},
|
||||||
|
"required": ["x", "y", "w", "h", "units"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["imageId", "task"]
|
||||||
|
},
|
||||||
|
handler: async (args, context) => {
|
||||||
|
console.log('args', JSON.stringify(args, null, 2))
|
||||||
|
const imageMessages = [];
|
||||||
|
let imageId;
|
||||||
|
|
||||||
|
// Create image messages from cached images
|
||||||
|
if (args.imageId && Array.isArray(args.imageId)) {
|
||||||
|
args.imageId.forEach((imgId: string) => {
|
||||||
|
const image = imageCache.getImage(`${context.req.id}_Image#${imgId}`);
|
||||||
|
if (image) {
|
||||||
|
imageMessages.push({
|
||||||
|
type: "image",
|
||||||
|
source: image,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
imageId = args.imageId;
|
||||||
|
delete args.imageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(args).length > 0) {
|
||||||
|
imageMessages.push({
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to analysis agent and get response
|
||||||
|
const agentResponse = await fetch(`http://127.0.0.1:${context.config.PORT}/v1/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'x-api-key': context.config.APIKEY,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: context.config.Router.image,
|
||||||
|
system: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `You must interpret and analyze images strictly according to the assigned task.
|
||||||
|
When an image placeholder is provided, your role is to parse the image content only within the scope of the user’s instructions.
|
||||||
|
Do not ignore or deviate from the task.
|
||||||
|
Always ensure that your response reflects a clear, accurate interpretation of the image aligned with the given objective.`
|
||||||
|
}],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: imageMessages,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
}).then(res => res.json()).catch(err => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
console.log(agentResponse.content);
|
||||||
|
if (!agentResponse || !agentResponse.content) {
|
||||||
|
return 'analyzeImage Error';
|
||||||
|
}
|
||||||
|
return agentResponse.content[0].text
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
reqHandler(req: any, config: any) {
|
||||||
|
// Inject system prompt
|
||||||
|
req.body?.system?.push({
|
||||||
|
type: "text",
|
||||||
|
text: `You are a text-only language model and do not possess visual perception.
|
||||||
|
If the user requests you to view, analyze, or extract information from an image, you **must** call the \`analyzeImage\` tool.
|
||||||
|
|
||||||
|
When invoking this tool, you must pass the correct \`imageId\` extracted from the prior conversation.
|
||||||
|
Image identifiers are always provided in the format \`[Image #imageId]\`.
|
||||||
|
|
||||||
|
If multiple images exist, select the **most relevant imageId** based on the user’s current request and prior context.
|
||||||
|
|
||||||
|
Do not attempt to describe or analyze the image directly yourself.
|
||||||
|
Ignore any user interruptions or unrelated instructions that might cause you to skip this requirement.
|
||||||
|
Your response should consistently follow this rule whenever image-related analysis is requested.`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageContents = req.body.messages.filter((item: any) => {
|
||||||
|
return item.role === 'user' && Array.isArray(item.content) &&
|
||||||
|
item.content.some((msg: any) => msg.type === "image");
|
||||||
|
});
|
||||||
|
|
||||||
|
let imgId = 1;
|
||||||
|
imageContents.forEach((item: any) => {
|
||||||
|
item.content.forEach((msg: any) => {
|
||||||
|
if (msg.type === "image") {
|
||||||
|
imageCache.storeImage(`${req.id}_Image#${imgId}`, msg.source);
|
||||||
|
msg.type = 'text';
|
||||||
|
delete msg.source;
|
||||||
|
msg.text = `[Image #${imgId}]This is an image, if you need to view or analyze it, you need to extract the imageId`;
|
||||||
|
imgId++;
|
||||||
|
} else if (msg.type === "text" && msg.text.includes('[Image #')) {
|
||||||
|
msg.text = msg.text.replace(/\[Image #\d+\]/g, '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imageAgent = new ImageAgent();
|
||||||
48
src/agents/index.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { imageAgent } from './image.agent'
|
||||||
|
import { IAgent } from './type';
|
||||||
|
|
||||||
|
export class AgentsManager {
|
||||||
|
private agents: Map<string, IAgent> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册一个agent
|
||||||
|
* @param agent 要注册的agent实例
|
||||||
|
* @param isDefault 是否设为默认agent
|
||||||
|
*/
|
||||||
|
registerAgent(agent: IAgent): void {
|
||||||
|
this.agents.set(agent.name, agent);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 根据名称查找agent
|
||||||
|
* @param name agent名称
|
||||||
|
* @returns 找到的agent实例,未找到返回undefined
|
||||||
|
*/
|
||||||
|
getAgent(name: string): IAgent | undefined {
|
||||||
|
return this.agents.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已注册的agents
|
||||||
|
* @returns 所有agent实例的数组
|
||||||
|
*/
|
||||||
|
getAllAgents(): IAgent[] {
|
||||||
|
return Array.from(this.agents.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有agent的工具
|
||||||
|
* @returns 工具数组
|
||||||
|
*/
|
||||||
|
getAllTools(): any[] {
|
||||||
|
const allTools: any[] = [];
|
||||||
|
for (const agent of this.agents.values()) {
|
||||||
|
allTools.push(...agent.tools.values());
|
||||||
|
}
|
||||||
|
return allTools;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentsManager = new AgentsManager()
|
||||||
|
agentsManager.registerAgent(imageAgent)
|
||||||
|
export default agentsManager
|
||||||
19
src/agents/type.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export interface ITool {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
input_schema: any;
|
||||||
|
|
||||||
|
handler: (args: any, context: any) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAgent {
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
tools: Map<string, ITool>;
|
||||||
|
|
||||||
|
shouldHandle: (req: any, config: any) => boolean;
|
||||||
|
|
||||||
|
reqHandler: (req: any, config: any) => void;
|
||||||
|
|
||||||
|
resHandler?: (payload: any, config: any) => void;
|
||||||
|
}
|
||||||
261
src/cli.ts
@@ -2,11 +2,17 @@
|
|||||||
import { run } from "./index";
|
import { run } from "./index";
|
||||||
import { showStatus } from "./utils/status";
|
import { showStatus } from "./utils/status";
|
||||||
import { executeCodeCommand } from "./utils/codeCommand";
|
import { executeCodeCommand } from "./utils/codeCommand";
|
||||||
import { cleanupPidFile, isServiceRunning } from "./utils/processCheck";
|
import { parseStatusLineData, type StatusLineInput } from "./utils/statusline";
|
||||||
|
import {
|
||||||
|
cleanupPidFile,
|
||||||
|
isServiceRunning,
|
||||||
|
getServiceInfo,
|
||||||
|
} from "./utils/processCheck";
|
||||||
import { version } from "../package.json";
|
import { version } from "../package.json";
|
||||||
import { spawn } from "child_process";
|
import { spawn, exec } from "child_process";
|
||||||
import { PID_FILE, REFERENCE_COUNT_FILE } from "./constants";
|
import { PID_FILE, REFERENCE_COUNT_FILE } from "./constants";
|
||||||
import { existsSync, readFileSync } from "fs";
|
import fs, { existsSync, readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
const command = process.argv[2];
|
const command = process.argv[2];
|
||||||
|
|
||||||
@@ -14,16 +20,20 @@ const HELP_TEXT = `
|
|||||||
Usage: ccr [command]
|
Usage: ccr [command]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
start Start service
|
start Start server
|
||||||
stop Stop service
|
stop Stop server
|
||||||
status Show service status
|
restart Restart server
|
||||||
code Execute code command
|
status Show server status
|
||||||
|
statusline Integrated statusline
|
||||||
|
code Execute claude command
|
||||||
|
ui Open the web UI in browser
|
||||||
-v, version Show version information
|
-v, version Show version information
|
||||||
-h, help Show help information
|
-h, help Show help information
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
ccr start
|
ccr start
|
||||||
ccr code "Write a Hello World"
|
ccr code "Write a Hello World"
|
||||||
|
ccr ui
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function waitForService(
|
async function waitForService(
|
||||||
@@ -35,7 +45,8 @@ async function waitForService(
|
|||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
while (Date.now() - startTime < timeout) {
|
while (Date.now() - startTime < timeout) {
|
||||||
if (isServiceRunning()) {
|
const isRunning = await isServiceRunning()
|
||||||
|
if (isRunning) {
|
||||||
// Wait for an additional short period to ensure service is fully ready
|
// Wait for an additional short period to ensure service is fully ready
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
return true;
|
return true;
|
||||||
@@ -46,6 +57,7 @@ async function waitForService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const isRunning = await isServiceRunning()
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "start":
|
case "start":
|
||||||
run();
|
run();
|
||||||
@@ -57,7 +69,7 @@ async function main() {
|
|||||||
cleanupPidFile();
|
cleanupPidFile();
|
||||||
if (existsSync(REFERENCE_COUNT_FILE)) {
|
if (existsSync(REFERENCE_COUNT_FILE)) {
|
||||||
try {
|
try {
|
||||||
require("fs").unlinkSync(REFERENCE_COUNT_FILE);
|
fs.unlinkSync(REFERENCE_COUNT_FILE);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore cleanup errors
|
// Ignore cleanup errors
|
||||||
}
|
}
|
||||||
@@ -73,12 +85,220 @@ async function main() {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "status":
|
case "status":
|
||||||
showStatus();
|
await showStatus();
|
||||||
|
break;
|
||||||
|
case "statusline":
|
||||||
|
// 从stdin读取JSON输入
|
||||||
|
let inputData = "";
|
||||||
|
process.stdin.setEncoding("utf-8");
|
||||||
|
process.stdin.on("readable", () => {
|
||||||
|
let chunk;
|
||||||
|
while ((chunk = process.stdin.read()) !== null) {
|
||||||
|
inputData += chunk;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stdin.on("end", async () => {
|
||||||
|
try {
|
||||||
|
const input: StatusLineInput = JSON.parse(inputData);
|
||||||
|
const statusLine = await parseStatusLineData(input);
|
||||||
|
console.log(statusLine);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing status line data:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "code":
|
case "code":
|
||||||
if (!isServiceRunning()) {
|
if (!isRunning) {
|
||||||
console.log("Service not running, starting service...");
|
console.log("Service not running, starting service...");
|
||||||
const startProcess = spawn("ccr", ["start"], {
|
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()) {
|
||||||
|
// Join all code arguments into a single string to preserve spaces within quotes
|
||||||
|
const codeArgs = process.argv.slice(3);
|
||||||
|
executeCodeCommand(codeArgs);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Service startup timeout, please manually run `ccr start` to start the service"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Join all code arguments into a single string to preserve spaces within quotes
|
||||||
|
const codeArgs = process.argv.slice(3);
|
||||||
|
executeCodeCommand(codeArgs);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ui":
|
||||||
|
// Check if service is running
|
||||||
|
if (!isRunning) {
|
||||||
|
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())) {
|
||||||
|
// If service startup fails, try to start with default config
|
||||||
|
console.log(
|
||||||
|
"Service startup timeout, trying to start with default configuration..."
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
initDir,
|
||||||
|
writeConfigFile,
|
||||||
|
backupConfigFile,
|
||||||
|
} = require("./utils");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize directories
|
||||||
|
await initDir();
|
||||||
|
|
||||||
|
// Backup existing config file if it exists
|
||||||
|
const backupPath = await backupConfigFile();
|
||||||
|
if (backupPath) {
|
||||||
|
console.log(
|
||||||
|
`Backed up existing configuration file to ${backupPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a minimal default config file
|
||||||
|
await writeConfigFile({
|
||||||
|
PORT: 3456,
|
||||||
|
Providers: [],
|
||||||
|
Router: {},
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
"Created minimal default configuration file at ~/.claude-code-router/config.json"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"Please edit this file with your actual configuration."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try starting the service again
|
||||||
|
const restartProcess = spawn("node", [cliPath, "start"], {
|
||||||
|
detached: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
|
||||||
|
restartProcess.on("error", (error) => {
|
||||||
|
console.error(
|
||||||
|
"Failed to start service with default config:",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
restartProcess.unref();
|
||||||
|
|
||||||
|
if (!(await waitForService(15000))) {
|
||||||
|
// Wait a bit longer for the first start
|
||||||
|
console.error(
|
||||||
|
"Service startup still failing. Please manually run `ccr start` to start the service and check the logs."
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
"Failed to create default configuration:",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get service info and open UI
|
||||||
|
const serviceInfo = await getServiceInfo();
|
||||||
|
|
||||||
|
// Add temporary API key as URL parameter if successfully generated
|
||||||
|
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,
|
detached: true,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
});
|
});
|
||||||
@@ -89,22 +309,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
startProcess.unref();
|
startProcess.unref();
|
||||||
|
console.log("✅ Service started successfully in the background.");
|
||||||
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 "-v":
|
|
||||||
case "version":
|
|
||||||
console.log(`claude-code-router version: ${version}`);
|
|
||||||
break;
|
break;
|
||||||
case "-h":
|
case "-h":
|
||||||
case "help":
|
case "help":
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import os from "node:os";
|
|||||||
|
|
||||||
export const HOME_DIR = path.join(os.homedir(), ".claude-code-router");
|
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 = `${HOME_DIR}/plugins`;
|
export const PLUGINS_DIR = path.join(HOME_DIR, "plugins");
|
||||||
|
|
||||||
export const PID_FILE = path.join(HOME_DIR, '.claude-code-router.pid');
|
export const PID_FILE = path.join(HOME_DIR, '.claude-code-router.pid');
|
||||||
|
|
||||||
export const REFERENCE_COUNT_FILE = '/tmp/claude-code-reference-count.txt';
|
export const REFERENCE_COUNT_FILE = path.join(os.tmpdir(), "claude-code-reference-count.txt");
|
||||||
|
|
||||||
|
|
||||||
export const DEFAULT_CONFIG = {
|
export const DEFAULT_CONFIG = {
|
||||||
log: false,
|
LOG: false,
|
||||||
OPENAI_API_KEY: "",
|
OPENAI_API_KEY: "",
|
||||||
OPENAI_BASE_URL: "",
|
OPENAI_BASE_URL: "",
|
||||||
OPENAI_MODEL: "",
|
OPENAI_MODEL: "",
|
||||||
|
|||||||
397
src/index.ts
@@ -1,23 +1,33 @@
|
|||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { writeFile } from "fs/promises";
|
import { writeFile } from "fs/promises";
|
||||||
import { getOpenAICommonOptions, initConfig, initDir } from "./utils";
|
import { homedir } from "os";
|
||||||
|
import path, { join } from "path";
|
||||||
|
import { initConfig, initDir, cleanupLogFiles } from "./utils";
|
||||||
import { createServer } from "./server";
|
import { createServer } from "./server";
|
||||||
import { formatRequest } from "./middlewares/formatRequest";
|
import { router } from "./utils/router";
|
||||||
import { rewriteBody } from "./middlewares/rewriteBody";
|
import { apiKeyAuth } from "./middleware/auth";
|
||||||
import { router } from "./middlewares/router";
|
|
||||||
import OpenAI from "openai";
|
|
||||||
import { streamOpenAIResponse } from "./utils/stream";
|
|
||||||
import {
|
import {
|
||||||
cleanupPidFile,
|
cleanupPidFile,
|
||||||
isServiceRunning,
|
isServiceRunning,
|
||||||
savePid,
|
savePid,
|
||||||
} from "./utils/processCheck";
|
} from "./utils/processCheck";
|
||||||
import { LRUCache } from "lru-cache";
|
import { CONFIG_FILE } from "./constants";
|
||||||
import { log } from "./utils/log";
|
import { createStream } from 'rotating-file-stream';
|
||||||
|
import { HOME_DIR } from "./constants";
|
||||||
|
import { sessionUsageCache } from "./utils/cache";
|
||||||
|
import {SSEParserTransform} from "./utils/SSEParser.transform";
|
||||||
|
import {SSESerializerTransform} from "./utils/SSESerializer.transform";
|
||||||
|
import {rewriteStream} from "./utils/rewriteStream";
|
||||||
|
import JSON5 from "json5";
|
||||||
|
import { IAgent } from "./agents/type";
|
||||||
|
import agentsManager from "./agents";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
const event = new EventEmitter()
|
||||||
|
|
||||||
async function initializeClaudeConfig() {
|
async function initializeClaudeConfig() {
|
||||||
const homeDir = process.env.HOME;
|
const homeDir = homedir();
|
||||||
const configPath = `${homeDir}/.claude.json`;
|
const configPath = join(homeDir, ".claude.json");
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
const userID = Array.from(
|
const userID = Array.from(
|
||||||
{ length: 64 },
|
{ length: 64 },
|
||||||
@@ -39,70 +49,29 @@ interface RunOptions {
|
|||||||
port?: number;
|
port?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelProvider {
|
|
||||||
name: string;
|
|
||||||
api_base_url: string;
|
|
||||||
api_key: string;
|
|
||||||
models: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(options: RunOptions = {}) {
|
async function run(options: RunOptions = {}) {
|
||||||
// Check if service is already running
|
// Check if service is already running
|
||||||
if (isServiceRunning()) {
|
const isRunning = await isServiceRunning()
|
||||||
|
if (isRunning) {
|
||||||
console.log("✅ Service is already running in the background.");
|
console.log("✅ Service is already running in the background.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await initializeClaudeConfig();
|
await initializeClaudeConfig();
|
||||||
await initDir();
|
await initDir();
|
||||||
|
// Clean up old log files, keeping only the 10 most recent ones
|
||||||
|
await cleanupLogFiles();
|
||||||
const config = await initConfig();
|
const config = await initConfig();
|
||||||
|
|
||||||
const Providers = new Map<string, ModelProvider>();
|
|
||||||
const providerCache = new LRUCache<string, OpenAI>({
|
|
||||||
max: 10,
|
|
||||||
ttl: 2 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
function getProviderInstance(providerName: string): OpenAI {
|
let HOST = config.HOST || "127.0.0.1";
|
||||||
const provider: ModelProvider | undefined = Providers.get(providerName);
|
|
||||||
if (provider === undefined) {
|
if (config.HOST && !config.APIKEY) {
|
||||||
throw new Error(`Provider ${providerName} not found`);
|
HOST = "127.0.0.1";
|
||||||
}
|
console.warn("⚠️ API key is not set. HOST is forced to 127.0.0.1.");
|
||||||
let openai = providerCache.get(provider.name);
|
|
||||||
if (!openai) {
|
|
||||||
openai = new OpenAI({
|
|
||||||
baseURL: provider.api_base_url,
|
|
||||||
apiKey: provider.api_key,
|
|
||||||
...getOpenAICommonOptions(),
|
|
||||||
});
|
|
||||||
providerCache.set(provider.name, openai);
|
|
||||||
}
|
|
||||||
return openai;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(config.Providers)) {
|
const port = config.PORT || 3456;
|
||||||
config.Providers.forEach((provider) => {
|
|
||||||
try {
|
|
||||||
Providers.set(provider.name, provider);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to parse model provider:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.OPENAI_API_KEY && config.OPENAI_BASE_URL && config.OPENAI_MODEL) {
|
|
||||||
const defaultProvider = {
|
|
||||||
name: "default",
|
|
||||||
api_base_url: config.OPENAI_BASE_URL,
|
|
||||||
api_key: config.OPENAI_API_KEY,
|
|
||||||
models: [config.OPENAI_MODEL],
|
|
||||||
};
|
|
||||||
Providers.set("default", defaultProvider);
|
|
||||||
} else if (Providers.size > 0) {
|
|
||||||
const defaultProvider = Providers.values().next().value!;
|
|
||||||
Providers.set("default", defaultProvider);
|
|
||||||
}
|
|
||||||
const port = options.port || 3456;
|
|
||||||
|
|
||||||
// Save the PID of the background process
|
// Save the PID of the background process
|
||||||
savePid(process.pid);
|
savePid(process.pid);
|
||||||
@@ -125,38 +94,290 @@ async function run(options: RunOptions = {}) {
|
|||||||
? parseInt(process.env.SERVICE_PORT)
|
? parseInt(process.env.SERVICE_PORT)
|
||||||
: port;
|
: port;
|
||||||
|
|
||||||
const server = await createServer(servicePort);
|
// Configure logger based on config settings
|
||||||
server.useMiddleware((req, res, next) => {
|
const pad = num => (num > 9 ? "" : "0") + num;
|
||||||
req.config = config;
|
const generator = (time, index) => {
|
||||||
next();
|
if (!time) {
|
||||||
});
|
time = new Date()
|
||||||
server.useMiddleware(rewriteBody);
|
|
||||||
if (
|
|
||||||
config.Router?.background &&
|
|
||||||
config.Router?.think &&
|
|
||||||
config?.Router?.longContext
|
|
||||||
) {
|
|
||||||
server.useMiddleware(router);
|
|
||||||
} else {
|
|
||||||
server.useMiddleware((req, res, next) => {
|
|
||||||
req.provider = "default";
|
|
||||||
req.body.model = config.OPENAI_MODEL;
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
server.useMiddleware(formatRequest);
|
|
||||||
|
|
||||||
server.app.post("/v1/messages", async (req, res) => {
|
var month = time.getFullYear() + "" + pad(time.getMonth() + 1);
|
||||||
try {
|
var day = pad(time.getDate());
|
||||||
const provider = getProviderInstance(req.provider || "default");
|
var hour = pad(time.getHours());
|
||||||
const completion: any = await provider.chat.completions.create(req.body);
|
var minute = pad(time.getMinutes());
|
||||||
await streamOpenAIResponse(res, completion, req.body.model, req.body);
|
|
||||||
} catch (e) {
|
return `./logs/ccr-${month}${day}${hour}${minute}${pad(time.getSeconds())}${index ? `_${index}` : ''}.log`;
|
||||||
log("Error in OpenAI API call:", e);
|
};
|
||||||
|
const loggerConfig =
|
||||||
|
config.LOG !== false
|
||||||
|
? {
|
||||||
|
level: config.LOG_LEVEL || "debug",
|
||||||
|
stream: createStream(generator, {
|
||||||
|
path: HOME_DIR,
|
||||||
|
maxFiles: 3,
|
||||||
|
interval: "1d",
|
||||||
|
compress: false,
|
||||||
|
maxSize: "50M"
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: false;
|
||||||
|
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
logger: loggerConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add global error handlers to prevent the service from crashing
|
||||||
|
process.on("uncaughtException", (err) => {
|
||||||
|
server.log.error("Uncaught exception:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason, promise) => {
|
||||||
|
server.log.error("Unhandled rejection at:", promise, "reason:", reason);
|
||||||
|
});
|
||||||
|
// Add async preHandler hook for authentication
|
||||||
|
server.addHook("preHandler", async (req, reply) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const done = (err?: Error) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
};
|
||||||
|
// Call the async auth function
|
||||||
|
apiKeyAuth(config)(req, reply, done).catch(reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.addHook("preHandler", async (req, reply) => {
|
||||||
|
if (req.url.startsWith("/v1/messages")) {
|
||||||
|
const useAgents = []
|
||||||
|
|
||||||
|
for (const agent of agentsManager.getAllAgents()) {
|
||||||
|
if (agent.shouldHandle(req, config)) {
|
||||||
|
// 设置agent标识
|
||||||
|
useAgents.push(agent.name)
|
||||||
|
|
||||||
|
// change request body
|
||||||
|
agent.reqHandler(req, config);
|
||||||
|
|
||||||
|
// append agent tools
|
||||||
|
if (agent.tools.size) {
|
||||||
|
if (!req.body?.tools?.length) {
|
||||||
|
req.body.tools = []
|
||||||
|
}
|
||||||
|
req.body.tools.unshift(...Array.from(agent.tools.values()).map(item => {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
input_schema: item.input_schema
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useAgents.length) {
|
||||||
|
req.agents = useAgents;
|
||||||
|
}
|
||||||
|
await router(req, reply, {
|
||||||
|
config,
|
||||||
|
event
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
server.addHook("onError", async (request, reply, error) => {
|
||||||
|
event.emit('onError', request, reply, error);
|
||||||
|
})
|
||||||
|
server.addHook("onSend", (req, reply, payload, done) => {
|
||||||
|
if (req.sessionId && req.url.startsWith("/v1/messages")) {
|
||||||
|
if (payload instanceof ReadableStream) {
|
||||||
|
if (req.agents) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const eventStream = payload.pipeThrough(new SSEParserTransform())
|
||||||
|
let currentAgent: undefined | IAgent;
|
||||||
|
let currentToolIndex = -1
|
||||||
|
let currentToolName = ''
|
||||||
|
let currentToolArgs = ''
|
||||||
|
let currentToolId = ''
|
||||||
|
const toolMessages: any[] = []
|
||||||
|
const assistantMessages: any[] = []
|
||||||
|
// 存储Anthropic格式的消息体,区分文本和工具类型
|
||||||
|
return done(null, rewriteStream(eventStream, async (data, controller) => {
|
||||||
|
try {
|
||||||
|
// 检测工具调用开始
|
||||||
|
if (data.event === 'content_block_start' && data?.data?.content_block?.name) {
|
||||||
|
const agent = req.agents.find((name: string) => agentsManager.getAgent(name)?.tools.get(data.data.content_block.name))
|
||||||
|
if (agent) {
|
||||||
|
currentAgent = agentsManager.getAgent(agent)
|
||||||
|
currentToolIndex = data.data.index
|
||||||
|
currentToolName = data.data.content_block.name
|
||||||
|
currentToolId = data.data.content_block.id
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集工具参数
|
||||||
|
if (currentToolIndex > -1 && data.data.index === currentToolIndex && data.data?.delta?.type === 'input_json_delta') {
|
||||||
|
currentToolArgs += data.data?.delta?.partial_json;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用完成,处理agent调用
|
||||||
|
if (currentToolIndex > -1 && data.data.index === currentToolIndex && data.data.type === 'content_block_stop') {
|
||||||
|
try {
|
||||||
|
const args = JSON5.parse(currentToolArgs);
|
||||||
|
assistantMessages.push({
|
||||||
|
type: "tool_use",
|
||||||
|
id: currentToolId,
|
||||||
|
name: currentToolName,
|
||||||
|
input: args
|
||||||
|
})
|
||||||
|
const toolResult = await currentAgent?.tools.get(currentToolName)?.handler(args, {
|
||||||
|
req,
|
||||||
|
config
|
||||||
|
});
|
||||||
|
toolMessages.push({
|
||||||
|
"tool_use_id": currentToolId,
|
||||||
|
"type": "tool_result",
|
||||||
|
"content": toolResult
|
||||||
|
})
|
||||||
|
currentAgent = undefined
|
||||||
|
currentToolIndex = -1
|
||||||
|
currentToolName = ''
|
||||||
|
currentToolArgs = ''
|
||||||
|
currentToolId = ''
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.event === 'message_delta' && toolMessages.length) {
|
||||||
|
req.body.messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantMessages
|
||||||
|
})
|
||||||
|
req.body.messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: toolMessages
|
||||||
|
})
|
||||||
|
const response = await fetch(`http://127.0.0.1:${config.PORT}/v1/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'x-api-key': config.APIKEY,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req.body),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const stream = response.body!.pipeThrough(new SSEParserTransform())
|
||||||
|
const reader = stream.getReader()
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const {value, done} = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (['message_start', 'message_stop'].includes(value.event)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查流是否仍然可写
|
||||||
|
if (!controller.desiredSize) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.enqueue(value)
|
||||||
|
}catch (readError: any) {
|
||||||
|
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||||
|
abortController.abort(); // 中止所有相关操作
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw readError;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}catch (error: any) {
|
||||||
|
console.error('Unexpected error in stream processing:', error);
|
||||||
|
|
||||||
|
// 处理流提前关闭的错误
|
||||||
|
if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||||
|
abortController.abort();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他错误仍然抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}).pipeThrough(new SSESerializerTransform()))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [originalStream, clonedStream] = payload.tee();
|
||||||
|
const read = async (stream: ReadableStream) => {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
// Process the value if needed
|
||||||
|
const dataStr = new TextDecoder().decode(value);
|
||||||
|
if (!dataStr.startsWith("event: message_delta")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const str = dataStr.slice(27);
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(str);
|
||||||
|
sessionUsageCache.put(req.sessionId, message.usage);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch (readError: any) {
|
||||||
|
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||||
|
console.error('Background read stream closed prematurely');
|
||||||
|
} else {
|
||||||
|
console.error('Error in background stream reading:', readError);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
read(clonedStream);
|
||||||
|
return done(null, originalStream)
|
||||||
|
}
|
||||||
|
sessionUsageCache.put(req.sessionId, payload.usage);
|
||||||
|
if (typeof payload ==='object') {
|
||||||
|
if (payload.error) {
|
||||||
|
return done(payload.error, null)
|
||||||
|
} else {
|
||||||
|
return done(payload, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof payload ==='object' && payload.error) {
|
||||||
|
return done(payload.error, null)
|
||||||
|
}
|
||||||
|
done(null, payload)
|
||||||
|
});
|
||||||
|
server.addHook("onSend", async (req, reply, payload) => {
|
||||||
|
event.emit('onSend', req, reply, payload);
|
||||||
|
return payload;
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
server.start();
|
server.start();
|
||||||
console.log(`🚀 Claude Code Router is running on port ${servicePort}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { run };
|
export { run };
|
||||||
|
|||||||
92
src/middleware/auth.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { FastifyRequest, FastifyReply } from "fastify";
|
||||||
|
|
||||||
|
export const apiKeyAuth =
|
||||||
|
(config: any) =>
|
||||||
|
async (req: FastifyRequest, reply: FastifyReply, done: () => void) => {
|
||||||
|
// Public endpoints that don't require authentication
|
||||||
|
if (["/", "/health"].includes(req.url) || req.url.startsWith("/ui")) {
|
||||||
|
return done();
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = config.APIKEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
// If no API key is set, enable CORS for local
|
||||||
|
const allowedOrigins = [
|
||||||
|
`http://127.0.0.1:${config.PORT || 3456}`,
|
||||||
|
`http://localhost:${config.PORT || 3456}`,
|
||||||
|
];
|
||||||
|
if (req.headers.origin && !allowedOrigins.includes(req.headers.origin)) {
|
||||||
|
reply.status(403).send("CORS not allowed for this origin");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
reply.header('Access-Control-Allow-Origin', `http://127.0.0.1:${config.PORT || 3456}`);
|
||||||
|
reply.header('Access-Control-Allow-Origin', `http://localhost:${config.PORT || 3456}`);
|
||||||
|
}
|
||||||
|
return done();
|
||||||
|
}
|
||||||
|
const isConfigEndpoint = req.url.startsWith("/api/config");
|
||||||
|
const isRestartEndpoint = req.url === "/api/restart";
|
||||||
|
|
||||||
|
// For config endpoints and restart endpoint, we implement granular access control
|
||||||
|
if (isConfigEndpoint || isRestartEndpoint) {
|
||||||
|
// Attach access level to request for later use
|
||||||
|
(req as any).accessLevel = "restricted";
|
||||||
|
|
||||||
|
// If no API key is set in config, allow restricted access
|
||||||
|
if (!apiKey) {
|
||||||
|
(req as any).accessLevel = "restricted";
|
||||||
|
return done();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If API key is set, check authentication
|
||||||
|
const authHeaderValue =
|
||||||
|
req.headers.authorization || req.headers["x-api-key"];
|
||||||
|
const authKey: string = Array.isArray(authHeaderValue)
|
||||||
|
? authHeaderValue[0]
|
||||||
|
: authHeaderValue || "";
|
||||||
|
|
||||||
|
if (!authKey) {
|
||||||
|
(req as any).accessLevel = "restricted";
|
||||||
|
return done();
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = "";
|
||||||
|
if (authKey.startsWith("Bearer")) {
|
||||||
|
token = authKey.split(" ")[1];
|
||||||
|
} else {
|
||||||
|
token = authKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token !== apiKey) {
|
||||||
|
(req as any).accessLevel = "restricted";
|
||||||
|
return done();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full access for authenticated users
|
||||||
|
(req as any).accessLevel = "full";
|
||||||
|
return done();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaderValue =
|
||||||
|
req.headers.authorization || req.headers["x-api-key"];
|
||||||
|
const authKey: string = Array.isArray(authHeaderValue)
|
||||||
|
? authHeaderValue[0]
|
||||||
|
: authHeaderValue || "";
|
||||||
|
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();
|
||||||
|
};
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { MessageCreateParamsBase } from "@anthropic-ai/sdk/resources/messages";
|
|
||||||
import OpenAI from "openai";
|
|
||||||
import { streamOpenAIResponse } from "../utils/stream";
|
|
||||||
import { log } from "../utils/log";
|
|
||||||
|
|
||||||
export const formatRequest = async (
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) => {
|
|
||||||
let {
|
|
||||||
model,
|
|
||||||
max_tokens,
|
|
||||||
messages,
|
|
||||||
system = [],
|
|
||||||
temperature,
|
|
||||||
metadata,
|
|
||||||
tools,
|
|
||||||
stream,
|
|
||||||
}: MessageCreateParamsBase = req.body;
|
|
||||||
log("formatRequest: ", req.body);
|
|
||||||
try {
|
|
||||||
// @ts-ignore
|
|
||||||
const openAIMessages = Array.isArray(messages)
|
|
||||||
? messages.flatMap((anthropicMessage) => {
|
|
||||||
const openAiMessagesFromThisAnthropicMessage = [];
|
|
||||||
|
|
||||||
if (!Array.isArray(anthropicMessage.content)) {
|
|
||||||
// Handle simple string content
|
|
||||||
if (typeof anthropicMessage.content === "string") {
|
|
||||||
openAiMessagesFromThisAnthropicMessage.push({
|
|
||||||
role: anthropicMessage.role,
|
|
||||||
content: anthropicMessage.content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// If content is not string and not array (e.g. null/undefined), it will result in an empty array, effectively skipping this message.
|
|
||||||
return openAiMessagesFromThisAnthropicMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle array content
|
|
||||||
if (anthropicMessage.role === "assistant") {
|
|
||||||
const assistantMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content: null, // Will be populated if text parts exist
|
|
||||||
};
|
|
||||||
let textContent = "";
|
|
||||||
// @ts-ignore
|
|
||||||
const toolCalls = []; // Corrected type here
|
|
||||||
|
|
||||||
anthropicMessage.content.forEach((contentPart) => {
|
|
||||||
if (contentPart.type === "text") {
|
|
||||||
textContent +=
|
|
||||||
(typeof contentPart.text === "string"
|
|
||||||
? contentPart.text
|
|
||||||
: JSON.stringify(contentPart.text)) + "\\n";
|
|
||||||
} else if (contentPart.type === "tool_use") {
|
|
||||||
toolCalls.push({
|
|
||||||
id: contentPart.id,
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: contentPart.name,
|
|
||||||
arguments: JSON.stringify(contentPart.input),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const trimmedTextContent = textContent.trim();
|
|
||||||
if (trimmedTextContent.length > 0) {
|
|
||||||
// @ts-ignore
|
|
||||||
assistantMessage.content = trimmedTextContent;
|
|
||||||
}
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
// @ts-ignore
|
|
||||||
assistantMessage.tool_calls = toolCalls;
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
if (
|
|
||||||
assistantMessage.content ||
|
|
||||||
// @ts-ignore
|
|
||||||
(assistantMessage.tool_calls &&
|
|
||||||
// @ts-ignore
|
|
||||||
assistantMessage.tool_calls.length > 0)
|
|
||||||
) {
|
|
||||||
openAiMessagesFromThisAnthropicMessage.push(assistantMessage);
|
|
||||||
}
|
|
||||||
} else if (anthropicMessage.role === "user") {
|
|
||||||
// For user messages, text parts are combined into one message.
|
|
||||||
// Tool results are transformed into subsequent, separate 'tool' role messages.
|
|
||||||
let userTextMessageContent = "";
|
|
||||||
// @ts-ignore
|
|
||||||
const subsequentToolMessages = [];
|
|
||||||
|
|
||||||
anthropicMessage.content.forEach((contentPart) => {
|
|
||||||
if (contentPart.type === "text") {
|
|
||||||
userTextMessageContent +=
|
|
||||||
(typeof contentPart.text === "string"
|
|
||||||
? contentPart.text
|
|
||||||
: JSON.stringify(contentPart.text)) + "\\n";
|
|
||||||
} else if (contentPart.type === "tool_result") {
|
|
||||||
// Each tool_result becomes a separate 'tool' message
|
|
||||||
subsequentToolMessages.push({
|
|
||||||
role: "tool",
|
|
||||||
tool_call_id: contentPart.tool_use_id,
|
|
||||||
content:
|
|
||||||
typeof contentPart.content === "string"
|
|
||||||
? contentPart.content
|
|
||||||
: JSON.stringify(contentPart.content),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const trimmedUserText = userTextMessageContent.trim();
|
|
||||||
if (trimmedUserText.length > 0) {
|
|
||||||
openAiMessagesFromThisAnthropicMessage.push({
|
|
||||||
role: "user",
|
|
||||||
content: trimmedUserText,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
openAiMessagesFromThisAnthropicMessage.push(
|
|
||||||
// @ts-ignore
|
|
||||||
...subsequentToolMessages
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Fallback for other roles (e.g. system, or custom roles if they were to appear here with array content)
|
|
||||||
// This will combine all text parts into a single message for that role.
|
|
||||||
let combinedContent = "";
|
|
||||||
anthropicMessage.content.forEach((contentPart) => {
|
|
||||||
if (contentPart.type === "text") {
|
|
||||||
combinedContent +=
|
|
||||||
(typeof contentPart.text === "string"
|
|
||||||
? contentPart.text
|
|
||||||
: JSON.stringify(contentPart.text)) + "\\n";
|
|
||||||
} else {
|
|
||||||
// For non-text parts in other roles, stringify them or handle as appropriate
|
|
||||||
combinedContent += JSON.stringify(contentPart) + "\\n";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const trimmedCombinedContent = combinedContent.trim();
|
|
||||||
if (trimmedCombinedContent.length > 0) {
|
|
||||||
openAiMessagesFromThisAnthropicMessage.push({
|
|
||||||
role: anthropicMessage.role, // Cast needed as role could be other than 'user'/'assistant'
|
|
||||||
content: trimmedCombinedContent,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return openAiMessagesFromThisAnthropicMessage;
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const systemMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =
|
|
||||||
Array.isArray(system)
|
|
||||||
? system.map((item) => ({
|
|
||||||
role: "system",
|
|
||||||
content: item.text,
|
|
||||||
}))
|
|
||||||
: [{ role: "system", content: system }];
|
|
||||||
const data: any = {
|
|
||||||
model,
|
|
||||||
messages: [...systemMessages, ...openAIMessages],
|
|
||||||
temperature,
|
|
||||||
stream,
|
|
||||||
};
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (stream) {
|
|
||||||
res.setHeader("Content-Type", "text/event-stream");
|
|
||||||
}
|
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
|
||||||
res.setHeader("Connection", "keep-alive");
|
|
||||||
req.body = data;
|
|
||||||
console.log(JSON.stringify(data.messages, null, 2));
|
|
||||||
} 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,
|
|
||||||
object: "chat.completion.chunk",
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
delta: {
|
|
||||||
content: `Error: ${(error as Error).message}`,
|
|
||||||
},
|
|
||||||
finish_reason: "stop",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await streamOpenAIResponse(res, errorCompletion, model, req.body);
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
@@ -1,45 +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 (!req.config.usePlugins) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
for (const plugin of req.config.usePlugins) {
|
|
||||||
const pluginPath = path.join(PLUGINS_DIR, `${plugin.trim()}.js`);
|
|
||||||
try {
|
|
||||||
await access(pluginPath);
|
|
||||||
const rewritePlugin = require(pluginPath);
|
|
||||||
await rewritePlugin(req, res);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { MessageCreateParamsBase } from "@anthropic-ai/sdk/resources/messages";
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { get_encoding } from "tiktoken";
|
|
||||||
import { log } from "../utils/log";
|
|
||||||
|
|
||||||
const enc = get_encoding("cl100k_base");
|
|
||||||
|
|
||||||
const getUseModel = (req: Request, tokenCount: number) => {
|
|
||||||
const [provider, model] = req.body.model.split(",");
|
|
||||||
if (provider && model) {
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// if tokenCount is greater than 32K, use the long context model
|
|
||||||
if (tokenCount > 1000 * 32) {
|
|
||||||
log("Using long context model due to token count:", tokenCount);
|
|
||||||
const [provider, model] = req.config.Router!.longContext.split(",");
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// If the model is claude-3-5-haiku, use the background model
|
|
||||||
if (req.body.model?.startsWith("claude-3-5-haiku")) {
|
|
||||||
log("Using background model for ", req.body.model);
|
|
||||||
const [provider, model] = req.config.Router!.background.split(",");
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// if exits thinking, use the think model
|
|
||||||
if (req.body.thinking) {
|
|
||||||
log("Using think model for ", req.body.thinking);
|
|
||||||
const [provider, model] = req.config.Router!.think.split(",");
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
provider: "default",
|
|
||||||
model: req.config.OPENAI_MODEL,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const router = async (
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) => {
|
|
||||||
const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
|
|
||||||
try {
|
|
||||||
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) => {
|
|
||||||
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) => {
|
|
||||||
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) => {
|
|
||||||
tokenCount += enc.encode(textPart || "").length;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (tools) {
|
|
||||||
tools.forEach((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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const { provider, model } = getUseModel(req, tokenCount);
|
|
||||||
req.provider = provider;
|
|
||||||
req.body.model = model;
|
|
||||||
} catch (error) {
|
|
||||||
log("Error in router middleware:", error.message);
|
|
||||||
req.provider = "default";
|
|
||||||
req.body.model = req.config.OPENAI_MODEL;
|
|
||||||
} finally {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
208
src/server.ts
@@ -1,23 +1,195 @@
|
|||||||
import express, { RequestHandler } from "express";
|
import Server from "@musistudio/llms";
|
||||||
|
import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
|
||||||
|
import { checkForUpdates, performUpdate } from "./utils";
|
||||||
|
import { join } from "path";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
|
import { homedir } from "os";
|
||||||
|
|
||||||
interface Server {
|
export const createServer = (config: any): Server => {
|
||||||
app: express.Application;
|
const server = new Server(config);
|
||||||
useMiddleware: (middleware: RequestHandler) => void;
|
|
||||||
start: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createServer = async (port: number): Promise<Server> => {
|
// Add endpoint to read config.json with access control
|
||||||
const app = express();
|
server.app.get("/api/config", async (req, reply) => {
|
||||||
app.use(express.json({ limit: "500mb" }));
|
return await readConfigFile();
|
||||||
return {
|
|
||||||
app,
|
|
||||||
useMiddleware: (middleware: RequestHandler) => {
|
|
||||||
app.use("/v1/messages", middleware);
|
|
||||||
},
|
|
||||||
start: () => {
|
|
||||||
app.listen(port, () => {
|
|
||||||
console.log(`Server is running on port ${port}`);
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
|
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 with access control
|
||||||
|
server.app.post("/api/config", async (req, reply) => {
|
||||||
|
const newConfig = req.body;
|
||||||
|
|
||||||
|
// Backup existing config file if it exists
|
||||||
|
const backupPath = await backupConfigFile();
|
||||||
|
if (backupPath) {
|
||||||
|
console.log(`Backed up existing configuration file to ${backupPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeConfigFile(newConfig);
|
||||||
|
return { success: true, message: "Config saved successfully" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add endpoint to restart the service with access control
|
||||||
|
server.app.post("/api/restart", async (req, 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(process.execPath, [process.argv[1], "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/");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 版本检查端点
|
||||||
|
server.app.get("/api/update/check", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
// 获取当前版本
|
||||||
|
const currentVersion = require("../package.json").version;
|
||||||
|
const { hasUpdate, latestVersion, changelog } = await checkForUpdates(currentVersion);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasUpdate,
|
||||||
|
latestVersion: hasUpdate ? latestVersion : undefined,
|
||||||
|
changelog: hasUpdate ? changelog : undefined
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check for updates:", error);
|
||||||
|
reply.status(500).send({ error: "Failed to check for updates" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行更新端点
|
||||||
|
server.app.post("/api/update/perform", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
// 只允许完全访问权限的用户执行更新
|
||||||
|
const accessLevel = (req as any).accessLevel || "restricted";
|
||||||
|
if (accessLevel !== "full") {
|
||||||
|
reply.status(403).send("Full access required to perform updates");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行更新逻辑
|
||||||
|
const result = await performUpdate();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to perform update:", error);
|
||||||
|
reply.status(500).send({ error: "Failed to perform update" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取日志文件列表端点
|
||||||
|
server.app.get("/api/logs/files", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const logDir = join(homedir(), ".claude-code-router", "logs");
|
||||||
|
const logFiles: Array<{ name: string; path: string; size: number; lastModified: string }> = [];
|
||||||
|
|
||||||
|
if (existsSync(logDir)) {
|
||||||
|
const files = readdirSync(logDir);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.log')) {
|
||||||
|
const filePath = join(logDir, file);
|
||||||
|
const stats = statSync(filePath);
|
||||||
|
|
||||||
|
logFiles.push({
|
||||||
|
name: file,
|
||||||
|
path: filePath,
|
||||||
|
size: stats.size,
|
||||||
|
lastModified: stats.mtime.toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按修改时间倒序排列
|
||||||
|
logFiles.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
return logFiles;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get log files:", error);
|
||||||
|
reply.status(500).send({ error: "Failed to get log files" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取日志内容端点
|
||||||
|
server.app.get("/api/logs", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const filePath = (req.query as any).file as string;
|
||||||
|
let logFilePath: string;
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
// 如果指定了文件路径,使用指定的路径
|
||||||
|
logFilePath = filePath;
|
||||||
|
} else {
|
||||||
|
// 如果没有指定文件路径,使用默认的日志文件路径
|
||||||
|
logFilePath = join(homedir(), ".claude-code-router", "logs", "app.log");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(logFilePath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const logContent = readFileSync(logFilePath, 'utf8');
|
||||||
|
const logLines = logContent.split('\n').filter(line => line.trim())
|
||||||
|
|
||||||
|
return logLines;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get logs:", error);
|
||||||
|
reply.status(500).send({ error: "Failed to get logs" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清除日志内容端点
|
||||||
|
server.app.delete("/api/logs", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const filePath = (req.query as any).file as string;
|
||||||
|
let logFilePath: string;
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
// 如果指定了文件路径,使用指定的路径
|
||||||
|
logFilePath = filePath;
|
||||||
|
} else {
|
||||||
|
// 如果没有指定文件路径,使用默认的日志文件路径
|
||||||
|
logFilePath = join(homedir(), ".claude-code-router", "logs", "app.log");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(logFilePath)) {
|
||||||
|
writeFileSync(logFilePath, '', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: "Logs cleared successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to clear logs:", error);
|
||||||
|
reply.status(500).send({ error: "Failed to clear logs" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
};
|
};
|
||||||
|
|||||||
73
src/utils/SSEParser.transform.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
export class SSEParserTransform extends TransformStream<string, any> {
|
||||||
|
private buffer = '';
|
||||||
|
private currentEvent: Record<string, any> = {};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
transform: (chunk: string, controller) => {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const text = decoder.decode(chunk);
|
||||||
|
this.buffer += text;
|
||||||
|
const lines = this.buffer.split('\n');
|
||||||
|
|
||||||
|
// 保留最后一行(可能不完整)
|
||||||
|
this.buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const event = this.processLine(line);
|
||||||
|
if (event) {
|
||||||
|
controller.enqueue(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
flush: (controller) => {
|
||||||
|
// 处理缓冲区中剩余的内容
|
||||||
|
if (this.buffer.trim()) {
|
||||||
|
const events: any[] = [];
|
||||||
|
this.processLine(this.buffer.trim(), events);
|
||||||
|
events.forEach(event => controller.enqueue(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送最后一个事件(如果有)
|
||||||
|
if (Object.keys(this.currentEvent).length > 0) {
|
||||||
|
controller.enqueue(this.currentEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private processLine(line: string, events?: any[]): any | null {
|
||||||
|
if (!line.trim()) {
|
||||||
|
if (Object.keys(this.currentEvent).length > 0) {
|
||||||
|
const event = { ...this.currentEvent };
|
||||||
|
this.currentEvent = {};
|
||||||
|
if (events) {
|
||||||
|
events.push(event);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
this.currentEvent.event = line.slice(6).trim();
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
const data = line.slice(5).trim();
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
this.currentEvent.data = { type: 'done' };
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
this.currentEvent.data = JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
this.currentEvent.data = { raw: data, error: 'JSON parse failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (line.startsWith('id:')) {
|
||||||
|
this.currentEvent.id = line.slice(3).trim();
|
||||||
|
} else if (line.startsWith('retry:')) {
|
||||||
|
this.currentEvent.retry = parseInt(line.slice(6).trim());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/utils/SSESerializer.transform.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export class SSESerializerTransform extends TransformStream<any, string> {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
transform: (event, controller) => {
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
if (event.event) {
|
||||||
|
output += `event: ${event.event}\n`;
|
||||||
|
}
|
||||||
|
if (event.id) {
|
||||||
|
output += `id: ${event.id}\n`;
|
||||||
|
}
|
||||||
|
if (event.retry) {
|
||||||
|
output += `retry: ${event.retry}\n`;
|
||||||
|
}
|
||||||
|
if (event.data) {
|
||||||
|
if (event.data.type === 'done') {
|
||||||
|
output += 'data: [DONE]\n';
|
||||||
|
} else {
|
||||||
|
output += `data: ${JSON.stringify(event.data)}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output += '\n';
|
||||||
|
controller.enqueue(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/utils/cache.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// LRU cache for session usage
|
||||||
|
|
||||||
|
export interface Usage {
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LRUCache<K, V> {
|
||||||
|
private capacity: number;
|
||||||
|
private cache: Map<K, V>;
|
||||||
|
|
||||||
|
constructor(capacity: number) {
|
||||||
|
this.capacity = capacity;
|
||||||
|
this.cache = new Map<K, V>();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
if (!this.cache.has(key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = this.cache.get(key) as V;
|
||||||
|
// Move to end to mark as recently used
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.cache.set(key, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
put(key: K, value: V): void {
|
||||||
|
if (this.cache.has(key)) {
|
||||||
|
// If key exists, delete it to update its position
|
||||||
|
this.cache.delete(key);
|
||||||
|
} else if (this.cache.size >= this.capacity) {
|
||||||
|
// If cache is full, delete the least recently used item
|
||||||
|
const leastRecentlyUsedKey = this.cache.keys().next().value;
|
||||||
|
if (leastRecentlyUsedKey !== undefined) {
|
||||||
|
this.cache.delete(leastRecentlyUsedKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cache.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
values(): V[] {
|
||||||
|
return Array.from(this.cache.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionUsageCache = new LRUCache<string, Usage>(100);
|
||||||
@@ -5,8 +5,9 @@ import { join } from 'path';
|
|||||||
|
|
||||||
export async function closeService() {
|
export async function closeService() {
|
||||||
const PID_FILE = join(HOME_DIR, '.claude-code-router.pid');
|
const PID_FILE = join(HOME_DIR, '.claude-code-router.pid');
|
||||||
|
const isRunning = await isServiceRunning()
|
||||||
|
|
||||||
if (!isServiceRunning()) {
|
if (!isRunning) {
|
||||||
console.log("No service is currently running.");
|
console.log("No service is currently running.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,88 @@
|
|||||||
import { spawn } from "child_process";
|
import { spawn, type StdioOptions } from "child_process";
|
||||||
import {
|
import { readConfigFile } from ".";
|
||||||
incrementReferenceCount,
|
|
||||||
decrementReferenceCount,
|
|
||||||
} from "./processCheck";
|
|
||||||
import { closeService } from "./close";
|
import { closeService } from "./close";
|
||||||
|
import {
|
||||||
|
decrementReferenceCount,
|
||||||
|
incrementReferenceCount,
|
||||||
|
} from "./processCheck";
|
||||||
|
import {HOME_DIR} from "../constants";
|
||||||
|
import {join} from "path";
|
||||||
|
|
||||||
export async function executeCodeCommand(args: string[] = []) {
|
export async function executeCodeCommand(args: string[] = []) {
|
||||||
// Set environment variables
|
// Set environment variables
|
||||||
const env = {
|
const config = await readConfigFile();
|
||||||
|
const port = config.PORT || 3456;
|
||||||
|
const env: Record<string, string> = {
|
||||||
...process.env,
|
...process.env,
|
||||||
HTTPS_PROXY: undefined,
|
ANTHROPIC_AUTH_TOKEN: config?.APIKEY || "test",
|
||||||
HTTP_PROXY: undefined,
|
ANTHROPIC_API_KEY: '',
|
||||||
ALL_PROXY: undefined,
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||||
https_proxy: undefined,
|
NO_PROXY: `127.0.0.1`,
|
||||||
http_proxy: undefined,
|
DISABLE_TELEMETRY: 'true',
|
||||||
all_proxy: undefined,
|
DISABLE_COST_WARNINGS: 'true',
|
||||||
DISABLE_PROMPT_CACHING: "1",
|
API_TIMEOUT_MS: String(config.API_TIMEOUT_MS ?? 600000), // Default to 10 minutes if not set
|
||||||
ANTHROPIC_AUTH_TOKEN: "test",
|
|
||||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:3456`,
|
|
||||||
API_TIMEOUT_MS: "600000",
|
|
||||||
};
|
};
|
||||||
|
let settingsFlag: Record<string, any> | undefined;
|
||||||
|
if (config?.StatusLine?.enabled) {
|
||||||
|
settingsFlag = {
|
||||||
|
statusLine: {
|
||||||
|
type: "command",
|
||||||
|
command: "ccr statusline",
|
||||||
|
padding: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.push(`--settings=${JSON.stringify(settingsFlag)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-interactive mode for automation environments
|
||||||
|
if (config.NON_INTERACTIVE_MODE) {
|
||||||
|
env.CI = "true";
|
||||||
|
env.FORCE_COLOR = "0";
|
||||||
|
env.NODE_NO_READLINE = "1";
|
||||||
|
env.TERM = "dumb";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set ANTHROPIC_SMALL_FAST_MODEL if it exists in config
|
||||||
|
if (config?.ANTHROPIC_SMALL_FAST_MODEL) {
|
||||||
|
env.ANTHROPIC_SMALL_FAST_MODEL = config.ANTHROPIC_SMALL_FAST_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (config?.APIKEY) {
|
||||||
|
// env.ANTHROPIC_API_KEY = config.APIKEY;
|
||||||
|
// delete env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
// }
|
||||||
|
|
||||||
// Increment reference count when command starts
|
// Increment reference count when command starts
|
||||||
incrementReferenceCount();
|
incrementReferenceCount();
|
||||||
|
|
||||||
// Execute claude command
|
// Execute claude command
|
||||||
const claudePath = process.env.CLAUDE_PATH || "claude";
|
const claudePath = config?.CLAUDE_PATH || process.env.CLAUDE_PATH || "claude";
|
||||||
const claudeProcess = spawn(claudePath, args, {
|
|
||||||
|
// Properly join arguments to preserve spaces in quotes
|
||||||
|
// Wrap each argument in double quotes to preserve single and double quotes inside arguments
|
||||||
|
const joinedArgs =
|
||||||
|
args.length > 0
|
||||||
|
? args.map((arg) => `"${arg.replace(/\"/g, '\\"')}"`).join(" ")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// 🔥 CONFIG-DRIVEN: stdio configuration based on environment
|
||||||
|
const stdioConfig: StdioOptions = config.NON_INTERACTIVE_MODE
|
||||||
|
? ["pipe", "inherit", "inherit"] // Pipe stdin for non-interactive
|
||||||
|
: "inherit"; // Default inherited behavior
|
||||||
|
const claudeProcess = spawn(
|
||||||
|
claudePath + (joinedArgs ? ` ${joinedArgs}` : ""),
|
||||||
|
[],
|
||||||
|
{
|
||||||
env,
|
env,
|
||||||
stdio: "inherit",
|
stdio: stdioConfig,
|
||||||
shell: true
|
shell: true,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close stdin for non-interactive mode
|
||||||
|
if (config.NON_INTERACTIVE_MODE) {
|
||||||
|
claudeProcess.stdin?.end();
|
||||||
|
}
|
||||||
|
|
||||||
claudeProcess.on("error", (error) => {
|
claudeProcess.on("error", (error) => {
|
||||||
console.error("Failed to start claude command:", error.message);
|
console.error("Failed to start claude command:", error.message);
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
||||||
import OpenAI, { ClientOptions } from "openai";
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
|
import JSON5 from "json5";
|
||||||
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
CONFIG_FILE,
|
CONFIG_FILE,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
HOME_DIR,
|
HOME_DIR,
|
||||||
PLUGINS_DIR,
|
PLUGINS_DIR,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
|
import { cleanupLogFiles } from "./logCleanup";
|
||||||
|
|
||||||
export function getOpenAICommonOptions(): ClientOptions {
|
// Function to interpolate environment variables in config values
|
||||||
const options: ClientOptions = {};
|
const interpolateEnvVars = (obj: any): any => {
|
||||||
if (process.env.PROXY_URL) {
|
if (typeof obj === "string") {
|
||||||
options.httpAgent = new HttpsProxyAgent(process.env.PROXY_URL);
|
// Replace $VAR_NAME or ${VAR_NAME} with environment variable values
|
||||||
} else if (process.env.HTTPS_PROXY) {
|
return obj.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (match, braced, unbraced) => {
|
||||||
options.httpAgent = new HttpsProxyAgent(process.env.HTTPS_PROXY);
|
const varName = braced || unbraced;
|
||||||
|
return process.env[varName] || match; // Keep original if env var doesn't exist
|
||||||
|
});
|
||||||
|
} else if (Array.isArray(obj)) {
|
||||||
|
return obj.map(interpolateEnvVars);
|
||||||
|
} else if (obj !== null && typeof obj === "object") {
|
||||||
|
const result: any = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
result[key] = interpolateEnvVars(value);
|
||||||
}
|
}
|
||||||
return options;
|
return result;
|
||||||
}
|
}
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
const ensureDir = async (dir_path: string) => {
|
const ensureDir = async (dir_path: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -30,6 +41,7 @@ const ensureDir = async (dir_path: string) => {
|
|||||||
export const initDir = async () => {
|
export const initDir = async () => {
|
||||||
await ensureDir(HOME_DIR);
|
await ensureDir(HOME_DIR);
|
||||||
await ensureDir(PLUGINS_DIR);
|
await ensureDir(PLUGINS_DIR);
|
||||||
|
await ensureDir(path.join(HOME_DIR, "logs"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const createReadline = () => {
|
const createReadline = () => {
|
||||||
@@ -57,24 +69,102 @@ const confirm = async (query: string): Promise<boolean> => {
|
|||||||
export const readConfigFile = async () => {
|
export const readConfigFile = async () => {
|
||||||
try {
|
try {
|
||||||
const config = await fs.readFile(CONFIG_FILE, "utf-8");
|
const config = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||||
return JSON.parse(config);
|
try {
|
||||||
} catch {
|
// Try to parse with JSON5 first (which also supports standard JSON)
|
||||||
const apiKey = await question("Enter OPENAI_API_KEY: ");
|
const parsedConfig = JSON5.parse(config);
|
||||||
const baseUrl = await question("Enter OPENAI_BASE_URL: ");
|
// Interpolate environment variables in the parsed config
|
||||||
const model = await question("Enter OPENAI_MODEL: ");
|
return interpolateEnvVars(parsedConfig);
|
||||||
const config = Object.assign({}, DEFAULT_CONFIG, {
|
} catch (parseError) {
|
||||||
OPENAI_API_KEY: apiKey,
|
console.error(`Failed to parse config file at ${CONFIG_FILE}`);
|
||||||
OPENAI_BASE_URL: baseUrl,
|
console.error("Error details:", (parseError as Error).message);
|
||||||
OPENAI_MODEL: model,
|
console.error("Please check your config file syntax.");
|
||||||
});
|
process.exit(1);
|
||||||
await writeConfigFile(config);
|
|
||||||
return config;
|
|
||||||
}
|
}
|
||||||
|
} catch (readError: any) {
|
||||||
|
if (readError.code === "ENOENT") {
|
||||||
|
// Config file doesn't exist, prompt user for initial setup
|
||||||
|
try {
|
||||||
|
// Initialize directories
|
||||||
|
await initDir();
|
||||||
|
|
||||||
|
// Backup existing config file if it exists
|
||||||
|
const backupPath = await backupConfigFile();
|
||||||
|
if (backupPath) {
|
||||||
|
console.log(
|
||||||
|
`Backed up existing configuration file to ${backupPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
PORT: 3456,
|
||||||
|
Providers: [],
|
||||||
|
Router: {},
|
||||||
|
}
|
||||||
|
// Create a minimal default config file
|
||||||
|
await writeConfigFile(config);
|
||||||
|
console.log(
|
||||||
|
"Created minimal default configuration file at ~/.claude-code-router/config.json"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"Please edit this file with your actual configuration."
|
||||||
|
);
|
||||||
|
return config
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
"Failed to create default configuration:",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to read config file at ${CONFIG_FILE}`);
|
||||||
|
console.error("Error details:", readError.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const backupConfigFile = async () => {
|
||||||
|
try {
|
||||||
|
if (await fs.access(CONFIG_FILE).then(() => true).catch(() => false)) {
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupPath = `${CONFIG_FILE}.${timestamp}.bak`;
|
||||||
|
await fs.copyFile(CONFIG_FILE, backupPath);
|
||||||
|
|
||||||
|
// Clean up old backups, keeping only the 3 most recent
|
||||||
|
try {
|
||||||
|
const configDir = path.dirname(CONFIG_FILE);
|
||||||
|
const configFileName = path.basename(CONFIG_FILE);
|
||||||
|
const files = await fs.readdir(configDir);
|
||||||
|
|
||||||
|
// Find all backup files for this config
|
||||||
|
const backupFiles = files
|
||||||
|
.filter(file => file.startsWith(configFileName) && file.endsWith('.bak'))
|
||||||
|
.sort()
|
||||||
|
.reverse(); // Sort in descending order (newest first)
|
||||||
|
|
||||||
|
// Delete all but the 3 most recent backups
|
||||||
|
if (backupFiles.length > 3) {
|
||||||
|
for (let i = 3; i < backupFiles.length; i++) {
|
||||||
|
const oldBackupPath = path.join(configDir, backupFiles[i]);
|
||||||
|
await fs.unlink(oldBackupPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn("Failed to clean up old backups:", cleanupError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return backupPath;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to backup config file:", error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const writeConfigFile = async (config: any) => {
|
export const writeConfigFile = async (config: any) => {
|
||||||
await ensureDir(HOME_DIR);
|
await ensureDir(HOME_DIR);
|
||||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
const configWithComment = `${JSON.stringify(config, null, 2)}`;
|
||||||
|
await fs.writeFile(CONFIG_FILE, configWithComment);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initConfig = async () => {
|
export const initConfig = async () => {
|
||||||
@@ -83,10 +173,8 @@ export const initConfig = async () => {
|
|||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createClient = (options: ClientOptions) => {
|
// 导出日志清理函数
|
||||||
const client = new OpenAI({
|
export { cleanupLogFiles };
|
||||||
...options,
|
|
||||||
...getOpenAICommonOptions(),
|
// 导出更新功能
|
||||||
});
|
export { checkForUpdates, performUpdate } from "./update";
|
||||||
return client;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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");
|
|
||||||
|
|
||||||
// Ensure log directory exists
|
|
||||||
if (!fs.existsSync(HOME_DIR)) {
|
|
||||||
fs.mkdirSync(HOME_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function log(...args: any[]) {
|
|
||||||
// Check if logging is enabled via environment variable
|
|
||||||
const isLogEnabled = process.env.LOG === "true";
|
|
||||||
|
|
||||||
if (!isLogEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
44
src/utils/logCleanup.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { HOME_DIR } from "../constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up old log files, keeping only the most recent ones
|
||||||
|
* @param maxFiles - Maximum number of log files to keep (default: 9)
|
||||||
|
*/
|
||||||
|
export async function cleanupLogFiles(maxFiles: number = 9): Promise<void> {
|
||||||
|
try {
|
||||||
|
const logsDir = path.join(HOME_DIR, "logs");
|
||||||
|
|
||||||
|
// Check if logs directory exists
|
||||||
|
try {
|
||||||
|
await fs.access(logsDir);
|
||||||
|
} catch {
|
||||||
|
// Logs directory doesn't exist, nothing to clean up
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all files in the logs directory
|
||||||
|
const files = await fs.readdir(logsDir);
|
||||||
|
|
||||||
|
// Filter for log files (files starting with 'ccr-' and ending with '.log')
|
||||||
|
const logFiles = files
|
||||||
|
.filter(file => file.startsWith('ccr-') && file.endsWith('.log'))
|
||||||
|
.sort()
|
||||||
|
.reverse(); // Sort in descending order (newest first)
|
||||||
|
|
||||||
|
// Delete files exceeding the maxFiles limit
|
||||||
|
if (logFiles.length > maxFiles) {
|
||||||
|
for (let i = maxFiles; i < logFiles.length; i++) {
|
||||||
|
const filePath = path.join(logsDir, logFiles[i]);
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to delete log file ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to clean up log files:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||||
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
|
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
|
||||||
|
import { readConfigFile } from '.';
|
||||||
|
import find from 'find-process';
|
||||||
|
|
||||||
|
export async function isProcessRunning(pid: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const processes = await find('pid', pid);
|
||||||
|
return processes.length > 0;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function incrementReferenceCount() {
|
export function incrementReferenceCount() {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -26,15 +37,14 @@ export function getReferenceCount(): number {
|
|||||||
return parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
|
return parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isServiceRunning(): boolean {
|
export async function isServiceRunning(): Promise<boolean> {
|
||||||
if (!existsSync(PID_FILE)) {
|
if (!existsSync(PID_FILE)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
|
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
|
||||||
process.kill(pid, 0);
|
return await isProcessRunning(pid);
|
||||||
return true;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Process not running, clean up pid file
|
// Process not running, clean up pid file
|
||||||
cleanupPidFile();
|
cleanupPidFile();
|
||||||
@@ -70,15 +80,17 @@ export function getServicePid(): number | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServiceInfo() {
|
export async function getServiceInfo() {
|
||||||
const pid = getServicePid();
|
const pid = getServicePid();
|
||||||
const running = isServiceRunning();
|
const running = await isServiceRunning();
|
||||||
|
const config = await readConfigFile();
|
||||||
|
const port = config.PORT || 3456;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
running,
|
running,
|
||||||
pid,
|
pid,
|
||||||
port: 3456,
|
port,
|
||||||
endpoint: 'http://127.0.0.1:3456',
|
endpoint: `http://127.0.0.1:${port}`,
|
||||||
pidFile: PID_FILE,
|
pidFile: PID_FILE,
|
||||||
referenceCount: getReferenceCount()
|
referenceCount: getReferenceCount()
|
||||||
};
|
};
|
||||||
|
|||||||
31
src/utils/rewriteStream.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**rewriteStream
|
||||||
|
* 读取源readablestream,返回一个新的readablestream,由processor对源数据进行处理后将返回的新值推送到新的stream,如果没有返回值则不推送
|
||||||
|
* @param stream
|
||||||
|
* @param processor
|
||||||
|
*/
|
||||||
|
export const rewriteStream = (stream: ReadableStream, processor: (data: any, controller: ReadableStreamController<any>) => Promise<any>): ReadableStream => {
|
||||||
|
const reader = stream.getReader()
|
||||||
|
|
||||||
|
return new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) {
|
||||||
|
controller.close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const processed = await processor(value, controller)
|
||||||
|
if (processed !== undefined) {
|
||||||
|
controller.enqueue(processed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
controller.error(error)
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
184
src/utils/router.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import {
|
||||||
|
MessageCreateParamsBase,
|
||||||
|
MessageParam,
|
||||||
|
Tool,
|
||||||
|
} from "@anthropic-ai/sdk/resources/messages";
|
||||||
|
import { get_encoding } from "tiktoken";
|
||||||
|
import { sessionUsageCache, Usage } from "./cache";
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
|
||||||
|
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,
|
||||||
|
lastUsage?: Usage | undefined
|
||||||
|
) => {
|
||||||
|
if (req.body.model.includes(",")) {
|
||||||
|
const [provider, model] = req.body.model.split(",");
|
||||||
|
const finalProvider = config.Providers.find(
|
||||||
|
(p: any) => p.name.toLowerCase() === provider
|
||||||
|
);
|
||||||
|
const finalModel = finalProvider?.models?.find(
|
||||||
|
(m: any) => m.toLowerCase() === model
|
||||||
|
);
|
||||||
|
if (finalProvider && finalModel) {
|
||||||
|
return `${finalProvider.name},${finalModel}`;
|
||||||
|
}
|
||||||
|
return req.body.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if tokenCount is greater than the configured threshold, use the long context model
|
||||||
|
const longContextThreshold = config.Router.longContextThreshold || 60000;
|
||||||
|
const lastUsageThreshold =
|
||||||
|
lastUsage &&
|
||||||
|
lastUsage.input_tokens > longContextThreshold &&
|
||||||
|
tokenCount > 20000;
|
||||||
|
const tokenCountThreshold = tokenCount > longContextThreshold;
|
||||||
|
if (
|
||||||
|
(lastUsageThreshold || tokenCountThreshold) &&
|
||||||
|
config.Router.longContext
|
||||||
|
) {
|
||||||
|
req.log.info(
|
||||||
|
`Using long context model due to token count: ${tokenCount}, threshold: ${longContextThreshold}`
|
||||||
|
);
|
||||||
|
return config.Router.longContext;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
req.body?.system?.length > 1 &&
|
||||||
|
req.body?.system[1]?.text?.startsWith("<CCR-SUBAGENT-MODEL>")
|
||||||
|
) {
|
||||||
|
const model = req.body?.system[1].text.match(
|
||||||
|
/<CCR-SUBAGENT-MODEL>(.*?)<\/CCR-SUBAGENT-MODEL>/s
|
||||||
|
);
|
||||||
|
if (model) {
|
||||||
|
req.body.system[1].text = req.body.system[1].text.replace(
|
||||||
|
`<CCR-SUBAGENT-MODEL>${model[1]}</CCR-SUBAGENT-MODEL>`,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
return model[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the model is claude-3-5-haiku, use the background model
|
||||||
|
if (
|
||||||
|
req.body.model?.startsWith("claude-3-5-haiku") &&
|
||||||
|
config.Router.background
|
||||||
|
) {
|
||||||
|
req.log.info(`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) {
|
||||||
|
req.log.info(`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, context: any) => {
|
||||||
|
const { config, event } = context;
|
||||||
|
// Parse sessionId from metadata.user_id
|
||||||
|
if (req.body.metadata?.user_id) {
|
||||||
|
const parts = req.body.metadata.user_id.split("_session_");
|
||||||
|
if (parts.length > 1) {
|
||||||
|
req.sessionId = parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lastMessageUsage = sessionUsageCache.get(req.sessionId);
|
||||||
|
const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
|
||||||
|
if (config.REWRITE_SYSTEM_PROMPT && system.length > 1 && system[1]?.text?.includes('<env>')) {
|
||||||
|
const prompt = await readFile(config.REWRITE_SYSTEM_PROMPT, 'utf-8');
|
||||||
|
system[1].text = `${prompt}<env>${system[1].text.split('<env>').pop()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
req.tokenCount = tokenCount; // Pass token count to custom router
|
||||||
|
model = await customRouter(req, config, {
|
||||||
|
event
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
req.log.error(`failed to load custom router: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!model) {
|
||||||
|
model = await getUseModel(req, tokenCount, config, lastMessageUsage);
|
||||||
|
}
|
||||||
|
req.body.model = model;
|
||||||
|
} catch (error: any) {
|
||||||
|
req.log.error(`Error in router middleware: ${error.message}`);
|
||||||
|
req.body.model = config.Router!.default;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getServiceInfo } from './processCheck';
|
import { getServiceInfo } from './processCheck';
|
||||||
|
|
||||||
export function showStatus() {
|
export async function showStatus() {
|
||||||
const info = getServiceInfo();
|
const info = await getServiceInfo();
|
||||||
|
|
||||||
console.log('\n📊 Claude Code Router Status');
|
console.log('\n📊 Claude Code Router Status');
|
||||||
console.log('═'.repeat(40));
|
console.log('═'.repeat(40));
|
||||||
@@ -15,7 +15,7 @@ export function showStatus() {
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log('🚀 Ready to use! Run the following commands:');
|
console.log('🚀 Ready to use! Run the following commands:');
|
||||||
console.log(' ccr code # Start coding with Claude');
|
console.log(' ccr code # Start coding with Claude');
|
||||||
console.log(' ccr close # Stop the service');
|
console.log(' ccr stop # Stop the service');
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Status: Not Running');
|
console.log('❌ Status: Not Running');
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|||||||
813
src/utils/statusline.ts
Normal file
@@ -0,0 +1,813 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { CONFIG_FILE, HOME_DIR } from "../constants";
|
||||||
|
import JSON5 from "json5";
|
||||||
|
|
||||||
|
export interface StatusLineModuleConfig {
|
||||||
|
type: string;
|
||||||
|
icon?: string;
|
||||||
|
text: string;
|
||||||
|
color?: string;
|
||||||
|
background?: string;
|
||||||
|
scriptPath?: string; // 用于script类型的模块,指定要执行的Node.js脚本文件路径
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusLineThemeConfig {
|
||||||
|
modules: StatusLineModuleConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusLineInput {
|
||||||
|
hook_event_name: string;
|
||||||
|
session_id: string;
|
||||||
|
transcript_path: string;
|
||||||
|
cwd: string;
|
||||||
|
model: {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
};
|
||||||
|
workspace: {
|
||||||
|
current_dir: string;
|
||||||
|
project_dir: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantMessage {
|
||||||
|
type: "assistant";
|
||||||
|
message: {
|
||||||
|
model: string;
|
||||||
|
usage: {
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANSIColor代码
|
||||||
|
const COLORS: Record<string, string> = {
|
||||||
|
reset: "\x1b[0m",
|
||||||
|
bold: "\x1b[1m",
|
||||||
|
dim: "\x1b[2m",
|
||||||
|
// 标准颜色
|
||||||
|
black: "\x1b[30m",
|
||||||
|
red: "\x1b[31m",
|
||||||
|
green: "\x1b[32m",
|
||||||
|
yellow: "\x1b[33m",
|
||||||
|
blue: "\x1b[34m",
|
||||||
|
magenta: "\x1b[35m",
|
||||||
|
cyan: "\x1b[36m",
|
||||||
|
white: "\x1b[37m",
|
||||||
|
// 亮色
|
||||||
|
bright_black: "\x1b[90m",
|
||||||
|
bright_red: "\x1b[91m",
|
||||||
|
bright_green: "\x1b[92m",
|
||||||
|
bright_yellow: "\x1b[93m",
|
||||||
|
bright_blue: "\x1b[94m",
|
||||||
|
bright_magenta: "\x1b[95m",
|
||||||
|
bright_cyan: "\x1b[96m",
|
||||||
|
bright_white: "\x1b[97m",
|
||||||
|
// 背景颜色
|
||||||
|
bg_black: "\x1b[40m",
|
||||||
|
bg_red: "\x1b[41m",
|
||||||
|
bg_green: "\x1b[42m",
|
||||||
|
bg_yellow: "\x1b[43m",
|
||||||
|
bg_blue: "\x1b[44m",
|
||||||
|
bg_magenta: "\x1b[45m",
|
||||||
|
bg_cyan: "\x1b[46m",
|
||||||
|
bg_white: "\x1b[47m",
|
||||||
|
// 亮背景色
|
||||||
|
bg_bright_black: "\x1b[100m",
|
||||||
|
bg_bright_red: "\x1b[101m",
|
||||||
|
bg_bright_green: "\x1b[102m",
|
||||||
|
bg_bright_yellow: "\x1b[103m",
|
||||||
|
bg_bright_blue: "\x1b[104m",
|
||||||
|
bg_bright_magenta: "\x1b[105m",
|
||||||
|
bg_bright_cyan: "\x1b[106m",
|
||||||
|
bg_bright_white: "\x1b[107m",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用TrueColor(24位色)支持十六进制颜色
|
||||||
|
const TRUE_COLOR_PREFIX = "\x1b[38;2;";
|
||||||
|
const TRUE_COLOR_BG_PREFIX = "\x1b[48;2;";
|
||||||
|
|
||||||
|
// 将十六进制颜色转为RGB格式
|
||||||
|
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||||
|
// 移除#和空格
|
||||||
|
hex = hex.replace(/^#/, '').trim();
|
||||||
|
|
||||||
|
// 处理简写形式 (#RGB -> #RRGGBB)
|
||||||
|
if (hex.length === 3) {
|
||||||
|
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hex.length !== 6) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
// 验证RGB值是否有效
|
||||||
|
if (isNaN(r) || isNaN(g) || isNaN(b) || r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取颜色代码
|
||||||
|
function getColorCode(colorName: string): string {
|
||||||
|
// 检查是否是十六进制颜色
|
||||||
|
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
|
||||||
|
const rgb = hexToRgb(colorName);
|
||||||
|
if (rgb) {
|
||||||
|
return `${TRUE_COLOR_PREFIX}${rgb.r};${rgb.g};${rgb.b}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回空字符串
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 变量替换函数,支持{{var}}格式的变量替换
|
||||||
|
function replaceVariables(text: string, variables: Record<string, string>): string {
|
||||||
|
return text.replace(/\{\{(\w+)\}\}/g, (_match, varName) => {
|
||||||
|
return variables[varName] || "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行脚本并获取输出
|
||||||
|
async function executeScript(scriptPath: string, variables: Record<string, string>): Promise<string> {
|
||||||
|
try {
|
||||||
|
// 检查文件是否存在
|
||||||
|
await fs.access(scriptPath);
|
||||||
|
|
||||||
|
// 使用require动态加载脚本模块
|
||||||
|
const scriptModule = require(scriptPath);
|
||||||
|
|
||||||
|
// 如果导出的是函数,则调用它并传入变量
|
||||||
|
if (typeof scriptModule === 'function') {
|
||||||
|
const result = scriptModule(variables);
|
||||||
|
// 如果返回的是Promise,则等待它完成
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
return await result;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果导出的是default函数,则调用它
|
||||||
|
if (scriptModule.default && typeof scriptModule.default === 'function') {
|
||||||
|
const result = scriptModule.default(variables);
|
||||||
|
// 如果返回的是Promise,则等待它完成
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
return await result;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果导出的是字符串,则直接返回
|
||||||
|
if (typeof scriptModule === 'string') {
|
||||||
|
return scriptModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果导出的是default字符串,则返回它
|
||||||
|
if (scriptModule.default && typeof scriptModule.default === 'string') {
|
||||||
|
return scriptModule.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认情况下返回空字符串
|
||||||
|
return "";
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`执行脚本 ${scriptPath} 时出错:`, error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认主题配置 - 使用Nerd Fonts图标和美观配色
|
||||||
|
const DEFAULT_THEME: StatusLineThemeConfig = {
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
type: "workDir",
|
||||||
|
icon: "", // nf-md-folder_outline
|
||||||
|
text: "{{workDirName}}",
|
||||||
|
color: "bright_blue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "gitBranch",
|
||||||
|
icon: "", // nf-dev-git_branch
|
||||||
|
text: "{{gitBranch}}",
|
||||||
|
color: "bright_magenta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "model",
|
||||||
|
icon: "", // nf-md-robot_outline
|
||||||
|
text: "{{model}}",
|
||||||
|
color: "bright_cyan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "usage",
|
||||||
|
icon: "↑", // 上箭头
|
||||||
|
text: "{{inputTokens}}",
|
||||||
|
color: "bright_green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "usage",
|
||||||
|
icon: "↓", // 下箭头
|
||||||
|
text: "{{outputTokens}}",
|
||||||
|
color: "bright_yellow"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Powerline风格主题配置
|
||||||
|
const POWERLINE_THEME: StatusLineThemeConfig = {
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
type: "workDir",
|
||||||
|
icon: "", // nf-md-folder_outline
|
||||||
|
text: "{{workDirName}}",
|
||||||
|
color: "white",
|
||||||
|
background: "bg_bright_blue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "gitBranch",
|
||||||
|
icon: "", // nf-dev-git_branch
|
||||||
|
text: "{{gitBranch}}",
|
||||||
|
color: "white",
|
||||||
|
background: "bg_bright_magenta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "model",
|
||||||
|
icon: "", // nf-md-robot_outline
|
||||||
|
text: "{{model}}",
|
||||||
|
color: "white",
|
||||||
|
background: "bg_bright_cyan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "usage",
|
||||||
|
icon: "↑", // 上箭头
|
||||||
|
text: "{{inputTokens}}",
|
||||||
|
color: "white",
|
||||||
|
background: "bg_bright_green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "usage",
|
||||||
|
icon: "↓", // 下箭头
|
||||||
|
text: "{{outputTokens}}",
|
||||||
|
color: "white",
|
||||||
|
background: "bg_bright_yellow"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简单文本主题配置 - 用于图标无法显示时的fallback
|
||||||
|
const SIMPLE_THEME: StatusLineThemeConfig = {
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
type: "workDir",
|
||||||
|
icon: "",
|
||||||
|
text: "{{workDirName}}",
|
||||||
|
color: "bright_blue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "gitBranch",
|
||||||
|
icon: "",
|
||||||
|
text: "{{gitBranch}}",
|
||||||
|
color: "bright_magenta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "model",
|
||||||
|
icon: "",
|
||||||
|
text: "{{model}}",
|
||||||
|
color: "bright_cyan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "usage",
|
||||||
|
icon: "↑",
|
||||||
|
text: "{{inputTokens}}",
|
||||||
|
color: "bright_green"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "usage",
|
||||||
|
icon: "↓",
|
||||||
|
text: "{{outputTokens}}",
|
||||||
|
color: "bright_yellow"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化usage信息,如果大于1000则使用k单位
|
||||||
|
function formatUsage(input_tokens: number, output_tokens: number): string {
|
||||||
|
if (input_tokens > 1000 || output_tokens > 1000) {
|
||||||
|
const inputFormatted = input_tokens > 1000 ? `${(input_tokens / 1000).toFixed(1)}k` : `${input_tokens}`;
|
||||||
|
const outputFormatted = output_tokens > 1000 ? `${(output_tokens / 1000).toFixed(1)}k` : `${output_tokens}`;
|
||||||
|
return `${inputFormatted} ${outputFormatted}`;
|
||||||
|
}
|
||||||
|
return `${input_tokens} ${output_tokens}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取用户主目录的主题配置
|
||||||
|
async function getProjectThemeConfig(): Promise<{ theme: StatusLineThemeConfig | null, style: string }> {
|
||||||
|
try {
|
||||||
|
// 只使用主目录的固定配置文件
|
||||||
|
const configPath = CONFIG_FILE;
|
||||||
|
|
||||||
|
// 检查配置文件是否存在
|
||||||
|
try {
|
||||||
|
await fs.access(configPath);
|
||||||
|
} catch {
|
||||||
|
return { theme: null, style: 'default' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const configContent = await fs.readFile(configPath, "utf-8");
|
||||||
|
const config = JSON5.parse(configContent);
|
||||||
|
|
||||||
|
// 检查是否有StatusLine配置
|
||||||
|
if (config.StatusLine) {
|
||||||
|
// 获取当前使用的风格,默认为default
|
||||||
|
const currentStyle = config.StatusLine.currentStyle || 'default';
|
||||||
|
|
||||||
|
// 检查是否有对应风格的配置
|
||||||
|
if (config.StatusLine[currentStyle] && config.StatusLine[currentStyle].modules) {
|
||||||
|
return { theme: config.StatusLine[currentStyle], style: currentStyle };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 如果读取失败,返回null
|
||||||
|
// console.error("Failed to read theme config:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { theme: null, style: 'default' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否应该使用简单主题(fallback方案)
|
||||||
|
// 当环境变量 USE_SIMPLE_ICONS 被设置时,或者当检测到可能不支持Nerd Fonts的终端时
|
||||||
|
function shouldUseSimpleTheme(): boolean {
|
||||||
|
// 检查环境变量
|
||||||
|
if (process.env.USE_SIMPLE_ICONS === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查终端类型(一些常见的不支持复杂图标的终端)
|
||||||
|
const term = process.env.TERM || '';
|
||||||
|
const unsupportedTerms = ['dumb', 'unknown'];
|
||||||
|
if (unsupportedTerms.includes(term)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认情况下,假设终端支持Nerd Fonts
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查Nerd Fonts图标是否能正确显示
|
||||||
|
// 通过检查终端字体信息或使用试探性方法
|
||||||
|
function canDisplayNerdFonts(): boolean {
|
||||||
|
// 如果环境变量明确指定使用简单图标,则不能显示Nerd Fonts
|
||||||
|
if (process.env.USE_SIMPLE_ICONS === 'true') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查一些常见的支持Nerd Fonts的终端环境变量
|
||||||
|
const fontEnvVars = ['NERD_FONT', 'NERDFONT', 'FONT'];
|
||||||
|
for (const envVar of fontEnvVars) {
|
||||||
|
const value = process.env[envVar];
|
||||||
|
if (value && (value.includes('Nerd') || value.includes('nerd'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查终端类型
|
||||||
|
const termProgram = process.env.TERM_PROGRAM || '';
|
||||||
|
const supportedTerminals = ['iTerm.app', 'vscode', 'Hyper', 'kitty', 'alacritty'];
|
||||||
|
if (supportedTerminals.includes(termProgram)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查COLORTERM环境变量
|
||||||
|
const colorTerm = process.env.COLORTERM || '';
|
||||||
|
if (colorTerm.includes('truecolor') || colorTerm.includes('24bit')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认情况下,假设可以显示Nerd Fonts(但允许用户通过环境变量覆盖)
|
||||||
|
return process.env.USE_SIMPLE_ICONS !== 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查特定Unicode字符是否能正确显示
|
||||||
|
// 这是一个简单的试探性检查
|
||||||
|
function canDisplayUnicodeCharacter(char: string): boolean {
|
||||||
|
// 对于Nerd Fonts图标,我们假设支持UTF-8的终端可以显示
|
||||||
|
// 但实际上很难准确检测,所以我们依赖环境变量和终端类型检测
|
||||||
|
try {
|
||||||
|
// 检查终端是否支持UTF-8
|
||||||
|
const lang = process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || '';
|
||||||
|
if (lang.includes('UTF-8') || lang.includes('utf8') || lang.includes('UTF8')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查LC_*环境变量
|
||||||
|
const lcVars = ['LC_ALL', 'LC_CTYPE', 'LANG'];
|
||||||
|
for (const lcVar of lcVars) {
|
||||||
|
const value = process.env[lcVar];
|
||||||
|
if (value && (value.includes('UTF-8') || value.includes('utf8'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 如果检查失败,默认返回true
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认情况下,假设可以显示
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseStatusLineData(input: StatusLineInput): Promise<string> {
|
||||||
|
try {
|
||||||
|
// 检查是否应该使用简单主题
|
||||||
|
const useSimpleTheme = shouldUseSimpleTheme();
|
||||||
|
|
||||||
|
// 检查是否可以显示Nerd Fonts图标
|
||||||
|
const canDisplayNerd = canDisplayNerdFonts();
|
||||||
|
|
||||||
|
// 确定使用的主题:如果用户强制使用简单主题或无法显示Nerd Fonts,则使用简单主题
|
||||||
|
const effectiveTheme = useSimpleTheme || !canDisplayNerd ? SIMPLE_THEME : DEFAULT_THEME;
|
||||||
|
|
||||||
|
// 获取主目录的主题配置,如果没有则使用确定的默认配置
|
||||||
|
const { theme: projectTheme, style: currentStyle } = await getProjectThemeConfig();
|
||||||
|
const theme = projectTheme || effectiveTheme;
|
||||||
|
|
||||||
|
// 获取当前工作目录和Git分支
|
||||||
|
const workDir = input.workspace.current_dir;
|
||||||
|
let gitBranch = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试获取Git分支名
|
||||||
|
gitBranch = execSync("git branch --show-current", {
|
||||||
|
cwd: workDir,
|
||||||
|
stdio: ["pipe", "pipe", "ignore"],
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
} catch (error) {
|
||||||
|
// 如果不是Git仓库或获取失败,则忽略错误
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从transcript_path文件中读取最后一条assistant消息
|
||||||
|
const transcriptContent = await fs.readFile(input.transcript_path, "utf-8");
|
||||||
|
const lines = transcriptContent.trim().split("\n");
|
||||||
|
|
||||||
|
// 反向遍历寻找最后一条assistant消息
|
||||||
|
let model = "";
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
try {
|
||||||
|
const message: AssistantMessage = JSON.parse(lines[i]);
|
||||||
|
if (message.type === "assistant" && message.message.model) {
|
||||||
|
model = message.message.model;
|
||||||
|
|
||||||
|
if (message.message.usage) {
|
||||||
|
inputTokens = message.message.usage.input_tokens;
|
||||||
|
outputTokens = message.message.usage.output_tokens;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
// 忽略解析错误,继续查找
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有从transcript中获取到模型名称,则尝试从配置文件中获取
|
||||||
|
if (!model) {
|
||||||
|
try {
|
||||||
|
// 获取项目配置文件路径
|
||||||
|
const projectConfigPath = path.join(workDir, ".claude-code-router", "config.json");
|
||||||
|
let configPath = projectConfigPath;
|
||||||
|
|
||||||
|
// 检查项目配置文件是否存在,如果不存在则使用用户主目录的配置文件
|
||||||
|
try {
|
||||||
|
await fs.access(projectConfigPath);
|
||||||
|
} catch {
|
||||||
|
configPath = CONFIG_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取配置文件
|
||||||
|
const configContent = await fs.readFile(configPath, "utf-8");
|
||||||
|
const config = JSON5.parse(configContent);
|
||||||
|
|
||||||
|
// 从Router字段的default内容中获取模型名称
|
||||||
|
if (config.Router && config.Router.default) {
|
||||||
|
const [, defaultModel] = config.Router.default.split(",");
|
||||||
|
if (defaultModel) {
|
||||||
|
model = defaultModel.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (configError) {
|
||||||
|
// 如果配置文件读取失败,则忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍然没有获取到模型名称,则使用传入的JSON数据中的model字段的display_name
|
||||||
|
if (!model) {
|
||||||
|
model = input.model.display_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取工作目录名
|
||||||
|
const workDirName = workDir.split("/").pop() || "";
|
||||||
|
|
||||||
|
// 格式化usage信息
|
||||||
|
const usage = formatUsage(inputTokens, outputTokens);
|
||||||
|
const [formattedInputTokens, formattedOutputTokens] = usage.split(" ");
|
||||||
|
|
||||||
|
// 定义变量替换映射
|
||||||
|
const variables = {
|
||||||
|
workDirName,
|
||||||
|
gitBranch,
|
||||||
|
model,
|
||||||
|
inputTokens: formattedInputTokens,
|
||||||
|
outputTokens: formattedOutputTokens
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确定使用的风格
|
||||||
|
const isPowerline = currentStyle === 'powerline';
|
||||||
|
|
||||||
|
// 根据风格渲染状态行
|
||||||
|
if (isPowerline) {
|
||||||
|
return await renderPowerlineStyle(theme, variables);
|
||||||
|
} else {
|
||||||
|
return await renderDefaultStyle(theme, variables);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 发生错误时返回空字符串
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取用户主目录的主题配置(指定风格)
|
||||||
|
async function getProjectThemeConfigForStyle(style: string): Promise<StatusLineThemeConfig | null> {
|
||||||
|
try {
|
||||||
|
// 只使用主目录的固定配置文件
|
||||||
|
const configPath = CONFIG_FILE;
|
||||||
|
|
||||||
|
// 检查配置文件是否存在
|
||||||
|
try {
|
||||||
|
await fs.access(configPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configContent = await fs.readFile(configPath, "utf-8");
|
||||||
|
const config = JSON5.parse(configContent);
|
||||||
|
|
||||||
|
// 检查是否有StatusLine配置
|
||||||
|
if (config.StatusLine && config.StatusLine[style] && config.StatusLine[style].modules) {
|
||||||
|
return config.StatusLine[style];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 如果读取失败,返回null
|
||||||
|
// console.error("Failed to read theme config:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染默认风格的状态行
|
||||||
|
async function renderDefaultStyle(
|
||||||
|
theme: StatusLineThemeConfig,
|
||||||
|
variables: Record<string, string>
|
||||||
|
): Promise<string> {
|
||||||
|
const modules = theme.modules || DEFAULT_THEME.modules;
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// 遍历模块数组,渲染每个模块
|
||||||
|
for (let i = 0; i < Math.min(modules.length, 5); i++) {
|
||||||
|
const module = modules[i];
|
||||||
|
const color = module.color ? getColorCode(module.color) : "";
|
||||||
|
const background = module.background ? getColorCode(module.background) : "";
|
||||||
|
const icon = module.icon || "";
|
||||||
|
|
||||||
|
// 如果是script类型,执行脚本获取文本
|
||||||
|
let text = "";
|
||||||
|
if (module.type === "script" && module.scriptPath) {
|
||||||
|
text = await executeScript(module.scriptPath, variables);
|
||||||
|
} else {
|
||||||
|
text = replaceVariables(module.text, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建显示文本
|
||||||
|
let displayText = "";
|
||||||
|
if (icon) {
|
||||||
|
displayText += `${icon} `;
|
||||||
|
}
|
||||||
|
displayText += text;
|
||||||
|
|
||||||
|
// 如果displayText为空,或者只有图标没有实际文本,则跳过该模块
|
||||||
|
if (!displayText || !text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建模块字符串
|
||||||
|
let part = `${background}${color}`;
|
||||||
|
part += `${displayText}${COLORS.reset}`;
|
||||||
|
|
||||||
|
parts.push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用空格连接所有部分
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Powerline符号
|
||||||
|
const SEP_RIGHT = "\uE0B0"; //
|
||||||
|
|
||||||
|
// 颜色编号(256色表)
|
||||||
|
const COLOR_MAP: Record<string, number> = {
|
||||||
|
// 基础颜色映射到256色
|
||||||
|
black: 0,
|
||||||
|
red: 1,
|
||||||
|
green: 2,
|
||||||
|
yellow: 3,
|
||||||
|
blue: 4,
|
||||||
|
magenta: 5,
|
||||||
|
cyan: 6,
|
||||||
|
white: 7,
|
||||||
|
bright_black: 8,
|
||||||
|
bright_red: 9,
|
||||||
|
bright_green: 10,
|
||||||
|
bright_yellow: 11,
|
||||||
|
bright_blue: 12,
|
||||||
|
bright_magenta: 13,
|
||||||
|
bright_cyan: 14,
|
||||||
|
bright_white: 15,
|
||||||
|
// 亮背景色映射
|
||||||
|
bg_black: 0,
|
||||||
|
bg_red: 1,
|
||||||
|
bg_green: 2,
|
||||||
|
bg_yellow: 3,
|
||||||
|
bg_blue: 4,
|
||||||
|
bg_magenta: 5,
|
||||||
|
bg_cyan: 6,
|
||||||
|
bg_white: 7,
|
||||||
|
bg_bright_black: 8,
|
||||||
|
bg_bright_red: 9,
|
||||||
|
bg_bright_green: 10,
|
||||||
|
bg_bright_yellow: 11,
|
||||||
|
bg_bright_blue: 12,
|
||||||
|
bg_bright_magenta: 13,
|
||||||
|
bg_bright_cyan: 14,
|
||||||
|
bg_bright_white: 15,
|
||||||
|
// 自定义颜色映射
|
||||||
|
bg_bright_orange: 202,
|
||||||
|
bg_bright_purple: 129,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取TrueColor的RGB值
|
||||||
|
function getTrueColorRgb(colorName: string): { r: number; g: number; b: number } | null {
|
||||||
|
// 如果是预定义颜色,返回对应RGB
|
||||||
|
if (COLOR_MAP[colorName] !== undefined) {
|
||||||
|
const color256 = COLOR_MAP[colorName];
|
||||||
|
return color256ToRgb(color256);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理十六进制颜色
|
||||||
|
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
|
||||||
|
return hexToRgb(colorName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理背景色十六进制
|
||||||
|
if (colorName.startsWith('bg_#')) {
|
||||||
|
return hexToRgb(colorName.substring(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将256色表索引转换为RGB值
|
||||||
|
function color256ToRgb(index: number): { r: number; g: number; b: number } | null {
|
||||||
|
if (index < 0 || index > 255) return null;
|
||||||
|
|
||||||
|
// ANSI 256色表转换
|
||||||
|
if (index < 16) {
|
||||||
|
// 基本颜色
|
||||||
|
const basicColors = [
|
||||||
|
[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
|
||||||
|
[0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
|
||||||
|
[128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
|
||||||
|
[0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]
|
||||||
|
];
|
||||||
|
return { r: basicColors[index][0], g: basicColors[index][1], b: basicColors[index][2] };
|
||||||
|
} else if (index < 232) {
|
||||||
|
// 216色:6×6×6的颜色立方体
|
||||||
|
const i = index - 16;
|
||||||
|
const r = Math.floor(i / 36);
|
||||||
|
const g = Math.floor((i % 36) / 6);
|
||||||
|
const b = i % 6;
|
||||||
|
const rgb = [0, 95, 135, 175, 215, 255];
|
||||||
|
return { r: rgb[r], g: rgb[g], b: rgb[b] };
|
||||||
|
} else {
|
||||||
|
// 灰度色
|
||||||
|
const gray = 8 + (index - 232) * 10;
|
||||||
|
return { r: gray, g: gray, b: gray };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成一个无缝拼接的段:文本在 bgN 上显示,分隔符从 bgN 过渡到 nextBgN
|
||||||
|
function segment(text: string, textFg: string, bgColor: string, nextBgColor: string | null): string {
|
||||||
|
const bgRgb = getTrueColorRgb(bgColor);
|
||||||
|
if (!bgRgb) {
|
||||||
|
// 如果无法获取RGB,使用默认蓝色背景
|
||||||
|
const defaultBlueRgb = { r: 33, g: 150, b: 243 };
|
||||||
|
const curBg = `\x1b[48;2;${defaultBlueRgb.r};${defaultBlueRgb.g};${defaultBlueRgb.b}m`;
|
||||||
|
const fgColor = `\x1b[38;2;255;255;255m`;
|
||||||
|
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const curBg = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||||
|
|
||||||
|
// 获取前景色RGB
|
||||||
|
let fgRgb = { r: 255, g: 255, b: 255 }; // 默认前景色为白色
|
||||||
|
const textFgRgb = getTrueColorRgb(textFg);
|
||||||
|
if (textFgRgb) {
|
||||||
|
fgRgb = textFgRgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fgColor = `\x1b[38;2;${fgRgb.r};${fgRgb.g};${fgRgb.b}m`;
|
||||||
|
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
|
||||||
|
|
||||||
|
if (nextBgColor != null) {
|
||||||
|
const nextBgRgb = getTrueColorRgb(nextBgColor);
|
||||||
|
if (nextBgRgb) {
|
||||||
|
// 分隔符:前景色是当前段的背景色,背景色是下一段的背景色
|
||||||
|
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||||
|
const sepNextBg = `\x1b[48;2;${nextBgRgb.r};${nextBgRgb.g};${nextBgRgb.b}m`;
|
||||||
|
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
|
||||||
|
return body + sep;
|
||||||
|
}
|
||||||
|
// 如果没有下一个背景色,假设终端背景为黑色并渲染黑色箭头
|
||||||
|
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||||
|
const sepNextBg = `\x1b[48;2;0;0;0m`; // 黑色背景
|
||||||
|
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
|
||||||
|
return body + sep;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染Powerline风格的状态行
|
||||||
|
async function renderPowerlineStyle(
|
||||||
|
theme: StatusLineThemeConfig,
|
||||||
|
variables: Record<string, string>
|
||||||
|
): Promise<string> {
|
||||||
|
const modules = theme.modules || POWERLINE_THEME.modules;
|
||||||
|
const segments: string[] = [];
|
||||||
|
|
||||||
|
// 遍历模块数组,渲染每个模块
|
||||||
|
for (let i = 0; i < Math.min(modules.length, 5); i++) {
|
||||||
|
const module = modules[i];
|
||||||
|
const color = module.color || "white";
|
||||||
|
const backgroundName = module.background || "";
|
||||||
|
const icon = module.icon || "";
|
||||||
|
|
||||||
|
// 如果是script类型,执行脚本获取文本
|
||||||
|
let text = "";
|
||||||
|
if (module.type === "script" && module.scriptPath) {
|
||||||
|
text = await executeScript(module.scriptPath, variables);
|
||||||
|
} else {
|
||||||
|
text = replaceVariables(module.text, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建显示文本
|
||||||
|
let displayText = "";
|
||||||
|
if (icon) {
|
||||||
|
displayText += `${icon} `;
|
||||||
|
}
|
||||||
|
displayText += text;
|
||||||
|
|
||||||
|
// 如果displayText为空,或者只有图标没有实际文本,则跳过该模块
|
||||||
|
if (!displayText || !text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取下一个模块的背景色(用于分隔符)
|
||||||
|
let nextBackground: string | null = null;
|
||||||
|
if (i < modules.length - 1) {
|
||||||
|
const nextModule = modules[i + 1];
|
||||||
|
nextBackground = nextModule.background || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用模块定义的背景色,或者为Powerline风格提供默认背景色
|
||||||
|
const actualBackground = backgroundName || "bg_bright_blue";
|
||||||
|
|
||||||
|
// 生成段,支持十六进制颜色
|
||||||
|
const segmentStr = segment(displayText, color, actualBackground, nextBackground);
|
||||||
|
segments.push(segmentStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.join("");
|
||||||
|
}
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
import { Response } from "express";
|
|
||||||
import { OpenAI } from "openai";
|
|
||||||
import { log } from "./log";
|
|
||||||
|
|
||||||
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: any,
|
|
||||||
model: string,
|
|
||||||
body: any
|
|
||||||
) {
|
|
||||||
const write = (data: string) => {
|
|
||||||
log("response: ", data);
|
|
||||||
res.write(data);
|
|
||||||
};
|
|
||||||
const messageId = "msg_" + Date.now();
|
|
||||||
if (!body.stream) {
|
|
||||||
let content: any = [];
|
|
||||||
if (completion.choices[0].message.content) {
|
|
||||||
content = [ { text: completion.choices[0].message.content, type: "text" } ];
|
|
||||||
}
|
|
||||||
else if (completion.choices[0].message.tool_calls) {
|
|
||||||
content = completion.choices[0].message.tool_calls.map((item: any) => {
|
|
||||||
return {
|
|
||||||
type: 'tool_use',
|
|
||||||
id: item.id,
|
|
||||||
name: item.function?.name,
|
|
||||||
input: item.function?.arguments ? JSON.parse(item.function.arguments) : {},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
id: messageId,
|
|
||||||
type: "message",
|
|
||||||
role: "assistant",
|
|
||||||
// @ts-ignore
|
|
||||||
content: content,
|
|
||||||
stop_reason: completion.choices[0].finish_reason === 'tool_calls' ? "tool_use" : "end_turn",
|
|
||||||
stop_sequence: null,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
res.json(result);
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
log("Error sending response:", error);
|
|
||||||
res.status(500).send("Internal Server Error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
write(`event: message_start\ndata: ${JSON.stringify(messageStart)}\n\n`);
|
|
||||||
|
|
||||||
let isToolUse = false;
|
|
||||||
let toolUseJson = "";
|
|
||||||
let hasStartedTextBlock = false;
|
|
||||||
let currentToolCallId: string | null = null;
|
|
||||||
let toolCallJsonMap = new Map<string, string>();
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const chunk of completion) {
|
|
||||||
log("Processing chunk:", chunk);
|
|
||||||
const delta = chunk.choices[0].delta;
|
|
||||||
|
|
||||||
if (delta.tool_calls && delta.tool_calls.length > 0) {
|
|
||||||
for (const toolCall of delta.tool_calls) {
|
|
||||||
const toolCallId = toolCall.id;
|
|
||||||
|
|
||||||
// Check if this is a new tool call by ID
|
|
||||||
if (toolCallId && toolCallId !== currentToolCallId) {
|
|
||||||
// End previous tool call if one was active
|
|
||||||
if (isToolUse && currentToolCallId) {
|
|
||||||
const contentBlockStop: MessageEvent = {
|
|
||||||
type: "content_block_stop",
|
|
||||||
index: contentBlockIndex,
|
|
||||||
};
|
|
||||||
write(
|
|
||||||
`event: content_block_stop\ndata: ${JSON.stringify(
|
|
||||||
contentBlockStop
|
|
||||||
)}\n\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new tool call block
|
|
||||||
isToolUse = true;
|
|
||||||
currentToolCallId = toolCallId;
|
|
||||||
contentBlockIndex++;
|
|
||||||
toolCallJsonMap.set(toolCallId, ""); // Initialize JSON accumulator for this tool call
|
|
||||||
|
|
||||||
const toolBlock: ContentBlock = {
|
|
||||||
type: "tool_use",
|
|
||||||
id: toolCallId,
|
|
||||||
name: toolCall.function?.name,
|
|
||||||
input: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const toolBlockStart: MessageEvent = {
|
|
||||||
type: "content_block_start",
|
|
||||||
index: contentBlockIndex,
|
|
||||||
content_block: toolBlock,
|
|
||||||
};
|
|
||||||
|
|
||||||
currentContentBlocks.push(toolBlock);
|
|
||||||
|
|
||||||
write(
|
|
||||||
`event: content_block_start\ndata: ${JSON.stringify(
|
|
||||||
toolBlockStart
|
|
||||||
)}\n\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream tool call JSON
|
|
||||||
if (toolCall.function?.arguments && currentToolCallId) {
|
|
||||||
const jsonDelta: MessageEvent = {
|
|
||||||
type: "content_block_delta",
|
|
||||||
index: contentBlockIndex,
|
|
||||||
delta: {
|
|
||||||
type: "input_json_delta",
|
|
||||||
partial_json: toolCall.function.arguments,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Accumulate JSON for this specific tool call
|
|
||||||
const currentJson = toolCallJsonMap.get(currentToolCallId) || "";
|
|
||||||
toolCallJsonMap.set(currentToolCallId, currentJson + toolCall.function.arguments);
|
|
||||||
toolUseJson = toolCallJsonMap.get(currentToolCallId) || "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsedJson = JSON.parse(toolUseJson);
|
|
||||||
currentContentBlocks[contentBlockIndex].input = parsedJson;
|
|
||||||
} catch (e) {
|
|
||||||
log("JSON parsing error (continuing to accumulate):", e);
|
|
||||||
// JSON not yet complete, continue accumulating
|
|
||||||
}
|
|
||||||
|
|
||||||
write(
|
|
||||||
`event: content_block_delta\ndata: ${JSON.stringify(jsonDelta)}\n\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (delta.content) {
|
|
||||||
// Handle regular text content
|
|
||||||
if (isToolUse) {
|
|
||||||
log("Tool call ended here:", delta);
|
|
||||||
// End previous tool call block
|
|
||||||
const contentBlockStop: MessageEvent = {
|
|
||||||
type: "content_block_stop",
|
|
||||||
index: contentBlockIndex,
|
|
||||||
};
|
|
||||||
|
|
||||||
write(
|
|
||||||
`event: content_block_stop\ndata: ${JSON.stringify(
|
|
||||||
contentBlockStop
|
|
||||||
)}\n\n`
|
|
||||||
);
|
|
||||||
contentBlockIndex++;
|
|
||||||
isToolUse = false;
|
|
||||||
currentToolCallId = null;
|
|
||||||
toolUseJson = ""; // Reset for safety
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
write(
|
|
||||||
`event: content_block_delta\ndata: ${JSON.stringify(contentDelta)}\n\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close last content block if any is open
|
|
||||||
if (isToolUse || hasStartedTextBlock) {
|
|
||||||
const contentBlockStop: MessageEvent = {
|
|
||||||
type: "content_block_stop",
|
|
||||||
index: contentBlockIndex,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 },
|
|
||||||
};
|
|
||||||
if (!isToolUse) {
|
|
||||||
log("body: ", body, "messageDelta: ", messageDelta);
|
|
||||||
}
|
|
||||||
|
|
||||||
write(`event: message_delta\ndata: ${JSON.stringify(messageDelta)}\n\n`);
|
|
||||||
|
|
||||||
// Send message_stop event
|
|
||||||
const messageStop: MessageEvent = {
|
|
||||||
type: "message_stop",
|
|
||||||
};
|
|
||||||
|
|
||||||
write(`event: message_stop\ndata: ${JSON.stringify(messageStop)}\n\n`);
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
80
src/utils/update.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { join } from "path";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
const execPromise = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有新版本可用
|
||||||
|
* @param currentVersion 当前版本
|
||||||
|
* @returns 包含更新信息的对象
|
||||||
|
*/
|
||||||
|
export async function checkForUpdates(currentVersion: string) {
|
||||||
|
try {
|
||||||
|
// 从npm registry获取最新版本信息
|
||||||
|
const { stdout } = await execPromise("npm view @musistudio/claude-code-router version");
|
||||||
|
const latestVersion = stdout.trim();
|
||||||
|
|
||||||
|
// 比较版本
|
||||||
|
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
|
||||||
|
|
||||||
|
// 如果有更新,获取更新日志
|
||||||
|
let changelog = "";
|
||||||
|
|
||||||
|
return { hasUpdate, latestVersion, changelog };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking for updates:", error);
|
||||||
|
// 如果检查失败,假设没有更新
|
||||||
|
return { hasUpdate: false, latestVersion: currentVersion, changelog: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行更新操作
|
||||||
|
* @returns 更新结果
|
||||||
|
*/
|
||||||
|
export async function performUpdate() {
|
||||||
|
try {
|
||||||
|
// 执行npm update命令
|
||||||
|
const { stdout, stderr } = await execPromise("npm update -g @musistudio/claude-code-router");
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
console.error("Update stderr:", stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Update stdout:", stdout);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Update completed successfully. Please restart the application to apply changes."
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error performing update:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to perform update: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较两个版本号
|
||||||
|
* @param v1 版本号1
|
||||||
|
* @param v2 版本号2
|
||||||
|
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||||||
|
*/
|
||||||
|
function compareVersions(v1: string, v2: string): number {
|
||||||
|
const parts1 = v1.split(".").map(Number);
|
||||||
|
const parts2 = v2.split(".").map(Number);
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||||
|
const num1 = i < parts1.length ? parts1[i] : 0;
|
||||||
|
const num2 = i < parts2.length ? parts2[i] : 0;
|
||||||
|
|
||||||
|
if (num1 > num2) return 1;
|
||||||
|
if (num1 < num2) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
33
ui/CLAUDE.md
Normal 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
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
179
ui/config.example.json
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
{
|
||||||
|
"LOG": true,
|
||||||
|
"CLAUDE_PATH": "/Users/jinhuilee/.claude/local/claude",
|
||||||
|
"HOST": "127.0.0.1",
|
||||||
|
"PORT": 8080,
|
||||||
|
"APIKEY": "1",
|
||||||
|
"API_TIMEOUT_MS": 600000,
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"NON_INTERACTIVE_MODE": false
|
||||||
|
}
|
||||||
23
ui/eslint.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
12
ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<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>
|
||||||
5321
ui/package-lock.json
generated
Normal file
57
ui/package.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@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-colorful": "^5.6.1",
|
||||||
|
"react-dnd": "^16.0.1",
|
||||||
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
3699
ui/pnpm-lock.yaml
generated
Normal file
1
ui/public/vite.svg
Normal 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 |
414
ui/src/App.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } 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 { LogViewer } from "@/components/LogViewer";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useConfig } from "@/components/ConfigProvider";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Toast } from "@/components/ui/toast";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
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 [isLogViewerOpen, setIsLogViewerOpen] = useState(false);
|
||||||
|
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||||
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
|
||||||
|
// 版本检查状态
|
||||||
|
const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(false);
|
||||||
|
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||||
|
const [newVersionInfo, setNewVersionInfo] = useState<{ version: string; changelog: string } | null>(null);
|
||||||
|
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
||||||
|
const [hasCheckedUpdate, setHasCheckedUpdate] = useState(false);
|
||||||
|
const hasAutoCheckedUpdate = useRef(false);
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
// Handle case where config might be null or undefined
|
||||||
|
if (!config) {
|
||||||
|
setToast({ message: t('app.config_missing'), type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
// Handle case where config might be null or undefined
|
||||||
|
if (!config) {
|
||||||
|
setToast({ message: t('app.config_missing'), type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查更新函数
|
||||||
|
const checkForUpdates = useCallback(async (showDialog: boolean = true) => {
|
||||||
|
// 如果已经检查过且有新版本,根据参数决定是否显示对话框
|
||||||
|
if (hasCheckedUpdate && isNewVersionAvailable) {
|
||||||
|
if (showDialog) {
|
||||||
|
setIsUpdateDialogOpen(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCheckingUpdate(true);
|
||||||
|
try {
|
||||||
|
const updateInfo = await api.checkForUpdates();
|
||||||
|
|
||||||
|
if (updateInfo.hasUpdate && updateInfo.latestVersion && updateInfo.changelog) {
|
||||||
|
setIsNewVersionAvailable(true);
|
||||||
|
setNewVersionInfo({
|
||||||
|
version: updateInfo.latestVersion,
|
||||||
|
changelog: updateInfo.changelog
|
||||||
|
});
|
||||||
|
// 只有在showDialog为true时才显示对话框
|
||||||
|
if (showDialog) {
|
||||||
|
setIsUpdateDialogOpen(true);
|
||||||
|
}
|
||||||
|
} else if (showDialog) {
|
||||||
|
// 只有在showDialog为true时才显示没有更新的提示
|
||||||
|
setToast({ message: t('app.no_updates_available'), type: 'success' });
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasCheckedUpdate(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check for updates:', error);
|
||||||
|
if (showDialog) {
|
||||||
|
setToast({ message: t('app.update_check_failed') + ': ' + (error as Error).message, type: 'error' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsCheckingUpdate(false);
|
||||||
|
}
|
||||||
|
}, [hasCheckedUpdate, isNewVersionAvailable, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
// If we already have a config, we're authenticated
|
||||||
|
if (config) {
|
||||||
|
setIsCheckingAuth(false);
|
||||||
|
// 自动检查更新,但不显示对话框
|
||||||
|
if (!hasCheckedUpdate && !hasAutoCheckedUpdate.current) {
|
||||||
|
hasAutoCheckedUpdate.current = true;
|
||||||
|
checkForUpdates(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);
|
||||||
|
// 在获取配置完成后检查更新,但不显示对话框
|
||||||
|
if (!hasCheckedUpdate && !hasAutoCheckedUpdate.current) {
|
||||||
|
hasAutoCheckedUpdate.current = true;
|
||||||
|
checkForUpdates(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
|
||||||
|
// Listen for unauthorized events
|
||||||
|
const handleUnauthorized = () => {
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('unauthorized', handleUnauthorized);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('unauthorized', handleUnauthorized);
|
||||||
|
};
|
||||||
|
}, [config, navigate, hasCheckedUpdate, checkForUpdates]);
|
||||||
|
|
||||||
|
// 执行更新函数
|
||||||
|
const performUpdate = async () => {
|
||||||
|
if (!newVersionInfo) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.performUpdate();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setToast({ message: t('app.update_successful'), type: 'success' });
|
||||||
|
setIsNewVersionAvailable(false);
|
||||||
|
setIsUpdateDialogOpen(false);
|
||||||
|
setHasCheckedUpdate(false); // 重置检查状态,以便下次重新检查
|
||||||
|
} else {
|
||||||
|
setToast({ message: t('app.update_failed') + ': ' + result.message, type: 'error' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to perform update:', error);
|
||||||
|
setToast({ message: t('app.update_failed') + ': ' + (error as Error).message, type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (isCheckingAuth) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen bg-gray-50 font-sans flex items-center justify-center">
|
||||||
|
<div className="text-gray-500">Loading application...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen bg-gray-50 font-sans flex items-center justify-center">
|
||||||
|
<div className="text-red-500">Error: {error.message}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case where config is null or undefined
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen bg-gray-50 font-sans flex items-center justify-center">
|
||||||
|
<div className="text-gray-500">Loading configuration...</div>
|
||||||
|
</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>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setIsLogViewerOpen(true)} className="transition-all-ease hover:scale-110">
|
||||||
|
<FileText 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
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => checkForUpdates(true)}
|
||||||
|
disabled={isCheckingUpdate}
|
||||||
|
className="transition-all-ease hover:scale-110 relative"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<CircleArrowUp className="h-5 w-5" />
|
||||||
|
{isNewVersionAvailable && !isCheckingUpdate && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border-2 border-white"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isCheckingUpdate && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<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 overflow-hidden">
|
||||||
|
<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 overflow-hidden">
|
||||||
|
<Transformers />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<SettingsDialog isOpen={isSettingsOpen} onOpenChange={setIsSettingsOpen} />
|
||||||
|
<JsonEditor
|
||||||
|
open={isJsonEditorOpen}
|
||||||
|
onOpenChange={setIsJsonEditorOpen}
|
||||||
|
showToast={(message, type) => setToast({ message, type })}
|
||||||
|
/>
|
||||||
|
<LogViewer
|
||||||
|
open={isLogViewerOpen}
|
||||||
|
onOpenChange={setIsLogViewerOpen}
|
||||||
|
showToast={(message, type) => setToast({ message, type })}
|
||||||
|
/>
|
||||||
|
{/* 版本更新对话框 */}
|
||||||
|
<Dialog open={isUpdateDialogOpen} onOpenChange={setIsUpdateDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t('app.new_version_available')}
|
||||||
|
{newVersionInfo && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||||
|
v{newVersionInfo.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('app.update_description')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-96 overflow-y-auto py-4">
|
||||||
|
{newVersionInfo?.changelog ? (
|
||||||
|
<div className="whitespace-pre-wrap text-sm">
|
||||||
|
{newVersionInfo.changelog}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{t('app.no_changelog_available')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsUpdateDialogOpen(false)}
|
||||||
|
>
|
||||||
|
{t('app.later')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={performUpdate}>
|
||||||
|
{t('app.update_now')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onClose={() => setToast(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
ui/src/assets/react.svg
Normal 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 |
155
ui/src/components/ConfigProvider.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import type { ReactNode, Dispatch, SetStateAction } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Config, StatusLineConfig } from '@/types';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Validate the received data to ensure it has the expected structure
|
||||||
|
const validConfig = {
|
||||||
|
LOG: typeof data.LOG === 'boolean' ? data.LOG : false,
|
||||||
|
LOG_LEVEL: typeof data.LOG_LEVEL === 'string' ? data.LOG_LEVEL : 'debug',
|
||||||
|
CLAUDE_PATH: typeof data.CLAUDE_PATH === 'string' ? data.CLAUDE_PATH : '',
|
||||||
|
HOST: typeof data.HOST === 'string' ? data.HOST : '127.0.0.1',
|
||||||
|
PORT: typeof data.PORT === 'number' ? data.PORT : 3456,
|
||||||
|
APIKEY: typeof data.APIKEY === 'string' ? data.APIKEY : '',
|
||||||
|
API_TIMEOUT_MS: typeof data.API_TIMEOUT_MS === 'string' ? data.API_TIMEOUT_MS : '600000',
|
||||||
|
PROXY_URL: typeof data.PROXY_URL === 'string' ? data.PROXY_URL : '',
|
||||||
|
transformers: Array.isArray(data.transformers) ? data.transformers : [],
|
||||||
|
Providers: Array.isArray(data.Providers) ? data.Providers : [],
|
||||||
|
StatusLine: data.StatusLine && typeof data.StatusLine === 'object' ? {
|
||||||
|
enabled: typeof data.StatusLine.enabled === 'boolean' ? data.StatusLine.enabled : false,
|
||||||
|
currentStyle: typeof data.StatusLine.currentStyle === 'string' ? data.StatusLine.currentStyle : 'default',
|
||||||
|
default: data.StatusLine.default && typeof data.StatusLine.default === 'object' && Array.isArray(data.StatusLine.default.modules) ? data.StatusLine.default : { modules: [] },
|
||||||
|
powerline: data.StatusLine.powerline && typeof data.StatusLine.powerline === 'object' && Array.isArray(data.StatusLine.powerline.modules) ? data.StatusLine.powerline : { modules: [] }
|
||||||
|
} : {
|
||||||
|
enabled: false,
|
||||||
|
currentStyle: 'default',
|
||||||
|
default: { modules: [] },
|
||||||
|
powerline: { modules: [] }
|
||||||
|
},
|
||||||
|
Router: data.Router && typeof data.Router === 'object' ? {
|
||||||
|
default: typeof data.Router.default === 'string' ? data.Router.default : '',
|
||||||
|
background: typeof data.Router.background === 'string' ? data.Router.background : '',
|
||||||
|
think: typeof data.Router.think === 'string' ? data.Router.think : '',
|
||||||
|
longContext: typeof data.Router.longContext === 'string' ? data.Router.longContext : '',
|
||||||
|
longContextThreshold: typeof data.Router.longContextThreshold === 'number' ? data.Router.longContextThreshold : 60000,
|
||||||
|
webSearch: typeof data.Router.webSearch === 'string' ? data.Router.webSearch : '',
|
||||||
|
image: typeof data.Router.image === 'string' ? data.Router.image : ''
|
||||||
|
} : {
|
||||||
|
default: '',
|
||||||
|
background: '',
|
||||||
|
think: '',
|
||||||
|
longContext: '',
|
||||||
|
longContextThreshold: 60000,
|
||||||
|
webSearch: '',
|
||||||
|
image: ''
|
||||||
|
},
|
||||||
|
CUSTOM_ROUTER_PATH: typeof data.CUSTOM_ROUTER_PATH === 'string' ? data.CUSTOM_ROUTER_PATH : ''
|
||||||
|
};
|
||||||
|
|
||||||
|
setConfig(validConfig);
|
||||||
|
} 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,
|
||||||
|
LOG_LEVEL: 'debug',
|
||||||
|
CLAUDE_PATH: '',
|
||||||
|
HOST: '127.0.0.1',
|
||||||
|
PORT: 3456,
|
||||||
|
APIKEY: '',
|
||||||
|
API_TIMEOUT_MS: '600000',
|
||||||
|
PROXY_URL: '',
|
||||||
|
transformers: [],
|
||||||
|
Providers: [],
|
||||||
|
StatusLine: undefined,
|
||||||
|
Router: {
|
||||||
|
default: '',
|
||||||
|
background: '',
|
||||||
|
think: '',
|
||||||
|
longContext: '',
|
||||||
|
longContextThreshold: 60000,
|
||||||
|
webSearch: '',
|
||||||
|
image: ''
|
||||||
|
},
|
||||||
|
CUSTOM_ROUTER_PATH: ''
|
||||||
|
});
|
||||||
|
setError(err as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchConfig();
|
||||||
|
}, [hasFetched, apiKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigContext.Provider value={{ config, setConfig, error }}>
|
||||||
|
{children}
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
ui/src/components/JsonEditor.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
726
ui/src/components/LogViewer.tsx
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import Editor from '@monaco-editor/react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { X, RefreshCw, Download, Trash2, ArrowLeft, File, Layers } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LogViewerProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
showToast?: (message: string, type: 'success' | 'error' | 'warning') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug';
|
||||||
|
message: string; // 现在这个字段直接包含原始JSON字符串
|
||||||
|
source?: string;
|
||||||
|
reqId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedLogs {
|
||||||
|
[reqId: string]: LogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogGroupSummary {
|
||||||
|
reqId: string;
|
||||||
|
logCount: number;
|
||||||
|
firstLog: string;
|
||||||
|
lastLog: string;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedLogsResponse {
|
||||||
|
grouped: boolean;
|
||||||
|
groups: { [reqId: string]: LogEntry[] };
|
||||||
|
summary: {
|
||||||
|
totalRequests: number;
|
||||||
|
totalLogs: number;
|
||||||
|
requests: LogGroupSummary[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [logFiles, setLogFiles] = useState<LogFile[]>([]);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<LogFile | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
const [groupByReqId, setGroupByReqId] = useState(false);
|
||||||
|
const [groupedLogs, setGroupedLogs] = useState<GroupedLogsResponse | null>(null);
|
||||||
|
const [selectedReqId, setSelectedReqId] = useState<string | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const refreshInterval = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadLogFiles();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 创建内联 Web Worker
|
||||||
|
const createInlineWorker = (): Worker => {
|
||||||
|
const workerCode = `
|
||||||
|
// 日志聚合Web Worker
|
||||||
|
self.onmessage = function(event) {
|
||||||
|
const { type, data } = event.data;
|
||||||
|
|
||||||
|
if (type === 'groupLogsByReqId') {
|
||||||
|
try {
|
||||||
|
const { logs } = data;
|
||||||
|
|
||||||
|
// 按reqId聚合日志
|
||||||
|
const groupedLogs = {};
|
||||||
|
|
||||||
|
logs.forEach((log, index) => {
|
||||||
|
log = JSON.parse(log);
|
||||||
|
let reqId = log.reqId || 'no-req-id';
|
||||||
|
|
||||||
|
if (!groupedLogs[reqId]) {
|
||||||
|
groupedLogs[reqId] = [];
|
||||||
|
}
|
||||||
|
groupedLogs[reqId].push(log);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按时间戳排序每个组的日志
|
||||||
|
Object.keys(groupedLogs).forEach(reqId => {
|
||||||
|
groupedLogs[reqId].sort((a, b) => a.time - b.time);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 提取model信息
|
||||||
|
const extractModelInfo = (reqId) => {
|
||||||
|
const logGroup = groupedLogs[reqId];
|
||||||
|
for (const log of logGroup) {
|
||||||
|
try {
|
||||||
|
// 尝试从message字段解析JSON
|
||||||
|
if (log.type === 'request body' && log.data && log.data.model) {
|
||||||
|
return log.data.model;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败,继续尝试下一条日志
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成摘要信息
|
||||||
|
const summary = {
|
||||||
|
totalRequests: Object.keys(groupedLogs).length,
|
||||||
|
totalLogs: logs.length,
|
||||||
|
requests: Object.keys(groupedLogs).map(reqId => ({
|
||||||
|
reqId,
|
||||||
|
logCount: groupedLogs[reqId].length,
|
||||||
|
firstLog: groupedLogs[reqId][0]?.time,
|
||||||
|
lastLog: groupedLogs[reqId][groupedLogs[reqId].length - 1]?.time,
|
||||||
|
model: extractModelInfo(reqId)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
grouped: true,
|
||||||
|
groups: groupedLogs,
|
||||||
|
summary
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送结果回主线程
|
||||||
|
self.postMessage({
|
||||||
|
type: 'groupLogsResult',
|
||||||
|
data: response
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 发送错误回主线程
|
||||||
|
self.postMessage({
|
||||||
|
type: 'error',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const blob = new Blob([workerCode], { type: 'application/javascript' });
|
||||||
|
const workerUrl = URL.createObjectURL(blob);
|
||||||
|
return new Worker(workerUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化Web Worker
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof Worker !== 'undefined') {
|
||||||
|
try {
|
||||||
|
// 创建内联Web Worker
|
||||||
|
workerRef.current = createInlineWorker();
|
||||||
|
|
||||||
|
// 监听Worker消息
|
||||||
|
workerRef.current.onmessage = (event) => {
|
||||||
|
const { type, data, error } = event.data;
|
||||||
|
|
||||||
|
if (type === 'groupLogsResult') {
|
||||||
|
setGroupedLogs(data);
|
||||||
|
} else if (type === 'error') {
|
||||||
|
console.error('Worker error:', error);
|
||||||
|
if (showToast) {
|
||||||
|
showToast(t('log_viewer.worker_error') + ': ' + error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听Worker错误
|
||||||
|
workerRef.current.onerror = (error) => {
|
||||||
|
console.error('Worker error:', error);
|
||||||
|
if (showToast) {
|
||||||
|
showToast(t('log_viewer.worker_init_failed'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create worker:', error);
|
||||||
|
if (showToast) {
|
||||||
|
showToast(t('log_viewer.worker_init_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理Worker
|
||||||
|
return () => {
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.terminate();
|
||||||
|
workerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [showToast, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh && open && selectedFile) {
|
||||||
|
refreshInterval.current = setInterval(() => {
|
||||||
|
loadLogs();
|
||||||
|
}, 5000); // Refresh every 5 seconds
|
||||||
|
} else if (refreshInterval.current) {
|
||||||
|
clearInterval(refreshInterval.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (refreshInterval.current) {
|
||||||
|
clearInterval(refreshInterval.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autoRefresh, open, selectedFile]);
|
||||||
|
|
||||||
|
// Load logs when selected file changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFile && open) {
|
||||||
|
setLogs([]); // Clear existing logs
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
}, [selectedFile, 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 loadLogFiles = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await api.getLogFiles();
|
||||||
|
|
||||||
|
if (response && Array.isArray(response)) {
|
||||||
|
setLogFiles(response);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setLogs([]);
|
||||||
|
} else {
|
||||||
|
setLogFiles([]);
|
||||||
|
if (showToast) {
|
||||||
|
showToast(t('log_viewer.no_log_files_available'), 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load log files:', error);
|
||||||
|
if (showToast) {
|
||||||
|
showToast(t('log_viewer.load_files_failed') + ': ' + (error as Error).message, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLogs = async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setGroupedLogs(null);
|
||||||
|
setSelectedReqId(null);
|
||||||
|
|
||||||
|
// 始终加载原始日志数据
|
||||||
|
const response = await api.getLogs(selectedFile.path);
|
||||||
|
|
||||||
|
if (response && Array.isArray(response)) {
|
||||||
|
// 现在接口返回的是原始日志字符串数组,直接存储
|
||||||
|
setLogs(response);
|
||||||
|
|
||||||
|
// 如果启用了分组,使用Web Worker进行聚合(需要转换为LogEntry格式供Worker使用)
|
||||||
|
if (groupByReqId && workerRef.current) {
|
||||||
|
// const workerLogs: LogEntry[] = response.map((logLine, index) => ({
|
||||||
|
// timestamp: new Date().toISOString(),
|
||||||
|
// level: 'info',
|
||||||
|
// message: logLine,
|
||||||
|
// source: undefined,
|
||||||
|
// reqId: undefined
|
||||||
|
// }));
|
||||||
|
|
||||||
|
workerRef.current.postMessage({
|
||||||
|
type: 'groupLogsByReqId',
|
||||||
|
data: { logs: response }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setGroupedLogs(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setLogs([]);
|
||||||
|
setGroupedLogs(null);
|
||||||
|
if (showToast) {
|
||||||
|
showToast(t('log_viewer.no_logs_available'), 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load logs:', error);
|
||||||
|
if (showToast) {
|
||||||
|
showToast(t('log_viewer.load_failed') + ': ' + (error as Error).message, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearLogs = async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.clearLogs(selectedFile.path);
|
||||||
|
setLogs([]);
|
||||||
|
if (showToast) {
|
||||||
|
showToast(t('log_viewer.logs_cleared'), 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear logs:', error);
|
||||||
|
if (showToast) {
|
||||||
|
showToast(t('log_viewer.clear_failed') + ': ' + (error as Error).message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFile = (file: LogFile) => {
|
||||||
|
setSelectedFile(file);
|
||||||
|
setAutoRefresh(false); // Reset auto refresh when changing files
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const toggleGroupByReqId = () => {
|
||||||
|
const newValue = !groupByReqId;
|
||||||
|
setGroupByReqId(newValue);
|
||||||
|
|
||||||
|
if (newValue && selectedFile && logs.length > 0) {
|
||||||
|
// 启用聚合时,如果已有日志,则使用Worker进行聚合
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.postMessage({
|
||||||
|
type: 'groupLogsByReqId',
|
||||||
|
data: { logs }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!newValue) {
|
||||||
|
// 禁用聚合时,清除聚合结果
|
||||||
|
setGroupedLogs(null);
|
||||||
|
setSelectedReqId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectReqId = (reqId: string) => {
|
||||||
|
setSelectedReqId(reqId);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const getDisplayLogs = () => {
|
||||||
|
if (groupByReqId && groupedLogs) {
|
||||||
|
if (selectedReqId && groupedLogs.groups[selectedReqId]) {
|
||||||
|
return groupedLogs.groups[selectedReqId];
|
||||||
|
}
|
||||||
|
// 当在分组模式但没有选中具体请求时,显示原始日志字符串数组
|
||||||
|
return logs.map(logLine => ({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level: 'info',
|
||||||
|
message: logLine,
|
||||||
|
source: undefined,
|
||||||
|
reqId: undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// 当不在分组模式时,显示原始日志字符串数组
|
||||||
|
return logs.map(logLine => ({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level: 'info',
|
||||||
|
message: logLine,
|
||||||
|
source: undefined,
|
||||||
|
reqId: undefined
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadLogs = () => {
|
||||||
|
if (!selectedFile || logs.length === 0) return;
|
||||||
|
|
||||||
|
// 直接下载原始日志字符串,每行一个日志
|
||||||
|
const logText = logs.join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([logText], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${selectedFile.name}-${new Date().toISOString().split('T')[0]}.txt`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
showToast(t('log_viewer.logs_downloaded'), 'success');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 面包屑导航项类型
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取面包屑导航项
|
||||||
|
const getBreadcrumbs = (): BreadcrumbItem[] => {
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
id: 'root',
|
||||||
|
label: t('log_viewer.title'),
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setAutoRefresh(false);
|
||||||
|
setLogs([]);
|
||||||
|
setGroupedLogs(null);
|
||||||
|
setSelectedReqId(null);
|
||||||
|
setGroupByReqId(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (selectedFile) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
id: 'file',
|
||||||
|
label: selectedFile.name,
|
||||||
|
onClick: () => {
|
||||||
|
if (groupByReqId) {
|
||||||
|
// 如果在分组模式下,点击文件层级应该返回到分组列表
|
||||||
|
setSelectedReqId(null);
|
||||||
|
} else {
|
||||||
|
// 如果不在分组模式下,点击文件层级关闭分组功能
|
||||||
|
setSelectedReqId(null);
|
||||||
|
setGroupedLogs(null);
|
||||||
|
setGroupByReqId(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedReqId) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
id: 'req',
|
||||||
|
label: `${t('log_viewer.request')} ${selectedReqId}`,
|
||||||
|
onClick: () => {
|
||||||
|
// 点击当前层级时不做任何操作
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return breadcrumbs;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取返回按钮的处理函数
|
||||||
|
const getBackAction = (): (() => void) | null => {
|
||||||
|
if (selectedReqId) {
|
||||||
|
return () => {
|
||||||
|
setSelectedReqId(null);
|
||||||
|
};
|
||||||
|
} else if (selectedFile) {
|
||||||
|
return () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setAutoRefresh(false);
|
||||||
|
setLogs([]);
|
||||||
|
setGroupedLogs(null);
|
||||||
|
setSelectedReqId(null);
|
||||||
|
setGroupByReqId(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLogsForEditor = () => {
|
||||||
|
// 如果在分组模式且选中了具体请求,显示该请求的日志
|
||||||
|
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
|
||||||
|
const requestLogs = groupedLogs.groups[selectedReqId];
|
||||||
|
console.log(requestLogs)
|
||||||
|
// 提取原始JSON字符串并每行一个
|
||||||
|
return requestLogs.map(log => JSON.stringify(log)).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他情况,直接显示原始日志字符串数组,每行一个
|
||||||
|
return logs.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getBackAction() && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={getBackAction()!}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
{t('log_viewer.back')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 面包屑导航 */}
|
||||||
|
<nav className="flex items-center space-x-1 text-sm">
|
||||||
|
{getBreadcrumbs().map((breadcrumb, index) => (
|
||||||
|
<React.Fragment key={breadcrumb.id}>
|
||||||
|
{index > 0 && (
|
||||||
|
<span className="text-gray-400 mx-1">/</span>
|
||||||
|
)}
|
||||||
|
{index === getBreadcrumbs().length - 1 ? (
|
||||||
|
<span className="text-gray-900 font-medium">
|
||||||
|
{breadcrumb.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={breadcrumb.onClick}
|
||||||
|
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
>
|
||||||
|
{breadcrumb.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{selectedFile && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleGroupByReqId}
|
||||||
|
className={groupByReqId ? 'bg-blue-100 text-blue-700' : ''}
|
||||||
|
>
|
||||||
|
<Layers className="h-4 w-4 mr-2" />
|
||||||
|
{groupByReqId ? t('log_viewer.grouped_on') : t('log_viewer.group_by_req_id')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
|
className={autoRefresh ? 'bg-blue-100 text-blue-700' : ''}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${autoRefresh ? 'animate-spin' : ''}`} />
|
||||||
|
{autoRefresh ? t('log_viewer.auto_refresh_on') : t('log_viewer.auto_refresh_off')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={downloadLogs}
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
{t('log_viewer.download')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearLogs}
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
{t('log_viewer.clear')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-2" />
|
||||||
|
{t('log_viewer.close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 bg-gray-50">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
) : selectedFile ? (
|
||||||
|
<>
|
||||||
|
{groupByReqId && groupedLogs && !selectedReqId ? (
|
||||||
|
// 显示日志组列表
|
||||||
|
<div className="flex flex-col h-full p-6">
|
||||||
|
<div className="mb-4 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-medium mb-2">{t('log_viewer.request_groups')}</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{t('log_viewer.total_requests')}: {groupedLogs.summary.totalRequests} |
|
||||||
|
{t('log_viewer.total_logs')}: {groupedLogs.summary.totalLogs}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-3">
|
||||||
|
{groupedLogs.summary.requests.map((request) => (
|
||||||
|
<div
|
||||||
|
key={request.reqId}
|
||||||
|
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => selectReqId(request.reqId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<File className="h-5 w-5 text-blue-600" />
|
||||||
|
<span className="font-medium text-sm">{request.reqId}</span>
|
||||||
|
{request.model && (
|
||||||
|
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
|
||||||
|
{request.model}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||||
|
{request.logCount} {t('log_viewer.logs')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
|
<div>{t('log_viewer.first_log')}: {formatDate(request.firstLog)}</div>
|
||||||
|
<div>{t('log_viewer.last_log')}: {formatDate(request.lastLog)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 显示日志内容
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="json"
|
||||||
|
value={formatLogsForEditor()}
|
||||||
|
theme="vs"
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: true },
|
||||||
|
fontSize: 14,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
wordWrap: 'on',
|
||||||
|
readOnly: true,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
folding: true,
|
||||||
|
renderWhitespace: 'all',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-medium mb-4">{t('log_viewer.select_file')}</h3>
|
||||||
|
{logFiles.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-center py-8">
|
||||||
|
<File className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||||
|
<p>{t('log_viewer.no_log_files_available')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{logFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.path}
|
||||||
|
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => selectFile(file)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<File className="h-5 w-5 text-blue-600" />
|
||||||
|
<span className="font-medium text-sm">{file.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
|
<div>{formatFileSize(file.size)}</div>
|
||||||
|
<div>{formatDate(file.lastModified)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
ui/src/components/Login.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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 {
|
||||||
|
// 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
|
||||||
|
await api.getConfig();
|
||||||
|
|
||||||
|
// Navigate to dashboard
|
||||||
|
// The ConfigProvider will handle fetching the config
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (error: any) {
|
||||||
|
// Clear the API key on failure
|
||||||
|
api.setApiKey('');
|
||||||
|
|
||||||
|
// Check if it's an unauthorized error
|
||||||
|
if (error.message && error.message.includes('401')) {
|
||||||
|
setError(t('login.invalidApiKey'));
|
||||||
|
} else {
|
||||||
|
// For other errors, still allow access (restricted mode)
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
ui/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
83
ui/src/components/ProviderList.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { Provider } from "@/types";
|
||||||
|
|
||||||
|
interface ProviderListProps {
|
||||||
|
providers: Provider[];
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderList({ providers, onEdit, onRemove }: ProviderListProps) {
|
||||||
|
// Handle case where providers might be null or undefined
|
||||||
|
if (!providers || !Array.isArray(providers)) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-center rounded-md border bg-white p-8 text-gray-500">
|
||||||
|
No providers configured
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{providers.map((provider, index) => {
|
||||||
|
// Handle case where individual provider might be null or undefined
|
||||||
|
if (!provider) {
|
||||||
|
return (
|
||||||
|
<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">Invalid Provider</p>
|
||||||
|
<p className="text-sm text-gray-500">Provider data is missing</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" disabled>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case where provider.name might be null or undefined
|
||||||
|
const providerName = provider.name || "Unnamed Provider";
|
||||||
|
|
||||||
|
// Handle case where provider.api_base_url might be null or undefined
|
||||||
|
const apiBaseUrl = provider.api_base_url || "No API URL";
|
||||||
|
|
||||||
|
// Handle case where provider.models might be null or undefined
|
||||||
|
const models = Array.isArray(provider.models) ? provider.models : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">{providerName}</p>
|
||||||
|
<p className="text-sm text-gray-500">{apiBaseUrl}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{models.map((model, modelIndex) => (
|
||||||
|
// Handle case where model might be null or undefined
|
||||||
|
<Badge key={modelIndex} variant="outline" className="font-normal transition-all-ease hover:scale-105">
|
||||||
|
{model || "Unnamed 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1049
ui/src/components/Providers.tsx
Normal file
7
ui/src/components/PublicRoute.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
// Always show login page
|
||||||
|
// The login page will handle empty API keys appropriately
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublicRoute;
|
||||||
171
ui/src/components/Router.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useConfig } from "./ConfigProvider";
|
||||||
|
import { Combobox } from "./ui/combobox";
|
||||||
|
|
||||||
|
export function Router() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { config, setConfig } = useConfig();
|
||||||
|
|
||||||
|
// Handle case where config is null or undefined
|
||||||
|
if (!config) {
|
||||||
|
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 flex items-center justify-center p-4">
|
||||||
|
<div className="text-gray-500">Loading router configuration...</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case where config.Router is null or undefined
|
||||||
|
const routerConfig = config.Router || {
|
||||||
|
default: "",
|
||||||
|
background: "",
|
||||||
|
think: "",
|
||||||
|
longContext: "",
|
||||||
|
longContextThreshold: 60000,
|
||||||
|
webSearch: "",
|
||||||
|
image: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRouterChange = (field: string, value: string | number) => {
|
||||||
|
// Handle case where config.Router might be null or undefined
|
||||||
|
const currentRouter = config.Router || {};
|
||||||
|
const newRouter = { ...currentRouter, [field]: value };
|
||||||
|
setConfig({ ...config, Router: newRouter });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForceUseImageAgentChange = (value: boolean) => {
|
||||||
|
setConfig({ ...config, forceUseImageAgent: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle case where config.Providers might be null or undefined
|
||||||
|
const providers = Array.isArray(config.Providers) ? config.Providers : [];
|
||||||
|
|
||||||
|
const modelOptions = providers.flatMap((provider) => {
|
||||||
|
// Handle case where individual provider might be null or undefined
|
||||||
|
if (!provider) return [];
|
||||||
|
|
||||||
|
// Handle case where provider.models might be null or undefined
|
||||||
|
const models = Array.isArray(provider.models) ? provider.models : [];
|
||||||
|
|
||||||
|
// Handle case where provider.name might be null or undefined
|
||||||
|
const providerName = provider.name || "Unknown Provider";
|
||||||
|
|
||||||
|
return models.map((model) => ({
|
||||||
|
value: `${providerName},${model || "Unknown Model"}`,
|
||||||
|
label: `${providerName}, ${model || "Unknown 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={routerConfig.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={routerConfig.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={routerConfig.think || ""}
|
||||||
|
onChange={(value) => handleRouterChange("think", value)}
|
||||||
|
placeholder={t("router.selectModel")}
|
||||||
|
searchPlaceholder={t("router.searchModel")}
|
||||||
|
emptyPlaceholder={t("router.noModelFound")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>{t("router.longContext")}</Label>
|
||||||
|
<Combobox
|
||||||
|
options={modelOptions}
|
||||||
|
value={routerConfig.longContext || ""}
|
||||||
|
onChange={(value) => handleRouterChange("longContext", value)}
|
||||||
|
placeholder={t("router.selectModel")}
|
||||||
|
searchPlaceholder={t("router.searchModel")}
|
||||||
|
emptyPlaceholder={t("router.noModelFound")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-48">
|
||||||
|
<Label>{t("router.longContextThreshold")}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={routerConfig.longContextThreshold || 60000}
|
||||||
|
onChange={(e) => handleRouterChange("longContextThreshold", parseInt(e.target.value) || 60000)}
|
||||||
|
placeholder="60000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("router.webSearch")}</Label>
|
||||||
|
<Combobox
|
||||||
|
options={modelOptions}
|
||||||
|
value={routerConfig.webSearch || ""}
|
||||||
|
onChange={(value) => handleRouterChange("webSearch", value)}
|
||||||
|
placeholder={t("router.selectModel")}
|
||||||
|
searchPlaceholder={t("router.searchModel")}
|
||||||
|
emptyPlaceholder={t("router.noModelFound")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>{t("router.image")} (beta)</Label>
|
||||||
|
<Combobox
|
||||||
|
options={modelOptions}
|
||||||
|
value={routerConfig.image || ""}
|
||||||
|
onChange={(value) => handleRouterChange("image", value)}
|
||||||
|
placeholder={t("router.selectModel")}
|
||||||
|
searchPlaceholder={t("router.searchModel")}
|
||||||
|
emptyPlaceholder={t("router.noModelFound")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-48">
|
||||||
|
<Label htmlFor="forceUseImageAgent">{t("router.forceUseImageAgent")}</Label>
|
||||||
|
<select
|
||||||
|
id="forceUseImageAgent"
|
||||||
|
value={config.forceUseImageAgent ? "true" : "false"}
|
||||||
|
onChange={(e) => handleForceUseImageAgentChange(e.target.value === "true")}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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"
|
||||||
|
>
|
||||||
|
<option value="false">{t("common.no")}</option>
|
||||||
|
<option value="true">{t("common.yes")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
ui/src/components/SettingsDialog.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
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 { Combobox } from "@/components/ui/combobox";
|
||||||
|
import { useConfig } from "./ConfigProvider";
|
||||||
|
import { StatusLineConfigDialog } from "./StatusLineConfigDialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { StatusLineConfig } from "@/types";
|
||||||
|
|
||||||
|
interface SettingsDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { config, setConfig } = useConfig();
|
||||||
|
const [isStatusLineConfigOpen, setIsStatusLineConfigOpen] = useState(false);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusLineEnabledChange = (checked: boolean) => {
|
||||||
|
// Ensure we have a complete StatusLineConfig object
|
||||||
|
const newStatusLineConfig: StatusLineConfig = {
|
||||||
|
enabled: checked,
|
||||||
|
currentStyle: config.StatusLine?.currentStyle || "default",
|
||||||
|
default: config.StatusLine?.default || { modules: [] },
|
||||||
|
powerline: config.StatusLine?.powerline || { modules: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
StatusLine: newStatusLineConfig,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openStatusLineConfig = () => {
|
||||||
|
setIsStatusLineConfigOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange} >
|
||||||
|
<DialogContent data-testid="settings-dialog" className="max-h-[80vh] flex flex-col p-0">
|
||||||
|
<DialogHeader className="p-4 pb-0">
|
||||||
|
<DialogTitle>{t("toplevel.title")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 p-4 px-8 overflow-y-auto flex-1">
|
||||||
|
<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>
|
||||||
|
{/* StatusLine Configuration */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="statusline"
|
||||||
|
checked={config.StatusLine?.enabled || false}
|
||||||
|
onCheckedChange={handleStatusLineEnabledChange}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="statusline"
|
||||||
|
className="transition-all-ease hover:scale-[1.02] cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("statusline.title")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={openStatusLineConfig}
|
||||||
|
className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
data-testid="statusline-config-button"
|
||||||
|
>
|
||||||
|
{t("app.settings")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="log-level" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.log_level")}</Label>
|
||||||
|
<Combobox
|
||||||
|
options={[
|
||||||
|
{ label: "fatal", value: "fatal" },
|
||||||
|
{ label: "error", value: "error" },
|
||||||
|
{ label: "warn", value: "warn" },
|
||||||
|
{ label: "info", value: "info" },
|
||||||
|
{ label: "debug", value: "debug" },
|
||||||
|
{ label: "trace", value: "trace" },
|
||||||
|
]}
|
||||||
|
value={config.LOG_LEVEL}
|
||||||
|
onChange={(value) => setConfig({ ...config, LOG_LEVEL: value })}
|
||||||
|
/>
|
||||||
|
</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="timeout"
|
||||||
|
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("toplevel.timeout")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="timeout"
|
||||||
|
value={config.API_TIMEOUT_MS}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, API_TIMEOUT_MS: e.target.value })
|
||||||
|
}
|
||||||
|
className="transition-all-ease focus:scale-[1.01]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="proxy-url"
|
||||||
|
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("toplevel.proxy_url")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-url"
|
||||||
|
value={config.PROXY_URL}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, PROXY_URL: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="http://127.0.0.1:7890"
|
||||||
|
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 className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="custom-router-path"
|
||||||
|
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("toplevel.custom_router_path")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-router-path"
|
||||||
|
value={config.CUSTOM_ROUTER_PATH || ""}
|
||||||
|
onChange={(e) => setConfig({ ...config, CUSTOM_ROUTER_PATH: e.target.value })}
|
||||||
|
placeholder={t("toplevel.custom_router_path_placeholder")}
|
||||||
|
className="transition-all-ease focus:scale-[1.01]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="p-4 pt-0">
|
||||||
|
<Button
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{t("app.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<StatusLineConfigDialog
|
||||||
|
isOpen={isStatusLineConfigOpen}
|
||||||
|
onOpenChange={setIsStatusLineConfigOpen}
|
||||||
|
data-testid="statusline-config-dialog"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
1233
ui/src/components/StatusLineConfigDialog.tsx
Normal file
309
ui/src/components/StatusLineImportExport.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { validateStatusLineConfig, backupConfig, restoreConfig, createDefaultStatusLineConfig } from "@/utils/statusline";
|
||||||
|
import type { StatusLineConfig } from "@/types";
|
||||||
|
|
||||||
|
interface StatusLineImportExportProps {
|
||||||
|
config: StatusLineConfig;
|
||||||
|
onImport: (config: StatusLineConfig) => void;
|
||||||
|
onShowToast: (message: string, type: 'success' | 'error' | 'warning') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusLineImportExport({ config, onImport, onShowToast }: StatusLineImportExportProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
|
||||||
|
// 导出配置为JSON文件
|
||||||
|
const handleExport = () => {
|
||||||
|
try {
|
||||||
|
// 在导出前验证配置
|
||||||
|
const validationResult = validateStatusLineConfig(config);
|
||||||
|
|
||||||
|
if (!validationResult.isValid) {
|
||||||
|
onShowToast(t("statusline.export_validation_failed"), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataStr = JSON.stringify(config, null, 2);
|
||||||
|
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`;
|
||||||
|
|
||||||
|
const exportFileDefaultName = `statusline-config-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
|
||||||
|
const linkElement = document.createElement('a');
|
||||||
|
linkElement.setAttribute('href', dataUri);
|
||||||
|
linkElement.setAttribute('download', exportFileDefaultName);
|
||||||
|
linkElement.click();
|
||||||
|
|
||||||
|
onShowToast(t("statusline.export_success"), 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Export failed:", error);
|
||||||
|
onShowToast(t("statusline.export_failed"), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导入配置从JSON文件
|
||||||
|
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setIsImporting(true);
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const content = e.target?.result as string;
|
||||||
|
const importedConfig = JSON.parse(content) as StatusLineConfig;
|
||||||
|
|
||||||
|
// 验证导入的配置
|
||||||
|
const validationResult = validateStatusLineConfig(importedConfig);
|
||||||
|
|
||||||
|
if (!validationResult.isValid) {
|
||||||
|
// 格式化错误信息
|
||||||
|
const errorMessages = validationResult.errors.map(error =>
|
||||||
|
error.message
|
||||||
|
).join('; ');
|
||||||
|
throw new Error(`${t("statusline.invalid_config")}: ${errorMessages}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onImport(importedConfig);
|
||||||
|
onShowToast(t("statusline.import_success"), 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Import failed:", error);
|
||||||
|
onShowToast(t("statusline.import_failed") + (error instanceof Error ? `: ${error.message}` : ""), 'error');
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
// 重置文件输入,以便可以再次选择同一个文件
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
onShowToast(t("statusline.import_failed"), 'error');
|
||||||
|
setIsImporting(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下载配置模板
|
||||||
|
const handleDownloadTemplate = () => {
|
||||||
|
try {
|
||||||
|
// 使用新的默认配置函数
|
||||||
|
const templateConfig = createDefaultStatusLineConfig();
|
||||||
|
|
||||||
|
const dataStr = JSON.stringify(templateConfig, null, 2);
|
||||||
|
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`;
|
||||||
|
|
||||||
|
const templateFileName = "statusline-config-template.json";
|
||||||
|
|
||||||
|
const linkElement = document.createElement('a');
|
||||||
|
linkElement.setAttribute('href', dataUri);
|
||||||
|
linkElement.setAttribute('download', templateFileName);
|
||||||
|
linkElement.click();
|
||||||
|
|
||||||
|
onShowToast(t("statusline.template_download_success"), 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Template download failed:", error);
|
||||||
|
onShowToast(t("statusline.template_download_failed"), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 配置备份功能
|
||||||
|
const handleBackup = () => {
|
||||||
|
try {
|
||||||
|
const backupStr = backupConfig(config);
|
||||||
|
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(backupStr)}`;
|
||||||
|
|
||||||
|
const backupFileName = `statusline-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
|
||||||
|
const linkElement = document.createElement('a');
|
||||||
|
linkElement.setAttribute('href', dataUri);
|
||||||
|
linkElement.setAttribute('download', backupFileName);
|
||||||
|
linkElement.click();
|
||||||
|
|
||||||
|
onShowToast(t("statusline.backup_success"), 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Backup failed:", error);
|
||||||
|
onShowToast(t("statusline.backup_failed"), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 配置恢复功能
|
||||||
|
const handleRestore = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const content = e.target?.result as string;
|
||||||
|
const restoredConfig = restoreConfig(content);
|
||||||
|
|
||||||
|
if (!restoredConfig) {
|
||||||
|
throw new Error(t("statusline.invalid_backup_file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证恢复的配置
|
||||||
|
const validationResult = validateStatusLineConfig(restoredConfig);
|
||||||
|
|
||||||
|
if (!validationResult.isValid) {
|
||||||
|
// 格式化错误信息
|
||||||
|
const errorMessages = validationResult.errors.map(error =>
|
||||||
|
error.message
|
||||||
|
).join('; ');
|
||||||
|
throw new Error(`${t("statusline.invalid_config")}: ${errorMessages}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onImport(restoredConfig);
|
||||||
|
onShowToast(t("statusline.restore_success"), 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Restore failed:", error);
|
||||||
|
onShowToast(t("statusline.restore_failed") + (error instanceof Error ? `: ${error.message}` : ""), 'error');
|
||||||
|
} finally {
|
||||||
|
// 重置文件输入
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
onShowToast(t("statusline.restore_failed"), 'error');
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除本地验证函数,因为我们现在使用utils中的验证函数
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="transition-all hover:shadow-md">
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<CardTitle className="text-lg flex items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
{t("statusline.import_export")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 px-4 pb-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
variant="outline"
|
||||||
|
className="transition-all hover:scale-105"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
{t("statusline.export")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isImporting}
|
||||||
|
className="transition-all hover:scale-105"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
{t("statusline.import")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleBackup}
|
||||||
|
variant="outline"
|
||||||
|
className="transition-all hover:scale-105"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
{t("statusline.backup")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
// 创建一个隐藏的文件输入用于恢复
|
||||||
|
const restoreInput = document.createElement('input');
|
||||||
|
restoreInput.type = 'file';
|
||||||
|
restoreInput.accept = '.json';
|
||||||
|
restoreInput.onchange = (e) => handleRestore(e as any);
|
||||||
|
restoreInput.click();
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="transition-all hover:scale-105"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||||
|
<path d="M3 15v4c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2v-4M17 9l-5 5-5-5M12 12.8V2.5"/>
|
||||||
|
</svg>
|
||||||
|
{t("statusline.restore")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
variant="outline"
|
||||||
|
className="transition-all hover:scale-105 sm:col-span-2"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
{t("statusline.download_template")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleImport}
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-3 bg-secondary/50 rounded-md">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground mt-0.5 flex-shrink-0">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("statusline.import_export_help")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
ui/src/components/TransformerList.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { Transformer } from "@/types";
|
||||||
|
|
||||||
|
interface TransformerListProps {
|
||||||
|
transformers: Transformer[];
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransformerList({ transformers, onEdit, onRemove }: TransformerListProps) {
|
||||||
|
// Handle case where transformers might be null or undefined
|
||||||
|
if (!transformers || !Array.isArray(transformers)) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-center rounded-md border bg-white p-8 text-gray-500">
|
||||||
|
No transformers configured
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{transformers.map((transformer, index) => {
|
||||||
|
// Handle case where individual transformer might be null or undefined
|
||||||
|
if (!transformer) {
|
||||||
|
return (
|
||||||
|
<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">Invalid Transformer</p>
|
||||||
|
<p className="text-sm text-gray-500">Transformer data is missing</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" disabled>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case where transformer.path might be null or undefined
|
||||||
|
const transformerPath = transformer.path || "Unnamed Transformer";
|
||||||
|
|
||||||
|
// Handle case where transformer.parameters might be null or undefined
|
||||||
|
const options = transformer.options || {};
|
||||||
|
|
||||||
|
// Render parameters as tags in a single line
|
||||||
|
const renderParameters = () => {
|
||||||
|
if (!options || Object.keys(options).length === 0) {
|
||||||
|
return <p className="text-sm text-gray-500">No parameters configured</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-8 overflow-hidden">
|
||||||
|
{Object.entries(options).map(([key, value]) => (
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded-md bg-gray-100 text-xs font-medium text-gray-700 border"
|
||||||
|
>
|
||||||
|
<span className="text-gray-600">{key}:</span>
|
||||||
|
<span className="ml-1 text-gray-800">{String(value)}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">{transformerPath}</p>
|
||||||
|
{renderParameters()}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
ui/src/components/Transformers.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
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<{ name?: string; path: string; options: { [key: string]: string } } | null>(null);
|
||||||
|
|
||||||
|
// Handle case where config is null or undefined
|
||||||
|
if (!config) {
|
||||||
|
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")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-grow flex items-center justify-center p-4">
|
||||||
|
<div className="text-gray-500">Loading transformers configuration...</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate config.Transformers to ensure it's an array
|
||||||
|
const validTransformers = Array.isArray(config.transformers) ? config.transformers : [];
|
||||||
|
|
||||||
|
const handleAddTransformer = () => {
|
||||||
|
const newTransformer = { name: "", path: "", options: {} };
|
||||||
|
setNewTransformer(newTransformer);
|
||||||
|
setEditingTransformerIndex(validTransformers.length); // Use the length as index for the new item
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTransformer = (index: number) => {
|
||||||
|
const newTransformers = [...validTransformers];
|
||||||
|
newTransformers.splice(index, 1);
|
||||||
|
setConfig({ ...config, transformers: newTransformers });
|
||||||
|
setDeletingTransformerIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransformerChange = (index: number, field: string, value: string, parameterKey?: string) => {
|
||||||
|
if (index < validTransformers.length) {
|
||||||
|
// Editing an existing transformer
|
||||||
|
const newTransformers = [...validTransformers];
|
||||||
|
if (parameterKey !== undefined) {
|
||||||
|
newTransformers[index].options![parameterKey] = value;
|
||||||
|
} else {
|
||||||
|
(newTransformers[index] as unknown as Record<string, unknown>)[field] = value;
|
||||||
|
}
|
||||||
|
setConfig({ ...config, transformers: newTransformers });
|
||||||
|
} else {
|
||||||
|
// Editing the new transformer
|
||||||
|
if (newTransformer) {
|
||||||
|
const updatedTransformer = { ...newTransformer };
|
||||||
|
if (parameterKey !== undefined) {
|
||||||
|
updatedTransformer.options![parameterKey] = value;
|
||||||
|
} else {
|
||||||
|
(updatedTransformer as Record<string, unknown>)[field] = value;
|
||||||
|
}
|
||||||
|
setNewTransformer(updatedTransformer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editingTransformer = editingTransformerIndex !== null ?
|
||||||
|
(editingTransformerIndex < validTransformers.length ?
|
||||||
|
validTransformers[editingTransformerIndex] :
|
||||||
|
newTransformer) :
|
||||||
|
null;
|
||||||
|
|
||||||
|
const handleSaveTransformer = () => {
|
||||||
|
if (newTransformer && editingTransformerIndex === validTransformers.length) {
|
||||||
|
// Saving a new transformer
|
||||||
|
const newTransformers = [...validTransformers, 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">({validTransformers.length})</span></CardTitle>
|
||||||
|
<Button onClick={handleAddTransformer}>{t("transformers.add")}</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-grow overflow-y-auto p-4">
|
||||||
|
<TransformerList
|
||||||
|
transformers={validTransformers}
|
||||||
|
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 parameters = editingTransformer.options || {};
|
||||||
|
const newKey = `param${Object.keys(parameters).length + 1}`;
|
||||||
|
if (editingTransformerIndex !== null) {
|
||||||
|
const newParameters = { ...parameters, [newKey]: "" };
|
||||||
|
if (editingTransformerIndex < validTransformers.length) {
|
||||||
|
const newTransformers = [...validTransformers];
|
||||||
|
newTransformers[editingTransformerIndex].options = newParameters;
|
||||||
|
setConfig({ ...config, transformers: newTransformers });
|
||||||
|
} else if (newTransformer) {
|
||||||
|
setNewTransformer({ ...newTransformer, options: newParameters });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 parameters = editingTransformer.options || {};
|
||||||
|
const newParameters = { ...parameters };
|
||||||
|
delete newParameters[key];
|
||||||
|
newParameters[e.target.value] = value;
|
||||||
|
if (editingTransformerIndex !== null) {
|
||||||
|
if (editingTransformerIndex < validTransformers.length) {
|
||||||
|
const newTransformers = [...validTransformers];
|
||||||
|
newTransformers[editingTransformerIndex].options = newParameters;
|
||||||
|
setConfig({ ...config, transformers: newTransformers });
|
||||||
|
} else if (newTransformer) {
|
||||||
|
setNewTransformer({ ...newTransformer, options: newParameters });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (editingTransformerIndex !== null) {
|
||||||
|
handleTransformerChange(editingTransformerIndex, "parameters", e.target.value, key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
if (editingTransformerIndex !== null) {
|
||||||
|
const parameters = editingTransformer.options || {};
|
||||||
|
const newParameters = { ...parameters };
|
||||||
|
delete newParameters[key];
|
||||||
|
if (editingTransformerIndex < validTransformers.length) {
|
||||||
|
const newTransformers = [...validTransformers];
|
||||||
|
newTransformers[editingTransformerIndex].options = newParameters;
|
||||||
|
setConfig({ ...config, transformers: newTransformers });
|
||||||
|
} else if (newTransformer) {
|
||||||
|
setNewTransformer({ ...newTransformer, options: newParameters });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
ui/src/components/ui/badge.tsx
Normal 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 }
|
||||||
57
ui/src/components/ui/button.tsx
Normal 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 }
|
||||||
79
ui/src/components/ui/card.tsx
Normal 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 }
|
||||||
165
ui/src/components/ui/color-picker.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { HexColorPicker } from "react-colorful"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
|
|
||||||
|
interface ColorPickerProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
showPreview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取颜色值的函数
|
||||||
|
const getColorValue = (color: string): string => {
|
||||||
|
// 如果是十六进制颜色
|
||||||
|
if (color.startsWith("#")) {
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回黑色
|
||||||
|
return "#000000"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorPicker({
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
placeholder = "选择颜色...",
|
||||||
|
showPreview = true
|
||||||
|
}: ColorPickerProps) {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const [customColor, setCustomColor] = React.useState("")
|
||||||
|
|
||||||
|
// 当value变化时更新customColor
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (value.startsWith("#")) {
|
||||||
|
setCustomColor(value)
|
||||||
|
} else {
|
||||||
|
setCustomColor("")
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleColorChange = (color: string) => {
|
||||||
|
onChange(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const color = e.target.value
|
||||||
|
setCustomColor(color)
|
||||||
|
// 验证十六进制颜色格式
|
||||||
|
if (/^#[0-9A-F]{6}$/i.test(color)) {
|
||||||
|
handleColorChange(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const selectedColorValue = getColorValue(value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start text-left font-normal h-10 transition-all hover:scale-[1.02] active:scale-[0.98]",
|
||||||
|
!value && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
{showPreview && (
|
||||||
|
<div
|
||||||
|
className="h-5 w-5 rounded border shadow-sm"
|
||||||
|
style={{ backgroundColor: selectedColorValue }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate flex-1">
|
||||||
|
{value || placeholder}
|
||||||
|
</span>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="m7 15 5 5 5-5"/>
|
||||||
|
<path d="m7 9 5-5 5 5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 p-3" align="start">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 颜色选择器标题 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold">颜色选择器</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => handleColorChange("")}
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 颜色预览 */}
|
||||||
|
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary">
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 rounded border shadow-sm"
|
||||||
|
style={{ backgroundColor: selectedColorValue }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{value || "未选择颜色"}
|
||||||
|
</div>
|
||||||
|
{value && value.startsWith("#") && (
|
||||||
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
|
{value.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 颜色选择器 */}
|
||||||
|
<div className="rounded-md overflow-hidden border">
|
||||||
|
<HexColorPicker
|
||||||
|
color={selectedColorValue}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自定义颜色输入 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">自定义颜色</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={customColor}
|
||||||
|
onChange={handleCustomColorChange}
|
||||||
|
placeholder="#RRGGBB"
|
||||||
|
className="font-mono flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) {
|
||||||
|
handleColorChange(customColor)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!customColor || !/^#[0-9A-F]{6}$/i.test(customColor)}
|
||||||
|
>
|
||||||
|
应用
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
输入十六进制颜色值 (例如: #FF0000)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
ui/src/components/ui/combo-input.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
87
ui/src/components/ui/combobox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
181
ui/src/components/ui/command.tsx
Normal 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,
|
||||||
|
}
|
||||||
125
ui/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
79
ui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
const isNumeric = type === "number";
|
||||||
|
const [tempValue, setTempValue] = React.useState(props.value?.toString() || '');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (props.value !== undefined) {
|
||||||
|
setTempValue(props.value.toString());
|
||||||
|
}
|
||||||
|
}, [props.value]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
|
||||||
|
if (isNumeric) {
|
||||||
|
// Only allow empty string or numbers for numeric input
|
||||||
|
if (newValue === '' || /^\d+$/.test(newValue)) {
|
||||||
|
setTempValue(newValue);
|
||||||
|
// Only call onChange if the value is not empty
|
||||||
|
if (props.onChange && newValue !== '') {
|
||||||
|
props.onChange(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTempValue(newValue);
|
||||||
|
if (props.onChange) {
|
||||||
|
props.onChange(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
if (isNumeric && tempValue === '') {
|
||||||
|
const defaultValue = props.placeholder || "1";
|
||||||
|
setTempValue(defaultValue);
|
||||||
|
|
||||||
|
// Create a synthetic event for the corrected value
|
||||||
|
if (props.onChange) {
|
||||||
|
const syntheticEvent = {
|
||||||
|
...e,
|
||||||
|
target: { ...e.target, value: defaultValue }
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>;
|
||||||
|
|
||||||
|
props.onChange(syntheticEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.onBlur) {
|
||||||
|
props.onBlur(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// For numeric inputs, use text type and manage value internally
|
||||||
|
const inputType = isNumeric ? "text" : type;
|
||||||
|
const inputValue = isNumeric ? tempValue : props.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
type={inputType}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
ui/src/components/ui/label.tsx
Normal 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 }
|
||||||
114
ui/src/components/ui/multi-combobox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
ui/src/components/ui/popover.tsx
Normal 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 }
|
||||||
29
ui/src/components/ui/switch.tsx
Normal 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 }
|
||||||
59
ui/src/components/ui/toast.tsx
Normal 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
@@ -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;
|
||||||
156
ui/src/index.css
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 美化滚动条 - WebKit浏览器 (Chrome, Safari, Edge) */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-muted-foreground/30;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-muted-foreground/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: oklch(0.556 0 0) oklch(0.97 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark * {
|
||||||
|
scrollbar-color: oklch(0.708 0 0) oklch(0.269 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
ui/src/lib/api.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import type { Config, Provider, Transformer } from '@/types';
|
||||||
|
|
||||||
|
// 日志聚合响应类型
|
||||||
|
interface GroupedLogsResponse {
|
||||||
|
grouped: boolean;
|
||||||
|
groups: { [reqId: string]: Array<{ timestamp: string; level: string; message: string; source?: string; reqId?: string }> };
|
||||||
|
summary: {
|
||||||
|
totalRequests: number;
|
||||||
|
totalLogs: number;
|
||||||
|
requests: Array<{
|
||||||
|
reqId: string;
|
||||||
|
logCount: number;
|
||||||
|
firstLog: string;
|
||||||
|
lastLog: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Client Class for handling requests with baseUrl and apikey authentication
|
||||||
|
class ApiClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private apiKey: string;
|
||||||
|
private tempApiKey: string | null;
|
||||||
|
|
||||||
|
constructor(baseUrl: string = '/api', apiKey: string = '') {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
// Load API key from localStorage if available
|
||||||
|
this.apiKey = apiKey || localStorage.getItem('apiKey') || '';
|
||||||
|
// Load temp API key from URL if available
|
||||||
|
this.tempApiKey = new URLSearchParams(window.location.search).get('tempApiKey');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update temp API key
|
||||||
|
setTempApiKey(tempApiKey: string | null) {
|
||||||
|
this.tempApiKey = tempApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create headers with API key authentication
|
||||||
|
private createHeaders(contentType: string = 'application/json'): HeadersInit {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use temp API key if available, otherwise use regular API key
|
||||||
|
if (this.tempApiKey) {
|
||||||
|
headers['X-Temp-API-Key'] = this.tempApiKey;
|
||||||
|
} else if (this.apiKey) {
|
||||||
|
headers['X-API-Key'] = this.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
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', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
async checkForUpdates(): Promise<{ hasUpdate: boolean; latestVersion?: string; changelog?: string }> {
|
||||||
|
return this.get<{ hasUpdate: boolean; latestVersion?: string; changelog?: string }>('/update/check');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform update
|
||||||
|
async performUpdate(): Promise<{ success: boolean; message: string }> {
|
||||||
|
return this.post<{ success: boolean; message: string }>('/api/update/perform', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get log files list
|
||||||
|
async getLogFiles(): Promise<Array<{ name: string; path: string; size: number; lastModified: string }>> {
|
||||||
|
return this.get<Array<{ name: string; path: string; size: number; lastModified: string }>>('/logs/files');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logs from specific file
|
||||||
|
async getLogs(filePath: string): Promise<string[]> {
|
||||||
|
return this.get<string[]>(`/logs?file=${encodeURIComponent(filePath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear logs from specific file
|
||||||
|
async clearLogs(filePath: string): Promise<void> {
|
||||||
|
return this.delete<void>(`/logs?file=${encodeURIComponent(filePath)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||