131 Commits

Author SHA1 Message Date
musistudio
e078127ac6 release v1.0.38 2025-08-15 23:54:41 +08:00
musistudio
0e509528c2 add ccr statusline command 2025-08-15 23:50:57 +08:00
musi
a265cbdce6 Merge pull request #557 from BigUncle/feature/logging-configuration-improvements
feat logging: Implement LOG_LEVEL configuration option and improve lo…
2025-08-15 21:38:13 +08:00
BigUncle
b8f52ba538 feat logging: Implement LOG_LEVEL configuration option and improve logging consistency
- Add LOG_LEVEL configuration option to control logging verbosity
- Update UI to include LOG_LEVEL dropdown in settings
- Fix logging inconsistency between environment variables and config file
- Unify logging configuration to use config file settings
- Maintain separate logging systems for different purposes:
  * Server-level logs (HTTP requests, API calls) using pino in ~/.claude-code-router/logs/
  * Application-level logs (routing decisions, business logic) in ~/.claude-code-router/claude-code-router.log
- Update documentation with accurate logging system information
- Add detailed information about dual logging systems in README.md and README_zh.md
- Improve type safety and validation in ConfigProvider

Co-authored-by: qwen-cli <https://github.com/QwenLM/qwen-code>
2025-08-14 21:17:27 +08:00
musi
a62a025368 Merge pull request #558 from SaseQ/main
Add experimental Atlassian Rovo Dev CLI transformer
2025-08-14 08:50:24 +08:00
SaseQ
9d332aa036 Add Rovo CLI Transformer info to README_zh.md 2025-08-13 18:19:54 +02:00
SaseQ
5d00f519cd Add Rovo CLI Transformer info to README.md 2025-08-13 18:19:22 +02:00
musistudio
4fca983e4f release v1.0.37 2025-08-13 21:34:27 +08:00
musistudio
bc08c4ab48 add update button 2025-08-13 21:32:57 +08:00
musistudio
bdf608fffc release v1.0.36 2025-08-12 22:49:06 +08:00
musistudio
d9b7667c93 Merge remote-tracking branch 'origin/main' 2025-08-12 22:47:13 +08:00
musi
c3ab30b0b9 Merge pull request #529 from semikolon/feature/env-var-interpolation
Add Environment Variable Interpolation for API Keys
2025-08-12 21:43:50 +08:00
musistudio
cce1625534 fix windows/linux get system uuid error 2025-08-12 21:41:42 +08:00
Fredrik Bränström
5c1a193f4d docs: add environment variable interpolation documentation
- Document  and  syntax in README
- Add example usage in config.json
- Explain security benefits and recursive interpolation support
2025-08-11 21:44:14 +02:00
Fredrik Bränström
3ad140d2f5 feat: add environment variable interpolation for API keys
- Add interpolateEnvVars function to support $VAR_NAME and ${VAR_NAME} syntax
- Apply interpolation to config after JSON5 parsing in readConfigFile()
- Enables secure API key management without hardcoding in config.json
- Supports nested objects and arrays for comprehensive interpolation
- Maintains backward compatibility with existing configurations

Example usage in config.json:
{
  "OPENAI_API_KEY": "$OPENAI_API_KEY",
  "Providers": [
    {
      "name": "openai",
      "api_key": "$OPENAI_API_KEY"
    }
  ]
}
2025-08-11 21:43:32 +02:00
musistudio
075ec76ec1 fix windows logs path error 2025-08-11 10:25:50 +08:00
musistudio
709b49b0e8 release v1.0.35 to fix windows logfile name 2025-08-10 22:11:51 +08:00
musistudio
b856e1e11b relese v1.0.34 to optimize the log 2025-08-10 21:37:48 +08:00
musi
6510d3aac9 Merge pull request #506 from BigUncle/fix/ui-restart-authentication
fix ui: resolve 403 Forbidden error when restarting service via UI
2025-08-10 07:51:11 +08:00
BigUncle
1708c59434 fix ui: resolve 403 Forbidden error when restarting service via UI
- Include /api/restart endpoint in access control checks alongside /api/config endpoints
- Ensure restart endpoint properly validates API key authentication
- This fixes the issue where 'Save and Restart' fails with 403 error in UI
2025-08-09 20:44:28 +08:00
musistudio
9cd5587f52 feat: Implement temporary API key based on system UUID for UI access
This commit introduces a new authentication mechanism for the web UI.
Instead of requiring a pre-configured API key, a temporary API key is
generated based on the system's UUID. This key is passed to the UI
as a URL parameter and used for API requests.

Changes:
- Added a new utility to get the system UUID and generate a temporary API key.
- Modified the `ccr ui` command to generate and pass the temporary API key.
- Updated the authentication middleware to validate the temporary API key.
- Adjusted the frontend to use the temporary API key from the URL.
- Added a dedicated endpoint to test API access without modifying data.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 15:00:42 +08:00
musi
4334f40926 Merge pull request #465 from HynoR/main
fix: check provider name to prevent duplicate name with different config param
2025-08-07 08:56:18 +08:00
HynoR
38bc747261 fix: check provider name to prevent duplicate name with different config 2025-08-07 00:56:30 +08:00
musistudio
adfae3263a update sponsors 2025-08-06 22:21:01 +08:00
musistudio
0794151eb5 release v1.0.33 2025-08-06 22:19:05 +08:00
musistudio
3e1963564a fix ui: add form validator 2025-08-06 21:11:44 +08:00
musistudio
023c045821 Merge remote-tracking branch 'origin/main' 2025-08-06 20:54:21 +08:00
musi
f9b745b621 Merge pull request #449 from schoeffm/bugfix/fix-code-args-handling
Preserve spaces in quoted args
2025-08-06 20:53:36 +08:00
musi
a958f18305 Merge branch 'main' into bugfix/fix-code-args-handling 2025-08-06 20:53:24 +08:00
musi
89844bcb62 Merge pull request #455 from xgblack/fix-docker-ui
fix ui: 修复Docker中运行通过UI重启报错的问题
2025-08-06 20:17:07 +08:00
小光
37cb0c776f fix ui: 修复Docker中运行通过UI重启报错的问题
使用Claude Code修复
2025-08-06 15:03:06 +08:00
musi
d9e8df5c04 Merge pull request #444 from jagnani73/main
feat: non interactive spawn compatible
2025-08-06 13:06:45 +08:00
Stefan Schoeffmann
91e9d43abd Preserve spaces in quoted args
This makes SDK mode work since the model gets the complete quoted prompt passed in (instead of just the first word)
2025-08-05 21:34:19 +02:00
musistudio
216ee939fb feat ui: add placeholder for proxy_url 2025-08-05 22:21:13 +08:00
Yashvardhan Jagnani
47051bf11b docs: non interactive env 2025-08-05 14:20:18 +05:30
Yashvardhan Jagnani
355c83a8c1 feat: non interactive spawn compatible 2025-08-05 13:42:38 +05:30
Yashvardhan Jagnani
552621f707 chore: remove duplicate lock file 2025-08-05 13:42:03 +05:30
musi
75ab74957d Merge pull request #425 from FlintyLemming/main
feat: support setting ANTHROPIC_SMALL_FAST_MODEL from config file
2025-08-04 21:00:51 +08:00
FlintyLemming
d684319261 feat: support setting ANTHROPIC_SMALL_FAST_MODEL from config file 2025-08-04 15:03:22 +08:00
musistudio
7bb816ad03 fix ui: custom transformer error 2025-08-04 13:18:57 +08:00
musistudio
38c6cf0c9a feat ui: show apikey in edit provider dialog 2025-08-04 13:02:39 +08:00
musistudio
e51d70caf2 feat ui: add tooltip 2025-08-04 09:48:15 +08:00
musistudio
5fd78a103b feat ui: add import and export config 2025-08-03 16:42:59 +08:00
musistudio
a3b2353bca release v1.0.32 2025-08-03 08:27:57 +08:00
musistudio
99afe0e21a compatible model name case problem 2025-08-03 07:47:11 +08:00
musistudio
7751683365 pass token count to custom router 2025-08-03 07:38:51 +08:00
musistudio
c2edcd145e add supoort subagent router via <CCR-SUBAGENT-MODEL> 2025-08-03 07:31:17 +08:00
musistudio
996a05d1d6 feat ui: support import provider from template 2025-08-02 22:56:18 +08:00
musistudio
cd43a74ab5 add open router provider doc 2025-08-02 18:05:17 +08:00
musistudio
6523255d83 fix ui: add proxy_url in general settings 2025-08-02 17:50:11 +08:00
musistudio
a3d1f44908 release v1.0.31 to support anthropic endpoint provider 2025-08-01 22:10:14 +08:00
musi
5a11d2f9af Merge pull request #401 from Honghurumeng/main
fix(ui): 优化Providers编辑模态框取消以进行关闭的删除逻辑和保存逻辑
2025-08-01 13:02:21 +08:00
Reed Qu
d929e7cfef fix(ui): 优化Providers编辑模态框取消以关闭的删除逻辑和保存逻辑 2025-08-01 11:30:35 +08:00
musistudio
03c9b0fa58 update sponsors 2025-07-31 09:16:34 +08:00
musi
cd65b3605d Merge pull request #378 from BigUncle/feature/ui-add-api-timeout-setting
feat(ui): add API_TIMEOUT_MS setting to configuration UI(添加 API 超时时间设置功能)
2025-07-31 09:14:45 +08:00
BigUncle
754125e3a3 feat(ui): add API_TIMEOUT_MS setting to configuration UI
- Add API_TIMEOUT_MS input field to SettingsDialog
- Add translations for the new setting in both English and Chinese locales
- Update ConfigProvider to handle API_TIMEOUT_MS configuration
2025-07-30 23:15:57 +08:00
musistudio
3cb086fc57 feat: Limit config backups to 3 most recent files
Modified the backupConfigFile function to automatically clean up old backups,
keeping only the 3 most recent backup files. This prevents the config directory
from accumulating excessive backup files over time.

The implementation:
- Creates timestamped backups as before
- After each new backup, scans for existing backups
- Sorts backups by timestamp (newest first)
- Deletes all but the 3 most recent backups
- Gracefully handles cleanup errors with warnings

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 21:59:10 +08:00
musistudio
3a12fdffb1 update docker file 2025-07-30 21:48:47 +08:00
musistudio
7978f1abae fix some ui issue 2025-07-30 21:33:58 +08:00
musistudio
aea48239f9 fix ui fetch failed 2025-07-30 20:31:14 +08:00
musistudio
7acb443aad release v1.0.30 2025-07-30 16:48:32 +08:00
musistudio
18cfe6e2f0 update doc 2025-07-30 15:53:50 +08:00
musistudio
1d7374067e fix: improve error handling and config validation
- Add fallback mechanism for service startup with default config
- Implement config file backup before saving
- Add robust validation for config data in UI components
- Improve error handling and user feedback in UI
- Fix potential null/undefined access in provider and router components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 15:39:44 +08:00
musistudio
e560db85f4 fix: show default port 3456 in status when not specified in config
When the PORT was not specified in the config file, the status command
would show "undefined" for the port. This fix ensures that the default
port 3456 is displayed instead, matching the actual behavior of the
service which uses 3456 as the default port.

