Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e078127ac6 | ||
|
|
0e509528c2 | ||
|
|
a265cbdce6 | ||
|
|
b8f52ba538 | ||
|
|
a62a025368 | ||
|
|
9d332aa036 | ||
|
|
5d00f519cd | ||
|
|
4fca983e4f | ||
|
|
bc08c4ab48 | ||
|
|
bdf608fffc | ||
|
|
d9b7667c93 | ||
|
|
c3ab30b0b9 | ||
|
|
cce1625534 | ||
|
|
5c1a193f4d | ||
|
|
3ad140d2f5 | ||
|
|
075ec76ec1 | ||
|
|
709b49b0e8 | ||
|
|
b856e1e11b | ||
|
|
6510d3aac9 | ||
|
|
1708c59434 | ||
|
|
9cd5587f52 | ||
|
|
4334f40926 | ||
|
|
38bc747261 | ||
|
|
adfae3263a | ||
|
|
0794151eb5 | ||
|
|
3e1963564a | ||
|
|
023c045821 | ||
|
|
f9b745b621 | ||
|
|
a958f18305 | ||
|
|
89844bcb62 | ||
|
|
37cb0c776f | ||
|
|
d9e8df5c04 | ||
|
|
91e9d43abd | ||
|
|
216ee939fb | ||
|
|
47051bf11b | ||
|
|
355c83a8c1 | ||
|
|
552621f707 | ||
|
|
75ab74957d | ||
|
|
d684319261 | ||
|
|
7bb816ad03 | ||
|
|
38c6cf0c9a | ||
|
|
e51d70caf2 | ||
|
|
5fd78a103b | ||
|
|
a3b2353bca | ||
|
|
99afe0e21a | ||
|
|
7751683365 | ||
|
|
c2edcd145e | ||
|
|
996a05d1d6 | ||
|
|
cd43a74ab5 | ||
|
|
6523255d83 | ||
|
|
a3d1f44908 | ||
|
|
5a11d2f9af | ||
|
|
d929e7cfef | ||
|
|
03c9b0fa58 | ||
|
|
cd65b3605d | ||
|
|
754125e3a3 | ||
|
|
3cb086fc57 | ||
|
|
3a12fdffb1 | ||
|
|
7978f1abae | ||
|
|
aea48239f9 | ||
|
|
7acb443aad | ||
|
|
18cfe6e2f0 | ||
|
|
1d7374067e | ||
|
|
e560db85f4 | ||
|
|
74fa03d3a5 | ||
|
|
c02e314d76 | ||
|
|
27fa655425 | ||
|
|
d013a8a01a | ||
|
|
2acfce5b63 | ||
|
|
2c44ea73c7 |
36
.claude/agents/code-implementation-expert.md
Normal file
36
.claude/agents/code-implementation-expert.md
Normal 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.
|
||||
@@ -12,3 +12,5 @@ docs
|
||||
.log
|
||||
blog
|
||||
config.json
|
||||
ui
|
||||
scripts
|
||||
@@ -40,4 +40,5 @@ This project is a TypeScript-based router for Claude Code requests. It allows ro
|
||||
- **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`.
|
||||
- `@musistudio/llms` is implemented based on `fastify` and exposes `fastify`'s hook and middleware interfaces, allowing direct use of `server.addHook`.
|
||||
- 无论如何你都不能自动提交git
|
||||
|
||||
125
README.md
125
README.md
@@ -1,5 +1,8 @@
|
||||
# Claude Code Router
|
||||
|
||||
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)
|
||||
|
||||
|
||||
[中文版](README_zh.md)
|
||||
|
||||
> A powerful tool to route Claude Code requests to different models and customize any request.
|
||||
@@ -38,12 +41,39 @@ Create and configure your `~/.claude-code-router/config.json` file. For more det
|
||||
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`. The log file will be located at `$HOME/.claude-code-router.log`.
|
||||
- **`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:
|
||||
|
||||
@@ -52,6 +82,8 @@ Here is a comprehensive example:
|
||||
"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",
|
||||
@@ -139,6 +171,16 @@ Here is a comprehensive example:
|
||||
"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": {
|
||||
@@ -166,6 +208,21 @@ ccr code
|
||||
> ccr restart
|
||||
> ```
|
||||
|
||||
### 4. UI Mode (Beta)
|
||||
|
||||
For a more intuitive experience, you can use the UI mode to manage your configuration:
|
||||
|
||||
```shell
|
||||
ccr ui
|
||||
```
|
||||
|
||||
This will open a web-based interface where you can easily view and edit your `config.json` file.
|
||||
|
||||