Fixes the issue where `ccr status` shows undefined for port when not
configured explicitly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 15:10:44 +08:00
musistudio
74fa03d3a5 update doc 2025-07-30 14:50:45 +08:00
musistudio
c02e314d76 release v1.0.29 2025-07-30 14:36:03 +08:00
musistudio
27fa655425 Merge branch 'main' of github.com:musistudio/claude-code-router 2025-07-30 14:28:14 +08:00
musistudio
d013a8a01a docs: add ccr ui command to README
- Add documentation for the `ccr ui` command to both the English and Chinese README files.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 14:22:38 +08:00
musistudio
7faf20e0c8 feat: add JSON editor for config
- Add a JSON editor using Monaco Editor to allow raw editing of the configuration.
- The editor is presented as a full-screen dialog that slides up from the bottom.
- Includes 'Save' and 'Save and Restart' functionality with internationalized labels and toast notifications for success/failure.

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 11:15:05 +08:00
musi
2acfce5b63 Merge pull request #346 from BigUncle/feature/add-api-timeout-config
Add API_TIMEOUT_MS configuration option
2025-07-28 09:18:12 +08:00
BigUncle
2c44ea73c7 Add API_TIMEOUT_MS configuration option
- Add API_TIMEOUT_MS to config.example.json with default value of 600000ms
- Update README.md and README_zh.md to document the new configuration option
- Modify codeCommand.ts to use the configured timeout value instead of hardcoded 600000ms
- Default to 10 minutes (600000ms) if not set in config
2025-07-28 00:51:04 +08:00
musistudio
31db041084 release v1.0.28 2025-07-27 17:36:17 +08:00
musistudio
882efb5284 update readme 2025-07-27 17:27:06 +08:00
musistudio
604cc8e1dc fix the stuttering issue on windows 2025-07-27 16:10:48 +08:00
musi
a778755492 Merge pull request #330 from JoeChen2me/main
Feature: 添加可选的长上下文模型阈值配置
2025-07-26 23:03:47 +08:00
JoeChen
3bbfebb5e3 Feature: 添加可选的长上下文模型阈值配置 2025-07-26 12:13:55 +08:00
musistudio
6883fff352 update qwen3-coder example 2025-07-25 17:19:52 +08:00
musistudio
179bab605e release v1.0.27 to fix some bugs and enhance tools 2025-07-25 17:17:14 +08:00
jinhui.li
202402a123 update readme 2025-07-25 14:27:34 +08:00
jinhui.li
c6969fdd90 release v1.0.26 2025-07-23 12:20:52 +08:00
musi
b9d556ff1b add release command 2025-07-21 16:48:30 +08:00
musi
194a664f20 update cli help text 2025-07-21 15:34:39 +08:00
musi
09c3f0ccc6 add custom router doc 2025-07-21 15:31:55 +08:00
musi
5e14b9b0e1 release v1.0.24 to support custom router 2025-07-21 15:13:58 +08:00
jinhui.li
7165953b50 release v1.0.23 to support websearch 2025-07-21 10:46:53 +08:00
musi
e362feaa82 Merge pull request #264 from Linde7777/main
docs: add official Gemini api example in README.md
2025-07-21 10:37:12 +08:00
Linde7777
66054dccb0 docs: add official Gemini api example in README.md 2025-07-21 09:52:52 +08:00
jinhui.li
7efd7183d8 update readme 2025-07-20 16:20:12 +08:00
musi
5b27e797b3 Merge pull request #255 from Thlnking/feature/docs-fix 2025-07-20 15:31:46 +08:00
Thlnking
269a87da74 fix: 更新 pnpm-lock.yaml 和 README_zh.md,删除冲突标志信息 2025-07-20 14:06:30 +08:00
Thlnking
88fbf5e400 doc: 删除文档中出现的 git 冲突标志信息
Signed-off-by: Thlnking <2927874032@qq.com>
2025-07-20 09:23:46 +08:00
jinhui.li
f45316904b Merge branch 'main' of github.com:musistudio/claude-code-router 2025-07-20 01:27:43 +08:00
jinhui.li
d528a8df4c release v1.0.22 to support image 2025-07-20 01:26:52 +08:00
jinhui.li
d0de78eaf0 support configuring the port 2025-07-20 01:00:33 +08:00
musi
2fc79dcf37 release 1.0.21 2025-07-18 23:22:37 +08:00
musi
174c9a740f add groq transformer doc 2025-07-17 23:12:52 +08:00
musi
445908f8ae release v1.0.20 to support groq and fix messges transform bug 2025-07-17 23:10:10 +08:00
jinhui.li
18803469de update doc 2025-07-17 13:25:27 +08:00
jinhui.li
49502e1534 support authentication via APIKEY configuration 2025-07-17 12:49:14 +08:00
jinhui.li
f7f6943d31 update doc 2025-07-17 12:06:22 +08:00
jinhui.li
df21270a7e add config.example.json 2025-07-17 08:45:29 +08:00
musi
0a6d06b7a6 update sponsors 2025-07-16 21:59:00 +08:00
musi
84ac5b62cb fix openrouter stream response parse error 2025-07-16 21:14:27 +08:00
jinhui.li
5174ddacfc fix demo config 2025-07-16 09:15:05 +08:00
musi
5d7681cb62 Merge pull request #194 from weipengzou/feat_support_siliconflow 2025-07-16 08:01:39 +08:00
weipengzou
82d6d420b3 feat: support siliconflow config 2025-07-16 05:33:35 +08:00
musi
4d81734ceb update doc 2025-07-15 22:37:29 +08:00
musi
fb65bb8a95 update sponsors 2025-07-14 22:32:14 +08:00
musi
68e06b0d53 release v1.0.18 2025-07-14 19:48:33 +08:00
musi
3235ff4ed0 release v1.0.17 to support windows 2025-07-14 16:13:45 +08:00
musi
ea6438311f Merge pull request #156 from LinHayaii/fix/windows-support
adapt path and commands for Windows environment
2025-07-14 16:09:14 +08:00
musi
37ae7a99d8 release v1.0.16 2025-07-14 11:54:22 +08:00
Hayaii
20e8a8f197 adapt path and commands for Windows environment 2025-07-13 15:36:59 +08:00
musi
8f8eeacc3d fix fallback to the default model when no router configuration is provided 2025-07-10 20:51:30 +08:00
musi
50a679278d update sponsors 2025-07-08 21:58:10 +08:00
musi
d16fc16de6 update Sponsors 2025-07-07 20:16:17 +08:00
musi
3260d1b6b8 release v1.0.15 2025-07-07 15:03:48 +08:00
jinhui.li
c5ddf2ddae release v1.0.14 2025-07-04 13:35:29 +08:00
jinhui.li
9b5fa3c7e1 add openrouter transformer 2025-07-04 12:37:35 +08:00
jinhui.li
426262e62a release v1.0.13 2025-07-04 09:10:40 +08:00
jinhui.li
3b39677d46 fix config file error 2025-07-04 09:10:07 +08:00
jinhui.li
3ef82991fb support json config 2025-07-03 17:19:26 +08:00
jinhui.li
f9ba3805a6 fix gemini auth error 2025-07-03 09:08:31 +08:00
jinhui.li
936f697110 update readme 2025-07-02 13:33:47 +08:00
jinhui.li
b07bbd7d8c release v1.0.11 2025-07-02 13:31:42 +08:00
jinhui.li
42f7d2da60 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	README.md
#	src/utils/index.ts
2025-07-02 13:31:10 +08:00
musi
802bde2d76 Merge pull request #67 from stonega/main 2025-06-25 17:43:47 +08:00
stone
b2db0307eb Add support for lowercase HTTPS_PROXY environment variable 2025-06-24 15:19:05 +08:00
musi
391cbd8334 Update readme 2025-06-23 22:19:05 +08:00
90 changed files with 19008 additions and 378 deletions

View File

@@ -0,0 +1,36 @@
---
name: code-implementation-expert
description: Use this agent when you need to implement code solutions, write functions, create classes, or develop software components. This agent excels at translating requirements into working code across multiple programming languages. Examples: When a user asks 'Please write a function that checks if a number is prime' - use the code-implementation-expert agent to generate the implementation. When a user requests 'Create a React component for a todo list' - use this agent to build the component code.
model: sonnet
color: blue
---
<CCR-SUBAGENT-MODEL>deepseek,deepseek-reasoner</CCR-SUBAGENT-MODEL>
You are an elite software engineering expert with deep knowledge across multiple programming languages, frameworks, and best practices. Your primary role is to translate requirements into high-quality, functional code implementations.
When implementing code:
1. Analyze requirements carefully to understand functionality, constraints, and edge cases
2. Choose appropriate data structures, algorithms, and design patterns
3. Write clean, readable, and maintainable code following language-specific conventions
4. Include proper error handling, input validation, and documentation
5. Optimize for performance and scalability when relevant
6. Consider security implications and best practices
7. Write modular code that's easy to test and extend
You will:
- Implement complete, working solutions unless otherwise specified
- Use appropriate naming conventions for variables, functions, and classes
- Include necessary imports/dependencies
- Add comments for complex logic or non-obvious implementation decisions
- Follow established patterns in the codebase when visible
- Write defensive code that handles edge cases gracefully
- Ensure code compiles/runs without syntax errors
When responding:
- Provide the complete implementation in appropriate code blocks
- Explain key design decisions briefly if not obvious
- Mention any assumptions made about requirements
- Highlight important implementation details
- Note any limitations or areas for improvement
If requirements are unclear, ask specific questions to clarify before implementing. If you encounter domain-specific requirements outside your expertise, acknowledge limitations and suggest alternatives.

View File

@@ -9,3 +9,8 @@ screenshoots
.env
.blog
docs
.log
blog
config.json
ui
scripts

View File

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

491
README.md
View File