|
||||
|
||||
> **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:
|
||||
@@ -231,13 +288,38 @@ Transformers allow you to modify the request and response payloads to ensure com
|
||||
|
||||
**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.
|
||||
- `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:**
|
||||
|
||||
@@ -247,7 +329,7 @@ You can also create your own transformers and load them via the `transformers` f
|
||||
{
|
||||
"transformers": [
|
||||
{
|
||||
"path": "$HOME/.claude-code-router/plugins/gemini-cli.js",
|
||||
"path": "/User/xxx/.claude-code-router/plugins/gemini-cli.js",
|
||||
"options": {
|
||||
"project": "xxx"
|
||||
}
|
||||
@@ -279,7 +361,7 @@ In your `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"CUSTOM_ROUTER_PATH": "$HOME/.claude-code-router/custom-router.js"
|
||||
"CUSTOM_ROUTER_PATH": "/User/xxx/.claude-code-router/custom-router.js"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -288,7 +370,7 @@ The custom router file must be a JavaScript module that exports an `async` funct
|
||||
Here is an example of a `custom-router.js` based on `custom-router.example.js`:
|
||||
|
||||
```javascript
|
||||
// $HOME/.claude-code-router/custom-router.js
|
||||
// /User/xxx/.claude-code-router/custom-router.js
|
||||
|
||||
/**
|
||||
* A custom router function to determine which model to use based on the request.
|
||||
@@ -310,6 +392,17 @@ module.exports = async function router(req, config) {
|
||||
};
|
||||
```
|
||||
|
||||
##### 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:
|
||||
@@ -346,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"
|
||||
@@ -367,6 +461,8 @@ jobs:
|
||||
anthropic_api_key: "any-string-is-ok"
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
This setup allows for interesting automations, like running tasks during off-peak hours to reduce API costs.
|
||||
|
||||
## 📝 Further Reading
|
||||
@@ -380,6 +476,8 @@ If you find this project helpful, please consider sponsoring its development. Yo
|
||||
|
||||
[](https://ko-fi.com/F1F31GN2GM)
|
||||
|
||||
[Paypal](https://paypal.me/musistudio1999)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
|
||||
@@ -391,6 +489,8 @@ If you find this project helpful, please consider sponsoring its development. Yo
|
||||
|
||||
A huge thank you to all our sponsors for their generous support!
|
||||
|
||||
|
||||
- [AIHubmix](https://aihubmix.com/)
|
||||
- @Simon Leischnig
|
||||
- [@duanshuaimin](https://github.com/duanshuaimin)
|
||||
- [@vrgitadmin](https://github.com/vrgitadmin)
|
||||
@@ -428,5 +528,20 @@ A huge thank you to all our sponsors for their generous support!
|
||||
- [@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.)
|
||||
|
||||
99
README_zh.md
99
README_zh.md
@@ -1,5 +1,7 @@
|
||||
# Claude Code Router
|
||||
|
||||
我正在为该项目寻求资金支持,以更好地维持其发展。如果您有任何想法,请随时与我联系: [m@musiiot.top](mailto:m@musiiot.top)
|
||||
|
||||
> 一款强大的工具,可将 Claude Code 请求路由到不同的模型,并自定义任何请求。
|
||||
|
||||

|
||||
@@ -35,11 +37,17 @@ npm install -g @musistudio/claude-code-router
|
||||
|
||||
`config.json` 文件有几个关键部分:
|
||||
- **`PROXY_URL`** (可选): 您可以为 API 请求设置代理,例如:`"PROXY_URL": "http://127.0.0.1:7890"`。
|
||||
- **`LOG`** (可选): 您可以通过将其设置为 `true` 来启用日志记录。日志文件将位于 `$HOME/.claude-code-router.log`。
|
||||
- **`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 请求超时时间,单位为毫秒。
|
||||
|
||||
这是一个综合示例:
|
||||
|
||||
@@ -48,6 +56,8 @@ npm install -g @musistudio/claude-code-router
|
||||
"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",
|
||||
@@ -135,6 +145,16 @@ npm install -g @musistudio/claude-code-router
|
||||
"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": {
|
||||
@@ -162,6 +182,20 @@ ccr code
|
||||
> ccr restart
|
||||
> ```
|
||||
|
||||
### 4. UI 模式 (Beta)
|
||||
|
||||
为了获得更直观的体验,您可以使用 UI 模式来管理您的配置:
|
||||
|
||||
```shell
|
||||
ccr ui
|
||||
```
|
||||
|
||||
这将打开一个基于 Web 的界面,您可以在其中轻松查看和编辑您的 `config.json` 文件。
|
||||
|
||||

|
||||
|
||||
> **注意**: UI 模式目前处于测试阶段。这是一个 100% vibe coding的项目,包括项目的初始化,我只是新建了一个文件夹和一个project.md文档。所有代码均由 ccr + qwen3-coder + gemini(webSearch) 实现。如有问题请提交 issue。
|
||||
|
||||
#### Providers
|
||||
|
||||
`Providers` 数组是您定义要使用的不同模型提供商的地方。每个提供商对象都需要:
|
||||
@@ -226,13 +260,38 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商
|
||||
|
||||
**可用的内置 Transformer:**
|
||||
|
||||
- `Anthropic`: 如果你只使用这一个转换器,则会直接透传请求和响应(你可以用它来接入其他支持Anthropic端点的服务商)。
|
||||
- `deepseek`: 适配 DeepSeek API 的请求/响应。
|
||||
- `gemini`: 适配 Gemini API 的请求/响应。
|
||||
- `openrouter`: 适配 OpenRouter 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:**
|
||||
|
||||
@@ -242,7 +301,7 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商
|
||||
{
|
||||
"transformers": [
|
||||
{
|
||||
"path": "$HOME/.claude-code-router/plugins/gemini-cli.js",
|
||||
"path": "/User/xxx/.claude-code-router/plugins/gemini-cli.js",
|
||||
"options": {
|
||||
"project": "xxx"
|
||||
}
|
||||
@@ -274,7 +333,7 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商
|
||||
|
||||
```json
|
||||
{
|
||||
"CUSTOM_ROUTER_PATH": "$HOME/.claude-code-router/custom-router.js"
|
||||
"CUSTOM_ROUTER_PATH": "/User/xxx/.claude-code-router/custom-router.js"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -283,7 +342,7 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商
|
||||
这是一个基于 `custom-router.example.js` 的 `custom-router.js` 示例:
|
||||
|
||||
```javascript
|
||||
// $HOME/.claude-code-router/custom-router.js
|
||||
// /User/xxx/.claude-code-router/custom-router.js
|
||||
|
||||
/**
|
||||
* 一个自定义路由函数,用于根据请求确定使用哪个模型。
|
||||
@@ -305,6 +364,17 @@ module.exports = async function router(req, config) {
|
||||
};
|
||||
```
|
||||
|
||||
##### 子代理路由
|
||||
|
||||
对于子代理内的路由,您必须在子代理提示词的**开头**包含 `<CCR-SUBAGENT-MODEL>provider,model</CCR-SUBAGENT-MODEL>` 来指定特定的提供商和模型。这样可以将特定的子代理任务定向到指定的模型。
|
||||
|
||||
**示例:**
|
||||
|
||||
```
|
||||
<CCR-SUBAGENT-MODEL>openrouter,anthropic/claude-3.5-sonnet</CCR-SUBAGENT-MODEL>
|
||||
请帮我分析这段代码是否存在潜在的优化空间...
|
||||
```
|
||||
|
||||
|
||||
## 🤖 GitHub Actions
|
||||
|
||||
@@ -342,6 +412,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"
|
||||
@@ -376,6 +447,8 @@ jobs:
|
||||
|
||||
[](https://ko-fi.com/F1F31GN2GM)
|
||||
|
||||
[Paypal](https://paypal.me/musistudio1999)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="/blog/images/alipay.jpg" width="200" alt="Alipay" /></td>
|
||||
@@ -387,6 +460,7 @@ jobs:
|
||||
|
||||
非常感谢所有赞助商的慷慨支持!
|
||||
|
||||
- [AIHubmix](https://aihubmix.com/)
|
||||
- @Simon Leischnig
|
||||
- [@duanshuaimin](https://github.com/duanshuaimin)
|
||||
- [@vrgitadmin](https://github.com/vrgitadmin)
|
||||
@@ -423,6 +497,21 @@ jobs:
|
||||
- [@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 用户名进行更新。)
|
||||
|
||||
|
||||
BIN
blog/images/ui.png
Normal file
BIN
blog/images/ui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 518 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 227 KiB |
@@ -1,117 +0,0 @@
|
||||
{
|
||||
"Providers": [
|
||||
{
|
||||
"name": "openrouter",
|
||||
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": [
|
||||
"google/gemini-2.5-pro-preview",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3.7-sonnet:thinking"
|
||||
],
|
||||
"transformer": {
|
||||
"use": ["openrouter"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deepseek",
|
||||
"api_base_url": "https://api.deepseek.com/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"transformer": {
|
||||
"use": ["deepseek"],
|
||||
"deepseek-chat": {
|
||||
"use": ["tooluse"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ollama",
|
||||
"api_base_url": "http://localhost:11434/v1/chat/completions",
|
||||
"api_key": "ollama",
|
||||
"models": ["qwen2.5-coder:latest"]
|
||||
},
|
||||
{
|
||||
"name": "gemini",
|
||||
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["gemini-2.5-flash", "gemini-2.5-pro"],
|
||||
"transformer": {
|
||||
"use": ["gemini"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "volcengine",
|
||||
"api_base_url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["deepseek-v3-250324", "deepseek-r1-250528"],
|
||||
"transformer": {
|
||||
"use": ["deepseek"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "siliconflow",
|
||||
"api_base_url": "https://api.siliconflow.cn/v1/chat/completions",
|
||||
"api_key": "sk-xxx",
|
||||
"models": ["moonshotai/Kimi-K2-Instruct"],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 16384
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "modelscope",
|
||||
"api_base_url": "https://api-inference.modelscope.cn/v1/chat/completions",
|
||||
"api_key": "",
|
||||
"models": ["Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-235B-A22B-Thinking-2507"],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 65536
|
||||
}
|
||||
],
|
||||
"enhancetool"
|
||||
],
|
||||
"Qwen/Qwen3-235B-A22B-Thinking-2507": {
|
||||
"use": ["reasoning"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dashscope",
|
||||
"api_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
"api_key": "",
|
||||
"models": ["qwen3-coder-plus"],
|
||||
"transformer": {
|
||||
"use": [
|
||||
[
|
||||
"maxtoken",
|
||||
{
|
||||
"max_tokens": 65536
|
||||
}
|
||||
],
|
||||
"enhancetool"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"Router": {
|
||||
"default": "deepseek,deepseek-chat",
|
||||
"background": "ollama,qwen2.5-coder:latest",
|
||||
"think": "deepseek,deepseek-reasoner",
|
||||
"longContext": "openrouter,google/gemini-2.5-pro-preview",
|
||||
"longContextThreshold": 60000,
|
||||
"webSearch": "gemini,gemini-2.5-flash"
|
||||
},
|
||||
"APIKEY": "your-secret-key",
|
||||
"HOST": "0.0.0.0"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
20
dockerfile
20
dockerfile
@@ -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"]
|
||||
|
||||
2051
package-lock.json
generated
2051
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@musistudio/claude-code-router",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.38",
|
||||
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
|
||||
"bin": {
|
||||
"ccr": "./dist/cli.js"
|
||||
@@ -20,10 +20,11 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@musistudio/llms": "file:../llms",
|
||||
"@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"
|
||||
},
|
||||
|
||||
110
pnpm-lock.yaml
generated
110
pnpm-lock.yaml
generated
@@ -12,20 +12,20 @@ importers:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
'@musistudio/llms':
|
||||
specifier: file:../llms
|
||||
version: file:../llms(ws@8.18.3)(zod@3.25.67)
|
||||
specifier: ^1.0.24
|
||||
version: 1.0.24(ws@8.18.3)(zod@3.25.67)
|
||||
dotenv:
|
||||
specifier: ^16.4.7
|
||||
version: 16.6.1
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
open:
|
||||
specifier: ^10.2.0
|
||||
version: 10.2.0
|
||||
openurl:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
pino-rotating-file-stream:
|
||||
specifier: ^0.0.2
|
||||
version: 0.0.2
|
||||
tiktoken:
|
||||
specifier: ^1.0.21
|
||||
version: 1.0.21
|
||||
@@ -260,8 +260,8 @@ packages:
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@musistudio/llms@file:../llms':
|
||||
resolution: {directory: ../llms, type: directory}
|
||||
'@musistudio/llms@1.0.24':
|
||||
resolution: {integrity: sha512-Hz6ZT92/ZM/eR5kTdCBHD6zoEMOvT5u6g/vfCir5Hwvl4QGHk3g30EmX1pZAXJf83kLnB/lSEq/HQimFIXHIhQ==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
@@ -332,10 +332,6 @@ packages:
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -372,18 +368,6 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
default-browser-id@5.0.0:
|
||||
resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
default-browser@5.2.1:
|
||||
resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
define-lazy-prop@3.0.0:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -561,11 +545,6 @@ packages:
|
||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
hasBin: true
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -578,11 +557,6 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||
engines: {node: '>=14.16'}
|
||||
hasBin: true
|
||||
|
||||
is-number@7.0.0:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
@@ -595,10 +569,6 @@ packages:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-wsl@3.1.0:
|
||||
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@@ -620,6 +590,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonrepair@3.13.0:
|
||||
resolution: {integrity: sha512-5YRzlAQ7tuzV1nAJu3LvDlrKtBFIALHN2+a+I1MGJCt3ldRDBF/bZuvIPzae8Epot6KBXd0awRZZcuoeAsZ/mw==}
|
||||
hasBin: true
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
@@ -692,10 +666,6 @@ packages:
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
open@10.2.0:
|
||||
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
openai@5.8.2:
|
||||
resolution: {integrity: sha512-8C+nzoHYgyYOXhHGN6r0fcb4SznuEn1R7YZMvlqDbnCuE0FM2mm3T1HiYW6WIcMS/F1Of2up/cSPjLPaWt0X9Q==}
|
||||
hasBin: true
|
||||
@@ -740,6 +710,9 @@ packages:
|
||||
pino-abstract-transport@2.0.0:
|
||||
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
|
||||
|
||||
pino-rotating-file-stream@0.0.2:
|
||||
resolution: {integrity: sha512-knF+ReDBMQMB7gzBfuFpUmCrXpRen6YYh5Q9Ymmj//dDHeH4QEMwAV7VoGEEM+30s7VHqfbabazs9wxkMO2BIQ==}
|
||||
|
||||
pino-std-serializers@7.0.0:
|
||||
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
|
||||
|
||||
@@ -790,9 +763,9 @@ packages:
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
run-applescript@7.0.0:
|
||||
resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==}
|
||||
engines: {node: '>=18'}
|
||||
rotating-file-stream@3.2.6:
|
||||
resolution: {integrity: sha512-r8yShzMWUvWXkRzbOXDM1fEaMpc3qo2PzK7bBH/0p0Nl/uz8Mud/Y+0XTQxe3kbSnDF7qBH2tSe83WDKA7o3ww==}
|
||||
engines: {node: '>=14.0'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
@@ -976,10 +949,6 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zod-to-json-schema@3.24.6:
|
||||
resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==}
|
||||
peerDependencies:
|
||||
@@ -1143,7 +1112,7 @@ snapshots:
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@musistudio/llms@file:../llms(ws@8.18.3)(zod@3.25.67)':
|
||||
'@musistudio/llms@1.0.24(ws@8.18.3)(zod@3.25.67)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.54.0
|
||||
'@fastify/cors': 11.0.1
|
||||
@@ -1152,6 +1121,7 @@ snapshots:
|
||||
fastify: 5.4.0
|
||||
google-auth-library: 10.2.0
|
||||
json5: 2.2.3
|
||||
jsonrepair: 3.13.0
|
||||
openai: 5.8.2(ws@8.18.3)(zod@3.25.67)
|
||||
undici: 7.11.0
|
||||
uuid: 11.1.0
|
||||
@@ -1222,10 +1192,6 @@ snapshots:
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
dependencies:
|
||||
run-applescript: 7.0.0
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -1258,15 +1224,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
default-browser-id@5.0.0: {}
|
||||
|
||||
default-browser@5.2.1:
|
||||
dependencies:
|
||||
bundle-name: 4.1.0
|
||||
default-browser-id: 5.0.0
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
@@ -1533,8 +1490,6 @@ snapshots:
|
||||
dependencies:
|
||||
hasown: 2.0.2
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
@@ -1543,20 +1498,12 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
dependencies:
|
||||
is-docker: 3.0.0
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
is-stream@1.1.0: {}
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-wsl@3.1.0:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jackspeak@4.1.1:
|
||||
@@ -1575,6 +1522,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonrepair@3.13.0: {}
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
@@ -1637,13 +1586,6 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
open@10.2.0:
|
||||
dependencies:
|
||||
default-browser: 5.2.1
|
||||
define-lazy-prop: 3.0.0
|
||||
is-inside-container: 1.0.0
|
||||
wsl-utils: 0.1.0
|
||||
|
||||
openai@5.8.2(ws@8.18.3)(zod@3.25.67):
|
||||
optionalDependencies:
|
||||
ws: 8.18.3
|
||||
@@ -1672,6 +1614,10 @@ snapshots:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pino-rotating-file-stream@0.0.2:
|
||||
dependencies:
|
||||
rotating-file-stream: 3.2.6
|
||||
|
||||
pino-std-serializers@7.0.0: {}
|
||||
|
||||
pino@9.7.0:
|
||||
@@ -1721,7 +1667,7 @@ snapshots:
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
run-applescript@7.0.0: {}
|
||||
rotating-file-stream@3.2.6: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
@@ -1864,10 +1810,6 @@ snapshots:
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
dependencies:
|
||||
is-wsl: 3.1.0
|
||||
|
||||
zod-to-json-schema@3.24.6(zod@3.25.67):
|
||||
dependencies:
|
||||
zod: 3.25.67
|
||||
|
||||
116
src/cli.ts
116
src/cli.ts
@@ -2,12 +2,17 @@
|
||||
import { run } from "./index";
|
||||
import { showStatus } from "./utils/status";
|
||||
import { executeCodeCommand } from "./utils/codeCommand";
|
||||
import { cleanupPidFile, isServiceRunning, getServiceInfo } from "./utils/processCheck";
|
||||
import { parseStatusLineData, type StatusLineInput } from "./utils/statusline";
|
||||
import {
|
||||
cleanupPidFile,
|
||||
isServiceRunning,
|
||||
getServiceInfo,
|
||||
} from "./utils/processCheck";
|
||||
import { version } from "../package.json";
|
||||
import { spawn, exec } from "child_process";
|
||||
import { PID_FILE, REFERENCE_COUNT_FILE } from "./constants";
|
||||
import fs, { existsSync, readFileSync } from "fs";
|
||||
import {join} from "path";
|
||||
import { join } from "path";
|
||||
|
||||
const command = process.argv[2];
|
||||
|
||||
@@ -19,6 +24,7 @@ Commands:
|
||||
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
|
||||
@@ -79,6 +85,28 @@ async function main() {
|
||||
case "status":
|
||||
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...");
|
||||
@@ -108,7 +136,9 @@ async function main() {
|
||||
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"
|
||||
@@ -116,7 +146,9 @@ 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":
|
||||
@@ -137,22 +169,86 @@ async function main() {
|
||||
startProcess.unref();
|
||||
|
||||
if (!(await waitForService())) {
|
||||
console.error(
|
||||
"Service startup timeout, please manually run `ccr start` to start the service"
|
||||
// If service startup fails, try to start with default config
|
||||
console.log(
|
||||
"Service startup timeout, trying to start with default configuration..."
|
||||
);
|
||||
process.exit(1);
|
||||
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}`;
|
||||
@@ -166,7 +262,7 @@ async function main() {
|
||||
console.error("Unsupported platform for opening browser");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
exec(openCommand, (error) => {
|
||||
if (error) {
|
||||
console.error("Failed to open browser:", error.message);
|
||||
|
||||
48
src/index.ts
48
src/index.ts
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
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 = homedir();
|
||||
@@ -46,14 +49,18 @@ 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;
|
||||
|
||||
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."
|
||||
);
|
||||
console.warn("⚠️ API key is not set. HOST is forced to 127.0.0.1.");
|
||||
}
|
||||
|
||||
const port = config.PORT || 3456;
|
||||
@@ -73,12 +80,24 @@ async function run(options: RunOptions = {}) {
|
||||
cleanupPidFile();
|
||||
process.exit(0);
|
||||
});
|
||||
console.log(HOST)
|
||||
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({
|
||||
jsonPath: CONFIG_FILE,
|
||||
initialConfig: {
|
||||
@@ -92,11 +111,22 @@ async function run(options: RunOptions = {}) {
|
||||
"claude-code-router.log"
|
||||
),
|
||||
},
|
||||
logger: loggerConfig,
|
||||
});
|
||||
server.addHook("preHandler", apiKeyAuth(config));
|
||||
// Add async preHandler hook for authentication
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
if(req.url.startsWith("/v1/messages")) {
|
||||
router(req, reply, config)
|
||||
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.start();
|
||||
|
||||
@@ -2,18 +2,76 @@ import { FastifyRequest, FastifyReply } from "fastify";
|
||||
|
||||
export const apiKeyAuth =
|
||||
(config: any) =>
|
||||
(req: FastifyRequest, reply: FastifyReply, done: () => void) => {
|
||||
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;
|
||||
|
||||
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 authKey: string =
|
||||
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;
|
||||
@@ -24,6 +82,7 @@ export const apiKeyAuth =
|
||||
} else {
|
||||
token = authKey;
|
||||
}
|
||||
|
||||
if (token !== apiKey) {
|
||||
reply.status(401).send("Invalid API key");
|
||||
return;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import Server from "@musistudio/llms";
|
||||
import { readConfigFile, writeConfigFile } from "./utils";
|
||||
import { CONFIG_FILE } from "./constants";
|
||||
import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
|
||||
import { checkForUpdates, performUpdate } from "./utils";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
|
||||
export const createServer = (config: any): Server => {
|
||||
const server = new Server(config);
|
||||
|
||||
// Add endpoint to read config.json
|
||||
server.app.get("/api/config", async () => {
|
||||
// Add endpoint to read config.json with access control
|
||||
server.app.get("/api/config", async (req, reply) => {
|
||||
return await readConfigFile();
|
||||
});
|
||||
|
||||
@@ -25,21 +24,31 @@ export const createServer = (config: any): Server => {
|
||||
return { transformers: transformerList };
|
||||
});
|
||||
|
||||
// Add endpoint to save config.json
|
||||
server.app.post("/api/config", async (req) => {
|
||||
// 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
|
||||
server.app.post("/api/restart", async (_, reply) => {
|
||||
// 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("ccr", ["restart"], { detached: true, stdio: "ignore" });
|
||||
spawn(process.execPath, [process.argv[1], "restart"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
@@ -54,6 +63,44 @@ export const createServer = (config: any): Server => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import { spawn } from "child_process";
|
||||
import {
|
||||
incrementReferenceCount,
|
||||
decrementReferenceCount,
|
||||
} from "./processCheck";
|
||||
import { closeService } from "./close";
|
||||
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 config = await readConfigFile();
|
||||
const env = {
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
ANTHROPIC_AUTH_TOKEN: "test",
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.PORT || 3456}`,
|
||||
API_TIMEOUT_MS: "600000",
|
||||
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;
|
||||
@@ -26,12 +39,27 @@ export async function executeCodeCommand(args: string[] = []) {
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -1,12 +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 {
|
||||
@@ -19,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 = () => {
|
||||
@@ -48,7 +71,9 @@ export const readConfigFile = async () => {
|
||||
const config = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
try {
|
||||
// Try to parse with JSON5 first (which also supports standard JSON)
|
||||
return JSON5.parse(config);
|
||||
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);
|
||||
@@ -85,6 +110,44 @@ export const readConfigFile = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
const configWithComment = `${JSON.stringify(config, null, 2)}`;
|
||||
@@ -96,3 +159,9 @@ export const initConfig = async () => {
|
||||
Object.assign(process.env, config);
|
||||
return config;
|
||||
};
|
||||
|
||||
// 导出日志清理函数
|
||||
export { cleanupLogFiles };
|
||||
|
||||
// 导出更新功能
|
||||
export { checkForUpdates, performUpdate } from "./update";
|
||||
|
||||
@@ -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
44
src/utils/logCleanup.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { HOME_DIR } from "../constants";
|
||||
|
||||
/**
|
||||
* Cleans up old log files, keeping only the most recent ones
|
||||
* @param maxFiles - Maximum number of log files to keep (default: 9)
|
||||
*/
|
||||
export async function cleanupLogFiles(maxFiles: number = 9): Promise<void> {
|
||||
try {
|
||||
const logsDir = path.join(HOME_DIR, "logs");
|
||||
|
||||
// Check if logs directory exists
|
||||
try {
|
||||
await fs.access(logsDir);
|
||||
} catch {
|
||||
// Logs directory doesn't exist, nothing to clean up
|
||||
return;
|
||||
}
|
||||
|
||||
// Read all files in the logs directory
|
||||
const files = await fs.readdir(logsDir);
|
||||
|
||||
// Filter for log files (files starting with 'ccr-' and ending with '.log')
|
||||
const logFiles = files
|
||||
.filter(file => file.startsWith('ccr-') && file.endsWith('.log'))
|
||||
.sort()
|
||||
.reverse(); // Sort in descending order (newest first)
|
||||
|
||||
// Delete files exceeding the maxFiles limit
|
||||
if (logFiles.length > maxFiles) {
|
||||
for (let i = maxFiles; i < logFiles.length; i++) {
|
||||
const filePath = path.join(logsDir, logFiles[i]);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete log file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to clean up log files:", error);
|
||||
}
|
||||
}
|
||||
@@ -75,12 +75,13 @@ export async function getServiceInfo() {
|
||||
const pid = getServicePid();
|
||||
const running = isServiceRunning();
|
||||
const config = await readConfigFile();
|
||||
const port = config.PORT || 3456;
|
||||
|
||||
return {
|
||||
running,
|
||||
pid,
|
||||
port: config.PORT,
|
||||
endpoint: `http://127.0.0.1:${config.PORT}`,
|
||||
port,
|
||||
endpoint: `http://127.0.0.1:${port}`,
|
||||
pidFile: PID_FILE,
|
||||
referenceCount: getReferenceCount()
|
||||
};
|
||||
|
||||
@@ -23,9 +23,7 @@ const calculateTokenCount = (
|
||||
if (contentPart.type === "text") {
|
||||
tokenCount += enc.encode(contentPart.text).length;
|
||||
} else if (contentPart.type === "tool_use") {
|
||||
tokenCount += enc.encode(
|
||||
JSON.stringify(contentPart.input)
|
||||
).length;
|
||||
tokenCount += enc.encode(JSON.stringify(contentPart.input)).length;
|
||||
} else if (contentPart.type === "tool_result") {
|
||||
tokenCount += enc.encode(
|
||||
typeof contentPart.content === "string"
|
||||
@@ -66,14 +64,44 @@ const calculateTokenCount = (
|
||||
|
||||
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 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);
|
||||
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") &&
|
||||
@@ -110,6 +138,7 @@ export const router = async (req: any, _res: any, config: any) => {
|
||||
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);
|
||||
|
||||
747
src/utils/statusline.ts
Normal file
747
src/utils/statusline.ts
Normal 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
80
src/utils/update.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
/**
|
||||
* 检查是否有新版本可用
|
||||
* @param currentVersion 当前版本
|
||||
* @returns 包含更新信息的对象
|
||||
*/
|
||||
export async function checkForUpdates(currentVersion: string) {
|
||||
try {
|
||||
// 从npm registry获取最新版本信息
|
||||
const { stdout } = await execPromise("npm view @musistudio/claude-code-router version");
|
||||
const latestVersion = stdout.trim();
|
||||
|
||||
// 比较版本
|
||||
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
|
||||
|
||||
// 如果有更新,获取更新日志
|
||||
let changelog = "";
|
||||
|
||||
return { hasUpdate, latestVersion, changelog };
|
||||
} catch (error) {
|
||||
console.error("Error checking for updates:", error);
|
||||
// 如果检查失败,假设没有更新
|
||||
return { hasUpdate: false, latestVersion: currentVersion, changelog: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行更新操作
|
||||
* @returns 更新结果
|
||||
*/
|
||||
export async function performUpdate() {
|
||||
try {
|
||||
// 执行npm update命令
|
||||
const { stdout, stderr } = await execPromise("npm update -g @musistudio/claude-code-router");
|
||||
|
||||
if (stderr) {
|
||||
console.error("Update stderr:", stderr);
|
||||
}
|
||||
|
||||
console.log("Update stdout:", stdout);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Update completed successfully. Please restart the application to apply changes."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error performing update:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to perform update: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个版本号
|
||||
* @param v1 版本号1
|
||||
* @param v2 版本号2
|
||||
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||||
*/
|
||||
function compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split(".").map(Number);
|
||||
const parts2 = v2.split(".").map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const num1 = i < parts1.length ? parts1[i] : 0;
|
||||
const num2 = i < parts2.length ? parts2[i] : 0;
|
||||
|
||||
if (num1 > num2) return 1;
|
||||
if (num1 < num2) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8080,
|
||||
"APIKEY": "1",
|
||||
"API_TIMEOUT_MS": 600000,
|
||||
"transformers": [
|
||||
{
|
||||
"path": "/Users/abc/.claude-code-router/plugins/gemini-cli.js",
|
||||
@@ -173,5 +174,6 @@
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CCR UI</title>
|
||||
</head>
|
||||
|
||||
279
ui/package-lock.json
generated
279
ui/package-lock.json
generated
@@ -8,11 +8,13 @@
|
||||
"name": "temp-project",
|
||||
"version": "0.0.0",
|
||||
"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",
|
||||
@@ -21,6 +23,8 @@
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.1.0",
|
||||
"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",
|
||||
@@ -1086,6 +1090,29 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
|
||||
"integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1514,6 +1541,129 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-rect": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1",
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@@ -1650,12 +1800,53 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-dnd/asap": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
|
||||
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-dnd/invariant": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
|
||||
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-dnd/shallowequal": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
|
||||
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2955,6 +3146,17 @@
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dnd-core": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
|
||||
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-dnd/asap": "^5.0.1",
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"redux": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.192",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.192.tgz",
|
||||
@@ -3221,7 +3423,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
@@ -3438,6 +3639,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
@@ -4016,6 +4226,13 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -4252,6 +4469,45 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
||||
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"@react-dnd/shallowequal": "^4.0.1",
|
||||
"dnd-core": "^16.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"hoist-non-react-statics": "^3.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||
"@types/node": ">= 12",
|
||||
"@types/react": ">= 16",
|
||||
"react": ">= 16.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/hoist-non-react-statics": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-html5-backend": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
|
||||
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dnd-core": "^16.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||
@@ -4290,6 +4546,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -4407,6 +4669,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -4545,6 +4816,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
|
||||
@@ -16,6 +16,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",
|
||||
@@ -24,6 +25,9 @@
|
||||
"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",
|
||||
|
||||
240
ui/pnpm-lock.yaml
generated
240
ui/pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
||||
'@radix-ui/react-switch':
|
||||
specifier: ^1.2.5
|
||||
version: 1.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.7
|
||||
version: 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(vite@7.0.5(@types/node@24.1.0)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0))
|
||||
@@ -50,6 +53,15 @@ importers:
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0
|
||||
react-colorful:
|
||||
specifier: ^5.6.1
|
||||
version: 5.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-dnd:
|
||||
specifier: ^16.0.1
|
||||
version: 16.0.1(@types/node@24.1.0)(@types/react@19.1.8)(react@19.1.0)
|
||||
react-dnd-html5-backend:
|
||||
specifier: ^16.0.1
|
||||
version: 16.0.1
|
||||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
@@ -489,6 +501,9 @@ packages:
|
||||
'@radix-ui/primitive@1.1.2':
|
||||
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
|
||||
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
@@ -546,6 +561,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11':
|
||||
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.2':
|
||||
resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==}
|
||||
peerDependencies:
|
||||
@@ -616,6 +644,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-portal@1.1.9':
|
||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||
peerDependencies:
|
||||
@@ -642,6 +683,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-presence@1.1.5':
|
||||
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3':
|
||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||
peerDependencies:
|
||||
@@ -677,6 +731,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
@@ -749,9 +816,31 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3':
|
||||
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@react-dnd/asap@5.0.2':
|
||||
resolution: {integrity: sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==}
|
||||
|
||||
'@react-dnd/invariant@4.0.2':
|
||||
resolution: {integrity: sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==}
|
||||
|
||||
'@react-dnd/shallowequal@4.0.2':
|
||||
resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
|
||||
@@ -1162,6 +1251,9 @@ packages:
|
||||
detect-node-es@1.1.0:
|
||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||
|
||||
dnd-core@16.0.1:
|
||||
resolution: {integrity: sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==}
|
||||
|
||||
electron-to-chromium@1.5.190:
|
||||
resolution: {integrity: sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==}
|
||||
|
||||
@@ -1320,6 +1412,9 @@ packages:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
@@ -1586,6 +1681,30 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
react-colorful@5.6.1:
|
||||
resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
react-dnd-html5-backend@16.0.1:
|
||||
resolution: {integrity: sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==}
|
||||
|
||||
react-dnd@16.0.1:
|
||||
resolution: {integrity: sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==}
|
||||
peerDependencies:
|
||||
'@types/hoist-non-react-statics': '>= 3.3.1'
|
||||
'@types/node': '>= 12'
|
||||
'@types/react': '>= 16'
|
||||
react: '>= 16.14'
|
||||
peerDependenciesMeta:
|
||||
'@types/hoist-non-react-statics':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-dom@19.1.0:
|
||||
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
|
||||
peerDependencies:
|
||||
@@ -1607,6 +1726,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-refresh@0.17.0:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1662,6 +1784,9 @@ packages:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
redux@4.2.1:
|
||||
resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2197,6 +2322,8 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.2': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@@ -2253,6 +2380,19 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.8)(react@19.1.0)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
@@ -2327,6 +2467,24 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@@ -2347,6 +2505,16 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
||||
@@ -2378,6 +2546,26 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.8)(react@19.1.0)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
@@ -2432,8 +2620,23 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@react-dnd/asap@5.0.2': {}
|
||||
|
||||
'@react-dnd/invariant@4.0.2': {}
|
||||
|
||||
'@react-dnd/shallowequal@4.0.2': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.45.1':
|
||||
@@ -2831,6 +3034,12 @@ snapshots:
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
dnd-core@16.0.1:
|
||||
dependencies:
|
||||
'@react-dnd/asap': 5.0.2
|
||||
'@react-dnd/invariant': 4.0.2
|
||||
redux: 4.2.1
|
||||
|
||||
electron-to-chromium@1.5.190: {}
|
||||
|
||||
enhanced-resolve@5.18.2:
|
||||
@@ -3017,6 +3226,10 @@ snapshots:
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
@@ -3222,6 +3435,27 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
react-colorful@5.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
react-dnd-html5-backend@16.0.1:
|
||||
dependencies:
|
||||
dnd-core: 16.0.1
|
||||
|
||||
react-dnd@16.0.1(@types/node@24.1.0)(@types/react@19.1.8)(react@19.1.0):
|
||||
dependencies:
|
||||
'@react-dnd/invariant': 4.0.2
|
||||
'@react-dnd/shallowequal': 4.0.2
|
||||
dnd-core: 16.0.1
|
||||
fast-deep-equal: 3.1.3
|
||||
hoist-non-react-statics: 3.3.2
|
||||
react: 19.1.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.1.0
|
||||
'@types/react': 19.1.8
|
||||
|
||||
react-dom@19.1.0(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
@@ -3237,6 +3471,8 @@ snapshots:
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
typescript: 5.8.3
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.0):
|
||||
@@ -3282,6 +3518,10 @@ snapshots:
|
||||
|
||||
react@19.1.0: {}
|
||||
|
||||
redux@4.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.6
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
315
ui/src/App.tsx
315
ui/src/App.tsx
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { SettingsDialog } from "@/components/SettingsDialog";
|
||||
@@ -9,13 +9,21 @@ import { JsonEditor } from "@/components/JsonEditor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/components/ConfigProvider";
|
||||
import { api } from "@/lib/api";
|
||||
import { Settings, Languages, Save, RefreshCw, FileJson } from "lucide-react";
|
||||
import { 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() {
|
||||
@@ -26,12 +34,143 @@ function App() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -57,6 +196,11 @@ function App() {
|
||||
}
|
||||
} finally {
|
||||
setIsCheckingAuth(false);
|
||||
// 在获取配置完成后检查更新,但不显示对话框
|
||||
if (!hasCheckedUpdate && !hasAutoCheckedUpdate.current) {
|
||||
hasAutoCheckedUpdate.current = true;
|
||||
checkForUpdates(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,90 +216,53 @@ function App() {
|
||||
return () => {
|
||||
window.removeEventListener('unauthorized', handleUnauthorized);
|
||||
};
|
||||
}, [config, navigate]);
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (config) {
|
||||
try {
|
||||
// Save to API
|
||||
const response = await api.updateConfig(config);
|
||||
// Show success message or handle as needed
|
||||
console.log('Config saved successfully');
|
||||
|
||||
// 根据响应信息进行提示
|
||||
if (response && typeof response === 'object' && 'success' in response) {
|
||||
const apiResponse = response as { success: boolean; message?: string };
|
||||
if (apiResponse.success) {
|
||||
setToast({ message: apiResponse.message || t('app.config_saved_success'), type: 'success' });
|
||||
} else {
|
||||
setToast({ message: apiResponse.message || t('app.config_saved_failed'), type: 'error' });
|
||||
}
|
||||
} else {
|
||||
// 默认成功提示
|
||||
setToast({ message: t('app.config_saved_success'), type: 'success' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
// Handle error appropriately
|
||||
setToast({ message: t('app.config_saved_failed') + ': ' + (error as Error).message, type: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfigAndRestart = async () => {
|
||||
if (config) {
|
||||
try {
|
||||
// Save to API
|
||||
const response = await api.updateConfig(config);
|
||||
|
||||
// Check if save was successful before restarting
|
||||
let saveSuccessful = true;
|
||||
if (response && typeof response === 'object' && 'success' in response) {
|
||||
const apiResponse = response as { success: boolean; message?: string };
|
||||
if (!apiResponse.success) {
|
||||
saveSuccessful = false;
|
||||
setToast({ message: apiResponse.message || t('app.config_saved_failed'), type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Only restart if save was successful
|
||||
if (saveSuccessful) {
|
||||
// Restart service
|
||||
const response = await api.restartService();
|
||||
|
||||
// Show success message or handle as needed
|
||||
console.log('Config saved and service restarted successfully');
|
||||
|
||||
// 根据响应信息进行提示
|
||||
if (response && typeof response === 'object' && 'success' in response) {
|
||||
const apiResponse = response as { success: boolean; message?: string };
|
||||
if (apiResponse.success) {
|
||||
setToast({ message: apiResponse.message || t('app.config_saved_restart_success'), type: 'success' });
|
||||
}
|
||||
} else {
|
||||
// 默认成功提示
|
||||
setToast({ message: t('app.config_saved_restart_success'), type: 'success' });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save config and restart:', error);
|
||||
// Handle error appropriately
|
||||
setToast({ message: t('app.config_saved_restart_failed') + ': ' + (error as Error).message, type: 'error' });
|
||||
}, [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>Loading...</div>;
|
||||
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>Error: {error.message}</div>;
|
||||
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>Loading...</div>;
|
||||
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 (
|
||||
@@ -194,6 +301,26 @@ function App() {
|
||||
</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')}
|
||||
@@ -223,6 +350,46 @@ function App() {
|
||||
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}
|
||||
|
||||
@@ -1,50 +1,7 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode, Dispatch, SetStateAction } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export interface Transformer {
|
||||
path: string;
|
||||
options: {
|
||||
[key: string]: string;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ProviderTransformer {
|
||||
use: (string | (string | Record<string, unknown> | { max_tokens: number })[])[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any; // for model specific transformers
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
name: string;
|
||||
api_base_url: string;
|
||||
api_key: string;
|
||||
models: string[];
|
||||
transformer?: ProviderTransformer;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface RouterConfig {
|
||||
default: string;
|
||||
background: string;
|
||||
think: string;
|
||||
longContext: string;
|
||||
webSearch: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
LOG: boolean;
|
||||
CLAUDE_PATH: string;
|
||||
HOST: string;
|
||||
PORT: number;
|
||||
APIKEY: string;
|
||||
transformers: Transformer[];
|
||||
Providers: Provider[];
|
||||
Router: RouterConfig;
|
||||
}
|
||||
import type { Config, StatusLineConfig } from '@/types';
|
||||
|
||||
interface ConfigContextType {
|
||||
config: Config | null;
|
||||
@@ -108,7 +65,48 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
|
||||
try {
|
||||
// Try to fetch config regardless of API key presence
|
||||
const data = await api.getConfig();
|
||||
setConfig(data);
|
||||
|
||||
// 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
|
||||
@@ -117,17 +115,22 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
|
||||
// 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: ''
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Login() {
|
||||
try {
|
||||
await api.getConfig();
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// If verification fails, remove the API key
|
||||
localStorage.removeItem('apiKey');
|
||||
} finally {
|
||||
@@ -61,18 +61,23 @@ export function Login() {
|
||||
url: window.location.href
|
||||
}));
|
||||
|
||||
// Test the API key by fetching config (skip if apiKey is empty)
|
||||
if (apiKey) {
|
||||
await api.getConfig();
|
||||
}
|
||||
// Test the API key by fetching config
|
||||
await api.getConfig();
|
||||
|
||||
// Navigate to dashboard
|
||||
// The ConfigProvider will handle fetching the config
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
} catch (error: any) {
|
||||
// Clear the API key on failure
|
||||
api.setApiKey('');
|
||||
setError(t('login.invalidApiKey'));
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
7
ui/src/components/ProtectedRoute.tsx
Normal file
7
ui/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
// For this application, we allow access without an API key
|
||||
// The App component will handle loading and error states
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { type Provider } from "./ConfigProvider";
|
||||
import type { Provider } from "@/types";
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Provider[];
|
||||
@@ -10,29 +10,74 @@ interface ProviderListProps {
|
||||
}
|
||||
|
||||
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) => (
|
||||
<div key={index} className="flex items-start justify-between rounded-md border bg-white p-4 transition-all hover:shadow-md animate-slide-in hover:scale-[1.01]">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<p className="text-md font-semibold text-gray-800">{provider.name}</p>
|
||||
<p className="text-sm text-gray-500">{provider.api_base_url}</p>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{provider.models.map((model) => (
|
||||
<Badge key={model} variant="outline" className="font-normal transition-all-ease hover:scale-105">{model}</Badge>
|
||||
))}
|
||||
{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 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>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { X, Trash2, Plus } from "lucide-react";
|
||||
import { X, Trash2, Plus, Eye, EyeOff } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { ComboInput } from "@/components/ui/combo-input";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Provider } from "@/types";
|
||||
|
||||
interface ProviderType extends Provider {}
|
||||
|
||||
export function Providers() {
|
||||
const { t } = useTranslation();
|
||||
@@ -30,8 +32,32 @@ export function Providers() {
|
||||
const [providerParamInputs, setProviderParamInputs] = useState<Record<string, {name: string, value: string}>>({});
|
||||
const [modelParamInputs, setModelParamInputs] = useState<Record<string, {name: string, value: string}>>({});
|
||||
const [availableTransformers, setAvailableTransformers] = useState<{name: string; endpoint: string | null;}[]>([]);
|
||||
const [editingProviderData, setEditingProviderData] = useState<ProviderType | null>(null);
|
||||
const [isNewProvider, setIsNewProvider] = useState<boolean>(false);
|
||||
const [providerTemplates, setProviderTemplates] = useState<ProviderType[]>([]);
|
||||
const [showApiKey, setShowApiKey] = useState<Record<number, boolean>>({});
|
||||
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const comboInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProviderTemplates = async () => {
|
||||
try {
|
||||
const response = await fetch('https://pub-0dc3e1677e894f07bbea11b17a29e032.r2.dev/providers.json');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProviderTemplates(data || []);
|
||||
} else {
|
||||
console.error('Failed to fetch provider templates');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch provider templates:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProviderTemplates();
|
||||
}, []);
|
||||
|
||||
// Fetch available transformers when component mounts
|
||||
useEffect(() => {
|
||||
const fetchTransformers = async () => {
|
||||
@@ -46,28 +72,109 @@ export function Providers() {
|
||||
fetchTransformers();
|
||||
}, []);
|
||||
|
||||
// Handle case where config is null or undefined
|
||||
if (!config) {
|
||||
return null;
|
||||
return (
|
||||
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
|
||||
<CardTitle className="text-lg">{t("providers.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow flex items-center justify-center p-4">
|
||||
<div className="text-gray-500">Loading providers configuration...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Validate config.Providers to ensure it's an array
|
||||
const validProviders = Array.isArray(config.Providers) ? config.Providers : [];
|
||||
|
||||
|
||||
const handleAddProvider = () => {
|
||||
const newProviders = [...config.Providers, { name: "", api_base_url: "", api_key: "", models: [] }];
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
setEditingProviderIndex(newProviders.length - 1);
|
||||
const newProvider: ProviderType = { name: "", api_base_url: "", api_key: "", models: [] };
|
||||
setEditingProviderIndex(config.Providers.length);
|
||||
setEditingProviderData(newProvider);
|
||||
setIsNewProvider(true);
|
||||
// Reset API key visibility and error when adding new provider
|
||||
setShowApiKey(prev => ({
|
||||
...prev,
|
||||
[config.Providers.length]: false
|
||||
}));
|
||||
setApiKeyError(null);
|
||||
setNameError(null);
|
||||
};
|
||||
|
||||
const handleEditProvider = (index: number) => {
|
||||
const provider = config.Providers[index];
|
||||
setEditingProviderIndex(index);
|
||||
setEditingProviderData(JSON.parse(JSON.stringify(provider))); // 深拷贝
|
||||
setIsNewProvider(false);
|
||||
// Reset API key visibility and error when opening edit dialog
|
||||
setShowApiKey(prev => ({
|
||||
...prev,
|
||||
[index]: false
|
||||
}));
|
||||
setApiKeyError(null);
|
||||
setNameError(null);
|
||||
};
|
||||
|
||||
const handleSaveProvider = () => {
|
||||
if (!editingProviderData) return;
|
||||
|
||||
// Validate name
|
||||
if (!editingProviderData.name || editingProviderData.name.trim() === '') {
|
||||
setNameError(t("providers.name_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate names (case-insensitive)
|
||||
const trimmedName = editingProviderData.name.trim();
|
||||
const isDuplicate = config.Providers.some((provider, index) => {
|
||||
// For edit mode, skip checking the current provider being edited
|
||||
if (!isNewProvider && index === editingProviderIndex) {
|
||||
return false;
|
||||
}
|
||||
return provider.name.toLowerCase() === trimmedName.toLowerCase();
|
||||
});
|
||||
|
||||
if (isDuplicate) {
|
||||
setNameError(t("providers.name_duplicate"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
if (!editingProviderData.api_key || editingProviderData.api_key.trim() === '') {
|
||||
setApiKeyError(t("providers.api_key_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear errors if validation passes
|
||||
setApiKeyError(null);
|
||||
setNameError(null);
|
||||
|
||||
if (editingProviderIndex !== null && editingProviderData) {
|
||||
const newProviders = [...config.Providers];
|
||||
if (isNewProvider) {
|
||||
newProviders.push(editingProviderData);
|
||||
} else {
|
||||
newProviders[editingProviderIndex] = editingProviderData;
|
||||
}
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
}
|
||||
// Reset API key visibility for this provider
|
||||
if (editingProviderIndex !== null) {
|
||||
setShowApiKey(prev => {
|
||||
const newState = { ...prev };
|
||||
delete newState[editingProviderIndex];
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
setEditingProviderIndex(null);
|
||||
setEditingProviderData(null);
|
||||
setIsNewProvider(false);
|
||||
};
|
||||
|
||||
const handleCancelAddProvider = () => {
|
||||
// If we're adding a new provider, remove it regardless of content
|
||||
if (editingProviderIndex !== null && editingProviderIndex === config.Providers.length - 1) {
|
||||
const newProviders = [...config.Providers];
|
||||
newProviders.pop();
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
}
|
||||
// Reset fetched models state for this provider
|
||||
if (editingProviderIndex !== null) {
|
||||
setHasFetchedModels(prev => {
|
||||
@@ -75,8 +182,18 @@ export function Providers() {
|
||||
delete newState[editingProviderIndex];
|
||||
return newState;
|
||||
});
|
||||
// Reset API key visibility for this provider
|
||||
setShowApiKey(prev => {
|
||||
const newState = { ...prev };
|
||||
delete newState[editingProviderIndex];
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
setEditingProviderIndex(null);
|
||||
setEditingProviderData(null);
|
||||
setIsNewProvider(false);
|
||||
setApiKeyError(null);
|
||||
setNameError(null);
|
||||
};
|
||||
|
||||
const handleRemoveProvider = (index: number) => {
|
||||
@@ -86,90 +203,97 @@ export function Providers() {
|
||||
setDeletingProviderIndex(null);
|
||||
};
|
||||
|
||||
const handleProviderChange = (index: number, field: string, value: string) => {
|
||||
const newProviders = [...config.Providers];
|
||||
newProviders[index][field] = value;
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
const handleProviderChange = (_index: number, field: string, value: string) => {
|
||||
if (editingProviderData) {
|
||||
const updatedProvider = { ...editingProviderData, [field]: value };
|
||||
setEditingProviderData(updatedProvider);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderTransformerChange = (index: number, transformerPath: string) => {
|
||||
if (!transformerPath) return; // Don't add empty transformers
|
||||
const handleProviderTransformerChange = (_index: number, transformerPath: string) => {
|
||||
if (!transformerPath || !editingProviderData) return; // Don't add empty transformers
|
||||
|
||||
const newProviders = [...config.Providers];
|
||||
const updatedProvider = { ...editingProviderData };
|
||||
|
||||
if (!newProviders[index].transformer) {
|
||||
newProviders[index].transformer = { use: [] };
|
||||
if (!updatedProvider.transformer) {
|
||||
updatedProvider.transformer = { use: [] };
|
||||
}
|
||||
|
||||
// Add transformer to the use array
|
||||
newProviders[index].transformer!.use = [...newProviders[index].transformer!.use, transformerPath];
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
updatedProvider.transformer.use = [...updatedProvider.transformer.use, transformerPath];
|
||||
setEditingProviderData(updatedProvider);
|
||||
};
|
||||
|
||||
const removeProviderTransformerAtIndex = (index: number, transformerIndex: number) => {
|
||||
const newProviders = [...config.Providers];
|
||||
const removeProviderTransformerAtIndex = (_index: number, transformerIndex: number) => {
|
||||
if (!editingProviderData) return;
|
||||
|
||||
if (newProviders[index].transformer) {
|
||||
const newUseArray = [...newProviders[index].transformer!.use];
|
||||
const updatedProvider = { ...editingProviderData };
|
||||
|
||||
if (updatedProvider.transformer) {
|
||||
const newUseArray = [...updatedProvider.transformer.use];
|
||||
newUseArray.splice(transformerIndex, 1);
|
||||
newProviders[index].transformer!.use = newUseArray;
|
||||
updatedProvider.transformer.use = newUseArray;
|
||||
|
||||
// If use array is now empty and no other properties, remove transformer entirely
|
||||
if (newUseArray.length === 0 && Object.keys(newProviders[index].transformer!).length === 1) {
|
||||
delete newProviders[index].transformer;
|
||||
if (newUseArray.length === 0 && Object.keys(updatedProvider.transformer).length === 1) {
|
||||
delete updatedProvider.transformer;
|
||||
}
|
||||
}
|
||||
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
setEditingProviderData(updatedProvider);
|
||||
};
|
||||
|
||||
const handleModelTransformerChange = (providerIndex: number, model: string, transformerPath: string) => {
|
||||
if (!transformerPath) return; // Don't add empty transformers
|
||||
const handleModelTransformerChange = (_providerIndex: number, model: string, transformerPath: string) => {
|
||||
if (!transformerPath || !editingProviderData) return; // Don't add empty transformers
|
||||
|
||||
const newProviders = [...config.Providers];
|
||||
const updatedProvider = { ...editingProviderData };
|
||||
|
||||
if (!newProviders[providerIndex].transformer) {
|
||||
newProviders[providerIndex].transformer = { use: [] };
|
||||
if (!updatedProvider.transformer) {
|
||||
updatedProvider.transformer = { use: [] };
|
||||
}
|
||||
|
||||
// Initialize model transformer if it doesn't exist
|
||||
if (!newProviders[providerIndex].transformer![model]) {
|
||||
newProviders[providerIndex].transformer![model] = { use: [] };
|
||||
if (!updatedProvider.transformer[model]) {
|
||||
updatedProvider.transformer[model] = { use: [] };
|
||||
}
|
||||
|
||||
// Add transformer to the use array
|
||||
newProviders[providerIndex].transformer![model].use = [...newProviders[providerIndex].transformer![model].use, transformerPath];
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
updatedProvider.transformer[model].use = [...updatedProvider.transformer[model].use, transformerPath];
|
||||
setEditingProviderData(updatedProvider);
|
||||
};
|
||||
|
||||
const removeModelTransformerAtIndex = (providerIndex: number, model: string, transformerIndex: number) => {
|
||||
const newProviders = [...config.Providers];
|
||||
const removeModelTransformerAtIndex = (_providerIndex: number, model: string, transformerIndex: number) => {
|
||||
if (!editingProviderData) return;
|
||||
|
||||
if (newProviders[providerIndex].transformer && newProviders[providerIndex].transformer![model]) {
|
||||
const newUseArray = [...newProviders[providerIndex].transformer![model].use];
|
||||
const updatedProvider = { ...editingProviderData };
|
||||
|
||||
if (updatedProvider.transformer && updatedProvider.transformer[model]) {
|
||||
const newUseArray = [...updatedProvider.transformer[model].use];
|
||||
newUseArray.splice(transformerIndex, 1);
|
||||
newProviders[providerIndex].transformer![model].use = newUseArray;
|
||||
updatedProvider.transformer[model].use = newUseArray;
|
||||
|
||||
// If use array is now empty and no other properties, remove model transformer entirely
|
||||
if (newUseArray.length === 0 && Object.keys(newProviders[providerIndex].transformer![model]).length === 1) {
|
||||
delete newProviders[providerIndex].transformer![model];
|
||||
if (newUseArray.length === 0 && Object.keys(updatedProvider.transformer[model]).length === 1) {
|
||||
delete updatedProvider.transformer[model];
|
||||
}
|
||||
}
|
||||
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
setEditingProviderData(updatedProvider);
|
||||
};
|
||||
|
||||
|
||||
const addProviderTransformerParameter = (providerIndex: number, transformerIndex: number, paramName: string, paramValue: string) => {
|
||||
const newProviders = [...config.Providers];
|
||||
const addProviderTransformerParameter = (_providerIndex: number, transformerIndex: number, paramName: string, paramValue: string) => {
|
||||
if (!editingProviderData) return;
|
||||
|
||||
if (!newProviders[providerIndex].transformer) {
|
||||
newProviders[providerIndex].transformer = { use: [] };
|
||||
const updatedProvider = { ...editingProviderData };
|
||||
|
||||
if (!updatedProvider.transformer) {
|
||||
updatedProvider.transformer = { use: [] };
|
||||
}
|
||||
|
||||
// Add parameter to the specified transformer in use array
|
||||
if (newProviders[providerIndex].transformer!.use && newProviders[providerIndex].transformer!.use.length > transformerIndex) {
|
||||
const targetTransformer = newProviders[providerIndex].transformer!.use[transformerIndex];
|
||||
if (updatedProvider.transformer.use && updatedProvider.transformer.use.length > transformerIndex) {
|
||||
const targetTransformer = updatedProvider.transformer.use[transformerIndex];
|
||||
|
||||
// If it's already an array with parameters, update it
|
||||
if (Array.isArray(targetTransformer)) {
|
||||
@@ -190,26 +314,28 @@ export function Providers() {
|
||||
transformerArray.push(paramsObj);
|
||||
}
|
||||
|
||||
newProviders[providerIndex].transformer!.use[transformerIndex] = transformerArray as any;
|
||||
updatedProvider.transformer.use[transformerIndex] = transformerArray as string | (string | Record<string, unknown> | { max_tokens: number })[];
|
||||
} else {
|
||||
// Convert to array format with parameters
|
||||
const paramsObj = { [paramName]: paramValue };
|
||||
newProviders[providerIndex].transformer!.use[transformerIndex] = [targetTransformer as string, paramsObj] as any;
|
||||
updatedProvider.transformer.use[transformerIndex] = [targetTransformer as string, paramsObj];
|
||||
}
|
||||
}
|
||||
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
setEditingProviderData(updatedProvider);
|
||||
};
|
||||
|
||||
|
||||
const removeProviderTransformerParameterAtIndex = (providerIndex: number, transformerIndex: number, paramName: string) => {
|
||||
const newProviders = [...config.Providers];
|
||||
const removeProviderTransformerParameterAtIndex = (_providerIndex: number, transformerIndex: number, paramName: string) => {
|
||||
if (!editingProviderData) return;
|
||||
|
||||
if (!newProviders[providerIndex].transformer?.use || newProviders[providerIndex].transformer.use.length <= transformerIndex) {
|
||||
const updatedProvider = { ...editingProviderData };
|
||||
|
||||
if (!updatedProvider.transformer?.use || updatedProvider.transformer.use.length <= transformerIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTransformer = newProviders[providerIndex].transformer.use[transformerIndex];
|
||||
const targetTransformer = updatedProvider.transformer.use[transformerIndex];
|
||||
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
|
||||
const transformerArray = [...targetTransformer];
|
||||
// Check if the second element is an object (parameters object)
|
||||
@@ -224,26 +350,28 @@ export function Providers() {
|
||||
transformerArray[1] = paramsObj;
|
||||
}
|
||||
|
||||
newProviders[providerIndex].transformer!.use[transformerIndex] = transformerArray;
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
updatedProvider.transformer.use[transformerIndex] = transformerArray;
|
||||
setEditingProviderData(updatedProvider);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addModelTransformerParameter = (providerIndex: number, model: string, transformerIndex: number, paramName: string, paramValue: string) => {
|
||||
const newProviders = [...config.Providers];
|
||||
const addModelTransformerParameter = (_providerIndex: number, model: string, transformerIndex: number, paramName: string, paramValue: string) => {
|
||||
if (!editingProviderData) return;
|
||||
|
||||
if (!newProviders[providerIndex].transformer) {
|
||||
newProviders[providerIndex].transformer = { use: [] };
|
||||
const updatedProvider = { ...editingProviderData };
|
||||
|
||||
if (!updatedProvider.transformer) {
|
||||
updatedProvider.transformer = { use: [] };
|
||||
}
|
||||
|
||||
if (!newProviders[providerIndex].transformer![model]) {
|
||||
newProviders[providerIndex].transformer![model] = { use: [] };
|
||||
if (!updatedProvider.transformer[model]) {
|
||||
updatedProvider.transformer[model] = { use: [] };
|
||||
}
|
||||
|
||||
// Add parameter to the specified transformer in use array
|
||||
if (newProviders[providerIndex].transformer![model].use && newProviders[providerIndex].transformer![model].use.length > transformerIndex) {
|
||||
const targetTransformer = newProviders[providerIndex].transformer![model].use[transformerIndex];
|
||||
if (updatedProvider.transformer[model].use && updatedProvider.transformer[model].use.length > transformerIndex) {
|
||||
const targetTransformer = updatedProvider.transformer[model].use[transformerIndex];
|
||||
|
||||
// If it's already an array with parameters, update it
|
||||
if (Array.isArray(targetTransformer)) {
|
||||
@@ -264,26 +392,28 @@ export function Providers() {
|
||||
transformerArray.push(paramsObj);
|
||||
}
|
||||
|
||||
newProviders[providerIndex].transformer![model].use[transformerIndex] = transformerArray as any;
|
||||
updatedProvider.transformer[model].use[transformerIndex] = transformerArray as string | (string | Record<string, unknown> | { max_tokens: number })[];
|
||||
} else {
|
||||
// Convert to array format with parameters
|
||||
const paramsObj = { [paramName]: paramValue };
|
||||
newProviders[providerIndex].transformer![model].use[transformerIndex] = [targetTransformer as string, paramsObj] as any;
|
||||
updatedProvider.transformer[model].use[transformerIndex] = [targetTransformer as string, paramsObj];
|
||||
}
|
||||
}
|
||||
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
setEditingProviderData(updatedProvider);
|
||||
};
|
||||
|
||||
|
||||
const removeModelTransformerParameterAtIndex = (providerIndex: number, model: string, transformerIndex: number, paramName: string) => {
|
||||
const newProviders = [...config.Providers];
|
||||
const removeModelTransformerParameterAtIndex = (_providerIndex: number, model: string, transformerIndex: number, paramName: string) => {
|
||||
if (!editingProviderData) return;
|
||||
|
||||
if (!newProviders[providerIndex].transformer?.[model]?.use || newProviders[providerIndex].transformer[model].use.length <= transformerIndex) {
|
||||
const updatedProvider = { ...editingProviderData };
|
||||
|
||||
if (!updatedProvider.transformer?.[model]?.use || updatedProvider.transformer[model].use.length <= transformerIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTransformer = newProviders[providerIndex].transformer[model].use[transformerIndex];
|
||||
const targetTransformer = updatedProvider.transformer[model].use[transformerIndex];
|
||||
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
|
||||
const transformerArray = [...targetTransformer];
|
||||
// Check if the second element is an object (parameters object)
|
||||
@@ -298,46 +428,75 @@ export function Providers() {
|
||||
transformerArray[1] = paramsObj;
|
||||
}
|
||||
|
||||
newProviders[providerIndex].transformer![model].use[transformerIndex] = transformerArray;
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
updatedProvider.transformer[model].use[transformerIndex] = transformerArray;
|
||||
setEditingProviderData(updatedProvider);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddModel = (index: number, model: string) => {
|
||||
if (!model.trim()) return;
|
||||
const handleAddModel = (_index: number, model: string) => {
|
||||
if (!model.trim() || !editingProviderData) return;
|
||||
|
||||
const newProviders = [...config.Providers];
|
||||
const models = [...newProviders[index].models];
|
||||
const updatedProvider = { ...editingProviderData };
|
||||
|
||||
// Handle case where provider.models might be null or undefined
|
||||
const models = Array.isArray(updatedProvider.models) ? [...updatedProvider.models] : [];
|
||||
|
||||
// Check if model already exists
|
||||
if (!models.includes(model.trim())) {
|
||||
models.push(model.trim());
|
||||
newProviders[index].models = models;
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
updatedProvider.models = models;
|
||||
setEditingProviderData(updatedProvider);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveModel = (providerIndex: number, modelIndex: number) => {
|
||||
const newProviders = [...config.Providers];
|
||||
const models = [...newProviders[providerIndex].models];
|
||||
models.splice(modelIndex, 1);
|
||||
newProviders[providerIndex].models = models;
|
||||
setConfig({ ...config, Providers: newProviders });
|
||||
const handleTemplateImport = (value: string) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
const selectedTemplate = JSON.parse(value);
|
||||
if (selectedTemplate) {
|
||||
const currentName = editingProviderData?.name;
|
||||
const newProviderData = JSON.parse(JSON.stringify(selectedTemplate));
|
||||
|
||||
if (!isNewProvider && currentName) {
|
||||
newProviderData.name = currentName;
|
||||
}
|
||||
|
||||
setEditingProviderData(newProviderData as ProviderType);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse template", e);
|
||||
}
|
||||
};
|
||||
|
||||
const editingProvider = editingProviderIndex !== null ? config.Providers[editingProviderIndex] : null;
|
||||
const handleRemoveModel = (_providerIndex: number, modelIndex: number) => {
|
||||
if (!editingProviderData) return;
|
||||
|
||||
const updatedProvider = { ...editingProviderData };
|
||||
|
||||
// Handle case where provider.models might be null or undefined
|
||||
const models = Array.isArray(updatedProvider.models) ? [...updatedProvider.models] : [];
|
||||
|
||||
// Handle case where modelIndex might be out of bounds
|
||||
if (modelIndex >= 0 && modelIndex < models.length) {
|
||||
models.splice(modelIndex, 1);
|
||||
updatedProvider.models = models;
|
||||
setEditingProviderData(updatedProvider);
|
||||
}
|
||||
};
|
||||
|
||||
const editingProvider = editingProviderData || (editingProviderIndex !== null ? validProviders[editingProviderIndex] : null);
|
||||
|
||||
return (
|
||||
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
|
||||
<CardTitle className="text-lg">{t("providers.title")} <span className="text-sm font-normal text-gray-500">({config.Providers.length})</span></CardTitle>
|
||||
<CardTitle className="text-lg">{t("providers.title")} <span className="text-sm font-normal text-gray-500">({validProviders.length})</span></CardTitle>
|
||||
<Button onClick={handleAddProvider}>{t("providers.add")}</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow overflow-y-auto p-4">
|
||||
<ProviderList
|
||||
providers={config.Providers}
|
||||
onEdit={setEditingProviderIndex}
|
||||
providers={validProviders}
|
||||
onEdit={handleEditProvider}
|
||||
onRemove={setDeletingProviderIndex}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -354,17 +513,73 @@ export function Providers() {
|
||||
</DialogHeader>
|
||||
{editingProvider && editingProviderIndex !== null && (
|
||||
<div className="space-y-4 p-4 overflow-y-auto flex-grow">
|
||||
{providerTemplates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("providers.import_from_template")}</Label>
|
||||
<Combobox
|
||||
options={providerTemplates.map(p => ({ label: p.name, value: JSON.stringify(p) }))}
|
||||
value=""
|
||||
onChange={handleTemplateImport}
|
||||
placeholder={t("providers.select_template")}
|
||||
emptyPlaceholder={t("providers.no_templates_found")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t("providers.name")}</Label>
|
||||
<Input id="name" value={editingProvider.name} onChange={(e) => handleProviderChange(editingProviderIndex, 'name', e.target.value)} />
|
||||
<Input
|
||||
id="name"
|
||||
value={editingProvider.name || ''}
|
||||
onChange={(e) => {
|
||||
handleProviderChange(editingProviderIndex, 'name', e.target.value);
|
||||
// Clear name error when user starts typing
|
||||
if (nameError) {
|
||||
setNameError(null);
|
||||
}
|
||||
}}
|
||||
className={nameError ? "border-red-500" : ""}
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="text-sm text-red-500">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_base_url">{t("providers.api_base_url")}</Label>
|
||||
<Input id="api_base_url" value={editingProvider.api_base_url} onChange={(e) => handleProviderChange(editingProviderIndex, 'api_base_url', e.target.value)} />
|
||||
<Input id="api_base_url" value={editingProvider.api_base_url || ''} onChange={(e) => handleProviderChange(editingProviderIndex, 'api_base_url', e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_key">{t("providers.api_key")}</Label>
|
||||
<Input id="api_key" type="password" value={editingProvider.api_key} onChange={(e) => handleProviderChange(editingProviderIndex, 'api_key', e.target.value)} />
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="api_key"
|
||||
type={showApiKey[editingProviderIndex || 0] ? "text" : "password"}
|
||||
value={editingProvider.api_key || ''}
|
||||
onChange={(e) => handleProviderChange(editingProviderIndex, 'api_key', e.target.value)}
|
||||
className={apiKeyError ? "border-red-500" : ""}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 h-8 w-8"
|
||||
onClick={() => {
|
||||
const index = editingProviderIndex || 0;
|
||||
setShowApiKey(prev => ({
|
||||
...prev,
|
||||
[index]: !prev[index]
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{showApiKey[editingProviderIndex || 0] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{apiKeyError && (
|
||||
<p className="text-sm text-red-500">{apiKeyError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="models">{t("providers.models")}</Label>
|
||||
@@ -374,9 +589,9 @@ export function Providers() {
|
||||
{hasFetchedModels[editingProviderIndex] ? (
|
||||
<ComboInput
|
||||
ref={comboInputRef}
|
||||
options={editingProvider.models.map(model => ({ label: model, value: model }))}
|
||||
options={(editingProvider.models || []).map((model: string) => ({ label: model, value: model }))}
|
||||
value=""
|
||||
onChange={(_) => {
|
||||
onChange={() => {
|
||||
// 只更新输入值,不添加模型
|
||||
}}
|
||||
onEnter={(value) => {
|
||||
@@ -403,7 +618,7 @@ export function Providers() {
|
||||
onClick={() => {
|
||||
if (hasFetchedModels[editingProviderIndex] && comboInputRef.current) {
|
||||
// 使用ComboInput的逻辑
|
||||
const comboInput = comboInputRef.current as any;
|
||||
const comboInput = comboInputRef.current as unknown as { getCurrentValue(): string; clearInput(): void };
|
||||
const currentValue = comboInput.getCurrentValue();
|
||||
if (currentValue && currentValue.trim() && editingProviderIndex !== null) {
|
||||
handleAddModel(editingProviderIndex, currentValue.trim());
|
||||
@@ -431,7 +646,7 @@ export function Providers() {
|
||||
</Button> */}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{editingProvider.models.map((model, modelIndex) => (
|
||||
{(editingProvider.models || []).map((model: string, modelIndex: number) => (
|
||||
<Badge key={modelIndex} variant="outline" className="font-normal flex items-center gap-1">
|
||||
{model}
|
||||
<button
|
||||
@@ -473,7 +688,7 @@ export function Providers() {
|
||||
{editingProvider.transformer?.use && editingProvider.transformer.use.length > 0 && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<div className="text-sm font-medium text-gray-700">{t("providers.selected_transformers")}</div>
|
||||
{editingProvider.transformer.use.map((transformer: any, transformerIndex: number) => (
|
||||
{editingProvider.transformer.use.map((transformer: string | (string | Record<string, unknown> | { max_tokens: number })[], transformerIndex: number) => (
|
||||
<div key={transformerIndex} className="border rounded-md p-3">
|
||||
<div className="flex gap-2 items-center mb-2">
|
||||
<div className="flex-1 bg-gray-50 rounded p-2 text-sm">
|
||||
@@ -596,11 +811,11 @@ export function Providers() {
|
||||
</div>
|
||||
|
||||
{/* Model-specific Transformers */}
|
||||
{editingProvider.models.length > 0 && (
|
||||
{editingProvider.models && editingProvider.models.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("providers.model_transformers")}</Label>
|
||||
<div className="space-y-3">
|
||||
{editingProvider.models.map((model, modelIndex) => (
|
||||
{(editingProvider.models || []).map((model: string, modelIndex: number) => (
|
||||
<div key={modelIndex} className="border rounded-md p-3">
|
||||
<div className="font-medium text-sm mb-2">{model}</div>
|
||||
{/* Add new transformer */}
|
||||
@@ -627,7 +842,7 @@ export function Providers() {
|
||||
{editingProvider.transformer?.[model]?.use && editingProvider.transformer[model].use.length > 0 && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<div className="text-sm font-medium text-gray-700">{t("providers.selected_transformers")}</div>
|
||||
{editingProvider.transformer[model].use.map((transformer: any, transformerIndex: number) => (
|
||||
{editingProvider.transformer[model].use.map((transformer: string | (string | Record<string, unknown> | { max_tokens: number })[], transformerIndex: number) => (
|
||||
<div key={transformerIndex} className="border rounded-md p-3">
|
||||
<div className="flex gap-2 items-center mb-2">
|
||||
<div className="flex-1 bg-gray-50 rounded p-2 text-sm">
|
||||
|
||||
7
ui/src/components/PublicRoute.tsx
Normal file
7
ui/src/components/PublicRoute.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
// Always show login page
|
||||
// The login page will handle empty API keys appropriately
|
||||
return children;
|
||||
};
|
||||
|
||||
export default PublicRoute;
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
|
||||
@@ -8,21 +9,55 @@ export function Router() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setConfig } = useConfig();
|
||||
|
||||
// Handle case where config is null or undefined
|
||||
if (!config) {
|
||||
return null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRouterChange = (field: string, value: string) => {
|
||||
const newRouter = { ...config.Router, [field]: value };
|
||||
// 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 });
|
||||
};
|
||||
|
||||
const modelOptions = config.Providers.flatMap((provider) =>
|
||||
provider.models.map((model) => ({
|
||||
value: `${provider.name},${model}`,
|
||||
label: `${provider.name}, ${model}`,
|
||||
}))
|
||||
);
|
||||
// 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">
|
||||
@@ -34,7 +69,7 @@ export function Router() {
|
||||
<Label>{t("router.default")}</Label>
|
||||
<Combobox
|
||||
options={modelOptions}
|
||||
value={config.Router.default}
|
||||
value={routerConfig.default || ""}
|
||||
onChange={(value) => handleRouterChange("default", value)}
|
||||
placeholder={t("router.selectModel")}
|
||||
searchPlaceholder={t("router.searchModel")}
|
||||
@@ -45,7 +80,7 @@ export function Router() {
|
||||
<Label>{t("router.background")}</Label>
|
||||
<Combobox
|
||||
options={modelOptions}
|
||||
value={config.Router.background}
|
||||
value={routerConfig.background || ""}
|
||||
onChange={(value) => handleRouterChange("background", value)}
|
||||
placeholder={t("router.selectModel")}
|
||||
searchPlaceholder={t("router.searchModel")}
|
||||
@@ -56,7 +91,7 @@ export function Router() {
|
||||
<Label>{t("router.think")}</Label>
|
||||
<Combobox
|
||||
options={modelOptions}
|
||||
value={config.Router.think}
|
||||
value={routerConfig.think || ""}
|
||||
onChange={(value) => handleRouterChange("think", value)}
|
||||
placeholder={t("router.selectModel")}
|
||||
searchPlaceholder={t("router.searchModel")}
|
||||
@@ -64,21 +99,34 @@ export function Router() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("router.longContext")}</Label>
|
||||
<Combobox
|
||||
options={modelOptions}
|
||||
value={config.Router.longContext}
|
||||
onChange={(value) => handleRouterChange("longContext", value)}
|
||||
placeholder={t("router.selectModel")}
|
||||
searchPlaceholder={t("router.searchModel")}
|
||||
emptyPlaceholder={t("router.noModelFound")}
|
||||
/>
|
||||
<div 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={config.Router.webSearch}
|
||||
value={routerConfig.webSearch || ""}
|
||||
onChange={(value) => handleRouterChange("webSearch", value)}
|
||||
placeholder={t("router.selectModel")}
|
||||
searchPlaceholder={t("router.searchModel")}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -11,7 +10,11 @@ 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;
|
||||
@@ -21,6 +24,7 @@ interface SettingsDialogProps {
|
||||
export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config, setConfig } = useConfig();
|
||||
const [isStatusLineConfigOpen, setIsStatusLineConfigOpen] = useState(false);
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
@@ -34,38 +38,196 @@ export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
|
||||
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>
|
||||
<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>
|
||||
<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="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]" />
|
||||
<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="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]" />
|
||||
<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="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]" />
|
||||
<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="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]" />
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
647
ui/src/components/StatusLineConfigDialog.tsx
Normal file
647
ui/src/components/StatusLineConfigDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
ui/src/components/StatusLineImportExport.tsx
Normal file
309
ui/src/components/StatusLineImportExport.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { validateStatusLineConfig, backupConfig, restoreConfig, createDefaultStatusLineConfig } from "@/utils/statusline";
|
||||
import type { StatusLineConfig } from "@/types";
|
||||
|
||||
interface StatusLineImportExportProps {
|
||||
config: StatusLineConfig;
|
||||
onImport: (config: StatusLineConfig) => void;
|
||||
onShowToast: (message: string, type: 'success' | 'error' | 'warning') => void;
|
||||
}
|
||||
|
||||
export function StatusLineImportExport({ config, onImport, onShowToast }: StatusLineImportExportProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
// 导出配置为JSON文件
|
||||
const handleExport = () => {
|
||||
try {
|
||||
// 在导出前验证配置
|
||||
const validationResult = validateStatusLineConfig(config);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
onShowToast(t("statusline.export_validation_failed"), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const dataStr = JSON.stringify(config, null, 2);
|
||||
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`;
|
||||
|
||||
const exportFileDefaultName = `statusline-config-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
linkElement.click();
|
||||
|
||||
onShowToast(t("statusline.export_success"), 'success');
|
||||
} catch (error) {
|
||||
console.error("Export failed:", error);
|
||||
onShowToast(t("statusline.export_failed"), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 导入配置从JSON文件
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsImporting(true);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const importedConfig = JSON.parse(content) as StatusLineConfig;
|
||||
|
||||
// 验证导入的配置
|
||||
const validationResult = validateStatusLineConfig(importedConfig);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
// 格式化错误信息
|
||||
const errorMessages = validationResult.errors.map(error =>
|
||||
error.message
|
||||
).join('; ');
|
||||
throw new Error(`${t("statusline.invalid_config")}: ${errorMessages}`);
|
||||
}
|
||||
|
||||
onImport(importedConfig);
|
||||
onShowToast(t("statusline.import_success"), 'success');
|
||||
} catch (error) {
|
||||
console.error("Import failed:", error);
|
||||
onShowToast(t("statusline.import_failed") + (error instanceof Error ? `: ${error.message}` : ""), 'error');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
// 重置文件输入,以便可以再次选择同一个文件
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
onShowToast(t("statusline.import_failed"), 'error');
|
||||
setIsImporting(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
// 下载配置模板
|
||||
const handleDownloadTemplate = () => {
|
||||
try {
|
||||
// 使用新的默认配置函数
|
||||
const templateConfig = createDefaultStatusLineConfig();
|
||||
|
||||
const dataStr = JSON.stringify(templateConfig, null, 2);
|
||||
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`;
|
||||
|
||||
const templateFileName = "statusline-config-template.json";
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', templateFileName);
|
||||
linkElement.click();
|
||||
|
||||
onShowToast(t("statusline.template_download_success"), 'success');
|
||||
} catch (error) {
|
||||
console.error("Template download failed:", error);
|
||||
onShowToast(t("statusline.template_download_failed"), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 配置备份功能
|
||||
const handleBackup = () => {
|
||||
try {
|
||||
const backupStr = backupConfig(config);
|
||||
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(backupStr)}`;
|
||||
|
||||
const backupFileName = `statusline-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', backupFileName);
|
||||
linkElement.click();
|
||||
|
||||
onShowToast(t("statusline.backup_success"), 'success');
|
||||
} catch (error) {
|
||||
console.error("Backup failed:", error);
|
||||
onShowToast(t("statusline.backup_failed"), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 配置恢复功能
|
||||
const handleRestore = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const restoredConfig = restoreConfig(content);
|
||||
|
||||
if (!restoredConfig) {
|
||||
throw new Error(t("statusline.invalid_backup_file"));
|
||||
}
|
||||
|
||||
// 验证恢复的配置
|
||||
const validationResult = validateStatusLineConfig(restoredConfig);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
// 格式化错误信息
|
||||
const errorMessages = validationResult.errors.map(error =>
|
||||
error.message
|
||||
).join('; ');
|
||||
throw new Error(`${t("statusline.invalid_config")}: ${errorMessages}`);
|
||||
}
|
||||
|
||||
onImport(restoredConfig);
|
||||
onShowToast(t("statusline.restore_success"), 'success');
|
||||
} catch (error) {
|
||||
console.error("Restore failed:", error);
|
||||
onShowToast(t("statusline.restore_failed") + (error instanceof Error ? `: ${error.message}` : ""), 'error');
|
||||
} finally {
|
||||
// 重置文件输入
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
onShowToast(t("statusline.restore_failed"), 'error');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
// 移除本地验证函数,因为我们现在使用utils中的验证函数
|
||||
|
||||
return (
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
{t("statusline.import_export")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-4 pb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
variant="outline"
|
||||
className="transition-all hover:scale-105"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
{t("statusline.export")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
disabled={isImporting}
|
||||
className="transition-all hover:scale-105"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
{t("statusline.import")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
onClick={handleBackup}
|
||||
variant="outline"
|
||||
className="transition-all hover:scale-105"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
{t("statusline.backup")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
// 创建一个隐藏的文件输入用于恢复
|
||||
const restoreInput = document.createElement('input');
|
||||
restoreInput.type = 'file';
|
||||
restoreInput.accept = '.json';
|
||||
restoreInput.onchange = (e) => handleRestore(e as any);
|
||||
restoreInput.click();
|
||||
}}
|
||||
variant="outline"
|
||||
className="transition-all hover:scale-105"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M3 15v4c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2v-4M17 9l-5 5-5-5M12 12.8V2.5"/>
|
||||
</svg>
|
||||
{t("statusline.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDownloadTemplate}
|
||||
variant="outline"
|
||||
className="transition-all hover:scale-105 sm:col-span-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
{t("statusline.download_template")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImport}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="p-3 bg-secondary/50 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground mt-0.5 flex-shrink-0">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("statusline.import_export_help")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type Transformer } from "./ConfigProvider";
|
||||
import type { Transformer } from "@/types";
|
||||
|
||||
interface TransformerListProps {
|
||||
transformers: Transformer[];
|
||||
@@ -9,24 +9,84 @@ interface TransformerListProps {
|
||||
}
|
||||
|
||||
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) => (
|
||||
<div key={index} className="flex items-start justify-between rounded-md border bg-white p-4 transition-all hover:shadow-md animate-slide-in hover:scale-[1.01]">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<p className="text-md font-semibold text-gray-800">{transformer.path}</p>
|
||||
<p className="text-sm text-gray-500">{transformer.options.project}</p>
|
||||
{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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,41 +21,54 @@ export function Transformers() {
|
||||
const { config, setConfig } = useConfig();
|
||||
const [editingTransformerIndex, setEditingTransformerIndex] = useState<number | null>(null);
|
||||
const [deletingTransformerIndex, setDeletingTransformerIndex] = useState<number | null>(null);
|
||||
const [newTransformer, setNewTransformer] = useState<{ path: string; options: { [key: string]: string } } | null>(null);
|
||||
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 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")}</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 = { path: "", options: {} };
|
||||
const newTransformer = { name: "", path: "", options: {} };
|
||||
setNewTransformer(newTransformer);
|
||||
setEditingTransformerIndex(config.transformers.length); // Use the length as index for the new item
|
||||
setEditingTransformerIndex(validTransformers.length); // Use the length as index for the new item
|
||||
};
|
||||
|
||||
const handleRemoveTransformer = (index: number) => {
|
||||
const newTransformers = [...config.transformers];
|
||||
const newTransformers = [...validTransformers];
|
||||
newTransformers.splice(index, 1);
|
||||
setConfig({ ...config, transformers: newTransformers });
|
||||
setDeletingTransformerIndex(null);
|
||||
};
|
||||
|
||||
const handleTransformerChange = (index: number, field: string, value: string, optionKey?: string) => {
|
||||
if (index < config.transformers.length) {
|
||||
const handleTransformerChange = (index: number, field: string, value: string, parameterKey?: string) => {
|
||||
if (index < validTransformers.length) {
|
||||
// Editing an existing transformer
|
||||
const newTransformers = [...config.transformers];
|
||||
if (optionKey !== undefined) {
|
||||
newTransformers[index].options[optionKey] = value;
|
||||
const newTransformers = [...validTransformers];
|
||||
if (parameterKey !== undefined) {
|
||||
newTransformers[index].options![parameterKey] = value;
|
||||
} else {
|
||||
(newTransformers[index] as Record<string, unknown>)[field] = value;
|
||||
(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 (optionKey !== undefined) {
|
||||
updatedTransformer.options[optionKey] = value;
|
||||
if (parameterKey !== undefined) {
|
||||
updatedTransformer.options![parameterKey] = value;
|
||||
} else {
|
||||
(updatedTransformer as Record<string, unknown>)[field] = value;
|
||||
}
|
||||
@@ -65,15 +78,15 @@ export function Transformers() {
|
||||
};
|
||||
|
||||
const editingTransformer = editingTransformerIndex !== null ?
|
||||
(editingTransformerIndex < config.transformers.length ?
|
||||
config.transformers[editingTransformerIndex] :
|
||||
(editingTransformerIndex < validTransformers.length ?
|
||||
validTransformers[editingTransformerIndex] :
|
||||
newTransformer) :
|
||||
null;
|
||||
|
||||
const handleSaveTransformer = () => {
|
||||
if (newTransformer && editingTransformerIndex === config.transformers.length) {
|
||||
if (newTransformer && editingTransformerIndex === validTransformers.length) {
|
||||
// Saving a new transformer
|
||||
const newTransformers = [...config.transformers, newTransformer];
|
||||
const newTransformers = [...validTransformers, newTransformer];
|
||||
setConfig({ ...config, transformers: newTransformers });
|
||||
}
|
||||
// Close the dialog
|
||||
@@ -90,12 +103,12 @@ export function Transformers() {
|
||||
return (
|
||||
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
|
||||
<CardTitle className="text-lg">{t("transformers.title")} <span className="text-sm font-normal text-gray-500">({config.transformers.length})</span></CardTitle>
|
||||
<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={config.transformers}
|
||||
transformers={validTransformers}
|
||||
onEdit={setEditingTransformerIndex}
|
||||
onRemove={setDeletingTransformerIndex}
|
||||
/>
|
||||
@@ -113,7 +126,7 @@ export function Transformers() {
|
||||
<Label htmlFor="transformer-path">{t("transformers.path")}</Label>
|
||||
<Input
|
||||
id="transformer-path"
|
||||
value={editingTransformer.path}
|
||||
value={editingTransformer.path || ''}
|
||||
onChange={(e) => handleTransformerChange(editingTransformerIndex, "path", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -124,15 +137,16 @@ export function Transformers() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newKey = `param${Object.keys(editingTransformer.options).length + 1}`;
|
||||
const parameters = editingTransformer.options || {};
|
||||
const newKey = `param${Object.keys(parameters).length + 1}`;
|
||||
if (editingTransformerIndex !== null) {
|
||||
const newOptions = { ...editingTransformer.options, [newKey]: "" };
|
||||
if (editingTransformerIndex < config.transformers.length) {
|
||||
const newTransformers = [...config.transformers];
|
||||
newTransformers[editingTransformerIndex].options = newOptions;
|
||||
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: newOptions });
|
||||
setNewTransformer({ ...newTransformer, options: newParameters });
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -140,21 +154,22 @@ export function Transformers() {
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{Object.entries(editingTransformer.options).map(([key, value]) => (
|
||||
{Object.entries(editingTransformer.options || {}).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newOptions = { ...editingTransformer.options };
|
||||
delete newOptions[key];
|
||||
newOptions[e.target.value] = value;
|
||||
const parameters = editingTransformer.options || {};
|
||||
const newParameters = { ...parameters };
|
||||
delete newParameters[key];
|
||||
newParameters[e.target.value] = value;
|
||||
if (editingTransformerIndex !== null) {
|
||||
if (editingTransformerIndex < config.transformers.length) {
|
||||
const newTransformers = [...config.transformers];
|
||||
newTransformers[editingTransformerIndex].options = newOptions;
|
||||
if (editingTransformerIndex < validTransformers.length) {
|
||||
const newTransformers = [...validTransformers];
|
||||
newTransformers[editingTransformerIndex].options = newParameters;
|
||||
setConfig({ ...config, transformers: newTransformers });
|
||||
} else if (newTransformer) {
|
||||
setNewTransformer({ ...newTransformer, options: newOptions });
|
||||
setNewTransformer({ ...newTransformer, options: newParameters });
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -164,7 +179,7 @@ export function Transformers() {
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
if (editingTransformerIndex !== null) {
|
||||
handleTransformerChange(editingTransformerIndex, "options", e.target.value, key);
|
||||
handleTransformerChange(editingTransformerIndex, "parameters", e.target.value, key);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
@@ -174,14 +189,15 @@ export function Transformers() {
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (editingTransformerIndex !== null) {
|
||||
const newOptions = { ...editingTransformer.options };
|
||||
delete newOptions[key];
|
||||
if (editingTransformerIndex < config.transformers.length) {
|
||||
const newTransformers = [...config.transformers];
|
||||
newTransformers[editingTransformerIndex].options = newOptions;
|
||||
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: newOptions });
|
||||
setNewTransformer({ ...newTransformer, options: newParameters });
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
262
ui/src/components/ui/color-picker.tsx
Normal file
262
ui/src/components/ui/color-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { Config, Provider, Transformer } from '@/components/ConfigProvider';
|
||||
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 = 'http://127.0.0.1:3456/api', apiKey: string = '') {
|
||||
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
|
||||
@@ -26,14 +29,25 @@ class ApiClient {
|
||||
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> = {
|
||||
'X-API-Key': this.apiKey,
|
||||
'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;
|
||||
}
|
||||
@@ -180,6 +194,16 @@ class ApiClient {
|
||||
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
|
||||
|
||||
@@ -12,7 +12,16 @@
|
||||
"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"
|
||||
"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",
|
||||
@@ -27,10 +36,13 @@
|
||||
"toplevel": {
|
||||
"title": "General Settings",
|
||||
"log": "Enable Logging",
|
||||
"log_level": "Log Level",
|
||||
"claude_path": "Claude Path",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"apikey": "API Key"
|
||||
"apikey": "API Key",
|
||||
"timeout": "API Timeout (ms)",
|
||||
"proxy_url": "Proxy URL"
|
||||
},
|
||||
"transformers": {
|
||||
"title": "Custom Transformers",
|
||||
@@ -46,7 +58,7 @@
|
||||
"providers": {
|
||||
"title": "Providers",
|
||||
"name": "Name",
|
||||
"api_base_url": "API Base URL",
|
||||
"api_base_url": "API Full URL",
|
||||
"api_key": "API Key",
|
||||
"models": "Models",
|
||||
"models_placeholder": "Enter model name and press Enter to add",
|
||||
@@ -75,7 +87,14 @@
|
||||
"add_parameter": "Add Parameter",
|
||||
"parameter_name": "Parameter Name",
|
||||
"parameter_value": "Parameter Value",
|
||||
"selected_transformers": "Selected Transformers"
|
||||
"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",
|
||||
@@ -83,6 +102,7 @@
|
||||
"background": "Background",
|
||||
"think": "Think",
|
||||
"longContext": "Long Context",
|
||||
"longContextThreshold": "Context Threshold",
|
||||
"webSearch": "Web Search",
|
||||
"selectModel": "Select a model...",
|
||||
"searchModel": "Search model...",
|
||||
@@ -95,5 +115,55 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,16 @@
|
||||
"config_saved_success": "配置保存成功",
|
||||
"config_saved_failed": "配置保存失败",
|
||||
"config_saved_restart_success": "配置保存并服务重启成功",
|
||||
"config_saved_restart_failed": "配置保存并服务重启失败"
|
||||
"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": "登录到您的账户",
|
||||
@@ -27,10 +36,13 @@
|
||||
"toplevel": {
|
||||
"title": "通用设置",
|
||||
"log": "启用日志",
|
||||
"log_level": "日志级别",
|
||||
"claude_path": "Claude 路径",
|
||||
"host": "主机",
|
||||
"port": "端口",
|
||||
"apikey": "API 密钥"
|
||||
"apikey": "API 密钥",
|
||||
"timeout": "API 超时时间 (毫秒)",
|
||||
"proxy_url": "代理地址"
|
||||
},
|
||||
"transformers": {
|
||||
"title": "自定义转换器",
|
||||
@@ -46,7 +58,7 @@
|
||||
"providers": {
|
||||
"title": "供应商",
|
||||
"name": "名称",
|
||||
"api_base_url": "API 基础地址",
|
||||
"api_base_url": "API 完整地址",
|
||||
"api_key": "API 密钥",
|
||||
"models": "模型",
|
||||
"models_placeholder": "输入模型名称并按回车键添加",
|
||||
@@ -75,7 +87,14 @@
|
||||
"add_parameter": "添加参数",
|
||||
"parameter_name": "参数名称",
|
||||
"parameter_value": "参数值",
|
||||
"selected_transformers": "已选转换器"
|
||||
"selected_transformers": "已选转换器",
|
||||
"import_from_template": "从模板导入",
|
||||
"no_templates_found": "未找到模板",
|
||||
"select_template": "选择一个模板...",
|
||||
"api_key_required": "API 密钥为必填项",
|
||||
"name_required": "名称为必填项",
|
||||
"name_duplicate": "已存在同名供应商"
|
||||
|
||||
},
|
||||
"router": {
|
||||
"title": "路由",
|
||||
@@ -83,6 +102,7 @@
|
||||
"background": "后台",
|
||||
"think": "思考",
|
||||
"longContext": "长上下文",
|
||||
"longContextThreshold": "上下文阈值",
|
||||
"webSearch": "网络搜索",
|
||||
"selectModel": "选择一个模型...",
|
||||
"searchModel": "搜索模型...",
|
||||
@@ -95,5 +115,55 @@
|
||||
"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": "模板下载失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { createMemoryRouter, Navigate } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { Login } from '@/components/Login';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
// For this application, we allow access without an API key
|
||||
// The App component will handle loading and error states
|
||||
return children;
|
||||
};
|
||||
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
// Always show login page
|
||||
// The login page will handle empty API keys appropriately
|
||||
return children;
|
||||
};
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import PublicRoute from '@/components/PublicRoute';
|
||||
|
||||
export const router = createMemoryRouter([
|
||||
{
|
||||
|
||||
65
ui/src/types.ts
Normal file
65
ui/src/types.ts
Normal 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
146
ui/src/utils/statusline.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/jsoneditor.tsx","./src/components/login.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/utils.ts"],"version":"5.8.3"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user