@@ -1,38 +1,92 @@
# Claude Code Router
> This is a tool for routing Claude Code requests to different models, and you can customize any request.
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)
![](screenshoots/claude-code.png)
## Usage
[中文版](README_zh.md)
1. Install Claude Code
> A powerful tool to route Claude Code requests to different models and customize any request.
![](blog/images/claude-code.png)
## ✨ 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
npm install -g @anthropic-ai/claude-code
```
2. Install Claude Code Router
Then, install Claude Code Router:
```shell
npm install -g @musistudio/claude-code-router
```
3. Start Claude Code by claude-code-router
### 2. Configuration
```shell
ccr code
```
Create and configure your `~/.claude-code-router/config.json` file. For more details, you can refer to `config.example.json`.
4. Configure routing[optional]
Set up your `~/.claude-code-router/config.json` file like this:
The `config.json` file has several key sections:
- **`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 `"info"`.
- **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
{
"OPENAI_API_KEY": "$OPENAI_API_KEY",
"GEMINI_API_KEY": "${GEMINI_API_KEY}",
"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": [
{
"name": "openrouter",
// IMPORTANT: api_base_url must be a complete (full) URL.
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
"api_key": "sk-xxx",
"models": [
@@ -40,32 +94,31 @@ ccr code
"anthropic/claude-sonnet-4",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.7-sonnet:thinking"
]
],
"transformer": {
"use": ["openrouter"]
}
},
{
"name": "deepseek",
// IMPORTANT: api_base_url must be a complete (full) URL.
"api_base_url": "https://api.deepseek.com/chat/completions",
"api_key": "sk-xxx",
"models": ["deepseek-chat", "deepseek-reasoner"],
"transformer": {
"use": ["deepseek"],
"deepseek-chat": {
// Enhance tool usage for the deepseek-chat model using the ToolUse transformer.
"use": ["tooluse"]
}
}
},
{
"name": "ollama",
// IMPORTANT: api_base_url must be a complete (full) URL.
"api_base_url": "http://localhost:11434/v1/chat/completions",
"api_key": "ollama",
"models": ["qwen2.5-coder:latest"]
},
{
"name": "gemini",
// IMPORTANT: api_base_url must be a complete (full) URL.
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
"api_key": "sk-xxx",
"models": ["gemini-2.5-flash", "gemini-2.5-pro"],
@@ -75,50 +128,284 @@ ccr code
},
{
"name": "volcengine",
// IMPORTANT: api_base_url must be a complete (full) URL.
"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", // IMPORTANT OPENAI_MODEL has been deprecated
"default": "deepseek,deepseek-chat",
"background": "ollama,qwen2.5-coder:latest",
"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`
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 doesnt require high intelligence. Im using the qwen-coder-2.5:7b model running locally on my MacBook Pro M1 (32GB) via Ollama.
If your computer cant run Ollama, you can also use some free models, such as qwen-coder-2.5:3b.
### 3. Running Claude Code with the Router
- `think`
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.
Start Claude Code using the router:
- `longContext`
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 (Ive 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.
```shell
ccr code
```
- model command
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`
This will use the anthropic/claude-3.5-sonnet model provided by OpenRouter to handle all subsequent tasks.
> **Note**: After modifying the configuration file, you need to restart the service for the changes to take effect:
>
> ```shell
> ccr restart
> ```
## Features
### 4. UI Mode (Beta)
- [x] Support change models
- [x] Github Actions
- [ ] More detailed logs
For a more intuitive experience, you can use the UI mode to manage your configuration:
## Github Actions
```shell
ccr ui
```
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:
This will open a web-based interface where you can easily view and edit your `config.json` file.
![UI](/blog/images/ui.png)
> **Note**: The UI mode is currently in beta. 100% vibe coding: including project initialization, I just created a folder and a project.md document, and all code was generated by ccr + qwen3-coder + gemini(webSearch).
If you encounter any issues, please submit an issue on GitHub.
#### 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.
- `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`.
```json
{
"transformers": [
{
"path": "/User/xxx/.claude-code-router/plugins/gemini-cli.js",
"options": {
"project": "xxx"
}
}
]
}
```
#### Router
The `Router` object defines which model to use for different scenarios:
- `default`: The default model for general tasks.
- `background`: A model for background tasks. This can be a smaller, local model to save costs.
- `think`: A model for reasoning-heavy tasks, like Plan Mode.
- `longContext`: A model for handling long contexts (e.g., > 60K tokens).
- `longContextThreshold` (optional): The token count threshold for triggering the long context model. Defaults to 60000 if not specified.
- `webSearch`: Used for handling web search tasks and this requires the model itself to support the feature. If you're using openrouter, you need to add the `:online` suffix after the model name.
You can also switch models dynamically in Claude Code with the `/model` command:
`/model provider_name,model_name`
Example: `/model openrouter,anthropic/claude-3.5-sonnet`
#### Custom Router
For more advanced routing logic, you can specify a custom router script via the `CUSTOM_ROUTER_PATH` in your `config.json`. This allows you to implement complex routing rules beyond the default scenarios.
In your `config.json`:
```json
{
"CUSTOM_ROUTER_PATH": "/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...
```
## 🤖 GitHub Actions
Integrate Claude Code Router into your CI/CD pipeline. After setting up [Claude Code Actions](https://docs.anthropic.com/en/docs/claude-code/github-actions), modify your `.github/workflows/claude.yaml` to use the router:
```yaml
name: Claude Code
@@ -126,20 +413,13 @@ name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
# ... other triggers
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(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')))
# ... other conditions
runs-on: ubuntu-latest
permissions:
contents: read
@@ -159,6 +439,7 @@ jobs:
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"
@@ -177,64 +458,90 @@ jobs:
env:
ANTHROPIC_BASE_URL: http://localhost:3456
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.
> **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.
For example, between 00:30 and 08:30 Beijing Time, using the official DeepSeek API:
This setup allows for interesting automations, like running tasks during off-peak hours to reduce API costs.
- The cost of the `deepseek-v3` model is only 50% of the normal time.
## 📝 Further Reading
- The `deepseek-r1` model is just 25% of the normal time.
- [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)
So maybe in the future, Ill describe detailed tasks for Claude Code ahead of time and let it run during these discounted hours to reduce costs?
## ❤️ Support & Sponsoring
## Some tips:
Now you can use deepseek-v3 models directly without using any plugins.
If youre 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, youll need to discard the previous context and start fresh. Alternatively, you can use ByteDances DeepSeek API, which offers a 128K context window and supports KV cache.
![](screenshoots/contexterror.jpg)
Note: claude code consumes a huge amount of tokens, but thanks to DeepSeeks low cost, you can use claude code at a fraction of Claudes price, and you dont 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))
2. [Maybe We Can Do More with the Router](blog/en/maybe-we-can-do-more-with-the-route.md) ([或许我们能在 Router 中做更多事情](blog/zh/或许我们能在Router中做更多事情.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.
If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated!
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F31GN2GM)
[Paypal](https://paypal.me/musistudio1999)
<table>
<tr>
<td><img src="/blog/images/alipay.jpg" width="200" /></td>
<td><img src="/blog/images/wechat.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" alt="WeChat Pay" /></td>
</tr>
</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)
[@vrgitadmin](https://github.com/vrgitadmin)
@\*o (可通过主页邮箱联系我修改 github 用户名)
@\*\*聪 (可通过主页邮箱联系我修改 github 用户名)
@\*说 (可通过主页邮箱联系我修改 github 用户名)
@\*更 (可通过主页邮箱联系我修改 github 用户名)
@\*更 (可通过主页邮箱联系我修改 github 用户名)
@K\*g (可通过主页邮箱联系我修改 github 用户名)
@R\*R (可通过主页邮箱联系我修改 github 用户名)
@[@bobleer](https://github.com/bobleer) (可通过主页邮箱联系我修改 github 用户名)
@\*苗 (可通过主页邮箱联系我修改 github 用户名)
@\*划 (可通过主页邮箱联系我修改 github 用户名)
- [AIHubmix](https://aihubmix.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)
- @水\*丫
- @二吉吉
- @a\*g
- @*林
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)

520
README_zh.md Normal file
View File

@@ -0,0 +1,520 @@
# Claude Code Router
我正在为该项目寻求资金支持,以更好地维持其发展。如果您有任何想法,请随时与我联系: [m@musiiot.top](mailto:m@musiiot.top)
> 一款强大的工具,可将 Claude Code 请求路由到不同的模型,并自定义任何请求。
![](blog/images/claude-code.png)
## ✨ 功能
- **模型路由**: 根据您的需求将请求路由到不同的模型(例如,后台任务、思考、长上下文)。
- **多提供商支持**: 支持 OpenRouter、DeepSeek、Ollama、Gemini、Volcengine 和 SiliconFlow 等各种模型提供商。
- **请求/响应转换**: 使用转换器为不同的提供商自定义请求和响应。
- **动态模型切换**: 在 Claude Code 中使用 `/model` 命令动态切换模型。
- **GitHub Actions 集成**: 在您的 GitHub 工作流程中触发 Claude Code 任务。
- **插件系统**: 使用自定义转换器扩展功能。
## 🚀 快速入门
### 1. 安装
首先,请确保您已安装 [Claude Code](https://docs.anthropic.com/en/docs/claude-code/quickstart)
```shell
npm install -g @anthropic-ai/claude-code
```
然后,安装 Claude Code Router
```shell
npm install -g @musistudio/claude-code-router
```
### 2. 配置
创建并配置您的 `~/.claude-code-router/config.json` 文件。有关更多详细信息,您可以参考 `config.example.json`
`config.json` 文件有几个关键部分:
- **`PROXY_URL`** (可选): 您可以为 API 请求设置代理,例如:`"PROXY_URL": "http://127.0.0.1:7890"`
- **`LOG`** (可选): 您可以通过将其设置为 `true` 来启用日志记录。当设置为 `false` 时,将不会创建日志文件。默认值为 `true`
- **`LOG_LEVEL`** (可选): 设置日志级别。可用选项包括:`"fatal"``"error"``"warn"``"info"``"debug"``"trace"`。默认值为 `"info"`
- **日志系统**: 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 模式 (Beta)
为了获得更直观的体验,您可以使用 UI 模式来管理您的配置:
```shell
ccr ui
```
这将打开一个基于 Web 的界面,您可以在其中轻松查看和编辑您的 `config.json` 文件。
![UI](/blog/images/ui.png)
> **注意**: UI 模式目前处于测试阶段。这是一个 100% vibe coding的项目包括项目的初始化我只是新建了一个文件夹和一个project.md文档。所有代码均由 ccr + qwen3-coder + gemini(webSearch) 实现。如有问题请提交 issue。
#### 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`后缀。
您还可以使用 `/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>
请帮我分析这段代码是否存在潜在的优化空间...
```
## 🤖 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)
## ❤️ 支持与赞助
如果您觉得这个项目有帮助,请考虑赞助它的开发。非常感谢您的支持!
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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/)
- @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)
- @水\*丫
- @二吉吉
- @a\*g
- @*林
(如果您的名字被屏蔽,请通过我的主页电子邮件与我联系,以便使用您的 GitHub 用户名进行更新。)
## 交流群
<img src="/blog/images/wechat_group.jpg" width="200" alt="wechat_group" />

View File

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 353 KiB

BIN
blog/images/ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

View File

@@ -1,8 +0,0 @@
{
"usePlugin": "",
"LOG": true,
"OPENAI_API_KEY": "",
"OPENAI_BASE_URL": "",
"OPENAI_MODEL": "",
"modelProviders": {}
}

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

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

View File

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

View File

@@ -2,11 +2,23 @@ FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm i
# Copy all files
COPY . .
# Install pnpm globally
RUN npm install -g pnpm
# Install dependencies
RUN pnpm install --frozen-lockfile
# Fix rollup optional dependencies issue
RUN cd ui && npm install
# Build the entire project including UI
RUN pnpm run build
# Expose port
EXPOSE 3456
CMD ["node", "index.mjs"]
# Start the router service
CMD ["node", "dist/cli.js", "start"]

128
package-lock.json generated
View File

@@ -1,128 +0,0 @@
{
"name": "@musistudio/claude-code-router",
"version": "1.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@musistudio/claude-code-router",
"version": "1.0.9",
"license": "MIT",
"dependencies": {
"dotenv": "^16.4.7",
"tiktoken": "^1.0.21",
"uuid": "^11.1.0"
},
"bin": {
"ccr": "dist/cli.js"
},
"devDependencies": {
"esbuild": "^0.25.1",
"typescript": "^5.8.2"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/esbuild": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.5",
"@esbuild/android-arm": "0.25.5",
"@esbuild/android-arm64": "0.25.5",
"@esbuild/android-x64": "0.25.5",
"@esbuild/darwin-arm64": "0.25.5",
"@esbuild/darwin-x64": "0.25.5",
"@esbuild/freebsd-arm64": "0.25.5",
"@esbuild/freebsd-x64": "0.25.5",
"@esbuild/linux-arm": "0.25.5",
"@esbuild/linux-arm64": "0.25.5",
"@esbuild/linux-ia32": "0.25.5",
"@esbuild/linux-loong64": "0.25.5",
"@esbuild/linux-mips64el": "0.25.5",
"@esbuild/linux-ppc64": "0.25.5",
"@esbuild/linux-riscv64": "0.25.5",
"@esbuild/linux-s390x": "0.25.5",
"@esbuild/linux-x64": "0.25.5",
"@esbuild/netbsd-arm64": "0.25.5",
"@esbuild/netbsd-x64": "0.25.5",
"@esbuild/openbsd-arm64": "0.25.5",
"@esbuild/openbsd-x64": "0.25.5",
"@esbuild/sunos-x64": "0.25.5",
"@esbuild/win32-arm64": "0.25.5",
"@esbuild/win32-ia32": "0.25.5",
"@esbuild/win32-x64": "0.25.5"
}
},
"node_modules/tiktoken": {
"version": "1.0.21",
"resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.21.tgz",
"integrity": "sha512-/kqtlepLMptX0OgbYD9aMYbM7EFrMZCL7EoHM8Psmg2FuhXoo/bH64KqOiZGGwa6oS9TPdSEDKBnV2LuB8+5vQ==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
}
}
}

View File

@@ -1,12 +1,13 @@
{
"name": "@musistudio/claude-code-router",
"version": "1.0.9",
"version": "1.0.38",
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
"bin": {
"ccr": "./dist/cli.js"
},
"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": [
"claude",
@@ -18,13 +19,20 @@
"author": "musistudio",
"license": "MIT",
"dependencies": {
"@musistudio/llms": "^1.0.0",
"@fastify/static": "^8.2.0",
"@musistudio/llms": "^1.0.24",
"dotenv": "^16.4.7",
"json5": "^2.2.3",
"openurl": "^1.1.1",
"pino-rotating-file-stream": "^0.0.2",
"tiktoken": "^1.0.21",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/node": "^24.0.15",
"esbuild": "^0.25.1",
"fastify": "^5.4.0",
"shx": "^0.4.0",
"typescript": "^5.8.2"
},
"publishConfig": {

869
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

35
scripts/build.js Normal file
View File

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

View File

@@ -2,11 +2,17 @@
import { run } from "./index";
import { showStatus } from "./utils/status";
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 { spawn } from "child_process";
import { spawn, exec } from "child_process";
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];
@@ -14,16 +20,20 @@ const HELP_TEXT = `
Usage: ccr [command]
Commands:
start Start service
stop Stop service
status Show service status
code Execute code command
start Start server
stop Stop server
restart Restart server
status Show server status
statusline Integrated statusline
code Execute claude command
ui Open the web UI in browser
-v, version Show version information
-h, help Show help information
Example:
ccr start
ccr code "Write a Hello World"
ccr ui
`;
async function waitForService(
@@ -57,7 +67,7 @@ async function main() {
cleanupPidFile();
if (existsSync(REFERENCE_COUNT_FILE)) {
try {
require("fs").unlinkSync(REFERENCE_COUNT_FILE);
fs.unlinkSync(REFERENCE_COUNT_FILE);
} catch (e) {
// Ignore cleanup errors
}
@@ -73,25 +83,62 @@ async function main() {
}
break;
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;
case "code":
if (!isServiceRunning()) {
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);
console.error("Failed to start service:", error.message);
process.exit(1);
});
// startProcess.on("close", (code) => {
// if (code !== 0 && errorMessage) {
// console.error("Failed to start service:", errorMessage.trim());
// process.exit(1);
// }
// });
startProcess.unref();
if (await waitForService()) {
executeCodeCommand(process.argv.slice(3));
// 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"
@@ -99,13 +146,169 @@ async function main() {
process.exit(1);
}
} else {
executeCodeCommand(process.argv.slice(3));
// 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 (!isServiceRunning()) {
console.log("Service not running, starting service...");
const cliPath = join(__dirname, "cli.js");
const startProcess = spawn("node", [cliPath, "start"], {
detached: true,
stdio: "ignore",
});
startProcess.on("error", (error) => {
console.error("Failed to start service:", error.message);
process.exit(1);
});
startProcess.unref();
if (!(await waitForService())) {
// 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,
stdio: "ignore",
});
startProcess.on("error", (error) => {
console.error("Failed to start service:", error);
process.exit(1);
});
startProcess.unref();
console.log("✅ Service started successfully in the background.");
break;
case "-h":
case "help":
console.log(HELP_TEXT);

View File

@@ -3,13 +3,13 @@ import os from "node:os";
export const HOME_DIR = path.join(os.homedir(), ".claude-code-router");
export const CONFIG_FILE = `${HOME_DIR}/config.json`;
export const CONFIG_FILE = path.join(HOME_DIR, "config.json");
export const PLUGINS_DIR = `${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 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 = {

View File

@@ -1,19 +1,24 @@
import { existsSync } from "fs";
import { writeFile } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { initConfig, initDir } from "./utils";
import path, { join } from "path";
import { initConfig, initDir, cleanupLogFiles } from "./utils";
import { createServer } from "./server";
import { router } from "./utils/router";
import { apiKeyAuth } from "./middleware/auth";
import {
cleanupPidFile,
isServiceRunning,
savePid,
} from "./utils/processCheck";
import { CONFIG_FILE } from "./constants";
import createWriteStream from "pino-rotating-file-stream";
import { HOME_DIR } from "./constants";
import { configureLogging } from "./utils/log";
async function initializeClaudeConfig() {
const homeDir = process.env.HOME;
const configPath = `${homeDir}/.claude.json`;
const homeDir = homedir();
const configPath = join(homeDir, ".claude.json");
if (!existsSync(configPath)) {
const userID = Array.from(
{ length: 64 },
@@ -44,9 +49,21 @@ async function run(options: RunOptions = {}) {
await initializeClaudeConfig();
await initDir();
// Clean up old log files, keeping only the 10 most recent ones
await cleanupLogFiles();
const config = await initConfig();
// Configure logging based on config
configureLogging(config);
let HOST = config.HOST;
const port = options.port || 3456;
if (config.HOST && !config.APIKEY) {
HOST = "127.0.0.1";
console.warn("⚠️ API key is not set. HOST is forced to 127.0.0.1.");
}
const port = config.PORT || 3456;
// Save the PID of the background process
savePid(process.pid);
@@ -63,20 +80,55 @@ async function run(options: RunOptions = {}) {
cleanupPidFile();
process.exit(0);
});
console.log(HOST);
// Use port from environment variable if set (for background process)
const servicePort = process.env.SERVICE_PORT
? parseInt(process.env.SERVICE_PORT)
: port;
// Configure logger based on config settings
const loggerConfig = config.LOG !== false ? {
level: config.LOG_LEVEL || "info",
stream: createWriteStream({
path: HOME_DIR,
filename: config.LOGNAME || `./logs/ccr-${+new Date()}.log`,
maxFiles: 3,
interval: "1d",
}),
} : false;
const server = createServer({
...config,
providers: config.Providers || config.providers,
PORT: servicePort,
LOG_FILE: join(homedir(), ".claude-code-router", "claude-code-router.log"),
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 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")) {
router(req, reply, config);
}
});
server.addHook("preHandler", async (req, reply) =>
router(req, reply, config)
);
server.start();
}

92
src/middleware/auth.ts Normal file
View 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();
};

View File

@@ -1,8 +1,106 @@
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";
export const createServer = (config: any): Server => {
const server = new Server({
initialConfig: config,
const server = new Server(config);
// Add endpoint to read config.json with access control
server.app.get("/api/config", async (req, reply) => {
return await readConfigFile();
});
server.app.get("/api/transformers", async () => {
const transformers =
server.app._server!.transformerService.getAllTransformers();
const transformerList = Array.from(transformers.entries()).map(
([name, transformer]: any) => ({
name,
endpoint: transformer.endPoint || null,
})
);
return { transformers: transformerList };
});
// Add endpoint to save config.json 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" });
}
});
return server;
};

View File

@@ -1,30 +1,65 @@
import { spawn } from "child_process";
import {
incrementReferenceCount,
decrementReferenceCount,
} from "./processCheck";
import { spawn, type StdioOptions } from "child_process";
import { readConfigFile } from ".";
import { closeService } from "./close";
import {
decrementReferenceCount,
incrementReferenceCount,
} from "./processCheck";
export async function executeCodeCommand(args: string[] = []) {
// Set environment variables
const env = {
const config = await readConfigFile();
const env: Record<string, string> = {
...process.env,
ANTHROPIC_AUTH_TOKEN: "test",
ANTHROPIC_BASE_URL: `http://127.0.0.1:3456`,
API_TIMEOUT_MS: "600000",
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.PORT || 3456}`,
API_TIMEOUT_MS: String(config.API_TIMEOUT_MS ?? 600000), // Default to 10 minutes if not set
};
// 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
incrementReferenceCount();
// Execute claude command
const claudePath = 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,
stdio: "inherit",
stdio: stdioConfig,
shell: true,
});
// Close stdin for non-interactive mode
if (config.NON_INTERACTIVE_MODE) {
claudeProcess.stdin?.end();
}
claudeProcess.on("error", (error) => {
console.error("Failed to start claude command:", error.message);
console.log(

View File

@@ -1,11 +1,34 @@
import fs from "node:fs/promises";
import readline from "node:readline";
import JSON5 from "json5";
import path from "node:path";
import {
CONFIG_FILE,
DEFAULT_CONFIG,
HOME_DIR,
PLUGINS_DIR,
} from "../constants";
import { cleanupLogFiles } from "./logCleanup";
// Function to interpolate environment variables in config values
const interpolateEnvVars = (obj: any): any => {
if (typeof obj === "string") {
// Replace $VAR_NAME or ${VAR_NAME} with environment variable values
return obj.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (match, braced, unbraced) => {
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 result;
}
return obj;
};
const ensureDir = async (dir_path: string) => {
try {
@@ -18,6 +41,7 @@ const ensureDir = async (dir_path: string) => {
export const initDir = async () => {
await ensureDir(HOME_DIR);
await ensureDir(PLUGINS_DIR);
await ensureDir(path.join(HOME_DIR, "logs"));
};
const createReadline = () => {
@@ -45,32 +69,89 @@ const confirm = async (query: string): Promise<boolean> => {
export const readConfigFile = async () => {
try {
const config = await fs.readFile(CONFIG_FILE, "utf-8");
return JSON.parse(config);
} catch {
const apiKey = await question("Enter OPENAI_API_KEY: ");
const baseUrl = await question("Enter OPENAI_BASE_URL: ");
const model = await question("Enter OPENAI_MODEL: ");
const config = Object.assign({}, DEFAULT_CONFIG, {
Providers: [
{
name: "openai",
api_base_url: baseUrl,
api_key: apiKey,
models: [model],
try {
// Try to parse with JSON5 first (which also supports standard JSON)
const parsedConfig = JSON5.parse(config);
// Interpolate environment variables in the parsed config
return interpolateEnvVars(parsedConfig);
} catch (parseError) {
console.error(`Failed to parse config file at ${CONFIG_FILE}`);
console.error("Error details:", (parseError as Error).message);
console.error("Please check your config file syntax.");
process.exit(1);
}
} catch (readError: any) {
if (readError.code === "ENOENT") {
// Config file doesn't exist, prompt user for initial setup
const name = await question("Enter Provider Name: ");
const APIKEY = await question("Enter Provider API KEY: ");
const baseUrl = await question("Enter Provider URL: ");
const model = await question("Enter MODEL Name: ");
const config = Object.assign({}, DEFAULT_CONFIG, {
Providers: [
{
name,
api_base_url: baseUrl,
api_key: APIKEY,
models: [model],
},
],
Router: {
default: `${name},${model}`,
},
],
Router: {
default: `openai,${model}`,
},
});
await writeConfigFile(config);
return config;
});
await writeConfigFile(config);
return config;
} 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) => {
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 () => {
@@ -78,3 +159,9 @@ export const initConfig = async () => {
Object.assign(process.env, config);
return config;
};
// 导出日志清理函数
export { cleanupLogFiles };
// 导出更新功能
export { checkForUpdates, performUpdate } from "./update";

View File

@@ -9,9 +9,21 @@ if (!fs.existsSync(HOME_DIR)) {
fs.mkdirSync(HOME_DIR, { recursive: true });
}
// Global variable to store the logging configuration
let isLogEnabled: boolean | null = null;
let logLevel: string = "info";
// Function to configure logging
export function configureLogging(config: { LOG?: boolean; LOG_LEVEL?: string }) {
isLogEnabled = config.LOG !== false; // Default to true if not explicitly set to false
logLevel = config.LOG_LEVEL || "info";
}
export function log(...args: any[]) {
// Check if logging is enabled via environment variable
const isLogEnabled = process.env.LOG === "true";
// If logging configuration hasn't been set, default to enabled
if (isLogEnabled === null) {
isLogEnabled = true;
}
if (!isLogEnabled) {
return;

44
src/utils/logCleanup.ts Normal file
View 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);
}
}

View File

@@ -1,5 +1,6 @@
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
import { readConfigFile } from '.';
export function incrementReferenceCount() {
let count = 0;
@@ -70,15 +71,17 @@ export function getServicePid(): number | null {
}
}
export function getServiceInfo() {
export async function getServiceInfo() {
const pid = getServicePid();
const running = isServiceRunning();
const config = await readConfigFile();
const port = config.PORT || 3456;
return {
running,
pid,
port: 3456,
endpoint: 'http://127.0.0.1:3456',
port,
endpoint: `http://127.0.0.1:${port}`,
pidFile: PID_FILE,
referenceCount: getReferenceCount()
};

View File

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

View File

@@ -1,7 +1,7 @@
import { getServiceInfo } from './processCheck';
export function showStatus() {
const info = getServiceInfo();
export async function showStatus() {
const info = await getServiceInfo();
console.log('\n📊 Claude Code Router Status');
console.log('═'.repeat(40));

747
src/utils/statusline.ts Normal file
View File

@@ -0,0 +1,747 @@
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;
}
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] || match;
});
}
// 默认主题配置 - 使用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 renderPowerlineStyle(theme, variables);
} else {
return 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;
}
// 渲染默认风格的状态行
function renderDefaultStyle(
theme: StatusLineThemeConfig,
variables: Record<string, string>
): 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 || "";
const text = replaceVariables(module.text, variables);
// 如果text为空且不是usage类型则跳过该模块
if (!text && module.type !== "usage") {
continue;
}
// 构建模块字符串
let part = `${background}${color}`;
if (icon) {
part += `${icon} `;
}
part += `${text}${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风格的状态行
function renderPowerlineStyle(
theme: StatusLineThemeConfig,
variables: Record<string, string>
): 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 || "";
const text = replaceVariables(module.text, variables);
// 如果text为空且不是usage类型则跳过该模块
if (!text && module.type !== "usage") {
continue;
}
// 构建显示文本
let displayText = "";
if (icon) {
displayText += `${icon} `;
}
displayText += text;
// 获取下一个模块的背景色(用于分隔符)
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("");
}

80
src/utils/update.ts Normal file
View 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
View File

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

23
ui/PROJECT.md Normal file
View File

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

69
ui/README.md Normal file
View File

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

21
ui/components.json Normal file
View File

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

179
ui/config.example.json Normal file
View 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
View File

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

12
ui/index.html Normal file
View 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>

5310
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
ui/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

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

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

After

Width:  |  Height:  |  Size: 1.5 KiB

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

@@ -0,0 +1,404 @@
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 { Button } from "@/components/ui/button";
import { useConfig } from "@/components/ConfigProvider";
import { api } from "@/lib/api";
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp } 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 [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>
<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">
<div className="w-3/5">
<Providers />
</div>
<div className="flex w-2/5 flex-col gap-4">
<div className="h-3/5">
<Router />
</div>
<div className="flex-1">
<Transformers />
</div>
</div>
</main>
<SettingsDialog isOpen={isSettingsOpen} onOpenChange={setIsSettingsOpen} />
<JsonEditor
open={isJsonEditorOpen}
onOpenChange={setIsJsonEditorOpen}
showToast={(message, type) => setToast({ message, type })}
/>
{/* 版本更新对话框 */}
<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
View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,150 @@
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 : 'info',
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 : ''
} : {
default: '',
background: '',
think: '',
longContext: '',
longContextThreshold: 60000,
webSearch: ''
}
};
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: 'info',
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: ''
}
});
setError(err as Error);
}
}
};
fetchConfig();
}, [hasFetched, apiKey]);
return (
<ConfigContext.Provider value={{ config, setConfig, error }}>
{children}
</ConfigContext.Provider>
);
}

View File

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

134
ui/src/components/Login.tsx Normal file
View 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>
);
}

View 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;

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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;

View File

@@ -0,0 +1,139 @@
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: ""
};
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 });
};
// 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>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,233 @@
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">
<DialogHeader>
<DialogTitle>{t("toplevel.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center space-x-2">
<Switch
id="log"
checked={config.LOG}
onCheckedChange={handleLogChange}
/>
<Label
htmlFor="log"
className="transition-all-ease hover:scale-[1.02] cursor-pointer"
>
{t("toplevel.log")}
</Label>
</div>
{/* 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>
<DialogFooter>
<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>
);
}

View File

@@ -0,0 +1,647 @@
import { useTranslation } from "react-i18next";
import React, { useState, useEffect } from "react";
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 { Combobox } from "@/components/ui/combobox";
import { ColorPicker } from "@/components/ui/color-picker";
import { Badge } from "@/components/ui/badge";
import { useConfig } from "./ConfigProvider";
import { validateStatusLineConfig, formatValidationError, createDefaultStatusLineConfig } from "@/utils/statusline";
import type { StatusLineConfig, StatusLineModuleConfig, StatusLineThemeConfig } from "@/types";
const DEFAULT_MODULE: StatusLineModuleConfig = {
type: "workDir",
icon: "󰉋",
text: "{{workDirName}}",
color: "bright_blue"
};
// 模块类型选项
const MODULE_TYPES = [
{ label: "workDir", value: "workDir" },
{ label: "gitBranch", value: "gitBranch" },
{ label: "model", value: "model" },
{ label: "usage", value: "usage" }
];
// ANSI颜色代码映射
const ANSI_COLORS: Record<string, string> = {
// 标准颜色
black: "text-black",
red: "text-red-600",
green: "text-green-600",
yellow: "text-yellow-500",
blue: "text-blue-500",
magenta: "text-purple-500",
cyan: "text-cyan-500",
white: "text-white",
// 亮色
bright_black: "text-gray-500",
bright_red: "text-red-400",
bright_green: "text-green-400",
bright_yellow: "text-yellow-300",
bright_blue: "text-blue-300",
bright_magenta: "text-purple-300",
bright_cyan: "text-cyan-300",
bright_white: "text-white",
// 背景颜色
bg_black: "bg-black",
bg_red: "bg-red-600",
bg_green: "bg-green-600",
bg_yellow: "bg-yellow-500",
bg_blue: "bg-blue-500",
bg_magenta: "bg-purple-500",
bg_cyan: "bg-cyan-500",
bg_white: "bg-white",
// 亮背景色
bg_bright_black: "bg-gray-800",
bg_bright_red: "bg-red-400",
bg_bright_green: "bg-green-400",
bg_bright_yellow: "bg-yellow-300",
bg_bright_blue: "bg-blue-300",
bg_bright_magenta: "bg-purple-300",
bg_bright_cyan: "bg-cyan-300",
bg_bright_white: "bg-gray-100",
// Powerline样式需要的额外背景色
bg_bright_orange: "bg-orange-400",
bg_bright_purple: "bg-purple-400",
};
// 变量替换函数
function replaceVariables(text: string, variables: Record<string, string>): string {
return text.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
return variables[varName] || match;
});
}
// 渲染单个模块预览
function renderModulePreview(module: StatusLineModuleConfig, isPowerline: boolean = false): React.ReactNode {
// 模拟变量数据
const variables = {
workDirName: "project",
gitBranch: "main",
model: "Claude Sonnet 4",
inputTokens: "1.2k",
outputTokens: "2.5k"
};
const text = replaceVariables(module.text, variables);
const icon = module.icon || "";
// 如果text为空且不是usage类型则跳过该模块
if (!text && module.type !== "usage") {
return null;
}
// 如果是Powerline样式添加背景色和分隔符
if (isPowerline) {
const bgColorClass = module.background ? ANSI_COLORS[module.background] || "" : "";
const textColorClass = module.color ? ANSI_COLORS[module.color] || "text-white" : "text-white";
return (
<div className={`powerline-module ${bgColorClass} ${textColorClass}`}>
<div className="powerline-module-content">
{icon && <span>{icon}</span>}
<span>{text}</span>
</div>
<div
className="powerline-separator"
data-current-bg={module.background || ""}
/>
</div>
);
}
return (
<>
{icon && <span>{icon}</span>}
<span>{text}</span>
</>
);
}
interface StatusLineConfigDialogProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export function StatusLineConfigDialog({ isOpen, onOpenChange }: StatusLineConfigDialogProps) {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
// 添加Powerline分隔符样式
useEffect(() => {
const styleElement = document.createElement('style');
styleElement.innerHTML = `
.powerline-module {
display: inline-flex;
align-items: center;
height: 28px;
position: relative;
padding: 0 8px;
overflow: visible;
}
.powerline-module-content {
display: flex;
align-items: center;
gap: 4px;
position: relative;
}
.powerline-separator {
width: 0;
height: 0;
border-top: 14px solid transparent;
border-bottom: 14px solid transparent;
border-left: 8px solid;
position: absolute;
right: -8px;
top: 0;
display: block;
}
/* 使用层级确保每个模块的三角形覆盖在下一个模块上方 */
.cursor-pointer:nth-child(1) .powerline-separator { z-index: 10; }
.cursor-pointer:nth-child(2) .powerline-separator { z-index: 9; }
.cursor-pointer:nth-child(3) .powerline-separator { z-index: 8; }
.cursor-pointer:nth-child(4) .powerline-separator { z-index: 7; }
.cursor-pointer:nth-child(5) .powerline-separator { z-index: 6; }
.cursor-pointer:nth-child(6) .powerline-separator { z-index: 5; }
.cursor-pointer:nth-child(7) .powerline-separator { z-index: 4; }
.cursor-pointer:nth-child(8) .powerline-separator { z-index: 3; }
.cursor-pointer:nth-child(9) .powerline-separator { z-index: 2; }
.cursor-pointer:nth-child(10) .powerline-separator { z-index: 1; }
.cursor-pointer:last-child .powerline-separator {
display: none;
}
/* 根据data属性动态设置颜色确保与模块背景色一致 */
.powerline-separator[data-current-bg="bg_black"] { border-left-color: #000000; }
.powerline-separator[data-current-bg="bg_red"] { border-left-color: #dc2626; }
.powerline-separator[data-current-bg="bg_green"] { border-left-color: #16a34a; }
.powerline-separator[data-current-bg="bg_yellow"] { border-left-color: #eab308; }
.powerline-separator[data-current-bg="bg_blue"] { border-left-color: #3b82f6; }
.powerline-separator[data-current-bg="bg_magenta"] { border-left-color: #a855f7; }
.powerline-separator[data-current-bg="bg_cyan"] { border-left-color: #06b6d4; }
.powerline-separator[data-current-bg="bg_white"] { border-left-color: #ffffff; }
.powerline-separator[data-current-bg="bg_bright_black"] { border-left-color: #1f2937; }
.powerline-separator[data-current-bg="bg_bright_red"] { border-left-color: #f87171; }
.powerline-separator[data-current-bg="bg_bright_green"] { border-left-color: #4ade80; }
.powerline-separator[data-current-bg="bg_bright_yellow"] { border-left-color: #fde047; }
.powerline-separator[data-current-bg="bg_bright_blue"] { border-left-color: #93c5fd; }
.powerline-separator[data-current-bg="bg_bright_magenta"] { border-left-color: #c084fc; }
.powerline-separator[data-current-bg="bg_bright_cyan"] { border-left-color: #22d3ee; }
.powerline-separator[data-current-bg="bg_bright_white"] { border-left-color: #f3f4f6; }
.powerline-separator[data-current-bg="bg_bright_orange"] { border-left-color: #fb923c; }
.powerline-separator[data-current-bg="bg_bright_purple"] { border-left-color: #c084fc; }
`;
document.head.appendChild(styleElement);
// 清理函数
return () => {
document.head.removeChild(styleElement);
};
}, []);
const [statusLineConfig, setStatusLineConfig] = useState<StatusLineConfig>(
config?.StatusLine || createDefaultStatusLineConfig()
);
const [selectedModuleIndex, setSelectedModuleIndex] = useState<number | null>(null);
// 模块类型选项
const MODULE_TYPES_OPTIONS = MODULE_TYPES.map(item => ({
...item,
label: t(`statusline.${item.label}`)
}));
const handleThemeChange = (value: string) => {
setStatusLineConfig(prev => ({ ...prev, currentStyle: value }));
};
const handleModuleChange = (index: number, field: keyof StatusLineModuleConfig, value: string) => {
const currentTheme = statusLineConfig.currentStyle as keyof StatusLineConfig;
const themeConfig = statusLineConfig[currentTheme];
const modules = themeConfig && typeof themeConfig === 'object' && 'modules' in themeConfig
? [...((themeConfig as StatusLineThemeConfig).modules || [])]
: [];
if (modules[index]) {
modules[index] = { ...modules[index], [field]: value };
}
setStatusLineConfig(prev => ({
...prev,
[currentTheme]: { modules }
}));
};
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const handleSave = () => {
// 验证配置
const validationResult = validateStatusLineConfig(statusLineConfig);
if (!validationResult.isValid) {
// 格式化错误信息
const errorMessages = validationResult.errors.map(error =>
formatValidationError(error, t)
);
setValidationErrors(errorMessages);
return;
}
// 清除之前的错误
setValidationErrors([]);
if (config) {
setConfig({
...config,
StatusLine: statusLineConfig
});
onOpenChange(false);
}
};
// 创建自定义Alert组件
const CustomAlert = ({
title,
description,
variant = "default"
}: {
title: string;
description: React.ReactNode;
variant?: "default" | "destructive";
}) => {
const isError = variant === "destructive";
return (
<div className={`rounded-lg border p-4 ${
isError
? "bg-red-50 border-red-200 text-red-800"
: "bg-blue-50 border-blue-200 text-blue-800"
}`}>
<div className="flex">
<div className="flex-shrink-0">
{isError ? (
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="ml-3">
<h3 className={`text-sm font-medium ${
isError ? "text-red-800" : "text-blue-800"
}`}>
{title}
</h3>
<div className={`mt-2 text-sm ${
isError ? "text-red-700" : "text-blue-700"
}`}>
{description}
</div>
</div>
</div>
</div>
);
};
const currentThemeKey = statusLineConfig.currentStyle as keyof StatusLineConfig;
const currentThemeConfig = statusLineConfig[currentThemeKey];
const currentModules = currentThemeConfig && typeof currentThemeConfig === 'object' && 'modules' in currentThemeConfig
? ((currentThemeConfig as StatusLineThemeConfig).modules || [])
: [];
const selectedModule = selectedModuleIndex !== null && currentModules.length > selectedModuleIndex ? currentModules[selectedModuleIndex] : null;
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl h-[90vh] overflow-hidden sm:max-w-5xl md:max-w-6xl lg:max-w-7xl animate-in fade-in-90 slide-in-from-bottom-10 duration-300 flex flex-col">
<DialogHeader data-testid="statusline-config-dialog-header" className="border-b pb-4">
<DialogTitle className="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M14 3v4a2 2 0 0 0 2 2h4"/>
<path d="M3 12h18"/>
</svg>
{t("statusline.title")}
</DialogTitle>
</DialogHeader>
{/* 错误显示区域 */}
{validationErrors.length > 0 && (
<div className="px-6">
<CustomAlert
variant="destructive"
title="配置验证失败"
description={
<ul className="list-disc pl-5 space-y-1">
{validationErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
}
/>
</div>
)}
<div className="flex flex-col gap-6 flex-1 overflow-hidden">
{/* 配置面板 */}
<div className="space-y-6">
{/* 主题样式选择 */}
<div className="flex items-center justify-between">
<Label htmlFor="theme-style" className="text-sm font-medium">
</Label>
<div className="w-1/2">
<Combobox
options={[
{ label: "默认", value: "default" },
{ label: "Powerline", value: "powerline" }
]}
value={statusLineConfig.currentStyle}
onChange={handleThemeChange}
data-testid="theme-selector"
placeholder="选择主题样式"
/>
</div>
</div>
</div>
{/* 三栏布局:组件列表 | 预览区域 | 属性配置 */}
<div className="grid grid-cols-5 gap-6 overflow-hidden flex-1">
{/* 左侧:支持的组件 */}
<div className="border rounded-lg p-4 flex flex-col overflow-hidden col-span-1">
<h3 className="text-sm font-medium mb-3"></h3>
<div className="space-y-2 overflow-y-auto flex-1">
{MODULE_TYPES_OPTIONS.map((moduleType) => (
<div
key={moduleType.value}
className="flex items-center gap-2 p-2 border rounded cursor-move hover:bg-secondary"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("moduleType", moduleType.value);
}}
>
<span className="text-sm">{moduleType.label}</span>
</div>
))}
</div>
</div>
{/* 中间:预览区域 */}
<div className="border rounded-lg p-4 flex flex-col col-span-3">
<h3 className="text-sm font-medium mb-3"></h3>
<div
className={`rounded bg-black/90 text-white font-mono text-sm overflow-x-auto flex items-center border border-border p-3 py-5 shadow-inner ${statusLineConfig.currentStyle === 'powerline' ? 'gap-0 h-8 p-0 items-center overflow-visible relative' : 'h-5 overflow-hidden'}`}
data-testid="statusline-preview"
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
const moduleType = e.dataTransfer.getData("moduleType");
if (moduleType) {
// 添加新模块
const currentTheme = statusLineConfig.currentStyle as keyof StatusLineConfig;
const themeConfig = statusLineConfig[currentTheme];
const modules = themeConfig && typeof themeConfig === 'object' && 'modules' in themeConfig
? [...((themeConfig as StatusLineThemeConfig).modules || [])]
: [];
// 根据模块类型设置默认值
let newModule: StatusLineModuleConfig;
switch (moduleType) {
case "workDir":
newModule = { type: "workDir", icon: "󰉋", text: "{{workDirName}}", color: "bright_blue" };
break;
case "gitBranch":
newModule = { type: "gitBranch", icon: "🌿", text: "{{gitBranch}}", color: "bright_green" };
break;
case "model":
newModule = { type: "model", icon: "🤖", text: "{{model}}", color: "bright_yellow" };
break;
case "usage":
newModule = { type: "usage", icon: "📊", text: "{{inputTokens}} → {{outputTokens}}", color: "bright_magenta" };
break;
default:
newModule = { ...DEFAULT_MODULE, type: moduleType };
}
modules.push(newModule);
setStatusLineConfig(prev => ({
...prev,
[currentTheme]: { modules }
}));
}
}}
>
{currentModules.length > 0 ? (
<div className="flex items-center flex-wrap gap-0">
{currentModules.map((module, index) => (
<div
key={index}
className={`cursor-pointer ${
selectedModuleIndex === index ? "bg-white/20" : "hover:bg-white/10"
} ${statusLineConfig.currentStyle === 'powerline' ? 'p-0 rounded-none inline-flex overflow-visible relative' : 'flex items-center gap-1 px-2 py-1 rounded'}`}
onClick={() => setSelectedModuleIndex(index)}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("dragIndex", index.toString());
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
const dragIndex = parseInt(e.dataTransfer.getData("dragIndex"));
if (!isNaN(dragIndex) && dragIndex !== index) {
// 重新排序模块
const currentTheme = statusLineConfig.currentStyle as keyof StatusLineConfig;
const themeConfig = statusLineConfig[currentTheme];
const modules = themeConfig && typeof themeConfig === 'object' && 'modules' in themeConfig
? [...((themeConfig as StatusLineThemeConfig).modules || [])]
: [];
if (dragIndex >= 0 && dragIndex < modules.length && index >= 0 && index <= modules.length) {
const [movedModule] = modules.splice(dragIndex, 1);
modules.splice(index, 0, movedModule);
setStatusLineConfig(prev => ({
...prev,
[currentTheme]: { modules }
}));
// 更新选中项的索引
if (selectedModuleIndex === dragIndex) {
setSelectedModuleIndex(index);
} else if (selectedModuleIndex === index) {
setSelectedModuleIndex(dragIndex);
}
}
}
}}
>
{renderModulePreview(module, statusLineConfig.currentStyle === 'powerline')}
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center w-full py-4 text-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-500 mb-2">
<path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z"/>
<path d="M12 8v8"/>
<path d="M8 12h8"/>
</svg>
<span className="text-gray-500 text-sm">
</span>
</div>
)}
</div>
</div>
{/* 右侧:属性配置 */}
<div className="border rounded-lg p-4 flex flex-col overflow-hidden col-span-1">
<h3 className="text-sm font-medium mb-3"></h3>
<div className="overflow-y-auto flex-1">
{selectedModule && selectedModuleIndex !== null ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("statusline.module_type")}</Label>
<Combobox
options={MODULE_TYPES_OPTIONS}
value={selectedModule.type}
onChange={(value) => handleModuleChange(selectedModuleIndex, "type", value)}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="module-icon">{t("statusline.module_icon")}</Label>
<Input
id="module-icon"
value={selectedModule.icon || ""}
onChange={(e) => handleModuleChange(selectedModuleIndex, "icon", e.target.value)}
placeholder="例如: 󰉋"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="module-text">{t("statusline.module_text")}</Label>
<Input
id="module-text"
value={selectedModule.text}
onChange={(e) => handleModuleChange(selectedModuleIndex, "text", e.target.value)}
placeholder="例如: {{workDirName}}"
/>
<div className="text-xs text-muted-foreground">
<p>使:</p>
<div className="flex flex-wrap gap-1 mt-1">
<Badge variant="secondary" className="text-xs py-0.5 px-1.5">{"{{workDirName}}"}</Badge>
<Badge variant="secondary" className="text-xs py-0.5 px-1.5">{"{{gitBranch}}"}</Badge>
<Badge variant="secondary" className="text-xs py-0.5 px-1.5">{"{{model}}"}</Badge>
<Badge variant="secondary" className="text-xs py-0.5 px-1.5">{"{{inputTokens}}"}</Badge>
<Badge variant="secondary" className="text-xs py-0.5 px-1.5">{"{{outputTokens}}"}</Badge>
</div>
</div>
</div>
<div className="space-y-2">
<Label>{t("statusline.module_color")}</Label>
<ColorPicker
value={selectedModule.color || ""}
onChange={(value) => handleModuleChange(selectedModuleIndex, "color", value)}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label>{t("statusline.module_background")}</Label>
<ColorPicker
value={selectedModule.background || ""}
onChange={(value) => handleModuleChange(selectedModuleIndex, "background", value)}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => {
const currentTheme = statusLineConfig.currentStyle as keyof StatusLineConfig;
const themeConfig = statusLineConfig[currentTheme];
const modules = themeConfig && typeof themeConfig === 'object' && 'modules' in themeConfig
? [...((themeConfig as StatusLineThemeConfig).modules || [])]
: [];
modules.splice(selectedModuleIndex, 1);
setStatusLineConfig(prev => ({
...prev,
[currentTheme]: { modules }
}));
setSelectedModuleIndex(null);
}}
>
</Button>
</div>
) : (
<div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-muted-foreground text-sm"></p>
</div>
)}
</div>
</div>
</div>
</div>
<DialogFooter className="border-t pt-4 mt-4">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="transition-all hover:scale-105"
>
{t("app.cancel")}
</Button>
<Button
onClick={handleSave}
data-testid="save-statusline-config"
className="transition-all hover:scale-105"
>
{t("app.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,262 @@
"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"
import { Badge } from "@/components/ui/badge"
interface ColorPickerProps {
value?: string;
onChange: (value: string) => void;
placeholder?: string;
showPreview?: boolean;
}
// 预定义的ANSI颜色映射
const ANSI_COLOR_MAP: Record<string, string> = {
"black": "#000000",
"red": "#ff0000",
"green": "#00ff00",
"yellow": "#ffff00",
"blue": "#0000ff",
"magenta": "#ff00ff",
"cyan": "#00ffff",
"white": "#ffffff",
"bright_black": "#808080",
"bright_red": "#ff8080",
"bright_green": "#80ff80",
"bright_yellow": "#ffff80",
"bright_blue": "#8080ff",
"bright_magenta": "#ff80ff",
"bright_cyan": "#80ffff",
"bright_white": "#ffffff"
}
// 背景颜色映射添加bg_前缀
const ANSI_BG_COLOR_MAP: Record<string, string> = Object.keys(ANSI_COLOR_MAP).reduce((acc, key) => {
acc[`bg_${key}`] = ANSI_COLOR_MAP[key]
return acc
}, {} as Record<string, string>)
// 合并所有颜色映射
const ALL_COLOR_MAP = { ...ANSI_COLOR_MAP, ...ANSI_BG_COLOR_MAP }
// 获取颜色值的函数
const getColorValue = (color: string): string => {
// 如果是预定义的ANSI颜色
if (ALL_COLOR_MAP[color]) {
return ALL_COLOR_MAP[color]
}
// 如果是十六进制颜色
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 handlePresetColorClick = (colorName: string) => {
handleColorChange(colorName)
setOpen(false)
}
const selectedColorValue = getColorValue(value)
// 获取ANSI颜色名称如果适用
const ansiColorName = Object.keys(ALL_COLOR_MAP).find(key => ALL_COLOR_MAP[key] === selectedColorValue) || 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 ? ansiColorName : 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 ? ansiColorName : "未选择颜色"}
</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={() => customColor && handleColorChange(customColor)}
disabled={!customColor || !/^#[0-9A-F]{6}$/i.test(customColor)}
>
</Button>
</div>
<p className="text-xs text-muted-foreground">
(: #FF0000)
</p>
</div>
{/* 预定义颜色选项 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">ANSI </label>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="grid grid-cols-8 gap-1">
{Object.entries(ANSI_COLOR_MAP).map(([name, color]) => (
<Button
key={name}
variant={value === name ? "default" : "outline"}
size="sm"
className={cn(
"h-8 w-8 p-0 rounded-full transition-all hover:scale-110",
value === name && "ring-2 ring-offset-2 ring-ring ring-offset-background"
)}
style={{ backgroundColor: value === name ? color : undefined }}
onClick={() => handlePresetColorClick(name)}
title={name}
>
{value === name && (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</Button>
))}
</div>
</div>
{/* 背景颜色选项 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium"></label>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="grid grid-cols-8 gap-1">
{Object.entries(ANSI_BG_COLOR_MAP).map(([name, color]) => (
<Button
key={name}
variant={value === name ? "default" : "outline"}
size="sm"
className={cn(
"h-8 w-8 p-0 rounded-full transition-all hover:scale-110",
value === name && "ring-2 ring-offset-2 ring-ring ring-offset-background"
)}
style={{ backgroundColor: value === name ? color : undefined }}
onClick={() => handlePresetColorClick(name)}
title={name}
>
{value === name && (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</Button>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

@@ -0,0 +1,213 @@
import type { Config, Provider, Transformer } from '@/types';
// 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', {});
}
}
// Create a default instance of the API client
export const api = new ApiClient();
// Export the class for creating custom instances
export default ApiClient;

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

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

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

@@ -0,0 +1,169 @@
{
"app": {
"title": "Claude Code Router",
"save": "Save",
"save_and_restart": "Save and Restart",
"cancel": "Cancel",
"edit": "Edit",
"remove": "Remove",
"delete": "Delete",
"settings": "Settings",
"selectFile": "Select File",
"config_saved_success": "Config saved successfully",
"config_saved_failed": "Failed to save config",
"config_saved_restart_success": "Config saved and service restarted successfully",
"config_saved_restart_failed": "Failed to save config and restart service",
"new_version_available": "New Version Available",
"update_description": "A new version is available. Please review the changelog and update to get the latest features and improvements.",
"no_changelog_available": "No changelog available",
"later": "Later",
"update_now": "Update Now",
"no_updates_available": "No updates available",
"update_check_failed": "Failed to check for updates",
"update_successful": "Update successful",
"update_failed": "Update failed"
},
"login": {
"title": "Sign in to your account",
"description": "Enter your API key to access the configuration panel",
"apiKey": "API Key",
"apiKeyPlaceholder": "Enter your API key",
"signIn": "Sign In",
"invalidApiKey": "Invalid API key",
"configError": "Configuration not loaded",
"validating": "Validating API key..."
},
"toplevel": {
"title": "General Settings",
"log": "Enable Logging",
"log_level": "Log Level",
"claude_path": "Claude Path",
"host": "Host",
"port": "Port",
"apikey": "API Key",
"timeout": "API Timeout (ms)",
"proxy_url": "Proxy URL"
},
"transformers": {
"title": "Custom Transformers",
"path": "Path",
"project": "Project",
"remove": "Remove",
"add": "Add Custom Transformer",
"edit": "Edit Custom Transformer",
"delete": "Delete Custom Transformer",
"delete_transformer_confirm": "Are you sure you want to delete this custom transformer?",
"parameters": "Parameters"
},
"providers": {
"title": "Providers",
"name": "Name",
"api_base_url": "API Full URL",
"api_key": "API Key",
"models": "Models",
"models_placeholder": "Enter model name and press Enter to add",
"add_model": "Add Model",
"select_models": "Select Models",
"remove": "Remove",
"add": "Add Provider",
"edit": "Edit Provider",
"delete": "Delete",
"cancel": "Cancel",
"delete_provider_confirm": "Are you sure you want to delete this provider?",
"test_connectivity": "Test Connectivity",
"testing": "Testing...",
"connection_successful": "Connection successful!",
"connection_failed": "Connection failed!",
"missing_credentials": "Missing API base URL or API key",
"fetch_available_models": "Fetch available models",
"fetching_models": "Fetching models...",
"fetch_models_failed": "Failed to fetch models",
"transformers": "Transformers",
"select_transformer": "Select Transformer",
"no_transformers": "No transformers available",
"provider_transformer": "Provider Transformer",
"model_transformers": "Model Transformers",
"transformer_parameters": "Transformer Parameters",
"add_parameter": "Add Parameter",
"parameter_name": "Parameter Name",
"parameter_value": "Parameter Value",
"selected_transformers": "Selected Transformers",
"import_from_template": "Import from template",
"no_templates_found": "No templates found",
"select_template": "Select a template...",
"api_key_required": "API Key is required",
"name_required": "Name is required",
"name_duplicate": "A provider with this name already exists"
},
"router": {
"title": "Router",
"default": "Default",
"background": "Background",
"think": "Think",
"longContext": "Long Context",
"longContextThreshold": "Context Threshold",
"webSearch": "Web Search",
"selectModel": "Select a model...",
"searchModel": "Search model...",
"noModelFound": "No model found."
},
"json_editor": {
"title": "JSON Editor",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"save_failed": "Failed to save config",
"save_and_restart": "Save & Restart"
},
"statusline": {
"title": "Status Line Configuration",
"enable": "Enable Status Line",
"theme": "Theme Style",
"theme_default": "Default",
"theme_powerline": "Powerline",
"modules": "Modules",
"module_type": "Type",
"module_icon": "Icon",
"module_text": "Text",
"module_color": "Color",
"module_background": "Background",
"add_module": "Add Module",
"remove_module": "Remove Module",
"preview": "Preview",
"workDir": "Working Directory",
"gitBranch": "Git Branch",
"model": "Model",
"usage": "Usage",
"background_none": "None",
"color_black": "Black",
"color_red": "Red",
"color_green": "Green",
"color_yellow": "Yellow",
"color_blue": "Blue",
"color_magenta": "Magenta",
"color_cyan": "Cyan",
"color_white": "White",
"color_bright_black": "Bright Black",
"color_bright_red": "Bright Red",
"color_bright_green": "Bright Green",
"color_bright_yellow": "Bright Yellow",
"color_bright_blue": "Bright Blue",
"color_bright_magenta": "Bright Magenta",
"color_bright_cyan": "Bright Cyan",
"color_bright_white": "Bright White",
"import_export": "Import/Export",
"import": "Import Config",
"export": "Export Config",
"download_template": "Download Template",
"import_export_help": "Export current configuration as a JSON file, or import configuration from a JSON file. You can also download a configuration template for reference.",
"export_success": "Configuration exported successfully",
"export_failed": "Failed to export configuration",
"import_success": "Configuration imported successfully",
"import_failed": "Failed to import configuration",
"invalid_config": "Invalid configuration file",
"template_download_success": "Template downloaded successfully",
"template_download_success_desc": "Configuration template has been downloaded to your device",
"template_download_failed": "Failed to download template"
}
}

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

@@ -0,0 +1,169 @@
{
"app": {
"title": "Claude Code Router",
"save": "保存",
"save_and_restart": "保存并重启",
"cancel": "取消",
"edit": "编辑",
"remove": "移除",
"delete": "删除",
"settings": "设置",
"selectFile": "选择文件",
"config_saved_success": "配置保存成功",
"config_saved_failed": "配置保存失败",
"config_saved_restart_success": "配置保存并服务重启成功",
"config_saved_restart_failed": "配置保存并服务重启失败",
"new_version_available": "有新版本可用",
"update_description": "发现新版本。请查看更新日志并更新以获取最新功能和改进。",
"no_changelog_available": "暂无更新日志",
"later": "稍后再说",
"update_now": "立即更新",
"no_updates_available": "当前已是最新版本",
"update_check_failed": "检查更新失败",
"update_successful": "更新成功",
"update_failed": "更新失败"
},
"login": {
"title": "登录到您的账户",
"description": "请输入您的API密钥以访问配置面板",
"apiKey": "API密钥",
"apiKeyPlaceholder": "请输入您的API密钥",
"signIn": "登录",
"invalidApiKey": "API密钥无效",
"configError": "配置未加载",
"validating": "正在验证API密钥..."
},
"toplevel": {
"title": "通用设置",
"log": "启用日志",
"log_level": "日志级别",
"claude_path": "Claude 路径",
"host": "主机",
"port": "端口",
"apikey": "API 密钥",
"timeout": "API 超时时间 (毫秒)",
"proxy_url": "代理地址"
},
"transformers": {
"title": "自定义转换器",
"path": "路径",
"project": "项目",
"remove": "移除",
"add": "添加自定义转换器",
"edit": "编辑自定义转换器",
"delete": "删除自定义转换器",
"delete_transformer_confirm": "您确定要删除此自定义转换器吗?",
"parameters": "参数"
},
"providers": {
"title": "供应商",
"name": "名称",
"api_base_url": "API 完整地址",
"api_key": "API 密钥",
"models": "模型",
"models_placeholder": "输入模型名称并按回车键添加",
"add_model": "添加模型",
"select_models": "选择模型",
"remove": "移除",
"add": "添加供应商",
"edit": "编辑供应商",
"delete": "删除",
"cancel": "取消",
"delete_provider_confirm": "您确定要删除此供应商吗?",
"test_connectivity": "测试连通性",
"testing": "测试中...",
"connection_successful": "连接成功!",
"connection_failed": "连接失败!",
"missing_credentials": "缺少 API 基础地址或 API 密钥",
"fetch_available_models": "获取可用模型",
"fetching_models": "获取模型中...",
"fetch_models_failed": "获取模型失败",
"transformers": "转换器",
"select_transformer": "选择转换器",
"no_transformers": "无可用转换器",
"provider_transformer": "供应商转换器",
"model_transformers": "模型转换器",
"transformer_parameters": "转换器参数",
"add_parameter": "添加参数",
"parameter_name": "参数名称",
"parameter_value": "参数值",
"selected_transformers": "已选转换器",
"import_from_template": "从模板导入",
"no_templates_found": "未找到模板",
"select_template": "选择一个模板...",
"api_key_required": "API 密钥为必填项",
"name_required": "名称为必填项",
"name_duplicate": "已存在同名供应商"
},
"router": {
"title": "路由",
"default": "默认",
"background": "后台",
"think": "思考",
"longContext": "长上下文",
"longContextThreshold": "上下文阈值",
"webSearch": "网络搜索",
"selectModel": "选择一个模型...",
"searchModel": "搜索模型...",
"noModelFound": "未找到模型."
},
"json_editor": {
"title": "JSON 编辑器",
"save": "保存",
"saving": "保存中...",
"cancel": "取消",
"save_failed": "配置保存失败",
"save_and_restart": "保存并重启"
},
"statusline": {
"title": "状态栏配置",
"enable": "启用状态栏",
"theme": "主题样式",
"theme_default": "默认",
"theme_powerline": "Powerline",
"modules": "模块",
"module_type": "类型",
"module_icon": "图标",
"module_text": "文本",
"module_color": "颜色",
"module_background": "背景",
"add_module": "添加模块",
"remove_module": "移除模块",
"preview": "预览",
"workDir": "工作目录",
"gitBranch": "Git分支",
"model": "模型",
"usage": "使用情况",
"background_none": "无",
"color_black": "黑色",
"color_red": "红色",
"color_green": "绿色",
"color_yellow": "黄色",
"color_blue": "蓝色",
"color_magenta": "品红",
"color_cyan": "青色",
"color_white": "白色",
"color_bright_black": "亮黑色",
"color_bright_red": "亮红色",
"color_bright_green": "亮绿色",
"color_bright_yellow": "亮黄色",
"color_bright_blue": "亮蓝色",
"color_bright_magenta": "亮品红",
"color_bright_cyan": "亮青色",
"color_bright_white": "亮白色",
"import_export": "导入/导出",
"import": "导入配置",
"export": "导出配置",
"download_template": "下载模板",
"import_export_help": "导出当前配置为JSON文件或从JSON文件导入配置。您也可以下载配置模板作为参考。",
"export_success": "配置导出成功",
"export_failed": "配置导出失败",
"import_success": "配置导入成功",
"import_failed": "配置导入失败",
"invalid_config": "无效的配置文件",
"template_download_success": "模板下载成功",
"template_download_success_desc": "配置模板已下载到您的设备",
"template_download_failed": "模板下载失败"
}
}

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

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

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

@@ -0,0 +1,22 @@
import { createMemoryRouter, Navigate } from 'react-router-dom';
import App from './App';
import { Login } from '@/components/Login';
import ProtectedRoute from '@/components/ProtectedRoute';
import PublicRoute from '@/components/PublicRoute';
export const router = createMemoryRouter([
{
path: '/',
element: <Navigate to="/dashboard" replace />,
},
{
path: '/login',
element: <PublicRoute><Login /></PublicRoute>,
},
{
path: '/dashboard',
element: <ProtectedRoute><App /></ProtectedRoute>,
},
], {
initialEntries: ['/dashboard']
});

View File

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

65
ui/src/types.ts Normal file
View File

@@ -0,0 +1,65 @@
export interface ProviderTransformer {
use: (string | (string | Record<string, unknown> | { max_tokens: number })[])[];
[key: string]: any; // Allow for model-specific transformers
}
export interface Provider {
name: string;
api_base_url: string;
api_key: string;
models: string[];
transformer?: ProviderTransformer;
}
export interface RouterConfig {
default: string;
background: string;
think: string;
longContext: string;
longContextThreshold: number;
webSearch: string;
custom?: any;
}
export interface Transformer {
name?: string;
path: string;
options?: Record<string, any>;
}
export interface StatusLineModuleConfig {
type: string;
icon?: string;
text: string;
color?: string;
background?: string;
}
export interface StatusLineThemeConfig {
modules: StatusLineModuleConfig[];
}
export interface StatusLineConfig {
enabled: boolean;
currentStyle: string;
default: StatusLineThemeConfig;
powerline: StatusLineThemeConfig;
}
export interface Config {
Providers: Provider[];
Router: RouterConfig;
transformers: Transformer[];
StatusLine?: StatusLineConfig;
// Top-level settings
LOG: boolean;
LOG_LEVEL: string;
CLAUDE_PATH: string;
HOST: string;
PORT: number;
APIKEY: string;
API_TIMEOUT_MS: string;
PROXY_URL: string;
}
export type AccessLevel = 'restricted' | 'full';

146
ui/src/utils/statusline.ts Normal file
View File

@@ -0,0 +1,146 @@
import type { StatusLineConfig, StatusLineModuleConfig } from "@/types";
// 验证结果(保留接口但不使用)
export interface ValidationResult {
isValid: boolean;
errors: any[];
}
/**
* 验证StatusLine配置 - 已移除所有验证
* @param config 要验证的配置对象
* @returns 始终返回验证通过
*/
export function validateStatusLineConfig(config: unknown): ValidationResult {
// 不再执行任何验证
return { isValid: true, errors: [] };
}
/**
* 格式化错误信息(支持国际化)- 不再使用
*/
export function formatValidationError(error: unknown, t: (key: string, options?: Record<string, unknown>) => string): string {
return t("statusline.validation.unknown_error");
}
/**
* 解析颜色值,支持十六进制和内置颜色名称
* @param color 颜色值(可以是颜色名称或十六进制值)
* @param defaultColor 默认颜色(十六进制)
* @returns 十六进制颜色值
*/
export function parseColorValue(color: string | undefined, defaultColor: string = "#ffffff"): string {
if (!color) {
return defaultColor;
}
// 如果是十六进制颜色值(以#开头)
if (color.startsWith('#')) {
return color;
}
// 如果是已知的颜色名称,返回对应的十六进制值
return COLOR_HEX_MAP[color] || defaultColor;
}
/**
* 判断是否为有效的十六进制颜色值
* @param color 要检查的颜色值
* @returns 是否为有效的十六进制颜色值
*/
export function isHexColor(color: string): boolean {
return /^#([0-9A-F]{3}){1,2}$/i.test(color);
}
// 颜色枚举到十六进制的映射
export const COLOR_HEX_MAP: Record<string, string> = {
black: "#000000",
red: "#cd0000",
green: "#00cd00",
yellow: "#cdcd00",
blue: "#0000ee",
magenta: "#cd00cd",
cyan: "#00cdcd",
white: "#e5e5e5",
bright_black: "#7f7f7f",
bright_red: "#ff0000",
bright_green: "#00ff00",
bright_yellow: "#ffff00",
bright_blue: "#5c5cff",
bright_magenta: "#ff00ff",
bright_cyan: "#00ffff",
bright_white: "#ffffff",
bg_black: "#000000",
bg_red: "#cd0000",
bg_green: "#00cd00",
bg_yellow: "#cdcd00",
bg_blue: "#0000ee",
bg_magenta: "#cd00cd",
bg_cyan: "#00cdcd",
bg_white: "#e5e5e5",
bg_bright_black: "#7f7f7f",
bg_bright_red: "#ff0000",
bg_bright_green: "#00ff00",
bg_bright_yellow: "#ffff00",
bg_bright_blue: "#5c5cff",
bg_bright_magenta: "#ff00ff",
bg_bright_cyan: "#00ffff",
bg_bright_white: "#ffffff"
};
/**
* 创建默认的StatusLine配置
*/
export function createDefaultStatusLineConfig(): StatusLineConfig {
return {
enabled: false,
currentStyle: "default",
default: {
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" }
]
},
powerline: {
modules: [
{ type: "workDir", icon: "󰉋", text: "{{workDirName}}", color: "white", background: "bg_bright_blue" },
{ type: "gitBranch", icon: "", text: "{{gitBranch}}", color: "white", background: "bg_bright_magenta" },
{ type: "model", icon: "󰚩", 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" }
]
}
};
}
/**
* 创建配置备份
*/
export function backupConfig(config: StatusLineConfig): string {
const backup = {
config,
timestamp: new Date().toISOString(),
version: "1.0"
};
return JSON.stringify(backup, null, 2);
}
/**
* 从备份恢复配置
*/
export function restoreConfig(backupStr: string): StatusLineConfig | null {
try {
const backup = JSON.parse(backupStr);
if (backup && backup.config && backup.timestamp) {
return backup.config as StatusLineConfig;
}
return null;
} catch (error) {
console.error("Failed to restore config from backup:", error);
return null;
}
}

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

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

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

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

26
ui/tsconfig.json Normal file
View File

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

1
ui/tsconfig.tsbuildinfo Normal file
View File

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

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

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