Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f72c67d5c1 | ||
|
|
fe06b57032 | ||
|
|
1b3a8f8803 | ||
|
|
cec8421dd9 | ||
|
|
1a7e90df39 | ||
|
|
e5741ae470 | ||
|
|
0152af5db9 | ||
|
|
e6b3e2a194 | ||
|
|
f7058dcdb5 | ||
|
|
e670302e9e | ||
|
|
5761e165fd | ||
|
|
8c4fec4f5f | ||
|
|
5d53571fe6 | ||
|
|
35fc4505b2 | ||
|
|
c7303775ad | ||
|
|
f7981b16cd | ||
|
|
b54687c4d5 | ||
|
|
0be4c3753f | ||
|
|
668e855a2d | ||
|
|
41108cea1d | ||
|
|
19522f496b | ||
|
|
3b9e58a823 | ||
|
|
615fe7629e | ||
|
|
656a5f9a97 | ||
|
|
d2a0815cb7 | ||
|
|
7cc41d83cf | ||
|
|
9a5ea191f8 | ||
|
|
6ab608943e | ||
|
|
50c8f6994f | ||
|
|
915495553a | ||
|
|
5ac4e8955d | ||
|
|
6b7d0926c4 | ||
|
|
01cd5d03a3 | ||
|
|
0c14a5c053 | ||
|
|
b72b05eb5c | ||
|
|
21ab7c61ce | ||
|
|
9f82aa2797 | ||
|
|
ac0263b226 | ||
|
|
6a4c1f7591 | ||
|
|
95b2dadd40 | ||
|
|
d6b11e1b60 | ||
|
|
d2969e4332 | ||
|
|
19d0f3b8f5 | ||
|
|
e078127ac6 | ||
|
|
0e509528c2 | ||
|
|
a265cbdce6 | ||
|
|
b8f52ba538 | ||
|
|
a62a025368 | ||
|
|
9d332aa036 | ||
|
|
5d00f519cd | ||
|
|
4fca983e4f | ||
|
|
bc08c4ab48 | ||
|
|
bdf608fffc | ||
|
|
d9b7667c93 | ||
|
|
c3ab30b0b9 | ||
|
|
cce1625534 | ||
|
|
5c1a193f4d | ||
|
|
3ad140d2f5 | ||
|
|
075ec76ec1 | ||
|
|
709b49b0e8 | ||
|
|
b856e1e11b | ||
|
|
6510d3aac9 | ||
|
|
1708c59434 | ||
|
|
9cd5587f52 | ||
|
|
4334f40926 | ||
|
|
38bc747261 | ||
|
|
adfae3263a | ||
|
|
0794151eb5 | ||
|
|
3e1963564a | ||
|
|
023c045821 | ||
|
|
f9b745b621 | ||
|
|
a958f18305 | ||
|
|
89844bcb62 | ||
|
|
37cb0c776f | ||
|
|
d9e8df5c04 | ||
|
|
91e9d43abd | ||
|
|
216ee939fb | ||
|
|
47051bf11b | ||
|
|
355c83a8c1 | ||
|
|
552621f707 | ||
|
|
75ab74957d | ||
|
|
d684319261 | ||
|
|
7bb816ad03 | ||
|
|
38c6cf0c9a | ||
|
|
e51d70caf2 | ||
|
|
5fd78a103b | ||
|
|
a3b2353bca | ||
|
|
99afe0e21a | ||
|
|
7751683365 | ||
|
|
c2edcd145e | ||
|
|
996a05d1d6 | ||
|
|
cd43a74ab5 | ||
|
|
6523255d83 | ||
|
|
a3d1f44908 | ||
|
|
5a11d2f9af | ||
|
|
d929e7cfef | ||
|
|
03c9b0fa58 | ||
|
|
cd65b3605d | ||
|
|
754125e3a3 | ||
|
|
3cb086fc57 | ||
|
|
3a12fdffb1 | ||
|
|
7978f1abae | ||
|
|
aea48239f9 | ||
|
|
7acb443aad | ||
|
|
18cfe6e2f0 | ||
|
|
1d7374067e | ||
|
|
e560db85f4 | ||
|
|
74fa03d3a5 | ||
|
|
c02e314d76 | ||
|
|
27fa655425 | ||
|
|
d013a8a01a | ||
|
|
2acfce5b63 | ||
|
|
2c44ea73c7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,4 +2,6 @@ node_modules
|
||||
.env
|
||||
log.txt
|
||||
.idea
|
||||
dist
|
||||
dist
|
||||
.DS_Store
|
||||
.vscode
|
||||
@@ -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
|
||||
|
||||
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN npm install -g @musistudio/claude-code-router
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
CMD ["ccr", "start"]
|
||||
157
README.md
157
README.md
@@ -1,9 +1,16 @@
|
||||
# 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.
|
||||
|
||||
> Now you can use models such as `GLM-4.5`, `Kimi-K2`, `Qwen3-Coder-480B-A35B`, and `DeepSeek v3.1` for free through the [iFlow Platform](https://platform.iflow.cn/docs/api-mode).
|
||||
> You can use the `ccr ui` command to directly import the `iflow` template in the UI. It’s worth noting that iFlow limits each user to a concurrency of 1, which means you’ll need to route background requests to other models.
|
||||
> If you’d like a better experience, you can try [iFlow CLI](https://cli.iflow.cn).
|
||||
|
||||

|
||||
|
||||
## ✨ Features
|
||||
@@ -38,12 +45,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 `"debug"`.
|
||||
- **Logging Systems**: The Claude Code Router uses two separate logging systems:
|
||||
- **Server-level logs**: HTTP requests, API calls, and server events are logged using pino in the `~/.claude-code-router/logs/` directory with filenames like `ccr-*.log`
|
||||
- **Application-level logs**: Routing decisions and business logic events are logged in `~/.claude-code-router/claude-code-router.log`
|
||||
- **`APIKEY`** (optional): You can set a secret key to authenticate requests. When set, clients must provide this key in the `Authorization` header (e.g., `Bearer your-secret-key`) or the `x-api-key` header. Example: `"APIKEY": "your-secret-key"`.
|
||||
- **`HOST`** (optional): You can set the host address for the server. If `APIKEY` is not set, the host will be forced to `127.0.0.1` for security reasons to prevent unauthorized access. Example: `"HOST": "0.0.0.0"`.
|
||||
- **`NON_INTERACTIVE_MODE`** (optional): When set to `true`, enables compatibility with non-interactive environments like GitHub Actions, Docker containers, or other CI/CD systems. This sets appropriate environment variables (`CI=true`, `FORCE_COLOR=0`, etc.) and configures stdin handling to prevent the process from hanging in automated environments. Example: `"NON_INTERACTIVE_MODE": true`.
|
||||
|
||||
- **`Providers`**: Used to configure different model providers.
|
||||
- **`Router`**: Used to set up routing rules. `default` specifies the default model, which will be used for all requests if no other route is configured.
|
||||
- **`API_TIMEOUT_MS`**: Specifies the timeout for API calls in milliseconds.
|
||||
|
||||
#### Environment Variable Interpolation
|
||||
|
||||
Claude Code Router supports environment variable interpolation for secure API key management. You can reference environment variables in your `config.json` using either `$VAR_NAME` or `${VAR_NAME}` syntax:
|
||||
|
||||
```json
|
||||
{
|
||||
"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 +86,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 +175,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 +212,18 @@ ccr code
|
||||
> ccr restart
|
||||
> ```
|
||||
|
||||
### 4. UI Mode
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
#### Providers
|
||||
|
||||
The `Providers` array is where you define the different model providers you want to use. Each provider object requires:
|
||||
@@ -231,13 +289,39 @@ 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.
|
||||
- `chutes-glm` Unofficial support for GLM 4.5 model via Chutes [chutes-glm-transformer.js](https://gist.github.com/vitobotta/2be3f33722e05e8d4f9d2b0138b8c863).
|
||||
- `qwen-cli` (experimental): Unofficial support for qwen3-coder-plus model via Qwen CLI [qwen-cli.js](https://gist.github.com/musistudio/f5a67841ced39912fd99e42200d5ca8b).
|
||||
- `rovo-cli` (experimental): Unofficial support for gpt-5 via Atlassian Rovo Dev CLI [rovo-cli.js](https://gist.github.com/SaseQ/c2a20a38b11276537ec5332d1f7a5e53).
|
||||
|
||||
**Custom Transformers:**
|
||||
|
||||
@@ -247,7 +331,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"
|
||||
}
|
||||
@@ -266,8 +350,9 @@ The `Router` object defines which model to use for different scenarios:
|
||||
- `longContext`: A model for handling long contexts (e.g., > 60K tokens).
|
||||
- `longContextThreshold` (optional): The token count threshold for triggering the long context model. Defaults to 60000 if not specified.
|
||||
- `webSearch`: Used for handling web search tasks and this requires the model itself to support the feature. If you're using openrouter, you need to add the `:online` suffix after the model name.
|
||||
- `image` (beta): Used for handling image-related tasks (supported by CCR’s built-in agent). If the model does not support tool calling, you need to set the `config.forceUseImageAgent` property to `true`.
|
||||
|
||||
You can also switch models dynamically in Claude Code with the `/model` command:
|
||||
- You can also switch models dynamically in Claude Code with the `/model` command:
|
||||
`/model provider_name,model_name`
|
||||
Example: `/model openrouter,anthropic/claude-3.5-sonnet`
|
||||
|
||||
@@ -279,7 +364,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 +373,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 +395,24 @@ 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...
|
||||
```
|
||||
|
||||
## Status Line (Beta)
|
||||
To better monitor the status of claude-code-router at runtime, version v1.0.40 includes a built-in statusline tool, which you can enable in the UI.
|
||||

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

|
||||
|
||||
## 🤖 GitHub Actions
|
||||
|
||||
Integrate Claude Code Router into your CI/CD pipeline. After setting up [Claude Code Actions](https://docs.anthropic.com/en/docs/claude-code/github-actions), modify your `.github/workflows/claude.yaml` to use the router:
|
||||
@@ -346,6 +449,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 +471,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 +486,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 +499,9 @@ 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/)
|
||||
- [BurnCloud](https://ai.burncloud.com)
|
||||
- @Simon Leischnig
|
||||
- [@duanshuaimin](https://github.com/duanshuaimin)
|
||||
- [@vrgitadmin](https://github.com/vrgitadmin)
|
||||
@@ -428,5 +539,39 @@ 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)
|
||||
- [@biguncle](https://github.com/biguncle)
|
||||
- @二吉吉
|
||||
- @a\*g
|
||||
- @\*林
|
||||
- @\*咸
|
||||
- @\*明
|
||||
- @S\*y
|
||||
- @f\*o
|
||||
- @\*智
|
||||
- @F\*t
|
||||
- @r\*c
|
||||
- [@qierkang](http://github.com/qierkang)
|
||||
- @\*军
|
||||
- [@snrise-z](http://github.com/snrise-z)
|
||||
- @\*王
|
||||
- [@greatheart1000](http://github.com/greatheart1000)
|
||||
- @\*王
|
||||
- @zcutlip
|
||||
- [@Peng-YM](http://github.com/Peng-YM)
|
||||
- @\*更
|
||||
- @\*.
|
||||
- @F\*t
|
||||
|
||||
|
||||
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)
|
||||
|
||||
128
README_zh.md
128
README_zh.md
@@ -1,7 +1,13 @@
|
||||
# Claude Code Router
|
||||
|
||||
我正在为该项目寻求资金支持,以更好地维持其发展。如果您有任何想法,请随时与我联系: [m@musiiot.top](mailto:m@musiiot.top)
|
||||
|
||||
> 一款强大的工具,可将 Claude Code 请求路由到不同的模型,并自定义任何请求。
|
||||
|
||||
> 现在你可以通过[心流平台](https://platform.iflow.cn/docs/api-mode)免费使用`GLM-4.5`、`Kimi-K2`、`Qwen3-Coder-480B-A35B`、`DeepSeek v3.1`等模型。
|
||||
> 你可以使用`ccr ui`命令在UI中直接导入`iflow`模板,值得注意的是心流限制每位用户的并发数为1,意味着你需要将`background`路由到其他模型。
|
||||
> 如果你想获得更好的体验,可以尝试[iFlow CLI](https://cli.iflow.cn)。
|
||||
|
||||

|
||||
|
||||
## ✨ 功能
|
||||
@@ -35,11 +41,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"`。默认值为 `"debug"`。
|
||||
- **日志系统**: Claude Code Router 使用两个独立的日志系统:
|
||||
- **服务器级别日志**: HTTP 请求、API 调用和服务器事件使用 pino 记录在 `~/.claude-code-router/logs/` 目录中,文件名类似于 `ccr-*.log`
|
||||
- **应用程序级别日志**: 路由决策和业务逻辑事件记录在 `~/.claude-code-router/claude-code-router.log` 文件中
|
||||
- **`APIKEY`** (可选): 您可以设置一个密钥来进行身份验证。设置后,客户端请求必须在 `Authorization` 请求头 (例如, `Bearer your-secret-key`) 或 `x-api-key` 请求头中提供此密钥。例如:`"APIKEY": "your-secret-key"`。
|
||||
- **`HOST`** (可选): 您可以设置服务的主机地址。如果未设置 `APIKEY`,出于安全考虑,主机地址将强制设置为 `127.0.0.1`,以防止未经授权的访问。例如:`"HOST": "0.0.0.0"`。
|
||||
- **`NON_INTERACTIVE_MODE`** (可选): 当设置为 `true` 时,启用与非交互式环境(如 GitHub Actions、Docker 容器或其他 CI/CD 系统)的兼容性。这会设置适当的环境变量(`CI=true`、`FORCE_COLOR=0` 等)并配置 stdin 处理,以防止进程在自动化环境中挂起。例如:`"NON_INTERACTIVE_MODE": true`。
|
||||
- **`Providers`**: 用于配置不同的模型提供商。
|
||||
- **`Router`**: 用于设置路由规则。`default` 指定默认模型,如果未配置其他路由,则该模型将用于所有请求。
|
||||
- **`API_TIMEOUT_MS`**: API 请求超时时间,单位为毫秒。
|
||||
|
||||
这是一个综合示例:
|
||||
|
||||
@@ -48,6 +60,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 +149,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 +186,18 @@ ccr code
|
||||
> ccr restart
|
||||
> ```
|
||||
|
||||
### 4. UI 模式
|
||||
|
||||
为了获得更直观的体验,您可以使用 UI 模式来管理您的配置:
|
||||
|
||||
```shell
|
||||
ccr ui
|
||||
```
|
||||
|
||||
这将打开一个基于 Web 的界面,您可以在其中轻松查看和编辑您的 `config.json` 文件。
|
||||
|
||||

|
||||
|
||||
#### Providers
|
||||
|
||||
`Providers` 数组是您定义要使用的不同模型提供商的地方。每个提供商对象都需要:
|
||||
@@ -226,13 +262,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 +303,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"
|
||||
}
|
||||
@@ -261,6 +322,7 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商
|
||||
- `longContext`: 用于处理长上下文(例如,> 60K 令牌)的模型。
|
||||
- `longContextThreshold` (可选): 触发长上下文模型的令牌数阈值。如果未指定,默认为 60000。
|
||||
- `webSearch`: 用于处理网络搜索任务,需要模型本身支持。如果使用`openrouter`需要在模型后面加上`:online`后缀。
|
||||
- `image`(测试版): 用于处理图片类任务(采用CCR内置的agent支持),如果该模型不支持工具调用,需要将`config.forceUseImageAgent`属性设置为`true`。
|
||||
|
||||
您还可以使用 `/model` 命令在 Claude Code 中动态切换模型:
|
||||
`/model provider_name,model_name`
|
||||
@@ -274,7 +336,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 +345,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 +367,23 @@ 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>
|
||||
请帮我分析这段代码是否存在潜在的优化空间...
|
||||
```
|
||||
|
||||
## Status Line (Beta)
|
||||
为了在运行时更好的查看claude-code-router的状态,claude-code-router在v1.0.40内置了一个statusline工具,你可以在UI中启用它,
|
||||

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

|
||||
|
||||
## 🤖 GitHub Actions
|
||||
|
||||
@@ -342,6 +421,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 +456,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 +469,8 @@ jobs:
|
||||
|
||||
非常感谢所有赞助商的慷慨支持!
|
||||
|
||||
- [AIHubmix](https://aihubmix.com/)
|
||||
- [BurnCloud](https://ai.burncloud.com)
|
||||
- @Simon Leischnig
|
||||
- [@duanshuaimin](https://github.com/duanshuaimin)
|
||||
- [@vrgitadmin](https://github.com/vrgitadmin)
|
||||
@@ -420,9 +504,43 @@ jobs:
|
||||
- @*琢
|
||||
- @*成
|
||||
- @Z*o
|
||||
- @\*琨
|
||||
- [@congzhangzh](https://github.com/congzhangzh)
|
||||
- @*_
|
||||
- @Z\*m
|
||||
- @*鑫
|
||||
- @c\*y
|
||||
- @\*昕
|
||||
- [@witsice](https://github.com/witsice)
|
||||
- @b\*g
|
||||
- @\*亿
|
||||
- @\*辉
|
||||
- @JACK
|
||||
- @\*光
|
||||
- @W\*l
|
||||
- [@kesku](https://github.com/kesku)
|
||||
- [@biguncle](https://github.com/biguncle)
|
||||
- @二吉吉
|
||||
- @a\*g
|
||||
- @\*林
|
||||
- @\*咸
|
||||
- @\*明
|
||||
- @S\*y
|
||||
- @f\*o
|
||||
- @\*智
|
||||
- @F\*t
|
||||
- @r\*c
|
||||
- [@qierkang](http://github.com/qierkang)
|
||||
- @\*军
|
||||
- [@snrise-z](http://github.com/snrise-z)
|
||||
- @\*王
|
||||
- [@greatheart1000](http://github.com/greatheart1000)
|
||||
- @\*王
|
||||
- @zcutlip
|
||||
- [@Peng-YM](http://github.com/Peng-YM)
|
||||
- @\*更
|
||||
- @\*.
|
||||
- @F\*t
|
||||
|
||||
(如果您的名字被屏蔽,请通过我的主页电子邮件与我联系,以便使用您的 GitHub 用户名进行更新。)
|
||||
|
||||
|
||||
BIN
blog/images/statusline-config.png
Normal file
BIN
blog/images/statusline-config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
blog/images/statusline.png
Normal file
BIN
blog/images/statusline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
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: 237 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
|
||||
|
||||
12
dockerfile
12
dockerfile
@@ -1,12 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm i
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
CMD ["node", "index.mjs"]
|
||||
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.47",
|
||||
"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.32",
|
||||
"dotenv": "^16.4.7",
|
||||
"json5": "^2.2.3",
|
||||
"openurl": "^1.1.1",
|
||||
"rotating-file-stream": "^3.2.7",
|
||||
"tiktoken": "^1.0.21",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
|
||||
446
pnpm-lock.yaml
generated
446
pnpm-lock.yaml
generated
@@ -12,42 +12,42 @@ 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.32
|
||||
version: 1.0.32(ws@8.18.3)
|
||||
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
|
||||
rotating-file-stream:
|
||||
specifier: ^3.2.7
|
||||
version: 3.2.7
|
||||
tiktoken:
|
||||
specifier: ^1.0.21
|
||||
version: 1.0.21
|
||||
version: 1.0.22
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.0.15
|
||||
version: 24.0.15
|
||||
version: 24.3.0
|
||||
esbuild:
|
||||
specifier: ^0.25.1
|
||||
version: 0.25.5
|
||||
version: 0.25.9
|
||||
fastify:
|
||||
specifier: ^5.4.0
|
||||
version: 5.4.0
|
||||
version: 5.5.0
|
||||
shx:
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0
|
||||
typescript:
|
||||
specifier: ^5.8.2
|
||||
version: 5.8.3
|
||||
version: 5.9.2
|
||||
|
||||
packages:
|
||||
|
||||
@@ -55,152 +55,158 @@ packages:
|
||||
resolution: {integrity: sha512-xyoCtHJnt/qg5GG6IgK+UJEndz8h8ljzt/caKXmq3LfBF81nC/BW6E4x2rOWCZcvsLyVW+e8U5mtIr6UCE/kJw==}
|
||||
hasBin: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.5':
|
||||
resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==}
|
||||
'@esbuild/aix-ppc64@0.25.9':
|
||||
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==}
|
||||
'@esbuild/android-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.5':
|
||||
resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==}
|
||||
'@esbuild/android-arm@0.25.9':
|
||||
resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.5':
|
||||
resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==}
|
||||
'@esbuild/android-x64@0.25.9':
|
||||
resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==}
|
||||
'@esbuild/darwin-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.5':
|
||||
resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==}
|
||||
'@esbuild/darwin-x64@0.25.9':
|
||||
resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==}
|
||||
'@esbuild/freebsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.5':
|
||||
resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==}
|
||||
'@esbuild/freebsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==}
|
||||
'@esbuild/linux-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.5':
|
||||
resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==}
|
||||
'@esbuild/linux-arm@0.25.9':
|
||||
resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.5':
|
||||
resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==}
|
||||
'@esbuild/linux-ia32@0.25.9':
|
||||
resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.5':
|
||||
resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==}
|
||||
'@esbuild/linux-loong64@0.25.9':
|
||||
resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.5':
|
||||
resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==}
|
||||
'@esbuild/linux-mips64el@0.25.9':
|
||||
resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.5':
|
||||
resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==}
|
||||
'@esbuild/linux-ppc64@0.25.9':
|
||||
resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.5':
|
||||
resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==}
|
||||
'@esbuild/linux-riscv64@0.25.9':
|
||||
resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.5':
|
||||
resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==}
|
||||
'@esbuild/linux-s390x@0.25.9':
|
||||
resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.5':
|
||||
resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==}
|
||||
'@esbuild/linux-x64@0.25.9':
|
||||
resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==}
|
||||
'@esbuild/netbsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.5':
|
||||
resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==}
|
||||
'@esbuild/netbsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==}
|
||||
'@esbuild/openbsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.5':
|
||||
resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==}
|
||||
'@esbuild/openbsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.5':
|
||||
resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==}
|
||||
'@esbuild/openharmony-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.9':
|
||||
resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.5':
|
||||
resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==}
|
||||
'@esbuild/win32-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.5':
|
||||
resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==}
|
||||
'@esbuild/win32-ia32@0.25.9':
|
||||
resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.5':
|
||||
resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==}
|
||||
'@esbuild/win32-x64@0.25.9':
|
||||
resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -211,8 +217,8 @@ packages:
|
||||
'@fastify/ajv-compiler@4.0.2':
|
||||
resolution: {integrity: sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ==}
|
||||
|
||||
'@fastify/cors@11.0.1':
|
||||
resolution: {integrity: sha512-dmZaE7M1f4SM8ZZuk5RhSsDJ+ezTgI7v3HHRj8Ow9CneczsPLZV6+2j2uwdaSLn8zhTv6QV0F4ZRcqdalGx1pQ==}
|
||||
'@fastify/cors@11.1.0':
|
||||
resolution: {integrity: sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA==}
|
||||
|
||||
'@fastify/error@4.2.0':
|
||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||
@@ -235,11 +241,11 @@ packages:
|
||||
'@fastify/static@8.2.0':
|
||||
resolution: {integrity: sha512-PejC/DtT7p1yo3p+W7LiUtLMsV8fEvxAK15sozHy9t8kwo5r0uLYmhV/inURmGz1SkHZFz/8CNtHLPyhKcx4SQ==}
|
||||
|
||||
'@google/genai@1.8.0':
|
||||
resolution: {integrity: sha512-n3KiMFesQCy2R9iSdBIuJ0JWYQ1HZBJJkmt4PPZMGZKvlgHhBAGw1kUMyX+vsAIzprN3lK45DI755lm70wPOOg==}
|
||||
'@google/genai@1.16.0':
|
||||
resolution: {integrity: sha512-hdTYu39QgDFxv+FB6BK2zi4UIJGWhx2iPc0pHQ0C5Q/RCi+m+4gsryIzTGO+riqWcUA8/WGYp6hpqckdOBNysw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@modelcontextprotocol/sdk': ^1.11.0
|
||||
'@modelcontextprotocol/sdk': ^1.11.4
|
||||
peerDependenciesMeta:
|
||||
'@modelcontextprotocol/sdk':
|
||||
optional: true
|
||||
@@ -260,8 +266,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.32':
|
||||
resolution: {integrity: sha512-i+dB7x4qxZ8oOM3TLijjJ2rwIOje6/ovyHdU8A5h6d2wcTKOd0JUpNixUgBO3dPJp2dYVXz0SSfhw7gzmt1Kkg==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
@@ -275,14 +281,14 @@ packages:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@types/node@24.0.15':
|
||||
resolution: {integrity: sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA==}
|
||||
'@types/node@24.3.0':
|
||||
resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
agent-base@7.1.3:
|
||||
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
@@ -322,8 +328,8 @@ packages:
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
bignumber.js@9.3.0:
|
||||
resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==}
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -332,10 +338,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 +374,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'}
|
||||
@@ -411,8 +401,8 @@ packages:
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
esbuild@0.25.5:
|
||||
resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
|
||||
esbuild@0.25.9:
|
||||
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
@@ -452,8 +442,8 @@ packages:
|
||||
fastify-plugin@5.0.1:
|
||||
resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
|
||||
|
||||
fastify@5.4.0:
|
||||
resolution: {integrity: sha512-I4dVlUe+WNQAhKSyv15w+dwUh2EPiEl4X2lGYMmNSgF83WzTMAPKGdWEv5tPsCQOb+SOZwz8Vlta2vF+OeDgRw==}
|
||||
fastify@5.5.0:
|
||||
resolution: {integrity: sha512-ZWSWlzj3K/DcULCnCjEiC2zn2FBPdlZsSA/pnPa/dbUfLvxkD/Nqmb0XXMXLrWkeM4uQPUvjdJpwtXmTfriXqw==}
|
||||
|
||||
fastq@1.19.1:
|
||||
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
||||
@@ -510,8 +500,8 @@ packages:
|
||||
engines: {node: 20 || >=22}
|
||||
hasBin: true
|
||||
|
||||
google-auth-library@10.2.0:
|
||||
resolution: {integrity: sha512-gy/0hRx8+Ye0HlUm3GrfpR4lbmJQ6bJ7F44DmN7GtMxxzWSojLzx0Bhv/hj7Wlj7a2On0FcT8jrz8Y1c1nxCyg==}
|
||||
google-auth-library@10.3.0:
|
||||
resolution: {integrity: sha512-ylSE3RlCRZfZB56PFJSfUCuiuPq83Fx8hqu1KPWGK8FVdSaxlp/qkeMMX/DT/18xkwXIHvXEXkZsljRwfrdEfQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
google-auth-library@9.15.1:
|
||||
@@ -561,11 +551,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 +563,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 +575,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 +596,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,12 +672,8 @@ 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==}
|
||||
openai@5.16.0:
|
||||
resolution: {integrity: sha512-hoEH8ZNvg1HXjU9mp88L/ZH8O082Z8r6FHCXGiWAzVRrEv443aI57qhch4snu07yQydj+AUAWLenAiBXhu89Tw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
@@ -743,8 +719,8 @@ packages:
|
||||
pino-std-serializers@7.0.0:
|
||||
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
|
||||
|
||||
pino@9.7.0:
|
||||
resolution: {integrity: sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==}
|
||||
pino@9.9.0:
|
||||
resolution: {integrity: sha512-zxsRIQG9HzG+jEljmvmZupOMDUQ0Jpj0yAgE28jQvvrdYTlEaiGwelJpdndMl/MBuRr70heIj83QyqJUWaU8mQ==}
|
||||
hasBin: true
|
||||
|
||||
process-warning@4.0.1:
|
||||
@@ -790,9 +766,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.7:
|
||||
resolution: {integrity: sha512-SVquhBEVvRFY+nWLUc791Y0MIlyZrEClRZwZFLLRgJKldHyV1z4e2e/dp9LPqCS3AM//uq/c3PnOFgjqnm5P+A==}
|
||||
engines: {node: '>=14.0'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
@@ -896,8 +872,8 @@ packages:
|
||||
thread-stream@3.1.0:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
|
||||
tiktoken@1.0.21:
|
||||
resolution: {integrity: sha512-/kqtlepLMptX0OgbYD9aMYbM7EFrMZCL7EoHM8Psmg2FuhXoo/bH64KqOiZGGwa6oS9TPdSEDKBnV2LuB8+5vQ==}
|
||||
tiktoken@1.0.22:
|
||||
resolution: {integrity: sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
@@ -914,16 +890,16 @@ packages:
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
typescript@5.8.3:
|
||||
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
|
||||
typescript@5.9.2:
|
||||
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.8.0:
|
||||
resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==}
|
||||
undici-types@7.10.0:
|
||||
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
|
||||
|
||||
undici@7.11.0:
|
||||
resolution: {integrity: sha512-heTSIac3iLhsmZhUCjyS3JQEkZELateufzZuBaVM5RHXdSBMb1LPMQf5x+FH7qjsZYDP0ttAc3nnVpUB+wYbOg==}
|
||||
undici@7.15.0:
|
||||
resolution: {integrity: sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
uuid@11.1.0:
|
||||
@@ -976,95 +952,86 @@ 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:
|
||||
zod: ^3.24.1
|
||||
|
||||
zod@3.25.67:
|
||||
resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@anthropic-ai/sdk@0.54.0': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.5':
|
||||
'@esbuild/aix-ppc64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.5':
|
||||
'@esbuild/android-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.5':
|
||||
'@esbuild/android-arm@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.5':
|
||||
'@esbuild/android-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.5':
|
||||
'@esbuild/darwin-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.5':
|
||||
'@esbuild/darwin-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.5':
|
||||
'@esbuild/freebsd-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.5':
|
||||
'@esbuild/freebsd-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.5':
|
||||
'@esbuild/linux-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.5':
|
||||
'@esbuild/linux-arm@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.5':
|
||||
'@esbuild/linux-ia32@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.5':
|
||||
'@esbuild/linux-loong64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.5':
|
||||
'@esbuild/linux-mips64el@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.5':
|
||||
'@esbuild/linux-ppc64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.5':
|
||||
'@esbuild/linux-riscv64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.5':
|
||||
'@esbuild/linux-s390x@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.5':
|
||||
'@esbuild/linux-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.5':
|
||||
'@esbuild/netbsd-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.5':
|
||||
'@esbuild/netbsd-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.5':
|
||||
'@esbuild/openbsd-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.5':
|
||||
'@esbuild/openbsd-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.5':
|
||||
'@esbuild/openharmony-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.5':
|
||||
'@esbuild/sunos-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.5':
|
||||
'@esbuild/win32-arm64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.5':
|
||||
'@esbuild/win32-ia32@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.9':
|
||||
optional: true
|
||||
|
||||
'@fastify/accept-negotiator@2.0.1': {}
|
||||
@@ -1075,7 +1042,7 @@ snapshots:
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
fast-uri: 3.0.6
|
||||
|
||||
'@fastify/cors@11.0.1':
|
||||
'@fastify/cors@11.1.0':
|
||||
dependencies:
|
||||
fastify-plugin: 5.0.1
|
||||
toad-cache: 3.7.0
|
||||
@@ -1114,12 +1081,10 @@ snapshots:
|
||||
fastq: 1.19.1
|
||||
glob: 11.0.3
|
||||
|
||||
'@google/genai@1.8.0':
|
||||
'@google/genai@1.16.0':
|
||||
dependencies:
|
||||
google-auth-library: 9.15.1
|
||||
ws: 8.18.3
|
||||
zod: 3.25.67
|
||||
zod-to-json-schema: 3.24.6(zod@3.25.67)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- encoding
|
||||
@@ -1143,17 +1108,18 @@ snapshots:
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@musistudio/llms@file:../llms(ws@8.18.3)(zod@3.25.67)':
|
||||
'@musistudio/llms@1.0.32(ws@8.18.3)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.54.0
|
||||
'@fastify/cors': 11.0.1
|
||||
'@google/genai': 1.8.0
|
||||
'@fastify/cors': 11.1.0
|
||||
'@google/genai': 1.16.0
|
||||
dotenv: 16.6.1
|
||||
fastify: 5.4.0
|
||||
google-auth-library: 10.2.0
|
||||
fastify: 5.5.0
|
||||
google-auth-library: 10.3.0
|
||||
json5: 2.2.3
|
||||
openai: 5.8.2(ws@8.18.3)(zod@3.25.67)
|
||||
undici: 7.11.0
|
||||
jsonrepair: 3.13.0
|
||||
openai: 5.16.0(ws@8.18.3)
|
||||
undici: 7.15.0
|
||||
uuid: 11.1.0
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
@@ -1176,13 +1142,13 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.19.1
|
||||
|
||||
'@types/node@24.0.15':
|
||||
'@types/node@24.3.0':
|
||||
dependencies:
|
||||
undici-types: 7.8.0
|
||||
undici-types: 7.10.0
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
agent-base@7.1.3: {}
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
@@ -1214,7 +1180,7 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
bignumber.js@9.3.0: {}
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
@@ -1222,10 +1188,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 +1220,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: {}
|
||||
@@ -1287,33 +1240,34 @@ snapshots:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
|
||||
esbuild@0.25.5:
|
||||
esbuild@0.25.9:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.5
|
||||
'@esbuild/android-arm': 0.25.5
|
||||
'@esbuild/android-arm64': 0.25.5
|
||||
'@esbuild/android-x64': 0.25.5
|
||||
'@esbuild/darwin-arm64': 0.25.5
|
||||
'@esbuild/darwin-x64': 0.25.5
|
||||
'@esbuild/freebsd-arm64': 0.25.5
|
||||
'@esbuild/freebsd-x64': 0.25.5
|
||||
'@esbuild/linux-arm': 0.25.5
|
||||
'@esbuild/linux-arm64': 0.25.5
|
||||
'@esbuild/linux-ia32': 0.25.5
|
||||
'@esbuild/linux-loong64': 0.25.5
|
||||
'@esbuild/linux-mips64el': 0.25.5
|
||||
'@esbuild/linux-ppc64': 0.25.5
|
||||
'@esbuild/linux-riscv64': 0.25.5
|
||||
'@esbuild/linux-s390x': 0.25.5
|
||||
'@esbuild/linux-x64': 0.25.5
|
||||
'@esbuild/netbsd-arm64': 0.25.5
|
||||
'@esbuild/netbsd-x64': 0.25.5
|
||||
'@esbuild/openbsd-arm64': 0.25.5
|
||||
'@esbuild/openbsd-x64': 0.25.5
|
||||
'@esbuild/sunos-x64': 0.25.5
|
||||
'@esbuild/win32-arm64': 0.25.5
|
||||
'@esbuild/win32-ia32': 0.25.5
|
||||
'@esbuild/win32-x64': 0.25.5
|
||||
'@esbuild/aix-ppc64': 0.25.9
|
||||
'@esbuild/android-arm': 0.25.9
|
||||
'@esbuild/android-arm64': 0.25.9
|
||||
'@esbuild/android-x64': 0.25.9
|
||||
'@esbuild/darwin-arm64': 0.25.9
|
||||
'@esbuild/darwin-x64': 0.25.9
|
||||
'@esbuild/freebsd-arm64': 0.25.9
|
||||
'@esbuild/freebsd-x64': 0.25.9
|
||||
'@esbuild/linux-arm': 0.25.9
|
||||
'@esbuild/linux-arm64': 0.25.9
|
||||
'@esbuild/linux-ia32': 0.25.9
|
||||
'@esbuild/linux-loong64': 0.25.9
|
||||
'@esbuild/linux-mips64el': 0.25.9
|
||||
'@esbuild/linux-ppc64': 0.25.9
|
||||
'@esbuild/linux-riscv64': 0.25.9
|
||||
'@esbuild/linux-s390x': 0.25.9
|
||||
'@esbuild/linux-x64': 0.25.9
|
||||
'@esbuild/netbsd-arm64': 0.25.9
|
||||
'@esbuild/netbsd-x64': 0.25.9
|
||||
'@esbuild/openbsd-arm64': 0.25.9
|
||||
'@esbuild/openbsd-x64': 0.25.9
|
||||
'@esbuild/openharmony-arm64': 0.25.9
|
||||
'@esbuild/sunos-x64': 0.25.9
|
||||
'@esbuild/win32-arm64': 0.25.9
|
||||
'@esbuild/win32-ia32': 0.25.9
|
||||
'@esbuild/win32-x64': 0.25.9
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
@@ -1360,7 +1314,7 @@ snapshots:
|
||||
|
||||
fastify-plugin@5.0.1: {}
|
||||
|
||||
fastify@5.4.0:
|
||||
fastify@5.5.0:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.2
|
||||
'@fastify/error': 4.2.0
|
||||
@@ -1371,7 +1325,7 @@ snapshots:
|
||||
fast-json-stringify: 6.0.1
|
||||
find-my-way: 9.3.0
|
||||
light-my-request: 6.6.0
|
||||
pino: 9.7.0
|
||||
pino: 9.9.0
|
||||
process-warning: 5.0.0
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 4.0.0
|
||||
@@ -1461,7 +1415,7 @@ snapshots:
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 2.0.0
|
||||
|
||||
google-auth-library@10.2.0:
|
||||
google-auth-library@10.3.0:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
@@ -1518,7 +1472,7 @@ snapshots:
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -1533,8 +1487,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 +1495,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:
|
||||
@@ -1565,7 +1509,7 @@ snapshots:
|
||||
|
||||
json-bigint@1.0.0:
|
||||
dependencies:
|
||||
bignumber.js: 9.3.0
|
||||
bignumber.js: 9.3.1
|
||||
|
||||
json-schema-ref-resolver@2.0.1:
|
||||
dependencies:
|
||||
@@ -1575,6 +1519,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonrepair@3.13.0: {}
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
@@ -1637,17 +1583,9 @@ 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):
|
||||
openai@5.16.0(ws@8.18.3):
|
||||
optionalDependencies:
|
||||
ws: 8.18.3
|
||||
zod: 3.25.67
|
||||
|
||||
openurl@1.1.1: {}
|
||||
|
||||
@@ -1674,7 +1612,7 @@ snapshots:
|
||||
|
||||
pino-std-serializers@7.0.0: {}
|
||||
|
||||
pino@9.7.0:
|
||||
pino@9.9.0:
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
fast-redact: 3.5.0
|
||||
@@ -1721,7 +1659,7 @@ snapshots:
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
run-applescript@7.0.0: {}
|
||||
rotating-file-stream@3.2.7: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
@@ -1809,7 +1747,7 @@ snapshots:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
|
||||
tiktoken@1.0.21: {}
|
||||
tiktoken@1.0.22: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
@@ -1821,11 +1759,11 @@ snapshots:
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
typescript@5.8.3: {}
|
||||
typescript@5.9.2: {}
|
||||
|
||||
undici-types@7.8.0: {}
|
||||
undici-types@7.10.0: {}
|
||||
|
||||
undici@7.11.0: {}
|
||||
undici@7.15.0: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
@@ -1863,13 +1801,3 @@ snapshots:
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
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
|
||||
|
||||
zod@3.25.67: {}
|
||||
|
||||
207
src/agents/image.agent.ts
Normal file
207
src/agents/image.agent.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import {IAgent, ITool} from "./type";
|
||||
import { createHash } from 'crypto';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
interface ImageCacheEntry {
|
||||
source: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
class ImageCache {
|
||||
private cache: LRUCache<string, ImageCacheEntry>;
|
||||
|
||||
constructor(maxSize = 100) {
|
||||
this.cache = new LRUCache({
|
||||
max: maxSize,
|
||||
ttl: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
storeImage(id: string, source: any): void {
|
||||
if (this.hasImage(id)) return;
|
||||
this.cache.set(id, {
|
||||
source,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
getImage(id: string): any {
|
||||
const entry = this.cache.get(id);
|
||||
return entry ? entry.source : null;
|
||||
}
|
||||
|
||||
hasImage(hash: string): boolean {
|
||||
return this.cache.has(hash);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
}
|
||||
|
||||
const imageCache = new ImageCache();
|
||||
|
||||
export class ImageAgent implements IAgent {
|
||||
name = "image";
|
||||
tools: Map<string, ITool>;
|
||||
|
||||
constructor() {
|
||||
this.tools = new Map<string, ITool>();
|
||||
this.appendTools()
|
||||
}
|
||||
|
||||
shouldHandle(req: any, config: any): boolean {
|
||||
if (!config.Router.image || req.body.model === config.Router.image) return false;
|
||||
const lastMessage = req.body.messages[req.body.messages.length - 1]
|
||||
if (!config.forceUseImageAgent && lastMessage.role === 'user' && Array.isArray(lastMessage.content) &&lastMessage.content.find((item: any) => item.type === 'image')) {
|
||||
req.body.model = config.Router.image
|
||||
return false;
|
||||
}
|
||||
return req.body.messages.some((msg: any) => msg.role === 'user' && Array.isArray(msg.content) && msg.content.some((item: any) => item.type === 'image'))
|
||||
}
|
||||
|
||||
appendTools() {
|
||||
this.tools.set('analyzeImage', {
|
||||
name: "analyzeImage",
|
||||
description: "Analyse image or images by ID and extract information such as OCR text, objects, layout, colors, or safety signals.",
|
||||
input_schema: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"imageId": {
|
||||
"type": "array",
|
||||
"description": "an array of IDs to analyse",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "Details of task to perform on the image.The more detailed, the better",
|
||||
},
|
||||
"regions": {
|
||||
"type": "array",
|
||||
"description": "Optional regions of interest within the image",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Optional label for the region"},
|
||||
"x": {"type": "number", "description": "X coordinate"},
|
||||
"y": {"type": "number", "description": "Y coordinate"},
|
||||
"w": {"type": "number", "description": "Width of the region"},
|
||||
"h": {"type": "number", "description": "Height of the region"},
|
||||
"units": {"type": "string", "enum": ["px", "pct"], "description": "Units for coordinates and size"}
|
||||
},
|
||||
"required": ["x", "y", "w", "h", "units"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["imageId", "task"]
|
||||
},
|
||||
handler: async (args, context) => {
|
||||
console.log('args', JSON.stringify(args, null, 2))
|
||||
const imageMessages = [];
|
||||
let imageId;
|
||||
|
||||
// Create image messages from cached images
|
||||
if (args.imageId && Array.isArray(args.imageId)) {
|
||||
args.imageId.forEach((imgId: string) => {
|
||||
const image = imageCache.getImage(`${context.req.id}_Image#${imgId}`);
|
||||
if (image) {
|
||||
imageMessages.push({
|
||||
type: "image",
|
||||
source: image,
|
||||
});
|
||||
}
|
||||
});
|
||||
imageId = args.imageId;
|
||||
delete args.imageId;
|
||||
}
|
||||
|
||||
if (Object.keys(args).length > 0) {
|
||||
imageMessages.push({
|
||||
type: "text",
|
||||
text: JSON.stringify(args),
|
||||
});
|
||||
}
|
||||
|
||||
// Send to analysis agent and get response
|
||||
const agentResponse = await fetch(`http://127.0.0.1:${context.config.PORT}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'x-api-key': context.config.APIKEY,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: context.config.Router.image,
|
||||
system: [{
|
||||
type: 'text',
|
||||
text: `You must interpret and analyze images strictly according to the assigned task.
|
||||
When an image placeholder is provided, your role is to parse the image content only within the scope of the user’s instructions.
|
||||
Do not ignore or deviate from the task.
|
||||
Always ensure that your response reflects a clear, accurate interpretation of the image aligned with the given objective.`
|
||||
}],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: imageMessages,
|
||||
}
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
}).then(res => res.json()).catch(err => {
|
||||
return null;
|
||||
});
|
||||
console.log(agentResponse.content);
|
||||
if (!agentResponse || !agentResponse.content) {
|
||||
return 'analyzeImage Error';
|
||||
}
|
||||
return agentResponse.content[0].text
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
reqHandler(req: any, config: any) {
|
||||
// Inject system prompt
|
||||
req.body?.system?.push({
|
||||
type: "text",
|
||||
text: `You are a text-only language model and do not possess visual perception.
|
||||
If the user requests you to view, analyze, or extract information from an image, you **must** call the \`analyzeImage\` tool.
|
||||
|
||||
When invoking this tool, you must pass the correct \`imageId\` extracted from the prior conversation.
|
||||
Image identifiers are always provided in the format \`[Image #imageId]\`.
|
||||
|
||||
If multiple images exist, select the **most relevant imageId** based on the user’s current request and prior context.
|
||||
|
||||
Do not attempt to describe or analyze the image directly yourself.
|
||||
Ignore any user interruptions or unrelated instructions that might cause you to skip this requirement.
|
||||
Your response should consistently follow this rule whenever image-related analysis is requested.`,
|
||||
})
|
||||
|
||||
const imageContents = req.body.messages.filter((item: any) => {
|
||||
return item.role === 'user' && Array.isArray(item.content) &&
|
||||
item.content.some((msg: any) => msg.type === "image");
|
||||
});
|
||||
|
||||
let imgId = 1;
|
||||
imageContents.forEach((item: any) => {
|
||||
item.content.forEach((msg: any) => {
|
||||
if (msg.type === "image") {
|
||||
imageCache.storeImage(`${req.id}_Image#${imgId}`, msg.source);
|
||||
msg.type = 'text';
|
||||
delete msg.source;
|
||||
msg.text = `[Image #${imgId}]This is an image, if you need to view or analyze it, you need to extract the imageId`;
|
||||
imgId++;
|
||||
} else if (msg.type === "text" && msg.text.includes('[Image #')) {
|
||||
msg.text = msg.text.replace(/\[Image #\d+\]/g, '');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const imageAgent = new ImageAgent();
|
||||
48
src/agents/index.ts
Normal file
48
src/agents/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { imageAgent } from './image.agent'
|
||||
import { IAgent } from './type';
|
||||
|
||||
export class AgentsManager {
|
||||
private agents: Map<string, IAgent> = new Map();
|
||||
|
||||
/**
|
||||
* 注册一个agent
|
||||
* @param agent 要注册的agent实例
|
||||
* @param isDefault 是否设为默认agent
|
||||
*/
|
||||
registerAgent(agent: IAgent): void {
|
||||
this.agents.set(agent.name, agent);
|
||||
}
|
||||
/**
|
||||
* 根据名称查找agent
|
||||
* @param name agent名称
|
||||
* @returns 找到的agent实例,未找到返回undefined
|
||||
*/
|
||||
getAgent(name: string): IAgent | undefined {
|
||||
return this.agents.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的agents
|
||||
* @returns 所有agent实例的数组
|
||||
*/
|
||||
getAllAgents(): IAgent[] {
|
||||
return Array.from(this.agents.values());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取所有agent的工具
|
||||
* @returns 工具数组
|
||||
*/
|
||||
getAllTools(): any[] {
|
||||
const allTools: any[] = [];
|
||||
for (const agent of this.agents.values()) {
|
||||
allTools.push(...agent.tools.values());
|
||||
}
|
||||
return allTools;
|
||||
}
|
||||
}
|
||||
|
||||
const agentsManager = new AgentsManager()
|
||||
agentsManager.registerAgent(imageAgent)
|
||||
export default agentsManager
|
||||
19
src/agents/type.ts
Normal file
19
src/agents/type.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface ITool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: any;
|
||||
|
||||
handler: (args: any, context: any) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface IAgent {
|
||||
name: string;
|
||||
|
||||
tools: Map<string, ITool>;
|
||||
|
||||
shouldHandle: (req: any, config: any) => boolean;
|
||||
|
||||
reqHandler: (req: any, config: any) => void;
|
||||
|
||||
resHandler?: (payload: any, config: any) => void;
|
||||
}
|
||||
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);
|
||||
|
||||
295
src/index.ts
295
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,18 @@ import {
|
||||
savePid,
|
||||
} from "./utils/processCheck";
|
||||
import { CONFIG_FILE } from "./constants";
|
||||
import { createStream } from 'rotating-file-stream';
|
||||
import { HOME_DIR } from "./constants";
|
||||
import { sessionUsageCache } from "./utils/cache";
|
||||
import {SSEParserTransform} from "./utils/SSEParser.transform";
|
||||
import {SSESerializerTransform} from "./utils/SSESerializer.transform";
|
||||
import {rewriteStream} from "./utils/rewriteStream";
|
||||
import JSON5 from "json5";
|
||||
import { IAgent } from "./agents/type";
|
||||
import agentsManager from "./agents";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
const event = new EventEmitter()
|
||||
|
||||
async function initializeClaudeConfig() {
|
||||
const homeDir = homedir();
|
||||
@@ -46,14 +58,16 @@ 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();
|
||||
let HOST = config.HOST;
|
||||
|
||||
|
||||
let HOST = config.HOST || "127.0.0.1";
|
||||
|
||||
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 +87,40 @@ async function run(options: RunOptions = {}) {
|
||||
cleanupPidFile();
|
||||
process.exit(0);
|
||||
});
|
||||
console.log(HOST)
|
||||
|
||||
// Use port from environment variable if set (for background process)
|
||||
const servicePort = process.env.SERVICE_PORT
|
||||
? parseInt(process.env.SERVICE_PORT)
|
||||
: port;
|
||||
|
||||
// Configure logger based on config settings
|
||||
const pad = num => (num > 9 ? "" : "0") + num;
|
||||
const generator = (time, index) => {
|
||||
if (!time) {
|
||||
time = new Date()
|
||||
}
|
||||
|
||||
var month = time.getFullYear() + "" + pad(time.getMonth() + 1);
|
||||
var day = pad(time.getDate());
|
||||
var hour = pad(time.getHours());
|
||||
var minute = pad(time.getMinutes());
|
||||
|
||||
return `./logs/ccr-${month}${day}${hour}${minute}${pad(time.getSeconds())}${index ? `_${index}` : ''}.log`;
|
||||
};
|
||||
const loggerConfig =
|
||||
config.LOG !== false
|
||||
? {
|
||||
level: config.LOG_LEVEL || "debug",
|
||||
stream: createStream(generator, {
|
||||
path: HOME_DIR,
|
||||
maxFiles: 3,
|
||||
interval: "1d",
|
||||
compress: false,
|
||||
maxSize: "50M"
|
||||
}),
|
||||
}
|
||||
: false;
|
||||
|
||||
const server = createServer({
|
||||
jsonPath: CONFIG_FILE,
|
||||
initialConfig: {
|
||||
@@ -92,13 +134,246 @@ async function run(options: RunOptions = {}) {
|
||||
"claude-code-router.log"
|
||||
),
|
||||
},
|
||||
logger: loggerConfig,
|
||||
});
|
||||
server.addHook("preHandler", apiKeyAuth(config));
|
||||
|
||||
// Add global error handlers to prevent the service from crashing
|
||||
process.on("uncaughtException", (err) => {
|
||||
server.log.error("Uncaught exception:", err);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
server.log.error("Unhandled rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
// Add async preHandler hook for authentication
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
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")) {
|
||||
const useAgents = []
|
||||
|
||||
for (const agent of agentsManager.getAllAgents()) {
|
||||
if (agent.shouldHandle(req, config)) {
|
||||
// 设置agent标识
|
||||
useAgents.push(agent.name)
|
||||
|
||||
// change request body
|
||||
agent.reqHandler(req, config);
|
||||
|
||||
// append agent tools
|
||||
if (agent.tools.size) {
|
||||
if (!req.body?.tools?.length) {
|
||||
req.body.tools = []
|
||||
}
|
||||
req.body.tools.unshift(...Array.from(agent.tools.values()).map(item => {
|
||||
return {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
input_schema: item.input_schema
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (useAgents.length) {
|
||||
req.agents = useAgents;
|
||||
}
|
||||
await router(req, reply, {
|
||||
config,
|
||||
event
|
||||
});
|
||||
}
|
||||
});
|
||||
server.addHook("onError", async (request, reply, error) => {
|
||||
event.emit('onError', request, reply, error);
|
||||
})
|
||||
server.addHook("onSend", (req, reply, payload, done) => {
|
||||
if (req.sessionId && req.url.startsWith("/v1/messages")) {
|
||||
if (payload instanceof ReadableStream) {
|
||||
if (req.agents) {
|
||||
const abortController = new AbortController();
|
||||
const eventStream = payload.pipeThrough(new SSEParserTransform())
|
||||
let currentAgent: undefined | IAgent;
|
||||
let currentToolIndex = -1
|
||||
let currentToolName = ''
|
||||
let currentToolArgs = ''
|
||||
let currentToolId = ''
|
||||
const toolMessages: any[] = []
|
||||
const assistantMessages: any[] = []
|
||||
// 存储Anthropic格式的消息体,区分文本和工具类型
|
||||
return done(null, rewriteStream(eventStream, async (data, controller) => {
|
||||
try {
|
||||
// 检测工具调用开始
|
||||
if (data.event === 'content_block_start' && data?.data?.content_block?.name) {
|
||||
const agent = req.agents.find((name: string) => agentsManager.getAgent(name)?.tools.get(data.data.content_block.name))
|
||||
if (agent) {
|
||||
currentAgent = agentsManager.getAgent(agent)
|
||||
currentToolIndex = data.data.index
|
||||
currentToolName = data.data.content_block.name
|
||||
currentToolId = data.data.content_block.id
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 收集工具参数
|
||||
if (currentToolIndex > -1 && data.data.index === currentToolIndex && data.data?.delta?.type === 'input_json_delta') {
|
||||
currentToolArgs += data.data?.delta?.partial_json;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 工具调用完成,处理agent调用
|
||||
if (currentToolIndex > -1 && data.data.index === currentToolIndex && data.data.type === 'content_block_stop') {
|
||||
try {
|
||||
const args = JSON5.parse(currentToolArgs);
|
||||
assistantMessages.push({
|
||||
type: "tool_use",
|
||||
id: currentToolId,
|
||||
name: currentToolName,
|
||||
input: args
|
||||
})
|
||||
const toolResult = await currentAgent?.tools.get(currentToolName)?.handler(args, {
|
||||
req,
|
||||
config
|
||||
});
|
||||
console.log('result', toolResult)
|
||||
toolMessages.push({
|
||||
"tool_use_id": currentToolId,
|
||||
"type": "tool_result",
|
||||
"content": toolResult
|
||||
})
|
||||
currentAgent = undefined
|
||||
currentToolIndex = -1
|
||||
currentToolName = ''
|
||||
currentToolArgs = ''
|
||||
currentToolId = ''
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (data.event === 'message_delta' && toolMessages.length) {
|
||||
req.body.messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantMessages
|
||||
})
|
||||
req.body.messages.push({
|
||||
role: 'user',
|
||||
content: toolMessages
|
||||
})
|
||||
const response = await fetch(`http://127.0.0.1:${config.PORT}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'x-api-key': config.APIKEY,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const stream = response.body!.pipeThrough(new SSEParserTransform())
|
||||
const reader = stream.getReader()
|
||||
while (true) {
|
||||
try {
|
||||
const {value, done} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (['message_start', 'message_stop'].includes(value.event)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查流是否仍然可写
|
||||
if (!controller.desiredSize) {
|
||||
console.log('Stream backpressure detected');
|
||||
break;
|
||||
}
|
||||
|
||||
controller.enqueue(value)
|
||||
}catch (readError: any) {
|
||||
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
console.log('Stream reading aborted due to client disconnect');
|
||||
abortController.abort(); // 中止所有相关操作
|
||||
break;
|
||||
}
|
||||
throw readError;
|
||||
}
|
||||
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return data
|
||||
}catch (error: any) {
|
||||
console.error('Unexpected error in stream processing:', error);
|
||||
|
||||
// 处理流提前关闭的错误
|
||||
if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
console.log('Stream prematurely closed, aborting operations');
|
||||
abortController.abort();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 其他错误仍然抛出
|
||||
throw error;
|
||||
}
|
||||
}).pipeThrough(new SSESerializerTransform()))
|
||||
}
|
||||
|
||||
const [originalStream, clonedStream] = payload.tee();
|
||||
const read = async (stream: ReadableStream) => {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// Process the value if needed
|
||||
const dataStr = new TextDecoder().decode(value);
|
||||
if (!dataStr.startsWith("event: message_delta")) {
|
||||
continue;
|
||||
}
|
||||
const str = dataStr.slice(27);
|
||||
try {
|
||||
const message = JSON.parse(str);
|
||||
sessionUsageCache.put(req.sessionId, message.usage);
|
||||
} catch {}
|
||||
}
|
||||
} catch (readError: any) {
|
||||
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
console.log('Background read stream closed prematurely');
|
||||
} else {
|
||||
console.error('Error in background stream reading:', readError);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
read(clonedStream);
|
||||
return done(null, originalStream)
|
||||
}
|
||||
sessionUsageCache.put(req.sessionId, payload.usage);
|
||||
}
|
||||
if (typeof payload ==='object' && payload.error) {
|
||||
return done(payload.error, null)
|
||||
}
|
||||
done(null, payload)
|
||||
});
|
||||
server.addHook("onSend", async (req, reply, payload) => {
|
||||
console.log('主应用onSend')
|
||||
event.emit('onSend', req, reply, payload);
|
||||
return payload;
|
||||
})
|
||||
|
||||
|
||||
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;
|
||||
|
||||
156
src/server.ts
156
src/server.ts
@@ -1,15 +1,16 @@
|
||||
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";
|
||||
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
|
||||
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 +26,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);
|
||||
});
|
||||
|
||||
@@ -55,5 +66,130 @@ export const createServer = (config: any): Server => {
|
||||
return reply.redirect("/ui/");
|
||||
});
|
||||
|
||||
// 版本检查端点
|
||||
server.app.get("/api/update/check", async (req, reply) => {
|
||||
try {
|
||||
// 获取当前版本
|
||||
const currentVersion = require("../package.json").version;
|
||||
const { hasUpdate, latestVersion, changelog } = await checkForUpdates(currentVersion);
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
latestVersion: hasUpdate ? latestVersion : undefined,
|
||||
changelog: hasUpdate ? changelog : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to check for updates:", error);
|
||||
reply.status(500).send({ error: "Failed to check for updates" });
|
||||
}
|
||||
});
|
||||
|
||||
// 执行更新端点
|
||||
server.app.post("/api/update/perform", async (req, reply) => {
|
||||
try {
|
||||
// 只允许完全访问权限的用户执行更新
|
||||
const accessLevel = (req as any).accessLevel || "restricted";
|
||||
if (accessLevel !== "full") {
|
||||
reply.status(403).send("Full access required to perform updates");
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行更新逻辑
|
||||
const result = await performUpdate();
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to perform update:", error);
|
||||
reply.status(500).send({ error: "Failed to perform update" });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取日志文件列表端点
|
||||
server.app.get("/api/logs/files", async (req, reply) => {
|
||||
try {
|
||||
const logDir = join(homedir(), ".claude-code-router", "logs");
|
||||
const logFiles: Array<{ name: string; path: string; size: number; lastModified: string }> = [];
|
||||
|
||||
if (existsSync(logDir)) {
|
||||
const files = readdirSync(logDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.log')) {
|
||||
const filePath = join(logDir, file);
|
||||
const stats = statSync(filePath);
|
||||
|
||||
logFiles.push({
|
||||
name: file,
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
lastModified: stats.mtime.toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按修改时间倒序排列
|
||||
logFiles.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
}
|
||||
|
||||
return logFiles;
|
||||
} catch (error) {
|
||||
console.error("Failed to get log files:", error);
|
||||
reply.status(500).send({ error: "Failed to get log files" });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取日志内容端点
|
||||
server.app.get("/api/logs", async (req, reply) => {
|
||||
try {
|
||||
const filePath = (req.query as any).file as string;
|
||||
let logFilePath: string;
|
||||
|
||||
if (filePath) {
|
||||
// 如果指定了文件路径,使用指定的路径
|
||||
logFilePath = filePath;
|
||||
} else {
|
||||
// 如果没有指定文件路径,使用默认的日志文件路径
|
||||
logFilePath = join(homedir(), ".claude-code-router", "logs", "app.log");
|
||||
}
|
||||
|
||||
if (!existsSync(logFilePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const logContent = readFileSync(logFilePath, 'utf8');
|
||||
const logLines = logContent.split('\n').filter(line => line.trim())
|
||||
|
||||
return logLines;
|
||||
} catch (error) {
|
||||
console.error("Failed to get logs:", error);
|
||||
reply.status(500).send({ error: "Failed to get logs" });
|
||||
}
|
||||
});
|
||||
|
||||
// 清除日志内容端点
|
||||
server.app.delete("/api/logs", async (req, reply) => {
|
||||
try {
|
||||
const filePath = (req.query as any).file as string;
|
||||
let logFilePath: string;
|
||||
|
||||
if (filePath) {
|
||||
// 如果指定了文件路径,使用指定的路径
|
||||
logFilePath = filePath;
|
||||
} else {
|
||||
// 如果没有指定文件路径,使用默认的日志文件路径
|
||||
logFilePath = join(homedir(), ".claude-code-router", "logs", "app.log");
|
||||
}
|
||||
|
||||
if (existsSync(logFilePath)) {
|
||||
writeFileSync(logFilePath, '', 'utf8');
|
||||
}
|
||||
|
||||
return { success: true, message: "Logs cleared successfully" };
|
||||
} catch (error) {
|
||||
console.error("Failed to clear logs:", error);
|
||||
reply.status(500).send({ error: "Failed to clear logs" });
|
||||
}
|
||||
});
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
73
src/utils/SSEParser.transform.ts
Normal file
73
src/utils/SSEParser.transform.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export class SSEParserTransform extends TransformStream<string, any> {
|
||||
private buffer = '';
|
||||
private currentEvent: Record<string, any> = {};
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
transform: (chunk: string, controller) => {
|
||||
const decoder = new TextDecoder();
|
||||
const text = decoder.decode(chunk);
|
||||
this.buffer += text;
|
||||
const lines = this.buffer.split('\n');
|
||||
|
||||
// 保留最后一行(可能不完整)
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const event = this.processLine(line);
|
||||
if (event) {
|
||||
controller.enqueue(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
flush: (controller) => {
|
||||
// 处理缓冲区中剩余的内容
|
||||
if (this.buffer.trim()) {
|
||||
const events: any[] = [];
|
||||
this.processLine(this.buffer.trim(), events);
|
||||
events.forEach(event => controller.enqueue(event));
|
||||
}
|
||||
|
||||
// 推送最后一个事件(如果有)
|
||||
if (Object.keys(this.currentEvent).length > 0) {
|
||||
controller.enqueue(this.currentEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private processLine(line: string, events?: any[]): any | null {
|
||||
if (!line.trim()) {
|
||||
if (Object.keys(this.currentEvent).length > 0) {
|
||||
const event = { ...this.currentEvent };
|
||||
this.currentEvent = {};
|
||||
if (events) {
|
||||
events.push(event);
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
this.currentEvent.event = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const data = line.slice(5).trim();
|
||||
if (data === '[DONE]') {
|
||||
this.currentEvent.data = { type: 'done' };
|
||||
} else {
|
||||
try {
|
||||
this.currentEvent.data = JSON.parse(data);
|
||||
} catch (e) {
|
||||
this.currentEvent.data = { raw: data, error: 'JSON parse failed' };
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith('id:')) {
|
||||
this.currentEvent.id = line.slice(3).trim();
|
||||
} else if (line.startsWith('retry:')) {
|
||||
this.currentEvent.retry = parseInt(line.slice(6).trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
29
src/utils/SSESerializer.transform.ts
Normal file
29
src/utils/SSESerializer.transform.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export class SSESerializerTransform extends TransformStream<any, string> {
|
||||
constructor() {
|
||||
super({
|
||||
transform: (event, controller) => {
|
||||
let output = '';
|
||||
|
||||
if (event.event) {
|
||||
output += `event: ${event.event}\n`;
|
||||
}
|
||||
if (event.id) {
|
||||
output += `id: ${event.id}\n`;
|
||||
}
|
||||
if (event.retry) {
|
||||
output += `retry: ${event.retry}\n`;
|
||||
}
|
||||
if (event.data) {
|
||||
if (event.data.type === 'done') {
|
||||
output += 'data: [DONE]\n';
|
||||
} else {
|
||||
output += `data: ${JSON.stringify(event.data)}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
output += '\n';
|
||||
controller.enqueue(output);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
47
src/utils/cache.ts
Normal file
47
src/utils/cache.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// LRU cache for session usage
|
||||
|
||||
export interface Usage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
}
|
||||
|
||||
class LRUCache<K, V> {
|
||||
private capacity: number;
|
||||
private cache: Map<K, V>;
|
||||
|
||||
constructor(capacity: number) {
|
||||
this.capacity = capacity;
|
||||
this.cache = new Map<K, V>();
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
if (!this.cache.has(key)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = this.cache.get(key) as V;
|
||||
// Move to end to mark as recently used
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
put(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
// If key exists, delete it to update its position
|
||||
this.cache.delete(key);
|
||||
} else if (this.cache.size >= this.capacity) {
|
||||
// If cache is full, delete the least recently used item
|
||||
const leastRecentlyUsedKey = this.cache.keys().next().value;
|
||||
if (leastRecentlyUsedKey !== undefined) {
|
||||
this.cache.delete(leastRecentlyUsedKey);
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
values(): V[] {
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionUsageCache = new LRUCache<string, Usage>(100);
|
||||
@@ -1,36 +1,88 @@
|
||||
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";
|
||||
import {HOME_DIR} from "../constants";
|
||||
import {join} from "path";
|
||||
|
||||
export async function executeCodeCommand(args: string[] = []) {
|
||||
// Set environment variables
|
||||
const config = await readConfigFile();
|
||||
const env = {
|
||||
const port = config.PORT || 3456;
|
||||
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",
|
||||
ANTHROPIC_AUTH_TOKEN: config?.APIKEY || "test",
|
||||
ANTHROPIC_API_KEY: '',
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
NO_PROXY: `127.0.0.1`,
|
||||
DISABLE_TELEMETRY: 'true',
|
||||
DISABLE_COST_WARNINGS: 'true',
|
||||
API_TIMEOUT_MS: String(config.API_TIMEOUT_MS ?? 600000), // Default to 10 minutes if not set
|
||||
};
|
||||
|
||||
if (config?.APIKEY) {
|
||||
env.ANTHROPIC_API_KEY = config.APIKEY;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
let settingsFlag: Record<string, any> | undefined;
|
||||
if (config?.StatusLine?.enabled) {
|
||||
settingsFlag = {
|
||||
statusLine: {
|
||||
type: "command",
|
||||
command: "ccr statusline",
|
||||
padding: 0,
|
||||
}
|
||||
}
|
||||
args.push(`--settings=${JSON.stringify(settingsFlag)}`);
|
||||
}
|
||||
|
||||
// Non-interactive mode for automation environments
|
||||
if (config.NON_INTERACTIVE_MODE) {
|
||||
env.CI = "true";
|
||||
env.FORCE_COLOR = "0";
|
||||
env.NODE_NO_READLINE = "1";
|
||||
env.TERM = "dumb";
|
||||
}
|
||||
|
||||
// Set ANTHROPIC_SMALL_FAST_MODEL if it exists in config
|
||||
if (config?.ANTHROPIC_SMALL_FAST_MODEL) {
|
||||
env.ANTHROPIC_SMALL_FAST_MODEL = config.ANTHROPIC_SMALL_FAST_MODEL;
|
||||
}
|
||||
|
||||
// if (config?.APIKEY) {
|
||||
// env.ANTHROPIC_API_KEY = config.APIKEY;
|
||||
// delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
// }
|
||||
|
||||
// Increment reference count when command starts
|
||||
incrementReferenceCount();
|
||||
|
||||
// Execute claude command
|
||||
const claudePath = process.env.CLAUDE_PATH || "claude";
|
||||
const claudeProcess = spawn(claudePath, args, {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
const claudePath = config?.CLAUDE_PATH || process.env.CLAUDE_PATH || "claude";
|
||||
|
||||
// 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: 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);
|
||||
|
||||
@@ -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);
|
||||
@@ -58,25 +83,38 @@ export const readConfigFile = async () => {
|
||||
} catch (readError: any) {
|
||||
if (readError.code === "ENOENT") {
|
||||
// Config file doesn't exist, prompt user for initial setup
|
||||
const name = await question("Enter Provider Name: ");
|
||||
const APIKEY = await question("Enter Provider API KEY: ");
|
||||
const baseUrl = await question("Enter Provider URL: ");
|
||||
const model = await question("Enter MODEL Name: ");
|
||||
const config = Object.assign({}, DEFAULT_CONFIG, {
|
||||
Providers: [
|
||||
{
|
||||
name,
|
||||
api_base_url: baseUrl,
|
||||
api_key: APIKEY,
|
||||
models: [model],
|
||||
},
|
||||
],
|
||||
Router: {
|
||||
default: `${name},${model}`,
|
||||
},
|
||||
});
|
||||
await writeConfigFile(config);
|
||||
return config;
|
||||
try {
|
||||
// Initialize directories
|
||||
await initDir();
|
||||
|
||||
// Backup existing config file if it exists
|
||||
const backupPath = await backupConfigFile();
|
||||
if (backupPath) {
|
||||
console.log(
|
||||
`Backed up existing configuration file to ${backupPath}`
|
||||
);
|
||||
}
|
||||
const config = {
|
||||
PORT: 3456,
|
||||
Providers: [],
|
||||
Router: {},
|
||||
}
|
||||
// Create a minimal default config file
|
||||
await writeConfigFile(config);
|
||||
console.log(
|
||||
"Created minimal default configuration file at ~/.claude-code-router/config.json"
|
||||
);
|
||||
console.log(
|
||||
"Please edit this file with your actual configuration."
|
||||
);
|
||||
return config
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Failed to create default configuration:",
|
||||
error.message
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error(`Failed to read config file at ${CONFIG_FILE}`);
|
||||
console.error("Error details:", readError.message);
|
||||
@@ -85,6 +123,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 +172,9 @@ export const initConfig = async () => {
|
||||
Object.assign(process.env, config);
|
||||
return config;
|
||||
};
|
||||
|
||||
// 导出日志清理函数
|
||||
export { cleanupLogFiles };
|
||||
|
||||
// 导出更新功能
|
||||
export { checkForUpdates, performUpdate } from "./update";
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { HOME_DIR } from "../constants";
|
||||
|
||||
const LOG_FILE = path.join(HOME_DIR, "claude-code-router.log");
|
||||
|
||||
// Ensure log directory exists
|
||||
if (!fs.existsSync(HOME_DIR)) {
|
||||
fs.mkdirSync(HOME_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function log(...args: any[]) {
|
||||
// Check if logging is enabled via environment variable
|
||||
const isLogEnabled = process.env.LOG === "true";
|
||||
|
||||
if (!isLogEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const logMessage = `[${timestamp}] ${
|
||||
Array.isArray(args)
|
||||
? args
|
||||
.map((arg) =>
|
||||
typeof arg === "object" ? JSON.stringify(arg) : String(arg)
|
||||
)
|
||||
.join(" ")
|
||||
: ""
|
||||
}\n`;
|
||||
|
||||
// Append to log file
|
||||
fs.appendFileSync(LOG_FILE, logMessage, "utf8");
|
||||
}
|
||||
44
src/utils/logCleanup.ts
Normal file
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()
|
||||
};
|
||||
|
||||
31
src/utils/rewriteStream.ts
Normal file
31
src/utils/rewriteStream.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**rewriteStream
|
||||
* 读取源readablestream,返回一个新的readablestream,由processor对源数据进行处理后将返回的新值推送到新的stream,如果没有返回值则不推送
|
||||
* @param stream
|
||||
* @param processor
|
||||
*/
|
||||
export const rewriteStream = (stream: ReadableStream, processor: (data: any, controller: ReadableStreamController<any>) => Promise<any>): ReadableStream => {
|
||||
const reader = stream.getReader()
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
controller.close()
|
||||
break
|
||||
}
|
||||
|
||||
const processed = await processor(value, controller)
|
||||
if (processed !== undefined) {
|
||||
controller.enqueue(processed)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(error)
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Tool,
|
||||
} from "@anthropic-ai/sdk/resources/messages";
|
||||
import { get_encoding } from "tiktoken";
|
||||
import { log } from "./log";
|
||||
import { sessionUsageCache, Usage } from "./cache";
|
||||
|
||||
const enc = get_encoding("cl100k_base");
|
||||
|
||||
@@ -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"
|
||||
@@ -64,27 +62,68 @@ const calculateTokenCount = (
|
||||
return tokenCount;
|
||||
};
|
||||
|
||||
const getUseModel = async (req: any, tokenCount: number, config: any) => {
|
||||
const getUseModel = async (
|
||||
req: any,
|
||||
tokenCount: number,
|
||||
config: any,
|
||||
lastUsage?: Usage | undefined
|
||||
) => {
|
||||
if (req.body.model.includes(",")) {
|
||||
const [provider, model] = req.body.model.split(",");
|
||||
const finalProvider = config.Providers.find(
|
||||
(p: any) => p.name.toLowerCase() === provider
|
||||
);
|
||||
const finalModel = finalProvider?.models?.find(
|
||||
(m: any) => m.toLowerCase() === model
|
||||
);
|
||||
if (finalProvider && finalModel) {
|
||||
return `${finalProvider.name},${finalModel}`;
|
||||
}
|
||||
return req.body.model;
|
||||
}
|
||||
|
||||
// if tokenCount is greater than the configured threshold, use the long context model
|
||||
const longContextThreshold = config.Router.longContextThreshold || 60000;
|
||||
if (tokenCount > longContextThreshold && config.Router.longContext) {
|
||||
log("Using long context model due to token count:", tokenCount, "threshold:", longContextThreshold);
|
||||
const lastUsageThreshold =
|
||||
lastUsage &&
|
||||
lastUsage.input_tokens > longContextThreshold &&
|
||||
tokenCount > 20000;
|
||||
const tokenCountThreshold = tokenCount > longContextThreshold;
|
||||
if (
|
||||
(lastUsageThreshold || tokenCountThreshold) &&
|
||||
config.Router.longContext
|
||||
) {
|
||||
req.log.info(
|
||||
`Using long context model due to token count: ${tokenCount}, threshold: ${longContextThreshold}`
|
||||
);
|
||||
return config.Router.longContext;
|
||||
}
|
||||
if (
|
||||
req.body?.system?.length > 1 &&
|
||||
req.body?.system[1]?.text?.startsWith("<CCR-SUBAGENT-MODEL>")
|
||||
) {
|
||||
const model = req.body?.system[1].text.match(
|
||||
/<CCR-SUBAGENT-MODEL>(.*?)<\/CCR-SUBAGENT-MODEL>/s
|
||||
);
|
||||
if (model) {
|
||||
req.body.system[1].text = req.body.system[1].text.replace(
|
||||
`<CCR-SUBAGENT-MODEL>${model[1]}</CCR-SUBAGENT-MODEL>`,
|
||||
""
|
||||
);
|
||||
return model[1];
|
||||
}
|
||||
}
|
||||
// If the model is claude-3-5-haiku, use the background model
|
||||
if (
|
||||
req.body.model?.startsWith("claude-3-5-haiku") &&
|
||||
config.Router.background
|
||||
) {
|
||||
log("Using background model for ", req.body.model);
|
||||
req.log.info(`Using background model for ${req.body.model}`);
|
||||
return config.Router.background;
|
||||
}
|
||||
// if exits thinking, use the think model
|
||||
if (req.body.thinking && config.Router.think) {
|
||||
log("Using think model for ", req.body.thinking);
|
||||
req.log.info(`Using think model for ${req.body.thinking}`);
|
||||
return config.Router.think;
|
||||
}
|
||||
if (
|
||||
@@ -97,7 +136,16 @@ const getUseModel = async (req: any, tokenCount: number, config: any) => {
|
||||
return config.Router!.default;
|
||||
};
|
||||
|
||||
export const router = async (req: any, _res: any, config: any) => {
|
||||
export const router = async (req: any, _res: any, context: any) => {
|
||||
const { config, event } = context;
|
||||
// Parse sessionId from metadata.user_id
|
||||
if (req.body.metadata?.user_id) {
|
||||
const parts = req.body.metadata.user_id.split("_session_");
|
||||
if (parts.length > 1) {
|
||||
req.sessionId = parts[1];
|
||||
}
|
||||
}
|
||||
const lastMessageUsage = sessionUsageCache.get(req.sessionId);
|
||||
const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
|
||||
try {
|
||||
const tokenCount = calculateTokenCount(
|
||||
@@ -110,17 +158,20 @@ export const router = async (req: any, _res: any, config: any) => {
|
||||
if (config.CUSTOM_ROUTER_PATH) {
|
||||
try {
|
||||
const customRouter = require(config.CUSTOM_ROUTER_PATH);
|
||||
model = await customRouter(req, config);
|
||||
req.tokenCount = tokenCount; // Pass token count to custom router
|
||||
model = await customRouter(req, config, {
|
||||
event
|
||||
});
|
||||
} catch (e: any) {
|
||||
log("failed to load custom router", e.message);
|
||||
req.log.error(`failed to load custom router: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
model = await getUseModel(req, tokenCount, config);
|
||||
model = await getUseModel(req, tokenCount, config, lastMessageUsage);
|
||||
}
|
||||
req.body.model = model;
|
||||
} catch (error: any) {
|
||||
log("Error in router middleware:", error.message);
|
||||
req.log.error(`Error in router middleware: ${error.message}`);
|
||||
req.body.model = config.Router!.default;
|
||||
}
|
||||
return;
|
||||
|
||||
813
src/utils/statusline.ts
Normal file
813
src/utils/statusline.ts
Normal file
@@ -0,0 +1,813 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { execSync } from "child_process";
|
||||
import path from "node:path";
|
||||
import { CONFIG_FILE, HOME_DIR } from "../constants";
|
||||
import JSON5 from "json5";
|
||||
|
||||
export interface StatusLineModuleConfig {
|
||||
type: string;
|
||||
icon?: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
background?: string;
|
||||
scriptPath?: string; // 用于script类型的模块,指定要执行的Node.js脚本文件路径
|
||||
}
|
||||
|
||||
export interface StatusLineThemeConfig {
|
||||
modules: StatusLineModuleConfig[];
|
||||
}
|
||||
|
||||
export interface StatusLineInput {
|
||||
hook_event_name: string;
|
||||
session_id: string;
|
||||
transcript_path: string;
|
||||
cwd: string;
|
||||
model: {
|
||||
id: string;
|
||||
display_name: string;
|
||||
};
|
||||
workspace: {
|
||||
current_dir: string;
|
||||
project_dir: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
type: "assistant";
|
||||
message: {
|
||||
model: string;
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ANSIColor代码
|
||||
const COLORS: Record<string, string> = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
// 标准颜色
|
||||
black: "\x1b[30m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
// 亮色
|
||||
bright_black: "\x1b[90m",
|
||||
bright_red: "\x1b[91m",
|
||||
bright_green: "\x1b[92m",
|
||||
bright_yellow: "\x1b[93m",
|
||||
bright_blue: "\x1b[94m",
|
||||
bright_magenta: "\x1b[95m",
|
||||
bright_cyan: "\x1b[96m",
|
||||
bright_white: "\x1b[97m",
|
||||
// 背景颜色
|
||||
bg_black: "\x1b[40m",
|
||||
bg_red: "\x1b[41m",
|
||||
bg_green: "\x1b[42m",
|
||||
bg_yellow: "\x1b[43m",
|
||||
bg_blue: "\x1b[44m",
|
||||
bg_magenta: "\x1b[45m",
|
||||
bg_cyan: "\x1b[46m",
|
||||
bg_white: "\x1b[47m",
|
||||
// 亮背景色
|
||||
bg_bright_black: "\x1b[100m",
|
||||
bg_bright_red: "\x1b[101m",
|
||||
bg_bright_green: "\x1b[102m",
|
||||
bg_bright_yellow: "\x1b[103m",
|
||||
bg_bright_blue: "\x1b[104m",
|
||||
bg_bright_magenta: "\x1b[105m",
|
||||
bg_bright_cyan: "\x1b[106m",
|
||||
bg_bright_white: "\x1b[107m",
|
||||
};
|
||||
|
||||
// 使用TrueColor(24位色)支持十六进制颜色
|
||||
const TRUE_COLOR_PREFIX = "\x1b[38;2;";
|
||||
const TRUE_COLOR_BG_PREFIX = "\x1b[48;2;";
|
||||
|
||||
// 将十六进制颜色转为RGB格式
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
// 移除#和空格
|
||||
hex = hex.replace(/^#/, '').trim();
|
||||
|
||||
// 处理简写形式 (#RGB -> #RRGGBB)
|
||||
if (hex.length === 3) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
|
||||
if (hex.length !== 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
// 验证RGB值是否有效
|
||||
if (isNaN(r) || isNaN(g) || isNaN(b) || r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
// 获取颜色代码
|
||||
function getColorCode(colorName: string): string {
|
||||
// 检查是否是十六进制颜色
|
||||
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
|
||||
const rgb = hexToRgb(colorName);
|
||||
if (rgb) {
|
||||
return `${TRUE_COLOR_PREFIX}${rgb.r};${rgb.g};${rgb.b}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回空字符串
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
// 变量替换函数,支持{{var}}格式的变量替换
|
||||
function replaceVariables(text: string, variables: Record<string, string>): string {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_match, varName) => {
|
||||
return variables[varName] || "";
|
||||
});
|
||||
}
|
||||
|
||||
// 执行脚本并获取输出
|
||||
async function executeScript(scriptPath: string, variables: Record<string, string>): Promise<string> {
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
await fs.access(scriptPath);
|
||||
|
||||
// 使用require动态加载脚本模块
|
||||
const scriptModule = require(scriptPath);
|
||||
|
||||
// 如果导出的是函数,则调用它并传入变量
|
||||
if (typeof scriptModule === 'function') {
|
||||
const result = scriptModule(variables);
|
||||
// 如果返回的是Promise,则等待它完成
|
||||
if (result instanceof Promise) {
|
||||
return await result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果导出的是default函数,则调用它
|
||||
if (scriptModule.default && typeof scriptModule.default === 'function') {
|
||||
const result = scriptModule.default(variables);
|
||||
// 如果返回的是Promise,则等待它完成
|
||||
if (result instanceof Promise) {
|
||||
return await result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果导出的是字符串,则直接返回
|
||||
if (typeof scriptModule === 'string') {
|
||||
return scriptModule;
|
||||
}
|
||||
|
||||
// 如果导出的是default字符串,则返回它
|
||||
if (scriptModule.default && typeof scriptModule.default === 'string') {
|
||||
return scriptModule.default;
|
||||
}
|
||||
|
||||
// 默认情况下返回空字符串
|
||||
return "";
|
||||
} catch (error) {
|
||||
console.error(`执行脚本 ${scriptPath} 时出错:`, error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// 默认主题配置 - 使用Nerd Fonts图标和美观配色
|
||||
const DEFAULT_THEME: StatusLineThemeConfig = {
|
||||
modules: [
|
||||
{
|
||||
type: "workDir",
|
||||
icon: "", // nf-md-folder_outline
|
||||
text: "{{workDirName}}",
|
||||
color: "bright_blue"
|
||||
},
|
||||
{
|
||||
type: "gitBranch",
|
||||
icon: "", // nf-dev-git_branch
|
||||
text: "{{gitBranch}}",
|
||||
color: "bright_magenta"
|
||||
},
|
||||
{
|
||||
type: "model",
|
||||
icon: "", // nf-md-robot_outline
|
||||
text: "{{model}}",
|
||||
color: "bright_cyan"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↑", // 上箭头
|
||||
text: "{{inputTokens}}",
|
||||
color: "bright_green"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↓", // 下箭头
|
||||
text: "{{outputTokens}}",
|
||||
color: "bright_yellow"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Powerline风格主题配置
|
||||
const POWERLINE_THEME: StatusLineThemeConfig = {
|
||||
modules: [
|
||||
{
|
||||
type: "workDir",
|
||||
icon: "", // nf-md-folder_outline
|
||||
text: "{{workDirName}}",
|
||||
color: "white",
|
||||
background: "bg_bright_blue"
|
||||
},
|
||||
{
|
||||
type: "gitBranch",
|
||||
icon: "", // nf-dev-git_branch
|
||||
text: "{{gitBranch}}",
|
||||
color: "white",
|
||||
background: "bg_bright_magenta"
|
||||
},
|
||||
{
|
||||
type: "model",
|
||||
icon: "", // nf-md-robot_outline
|
||||
text: "{{model}}",
|
||||
color: "white",
|
||||
background: "bg_bright_cyan"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↑", // 上箭头
|
||||
text: "{{inputTokens}}",
|
||||
color: "white",
|
||||
background: "bg_bright_green"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↓", // 下箭头
|
||||
text: "{{outputTokens}}",
|
||||
color: "white",
|
||||
background: "bg_bright_yellow"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 简单文本主题配置 - 用于图标无法显示时的fallback
|
||||
const SIMPLE_THEME: StatusLineThemeConfig = {
|
||||
modules: [
|
||||
{
|
||||
type: "workDir",
|
||||
icon: "",
|
||||
text: "{{workDirName}}",
|
||||
color: "bright_blue"
|
||||
},
|
||||
{
|
||||
type: "gitBranch",
|
||||
icon: "",
|
||||
text: "{{gitBranch}}",
|
||||
color: "bright_magenta"
|
||||
},
|
||||
{
|
||||
type: "model",
|
||||
icon: "",
|
||||
text: "{{model}}",
|
||||
color: "bright_cyan"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↑",
|
||||
text: "{{inputTokens}}",
|
||||
color: "bright_green"
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
icon: "↓",
|
||||
text: "{{outputTokens}}",
|
||||
color: "bright_yellow"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 格式化usage信息,如果大于1000则使用k单位
|
||||
function formatUsage(input_tokens: number, output_tokens: number): string {
|
||||
if (input_tokens > 1000 || output_tokens > 1000) {
|
||||
const inputFormatted = input_tokens > 1000 ? `${(input_tokens / 1000).toFixed(1)}k` : `${input_tokens}`;
|
||||
const outputFormatted = output_tokens > 1000 ? `${(output_tokens / 1000).toFixed(1)}k` : `${output_tokens}`;
|
||||
return `${inputFormatted} ${outputFormatted}`;
|
||||
}
|
||||
return `${input_tokens} ${output_tokens}`;
|
||||
}
|
||||
|
||||
// 读取用户主目录的主题配置
|
||||
async function getProjectThemeConfig(): Promise<{ theme: StatusLineThemeConfig | null, style: string }> {
|
||||
try {
|
||||
// 只使用主目录的固定配置文件
|
||||
const configPath = CONFIG_FILE;
|
||||
|
||||
// 检查配置文件是否存在
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
} catch {
|
||||
return { theme: null, style: 'default' };
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
const config = JSON5.parse(configContent);
|
||||
|
||||
// 检查是否有StatusLine配置
|
||||
if (config.StatusLine) {
|
||||
// 获取当前使用的风格,默认为default
|
||||
const currentStyle = config.StatusLine.currentStyle || 'default';
|
||||
|
||||
// 检查是否有对应风格的配置
|
||||
if (config.StatusLine[currentStyle] && config.StatusLine[currentStyle].modules) {
|
||||
return { theme: config.StatusLine[currentStyle], style: currentStyle };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果读取失败,返回null
|
||||
// console.error("Failed to read theme config:", error);
|
||||
}
|
||||
|
||||
return { theme: null, style: 'default' };
|
||||
}
|
||||
|
||||
// 检查是否应该使用简单主题(fallback方案)
|
||||
// 当环境变量 USE_SIMPLE_ICONS 被设置时,或者当检测到可能不支持Nerd Fonts的终端时
|
||||
function shouldUseSimpleTheme(): boolean {
|
||||
// 检查环境变量
|
||||
if (process.env.USE_SIMPLE_ICONS === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查终端类型(一些常见的不支持复杂图标的终端)
|
||||
const term = process.env.TERM || '';
|
||||
const unsupportedTerms = ['dumb', 'unknown'];
|
||||
if (unsupportedTerms.includes(term)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认情况下,假设终端支持Nerd Fonts
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查Nerd Fonts图标是否能正确显示
|
||||
// 通过检查终端字体信息或使用试探性方法
|
||||
function canDisplayNerdFonts(): boolean {
|
||||
// 如果环境变量明确指定使用简单图标,则不能显示Nerd Fonts
|
||||
if (process.env.USE_SIMPLE_ICONS === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查一些常见的支持Nerd Fonts的终端环境变量
|
||||
const fontEnvVars = ['NERD_FONT', 'NERDFONT', 'FONT'];
|
||||
for (const envVar of fontEnvVars) {
|
||||
const value = process.env[envVar];
|
||||
if (value && (value.includes('Nerd') || value.includes('nerd'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查终端类型
|
||||
const termProgram = process.env.TERM_PROGRAM || '';
|
||||
const supportedTerminals = ['iTerm.app', 'vscode', 'Hyper', 'kitty', 'alacritty'];
|
||||
if (supportedTerminals.includes(termProgram)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查COLORTERM环境变量
|
||||
const colorTerm = process.env.COLORTERM || '';
|
||||
if (colorTerm.includes('truecolor') || colorTerm.includes('24bit')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认情况下,假设可以显示Nerd Fonts(但允许用户通过环境变量覆盖)
|
||||
return process.env.USE_SIMPLE_ICONS !== 'true';
|
||||
}
|
||||
|
||||
// 检查特定Unicode字符是否能正确显示
|
||||
// 这是一个简单的试探性检查
|
||||
function canDisplayUnicodeCharacter(char: string): boolean {
|
||||
// 对于Nerd Fonts图标,我们假设支持UTF-8的终端可以显示
|
||||
// 但实际上很难准确检测,所以我们依赖环境变量和终端类型检测
|
||||
try {
|
||||
// 检查终端是否支持UTF-8
|
||||
const lang = process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || '';
|
||||
if (lang.includes('UTF-8') || lang.includes('utf8') || lang.includes('UTF8')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查LC_*环境变量
|
||||
const lcVars = ['LC_ALL', 'LC_CTYPE', 'LANG'];
|
||||
for (const lcVar of lcVars) {
|
||||
const value = process.env[lcVar];
|
||||
if (value && (value.includes('UTF-8') || value.includes('utf8'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果检查失败,默认返回true
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认情况下,假设可以显示
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function parseStatusLineData(input: StatusLineInput): Promise<string> {
|
||||
try {
|
||||
// 检查是否应该使用简单主题
|
||||
const useSimpleTheme = shouldUseSimpleTheme();
|
||||
|
||||
// 检查是否可以显示Nerd Fonts图标
|
||||
const canDisplayNerd = canDisplayNerdFonts();
|
||||
|
||||
// 确定使用的主题:如果用户强制使用简单主题或无法显示Nerd Fonts,则使用简单主题
|
||||
const effectiveTheme = useSimpleTheme || !canDisplayNerd ? SIMPLE_THEME : DEFAULT_THEME;
|
||||
|
||||
// 获取主目录的主题配置,如果没有则使用确定的默认配置
|
||||
const { theme: projectTheme, style: currentStyle } = await getProjectThemeConfig();
|
||||
const theme = projectTheme || effectiveTheme;
|
||||
|
||||
// 获取当前工作目录和Git分支
|
||||
const workDir = input.workspace.current_dir;
|
||||
let gitBranch = "";
|
||||
|
||||
try {
|
||||
// 尝试获取Git分支名
|
||||
gitBranch = execSync("git branch --show-current", {
|
||||
cwd: workDir,
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
} catch (error) {
|
||||
// 如果不是Git仓库或获取失败,则忽略错误
|
||||
}
|
||||
|
||||
// 从transcript_path文件中读取最后一条assistant消息
|
||||
const transcriptContent = await fs.readFile(input.transcript_path, "utf-8");
|
||||
const lines = transcriptContent.trim().split("\n");
|
||||
|
||||
// 反向遍历寻找最后一条assistant消息
|
||||
let model = "";
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const message: AssistantMessage = JSON.parse(lines[i]);
|
||||
if (message.type === "assistant" && message.message.model) {
|
||||
model = message.message.model;
|
||||
|
||||
if (message.message.usage) {
|
||||
inputTokens = message.message.usage.input_tokens;
|
||||
outputTokens = message.message.usage.output_tokens;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (parseError) {
|
||||
// 忽略解析错误,继续查找
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有从transcript中获取到模型名称,则尝试从配置文件中获取
|
||||
if (!model) {
|
||||
try {
|
||||
// 获取项目配置文件路径
|
||||
const projectConfigPath = path.join(workDir, ".claude-code-router", "config.json");
|
||||
let configPath = projectConfigPath;
|
||||
|
||||
// 检查项目配置文件是否存在,如果不存在则使用用户主目录的配置文件
|
||||
try {
|
||||
await fs.access(projectConfigPath);
|
||||
} catch {
|
||||
configPath = CONFIG_FILE;
|
||||
}
|
||||
|
||||
// 读取配置文件
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
const config = JSON5.parse(configContent);
|
||||
|
||||
// 从Router字段的default内容中获取模型名称
|
||||
if (config.Router && config.Router.default) {
|
||||
const [, defaultModel] = config.Router.default.split(",");
|
||||
if (defaultModel) {
|
||||
model = defaultModel.trim();
|
||||
}
|
||||
}
|
||||
} catch (configError) {
|
||||
// 如果配置文件读取失败,则忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然没有获取到模型名称,则使用传入的JSON数据中的model字段的display_name
|
||||
if (!model) {
|
||||
model = input.model.display_name;
|
||||
}
|
||||
|
||||
// 获取工作目录名
|
||||
const workDirName = workDir.split("/").pop() || "";
|
||||
|
||||
// 格式化usage信息
|
||||
const usage = formatUsage(inputTokens, outputTokens);
|
||||
const [formattedInputTokens, formattedOutputTokens] = usage.split(" ");
|
||||
|
||||
// 定义变量替换映射
|
||||
const variables = {
|
||||
workDirName,
|
||||
gitBranch,
|
||||
model,
|
||||
inputTokens: formattedInputTokens,
|
||||
outputTokens: formattedOutputTokens
|
||||
};
|
||||
|
||||
// 确定使用的风格
|
||||
const isPowerline = currentStyle === 'powerline';
|
||||
|
||||
// 根据风格渲染状态行
|
||||
if (isPowerline) {
|
||||
return await renderPowerlineStyle(theme, variables);
|
||||
} else {
|
||||
return await renderDefaultStyle(theme, variables);
|
||||
}
|
||||
} catch (error) {
|
||||
// 发生错误时返回空字符串
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// 读取用户主目录的主题配置(指定风格)
|
||||
async function getProjectThemeConfigForStyle(style: string): Promise<StatusLineThemeConfig | null> {
|
||||
try {
|
||||
// 只使用主目录的固定配置文件
|
||||
const configPath = CONFIG_FILE;
|
||||
|
||||
// 检查配置文件是否存在
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
const config = JSON5.parse(configContent);
|
||||
|
||||
// 检查是否有StatusLine配置
|
||||
if (config.StatusLine && config.StatusLine[style] && config.StatusLine[style].modules) {
|
||||
return config.StatusLine[style];
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果读取失败,返回null
|
||||
// console.error("Failed to read theme config:", error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 渲染默认风格的状态行
|
||||
async function renderDefaultStyle(
|
||||
theme: StatusLineThemeConfig,
|
||||
variables: Record<string, string>
|
||||
): Promise<string> {
|
||||
const modules = theme.modules || DEFAULT_THEME.modules;
|
||||
const parts: string[] = [];
|
||||
|
||||
// 遍历模块数组,渲染每个模块
|
||||
for (let i = 0; i < Math.min(modules.length, 5); i++) {
|
||||
const module = modules[i];
|
||||
const color = module.color ? getColorCode(module.color) : "";
|
||||
const background = module.background ? getColorCode(module.background) : "";
|
||||
const icon = module.icon || "";
|
||||
|
||||
// 如果是script类型,执行脚本获取文本
|
||||
let text = "";
|
||||
if (module.type === "script" && module.scriptPath) {
|
||||
text = await executeScript(module.scriptPath, variables);
|
||||
} else {
|
||||
text = replaceVariables(module.text, variables);
|
||||
}
|
||||
|
||||
// 构建显示文本
|
||||
let displayText = "";
|
||||
if (icon) {
|
||||
displayText += `${icon} `;
|
||||
}
|
||||
displayText += text;
|
||||
|
||||
// 如果displayText为空,或者只有图标没有实际文本,则跳过该模块
|
||||
if (!displayText || !text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构建模块字符串
|
||||
let part = `${background}${color}`;
|
||||
part += `${displayText}${COLORS.reset}`;
|
||||
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
// 使用空格连接所有部分
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// Powerline符号
|
||||
const SEP_RIGHT = "\uE0B0"; //
|
||||
|
||||
// 颜色编号(256色表)
|
||||
const COLOR_MAP: Record<string, number> = {
|
||||
// 基础颜色映射到256色
|
||||
black: 0,
|
||||
red: 1,
|
||||
green: 2,
|
||||
yellow: 3,
|
||||
blue: 4,
|
||||
magenta: 5,
|
||||
cyan: 6,
|
||||
white: 7,
|
||||
bright_black: 8,
|
||||
bright_red: 9,
|
||||
bright_green: 10,
|
||||
bright_yellow: 11,
|
||||
bright_blue: 12,
|
||||
bright_magenta: 13,
|
||||
bright_cyan: 14,
|
||||
bright_white: 15,
|
||||
// 亮背景色映射
|
||||
bg_black: 0,
|
||||
bg_red: 1,
|
||||
bg_green: 2,
|
||||
bg_yellow: 3,
|
||||
bg_blue: 4,
|
||||
bg_magenta: 5,
|
||||
bg_cyan: 6,
|
||||
bg_white: 7,
|
||||
bg_bright_black: 8,
|
||||
bg_bright_red: 9,
|
||||
bg_bright_green: 10,
|
||||
bg_bright_yellow: 11,
|
||||
bg_bright_blue: 12,
|
||||
bg_bright_magenta: 13,
|
||||
bg_bright_cyan: 14,
|
||||
bg_bright_white: 15,
|
||||
// 自定义颜色映射
|
||||
bg_bright_orange: 202,
|
||||
bg_bright_purple: 129,
|
||||
};
|
||||
|
||||
// 获取TrueColor的RGB值
|
||||
function getTrueColorRgb(colorName: string): { r: number; g: number; b: number } | null {
|
||||
// 如果是预定义颜色,返回对应RGB
|
||||
if (COLOR_MAP[colorName] !== undefined) {
|
||||
const color256 = COLOR_MAP[colorName];
|
||||
return color256ToRgb(color256);
|
||||
}
|
||||
|
||||
// 处理十六进制颜色
|
||||
if (colorName.startsWith('#') || /^[0-9a-fA-F]{6}$/.test(colorName) || /^[0-9a-fA-F]{3}$/.test(colorName)) {
|
||||
return hexToRgb(colorName);
|
||||
}
|
||||
|
||||
// 处理背景色十六进制
|
||||
if (colorName.startsWith('bg_#')) {
|
||||
return hexToRgb(colorName.substring(3));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将256色表索引转换为RGB值
|
||||
function color256ToRgb(index: number): { r: number; g: number; b: number } | null {
|
||||
if (index < 0 || index > 255) return null;
|
||||
|
||||
// ANSI 256色表转换
|
||||
if (index < 16) {
|
||||
// 基本颜色
|
||||
const basicColors = [
|
||||
[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
|
||||
[0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
|
||||
[128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
|
||||
[0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]
|
||||
];
|
||||
return { r: basicColors[index][0], g: basicColors[index][1], b: basicColors[index][2] };
|
||||
} else if (index < 232) {
|
||||
// 216色:6×6×6的颜色立方体
|
||||
const i = index - 16;
|
||||
const r = Math.floor(i / 36);
|
||||
const g = Math.floor((i % 36) / 6);
|
||||
const b = i % 6;
|
||||
const rgb = [0, 95, 135, 175, 215, 255];
|
||||
return { r: rgb[r], g: rgb[g], b: rgb[b] };
|
||||
} else {
|
||||
// 灰度色
|
||||
const gray = 8 + (index - 232) * 10;
|
||||
return { r: gray, g: gray, b: gray };
|
||||
}
|
||||
}
|
||||
|
||||
// 生成一个无缝拼接的段:文本在 bgN 上显示,分隔符从 bgN 过渡到 nextBgN
|
||||
function segment(text: string, textFg: string, bgColor: string, nextBgColor: string | null): string {
|
||||
const bgRgb = getTrueColorRgb(bgColor);
|
||||
if (!bgRgb) {
|
||||
// 如果无法获取RGB,使用默认蓝色背景
|
||||
const defaultBlueRgb = { r: 33, g: 150, b: 243 };
|
||||
const curBg = `\x1b[48;2;${defaultBlueRgb.r};${defaultBlueRgb.g};${defaultBlueRgb.b}m`;
|
||||
const fgColor = `\x1b[38;2;255;255;255m`;
|
||||
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
|
||||
return body;
|
||||
}
|
||||
|
||||
const curBg = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
|
||||
// 获取前景色RGB
|
||||
let fgRgb = { r: 255, g: 255, b: 255 }; // 默认前景色为白色
|
||||
const textFgRgb = getTrueColorRgb(textFg);
|
||||
if (textFgRgb) {
|
||||
fgRgb = textFgRgb;
|
||||
}
|
||||
|
||||
const fgColor = `\x1b[38;2;${fgRgb.r};${fgRgb.g};${fgRgb.b}m`;
|
||||
const body = `${curBg}${fgColor} ${text} \x1b[0m`;
|
||||
|
||||
if (nextBgColor != null) {
|
||||
const nextBgRgb = getTrueColorRgb(nextBgColor);
|
||||
if (nextBgRgb) {
|
||||
// 分隔符:前景色是当前段的背景色,背景色是下一段的背景色
|
||||
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
const sepNextBg = `\x1b[48;2;${nextBgRgb.r};${nextBgRgb.g};${nextBgRgb.b}m`;
|
||||
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
|
||||
return body + sep;
|
||||
}
|
||||
// 如果没有下一个背景色,假设终端背景为黑色并渲染黑色箭头
|
||||
const sepCurFg = `\x1b[38;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
const sepNextBg = `\x1b[48;2;0;0;0m`; // 黑色背景
|
||||
const sep = `${sepCurFg}${sepNextBg}${SEP_RIGHT}\x1b[0m`;
|
||||
return body + sep;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// 渲染Powerline风格的状态行
|
||||
async function renderPowerlineStyle(
|
||||
theme: StatusLineThemeConfig,
|
||||
variables: Record<string, string>
|
||||
): Promise<string> {
|
||||
const modules = theme.modules || POWERLINE_THEME.modules;
|
||||
const segments: string[] = [];
|
||||
|
||||
// 遍历模块数组,渲染每个模块
|
||||
for (let i = 0; i < Math.min(modules.length, 5); i++) {
|
||||
const module = modules[i];
|
||||
const color = module.color || "white";
|
||||
const backgroundName = module.background || "";
|
||||
const icon = module.icon || "";
|
||||
|
||||
// 如果是script类型,执行脚本获取文本
|
||||
let text = "";
|
||||
if (module.type === "script" && module.scriptPath) {
|
||||
text = await executeScript(module.scriptPath, variables);
|
||||
} else {
|
||||
text = replaceVariables(module.text, variables);
|
||||
}
|
||||
|
||||
// 构建显示文本
|
||||
let displayText = "";
|
||||
if (icon) {
|
||||
displayText += `${icon} `;
|
||||
}
|
||||
displayText += text;
|
||||
|
||||
// 如果displayText为空,或者只有图标没有实际文本,则跳过该模块
|
||||
if (!displayText || !text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取下一个模块的背景色(用于分隔符)
|
||||
let nextBackground: string | null = null;
|
||||
if (i < modules.length - 1) {
|
||||
const nextModule = modules[i + 1];
|
||||
nextBackground = nextModule.background || null;
|
||||
}
|
||||
|
||||
// 使用模块定义的背景色,或者为Powerline风格提供默认背景色
|
||||
const actualBackground = backgroundName || "bg_bright_blue";
|
||||
|
||||
// 生成段,支持十六进制颜色
|
||||
const segmentStr = segment(displayText, color, actualBackground, nextBackground);
|
||||
segments.push(segmentStr);
|
||||
}
|
||||
|
||||
return segments.join("");
|
||||
}
|
||||
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>
|
||||
|
||||
290
ui/package-lock.json
generated
290
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,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",
|
||||
@@ -1086,6 +1091,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 +1542,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 +1801,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 +3147,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 +3424,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 +3640,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 +4227,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 +4470,55 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-colorful": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
|
||||
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.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 +4557,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 +4680,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 +4827,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,8 @@
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -24,6 +26,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",
|
||||
|
||||
342
ui/pnpm-lock.yaml
generated
342
ui/pnpm-lock.yaml
generated
@@ -26,6 +26,12 @@ 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-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@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 +56,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 +504,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:
|
||||
@@ -502,6 +520,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collection@1.1.7':
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
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-compose-refs@1.1.2':
|
||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||
peerDependencies:
|
||||
@@ -533,6 +564,15 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-direction@1.1.1':
|
||||
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.10':
|
||||
resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==}
|
||||
peerDependencies:
|
||||
@@ -546,6 +586,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 +669,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 +708,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:
|
||||
@@ -655,6 +734,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11':
|
||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||
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-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
@@ -677,6 +769,32 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13':
|
||||
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||
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-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 +867,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 +1302,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 +1463,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 +1732,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 +1777,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 +1835,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 +2373,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)
|
||||
@@ -2206,6 +2384,18 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-collection@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-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-slot': 1.2.3(@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-compose-refs@1.1.2(@types/react@19.1.8)(react@19.1.0)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
@@ -2240,6 +2430,12 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-direction@1.1.1(@types/react@19.1.8)(react@19.1.0)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.10(@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.2
|
||||
@@ -2253,6 +2449,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 +2536,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 +2574,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)
|
||||
@@ -2356,6 +2593,23 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-roving-focus@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-collection': 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-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-id': 1.1.1(@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-controllable-state': 1.2.2(@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-slot@1.2.3(@types/react@19.1.8)(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
@@ -2378,6 +2632,42 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13(@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-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(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-roving-focus': 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-use-controllable-state': 1.2.2(@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-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 +2722,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 +3136,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 +3328,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 +3537,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 +3573,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 +3620,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: {}
|
||||
|
||||
329
ui/src/App.tsx
329
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";
|
||||
@@ -6,16 +6,25 @@ import { Transformers } from "@/components/Transformers";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import { Router } from "@/components/Router";
|
||||
import { JsonEditor } from "@/components/JsonEditor";
|
||||
import { LogViewer } from "@/components/LogViewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/components/ConfigProvider";
|
||||
import { api } from "@/lib/api";
|
||||
import { Settings, Languages, Save, RefreshCw, FileJson } from "lucide-react";
|
||||
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import "@/styles/animations.css";
|
||||
|
||||
function App() {
|
||||
@@ -24,14 +33,146 @@ function App() {
|
||||
const { config, error } = useConfig();
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState(false);
|
||||
const [isLogViewerOpen, setIsLogViewerOpen] = useState(false);
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
|
||||
// 版本检查状态
|
||||
const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(false);
|
||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||
const [newVersionInfo, setNewVersionInfo] = useState<{ version: string; changelog: string } | null>(null);
|
||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
||||
const [hasCheckedUpdate, setHasCheckedUpdate] = useState(false);
|
||||
const hasAutoCheckedUpdate = useRef(false);
|
||||
|
||||
const saveConfig = async () => {
|
||||
// Handle case where config might be null or undefined
|
||||
if (!config) {
|
||||
setToast({ message: t('app.config_missing'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Save to API
|
||||
const response = await api.updateConfig(config);
|
||||
// Show success message or handle as needed
|
||||
console.log('Config saved successfully');
|
||||
|
||||
// 根据响应信息进行提示
|
||||
if (response && typeof response === 'object' && 'success' in response) {
|
||||
const apiResponse = response as { success: boolean; message?: string };
|
||||
if (apiResponse.success) {
|
||||
setToast({ message: apiResponse.message || t('app.config_saved_success'), type: 'success' });
|
||||
} else {
|
||||
setToast({ message: apiResponse.message || t('app.config_saved_failed'), type: 'error' });
|
||||
}
|
||||
} else {
|
||||
// 默认成功提示
|
||||
setToast({ message: t('app.config_saved_success'), type: 'success' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
// Handle error appropriately
|
||||
setToast({ message: t('app.config_saved_failed') + ': ' + (error as Error).message, type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfigAndRestart = async () => {
|
||||
// Handle case where config might be null or undefined
|
||||
if (!config) {
|
||||
setToast({ message: t('app.config_missing'), type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Save to API
|
||||
const response = await api.updateConfig(config);
|
||||
|
||||
// Check if save was successful before restarting
|
||||
let saveSuccessful = true;
|
||||
if (response && typeof response === 'object' && 'success' in response) {
|
||||
const apiResponse = response as { success: boolean; message?: string };
|
||||
if (!apiResponse.success) {
|
||||
saveSuccessful = false;
|
||||
setToast({ message: apiResponse.message || t('app.config_saved_failed'), type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Only restart if save was successful
|
||||
if (saveSuccessful) {
|
||||
// Restart service
|
||||
const response = await api.restartService();
|
||||
|
||||
// Show success message or handle as needed
|
||||
console.log('Config saved and service restarted successfully');
|
||||
|
||||
// 根据响应信息进行提示
|
||||
if (response && typeof response === 'object' && 'success' in response) {
|
||||
const apiResponse = response as { success: boolean; message?: string };
|
||||
if (apiResponse.success) {
|
||||
setToast({ message: apiResponse.message || t('app.config_saved_restart_success'), type: 'success' });
|
||||
}
|
||||
} else {
|
||||
// 默认成功提示
|
||||
setToast({ message: t('app.config_saved_restart_success'), type: 'success' });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save config and restart:', error);
|
||||
// Handle error appropriately
|
||||
setToast({ message: t('app.config_saved_restart_failed') + ': ' + (error as Error).message, type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// 检查更新函数
|
||||
const checkForUpdates = useCallback(async (showDialog: boolean = true) => {
|
||||
// 如果已经检查过且有新版本,根据参数决定是否显示对话框
|
||||
if (hasCheckedUpdate && isNewVersionAvailable) {
|
||||
if (showDialog) {
|
||||
setIsUpdateDialogOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCheckingUpdate(true);
|
||||
try {
|
||||
const updateInfo = await api.checkForUpdates();
|
||||
|
||||
if (updateInfo.hasUpdate && updateInfo.latestVersion && updateInfo.changelog) {
|
||||
setIsNewVersionAvailable(true);
|
||||
setNewVersionInfo({
|
||||
version: updateInfo.latestVersion,
|
||||
changelog: updateInfo.changelog
|
||||
});
|
||||
// 只有在showDialog为true时才显示对话框
|
||||
if (showDialog) {
|
||||
setIsUpdateDialogOpen(true);
|
||||
}
|
||||
} else if (showDialog) {
|
||||
// 只有在showDialog为true时才显示没有更新的提示
|
||||
setToast({ message: t('app.no_updates_available'), type: 'success' });
|
||||
}
|
||||
|
||||
setHasCheckedUpdate(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
if (showDialog) {
|
||||
setToast({ message: t('app.update_check_failed') + ': ' + (error as Error).message, type: 'error' });
|
||||
}
|
||||
} finally {
|
||||
setIsCheckingUpdate(false);
|
||||
}
|
||||
}, [hasCheckedUpdate, isNewVersionAvailable, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
// If we already have a config, we're authenticated
|
||||
if (config) {
|
||||
setIsCheckingAuth(false);
|
||||
// 自动检查更新,但不显示对话框
|
||||
if (!hasCheckedUpdate && !hasAutoCheckedUpdate.current) {
|
||||
hasAutoCheckedUpdate.current = true;
|
||||
checkForUpdates(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,6 +198,11 @@ function App() {
|
||||
}
|
||||
} finally {
|
||||
setIsCheckingAuth(false);
|
||||
// 在获取配置完成后检查更新,但不显示对话框
|
||||
if (!hasCheckedUpdate && !hasAutoCheckedUpdate.current) {
|
||||
hasAutoCheckedUpdate.current = true;
|
||||
checkForUpdates(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,90 +218,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 (
|
||||
@@ -169,6 +278,9 @@ function App() {
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsJsonEditorOpen(true)} className="transition-all-ease hover:scale-110">
|
||||
<FileJson className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsLogViewerOpen(true)} className="transition-all-ease hover:scale-110">
|
||||
<FileText className="h-5 w-5" />
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="transition-all-ease hover:scale-110">
|
||||
@@ -194,6 +306,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')}
|
||||
@@ -204,7 +336,7 @@ function App() {
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex h-[calc(100vh-4rem)] gap-4 p-4">
|
||||
<main className="flex h-[calc(100vh-4rem)] gap-4 p-4 overflow-hidden">
|
||||
<div className="w-3/5">
|
||||
<Providers />
|
||||
</div>
|
||||
@@ -212,7 +344,7 @@ function App() {
|
||||
<div className="h-3/5">
|
||||
<Router />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Transformers />
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,6 +355,51 @@ function App() {
|
||||
onOpenChange={setIsJsonEditorOpen}
|
||||
showToast={(message, type) => setToast({ message, type })}
|
||||
/>
|
||||
<LogViewer
|
||||
open={isLogViewerOpen}
|
||||
onOpenChange={setIsLogViewerOpen}
|
||||
showToast={(message, type) => setToast({ message, type })}
|
||||
/>
|
||||
{/* 版本更新对话框 */}
|
||||
<Dialog open={isUpdateDialogOpen} onOpenChange={setIsUpdateDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('app.new_version_available')}
|
||||
{newVersionInfo && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
v{newVersionInfo.version}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('app.update_description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-96 overflow-y-auto py-4">
|
||||
{newVersionInfo?.changelog ? (
|
||||
<div className="whitespace-pre-wrap text-sm">
|
||||
{newVersionInfo.changelog}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{t('app.no_changelog_available')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsUpdateDialogOpen(false)}
|
||||
>
|
||||
{t('app.later')}
|
||||
</Button>
|
||||
<Button onClick={performUpdate}>
|
||||
{t('app.update_now')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
|
||||
@@ -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,51 @@ 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 : 'debug',
|
||||
CLAUDE_PATH: typeof data.CLAUDE_PATH === 'string' ? data.CLAUDE_PATH : '',
|
||||
HOST: typeof data.HOST === 'string' ? data.HOST : '127.0.0.1',
|
||||
PORT: typeof data.PORT === 'number' ? data.PORT : 3456,
|
||||
APIKEY: typeof data.APIKEY === 'string' ? data.APIKEY : '',
|
||||
API_TIMEOUT_MS: typeof data.API_TIMEOUT_MS === 'string' ? data.API_TIMEOUT_MS : '600000',
|
||||
PROXY_URL: typeof data.PROXY_URL === 'string' ? data.PROXY_URL : '',
|
||||
transformers: Array.isArray(data.transformers) ? data.transformers : [],
|
||||
Providers: Array.isArray(data.Providers) ? data.Providers : [],
|
||||
StatusLine: data.StatusLine && typeof data.StatusLine === 'object' ? {
|
||||
enabled: typeof data.StatusLine.enabled === 'boolean' ? data.StatusLine.enabled : false,
|
||||
currentStyle: typeof data.StatusLine.currentStyle === 'string' ? data.StatusLine.currentStyle : 'default',
|
||||
default: data.StatusLine.default && typeof data.StatusLine.default === 'object' && Array.isArray(data.StatusLine.default.modules) ? data.StatusLine.default : { modules: [] },
|
||||
powerline: data.StatusLine.powerline && typeof data.StatusLine.powerline === 'object' && Array.isArray(data.StatusLine.powerline.modules) ? data.StatusLine.powerline : { modules: [] }
|
||||
} : {
|
||||
enabled: false,
|
||||
currentStyle: 'default',
|
||||
default: { modules: [] },
|
||||
powerline: { modules: [] }
|
||||
},
|
||||
Router: data.Router && typeof data.Router === 'object' ? {
|
||||
default: typeof data.Router.default === 'string' ? data.Router.default : '',
|
||||
background: typeof data.Router.background === 'string' ? data.Router.background : '',
|
||||
think: typeof data.Router.think === 'string' ? data.Router.think : '',
|
||||
longContext: typeof data.Router.longContext === 'string' ? data.Router.longContext : '',
|
||||
longContextThreshold: typeof data.Router.longContextThreshold === 'number' ? data.Router.longContextThreshold : 60000,
|
||||
webSearch: typeof data.Router.webSearch === 'string' ? data.Router.webSearch : '',
|
||||
image: typeof data.Router.image === 'string' ? data.Router.image : ''
|
||||
} : {
|
||||
default: '',
|
||||
background: '',
|
||||
think: '',
|
||||
longContext: '',
|
||||
longContextThreshold: 60000,
|
||||
webSearch: '',
|
||||
image: ''
|
||||
},
|
||||
CUSTOM_ROUTER_PATH: typeof data.CUSTOM_ROUTER_PATH === 'string' ? data.CUSTOM_ROUTER_PATH : ''
|
||||
};
|
||||
|
||||
setConfig(validConfig);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config:', err);
|
||||
// If we get a 401, the API client will redirect to login
|
||||
@@ -117,19 +118,26 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
|
||||
// Set default empty config when fetch fails
|
||||
setConfig({
|
||||
LOG: false,
|
||||
LOG_LEVEL: 'debug',
|
||||
CLAUDE_PATH: '',
|
||||
HOST: '127.0.0.1',
|
||||
PORT: 3456,
|
||||
APIKEY: '',
|
||||
API_TIMEOUT_MS: '600000',
|
||||
PROXY_URL: '',
|
||||
transformers: [],
|
||||
Providers: [],
|
||||
StatusLine: undefined,
|
||||
Router: {
|
||||
default: '',
|
||||
background: '',
|
||||
think: '',
|
||||
longContext: '',
|
||||
webSearch: ''
|
||||
}
|
||||
longContextThreshold: 60000,
|
||||
webSearch: '',
|
||||
image: ''
|
||||
},
|
||||
CUSTOM_ROUTER_PATH: ''
|
||||
});
|
||||
setError(err as Error);
|
||||
}
|
||||
|
||||
496
ui/src/components/DebugPage.tsx
Normal file
496
ui/src/components/DebugPage.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, Send, Copy, Square, History, Maximize } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MonacoEditor from '@monaco-editor/react';
|
||||
import { RequestHistoryDrawer } from './RequestHistoryDrawer';
|
||||
import { requestHistoryDB } from '@/lib/db';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
export function DebugPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [requestData, setRequestData] = useState({
|
||||
url: '',
|
||||
method: 'POST',
|
||||
headers: '{}',
|
||||
body: '{}'
|
||||
});
|
||||
const [responseData, setResponseData] = useState({
|
||||
status: 0,
|
||||
responseTime: 0,
|
||||
body: '',
|
||||
headers: '{}'
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isHistoryDrawerOpen, setIsHistoryDrawerOpen] = useState(false);
|
||||
const [fullscreenEditor, setFullscreenEditor] = useState<'headers' | 'body' | null>(null);
|
||||
const headersEditorRef = useRef<any>(null);
|
||||
const bodyEditorRef = useRef<any>(null);
|
||||
|
||||
// 切换全屏模式
|
||||
const toggleFullscreen = (editorType: 'headers' | 'body') => {
|
||||
const isEnteringFullscreen = fullscreenEditor !== editorType;
|
||||
setFullscreenEditor(isEnteringFullscreen ? editorType : null);
|
||||
|
||||
// 延迟触发Monaco编辑器的重新布局,等待DOM更新完成
|
||||
setTimeout(() => {
|
||||
if (headersEditorRef.current) {
|
||||
headersEditorRef.current.layout();
|
||||
}
|
||||
if (bodyEditorRef.current) {
|
||||
bodyEditorRef.current.layout();
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 从URL参数中解析日志数据
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const logDataParam = params.get('logData');
|
||||
|
||||
if (logDataParam) {
|
||||
try {
|
||||
const parsedData = JSON.parse(decodeURIComponent(logDataParam));
|
||||
|
||||
// 解析URL - 支持多种字段名
|
||||
const url = parsedData.url || parsedData.requestUrl || parsedData.endpoint || '';
|
||||
|
||||
// 解析Method - 支持多种字段名和大小写
|
||||
const method = (parsedData.method || parsedData.requestMethod || 'POST').toUpperCase();
|
||||
|
||||
// 解析Headers - 支持多种格式
|
||||
let headers: Record<string, string> = {};
|
||||
if (parsedData.headers) {
|
||||
if (typeof parsedData.headers === 'string') {
|
||||
try {
|
||||
headers = JSON.parse(parsedData.headers);
|
||||
} catch {
|
||||
// 如果是字符串格式,尝试解析为键值对
|
||||
const headerLines = parsedData.headers.split('\n');
|
||||
headerLines.forEach((line: string) => {
|
||||
const [key, ...values] = line.split(':');
|
||||
if (key && values.length > 0) {
|
||||
headers[key.trim()] = values.join(':').trim();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
headers = parsedData.headers;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析Body - 支持多种格式和嵌套结构
|
||||
let body: Record<string, unknown> = {};
|
||||
let bodyData = null;
|
||||
|
||||
// 支持多种字段名和嵌套结构
|
||||
if (parsedData.body) {
|
||||
bodyData = parsedData.body;
|
||||
} else if (parsedData.request && parsedData.request.body) {
|
||||
bodyData = parsedData.request.body;
|
||||
}
|
||||
|
||||
if (bodyData) {
|
||||
if (typeof bodyData === 'string') {
|
||||
try {
|
||||
// 尝试解析为JSON对象
|
||||
const parsed = JSON.parse(bodyData);
|
||||
body = parsed;
|
||||
} catch {
|
||||
// 如果不是JSON,检查是否是纯文本
|
||||
const trimmed = bodyData.trim();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
// 看起来像JSON但解析失败,作为字符串保存
|
||||
body = { raw: bodyData };
|
||||
} else {
|
||||
// 普通文本,直接保存
|
||||
body = { content: bodyData };
|
||||
}
|
||||
}
|
||||
} else if (typeof bodyData === 'object') {
|
||||
// 已经是对象,直接使用
|
||||
body = bodyData;
|
||||
} else {
|
||||
// 其他类型,转换为字符串
|
||||
body = { content: String(bodyData) };
|
||||
}
|
||||
}
|
||||
|
||||
// 预填充请求表单
|
||||
setRequestData({
|
||||
url,
|
||||
method,
|
||||
headers: JSON.stringify(headers, null, 2),
|
||||
body: JSON.stringify(body, null, 2)
|
||||
});
|
||||
|
||||
console.log('Log data parsed successfully:', { url, method, headers, body });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse log data:', error);
|
||||
console.error('Raw log data:', logDataParam);
|
||||
}
|
||||
}
|
||||
}, [location.search]);
|
||||
|
||||
// 发送请求
|
||||
const sendRequest = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const headers = JSON.parse(requestData.headers);
|
||||
const body = JSON.parse(requestData.body);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await fetch(requestData.url, {
|
||||
method: requestData.method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
body: requestData.method !== 'GET' ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
const endTime = Date.now();
|
||||
const responseTime = endTime - startTime;
|
||||
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
let responseBody = responseText;
|
||||
|
||||
// 尝试解析JSON响应
|
||||
try {
|
||||
const jsonResponse = JSON.parse(responseText);
|
||||
responseBody = JSON.stringify(jsonResponse, null, 2);
|
||||
} catch {
|
||||
// 如果不是JSON,保持原样
|
||||
}
|
||||
|
||||
const responseHeadersString = JSON.stringify(responseHeaders, null, 2);
|
||||
|
||||
setResponseData({
|
||||
status: response.status,
|
||||
responseTime,
|
||||
body: responseBody,
|
||||
headers: responseHeadersString
|
||||
});
|
||||
|
||||
// 保存到IndexedDB
|
||||
await requestHistoryDB.saveRequest({
|
||||
url: requestData.url,
|
||||
method: requestData.method,
|
||||
headers: requestData.headers,
|
||||
body: requestData.body,
|
||||
status: response.status,
|
||||
responseTime,
|
||||
responseBody,
|
||||
responseHeaders: responseHeadersString
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Request failed:', error);
|
||||
setResponseData({
|
||||
status: 0,
|
||||
responseTime: 0,
|
||||
body: `请求失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
headers: '{}'
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 从历史记录中选择请求
|
||||
const handleSelectRequest = (request: import('@/lib/db').RequestHistoryItem) => {
|
||||
setRequestData({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body
|
||||
});
|
||||
|
||||
setResponseData({
|
||||
status: request.status,
|
||||
responseTime: request.responseTime,
|
||||
body: request.responseBody,
|
||||
headers: request.responseHeaders
|
||||
});
|
||||
};
|
||||
|
||||
// 复制cURL命令
|
||||
const copyCurl = () => {
|
||||
try {
|
||||
const headers = JSON.parse(requestData.headers);
|
||||
const body = JSON.parse(requestData.body);
|
||||
|
||||
let curlCommand = `curl -X ${requestData.method} "${requestData.url}"`;
|
||||
|
||||
// 添加headers
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
curlCommand += ` \\\n -H "${key}: ${value}"`;
|
||||
});
|
||||
|
||||
// 添加body
|
||||
if (requestData.method !== 'GET' && Object.keys(body).length > 0) {
|
||||
curlCommand += ` \\\n -d '${JSON.stringify(body)}'`;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(curlCommand);
|
||||
alert('cURL命令已复制到剪贴板');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy cURL:', error);
|
||||
alert('复制cURL命令失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-50 font-sans">
|
||||
{/* 头部 */}
|
||||
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/dashboard')}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
返回
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-gray-800">HTTP 调试器</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setIsHistoryDrawerOpen(true)}>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
历史记录
|
||||
</Button>
|
||||
<Button variant="outline" onClick={copyCurl}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
复制 cURL
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主要内容 */}
|
||||
<main className="flex h-[calc(100vh-4rem)] flex-col gap-4 p-4 overflow-hidden">
|
||||
{/* 上部分:请求参数配置 - 上中下布局 */}
|
||||
<div className="h-1/2 flex flex-col gap-4">
|
||||
<div className="bg-white rounded-lg border p-4 flex-1 flex flex-col">
|
||||
<h3 className="font-medium mb-4">请求参数配置</h3>
|
||||
<div className="flex flex-col gap-4 flex-1">
|
||||
{/* 上:Method、URL和发送请求按钮配置 */}
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="w-32">
|
||||
<label className="block text-sm font-medium mb-1">Method</label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
||||
value={requestData.method}
|
||||
onChange={(e) => setRequestData(prev => ({ ...prev, method: e.target.value }))}
|
||||
>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium mb-1">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
||||
value={requestData.url}
|
||||
onChange={(e) => setRequestData(prev => ({ ...prev, url: e.target.value }))}
|
||||
placeholder="https://api.example.com/endpoint"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={isLoading ? "destructive" : "default"}
|
||||
onClick={isLoading ? () => {} : sendRequest}
|
||||
disabled={isLoading || !requestData.url.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
请求中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
发送请求
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Headers和Body配置 - 使用tab布局 */}
|
||||
<div className="flex-1">
|
||||
<Tabs defaultValue="headers" className="h-full flex flex-col">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="headers">Headers</TabsTrigger>
|
||||
<TabsTrigger value="body">Body</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="headers" className="flex-1 mt-2">
|
||||
<div
|
||||
className={`${fullscreenEditor === 'headers' ? '' : 'h-full'} flex flex-col ${
|
||||
fullscreenEditor === 'headers' ? 'fixed bg-white w-[100vw] h-[100vh] z-[9999] top-0 left-0 p-4' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium">Headers (JSON)</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleFullscreen('headers')}
|
||||
>
|
||||
<Maximize className="h-4 w-4 mr-1" />
|
||||
{fullscreenEditor === 'headers' ? '退出全屏' : '全屏'}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
id="fullscreen-headers"
|
||||
className={`${fullscreenEditor === 'headers' ? 'h-full' : 'flex-1'} border border-gray-300 rounded-md overflow-hidden relative`}
|
||||
>
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language="json"
|
||||
value={requestData.headers}
|
||||
onChange={(value) => setRequestData(prev => ({ ...prev, headers: value || '{}' }))}
|
||||
onMount={(editor) => {
|
||||
headersEditorRef.current = editor;
|
||||
}}
|
||||
options={{
|
||||
minimap: { enabled: fullscreenEditor === 'headers' },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="body" className="flex-1 mt-2">
|
||||
<div
|
||||
className={`${fullscreenEditor === 'body' ? '' : 'h-full'} flex flex-col ${
|
||||
fullscreenEditor === 'body' ? 'fixed bg-white w-[100vw] h-[100vh] z-[9999] top-0 left-0 p-4' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium">Body (JSON)</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleFullscreen('body')}
|
||||
>
|
||||
<Maximize className="h-4 w-4 mr-1" />
|
||||
{fullscreenEditor === 'body' ? '退出全屏' : '全屏'}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
id="fullscreen-body"
|
||||
className={`${fullscreenEditor === 'body' ? 'h-full' : 'flex-1'} border border-gray-300 rounded-md overflow-hidden relative`}
|
||||
>
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language="json"
|
||||
value={requestData.body}
|
||||
onChange={(value) => setRequestData(prev => ({ ...prev, body: value || '{}' }))}
|
||||
onMount={(editor) => {
|
||||
bodyEditorRef.current = editor;
|
||||
}}
|
||||
options={{
|
||||
minimap: { enabled: fullscreenEditor === 'body' },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下部分:响应信息查看 */}
|
||||
<div className="h-1/2 flex flex-col gap-4">
|
||||
<div className="flex-1 bg-white rounded-lg border p-4 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium">响应信息</h3>
|
||||
{responseData.status > 0 && (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
状态码: <span className={`font-mono px-2 py-1 rounded ${
|
||||
responseData.status >= 200 && responseData.status < 300
|
||||
? 'bg-green-100 text-green-800'
|
||||
: responseData.status >= 400
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{responseData.status}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
响应时间: <span className="font-mono">{responseData.responseTime}ms</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{responseData.body ? (
|
||||
<div className="flex-1">
|
||||
<Tabs defaultValue="body" className="h-full flex flex-col">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="body">响应体</TabsTrigger>
|
||||
<TabsTrigger value="headers">响应头</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="body" className="flex-1 mt-2">
|
||||
<div className="bg-gray-50 border rounded-md p-3 h-full overflow-auto">
|
||||
<pre className="text-sm whitespace-pre-wrap">
|
||||
{responseData.body}
|
||||
</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="headers" className="flex-1 mt-2">
|
||||
<div className="bg-gray-50 border rounded-md p-3 h-full overflow-auto">
|
||||
<pre className="text-sm">
|
||||
{responseData.headers}
|
||||
</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
{isLoading ? '发送请求中...' : '发送请求后将在此显示响应'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 请求历史抽屉 */}
|
||||
<RequestHistoryDrawer
|
||||
isOpen={isHistoryDrawerOpen}
|
||||
onClose={() => setIsHistoryDrawerOpen(false)}
|
||||
onSelectRequest={handleSelectRequest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
911
ui/src/components/LogViewer.tsx
Normal file
911
ui/src/components/LogViewer.tsx
Normal file
@@ -0,0 +1,911 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, RefreshCw, Download, Trash2, ArrowLeft, File, Layers, Bug } from 'lucide-react';
|
||||
|
||||
interface LogViewerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
showToast?: (message: string, type: 'success' | 'error' | 'warning') => void;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
level: 'info' | 'warn' | 'error' | 'debug';
|
||||
message: string; // 现在这个字段直接包含原始JSON字符串
|
||||
source?: string;
|
||||
reqId?: string;
|
||||
[key: string]: any; // 允许动态属性,如msg、url、body等
|
||||
}
|
||||
|
||||
interface LogFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
interface GroupedLogs {
|
||||
[reqId: string]: LogEntry[];
|
||||
}
|
||||
|
||||
interface LogGroupSummary {
|
||||
reqId: string;
|
||||
logCount: number;
|
||||
firstLog: string;
|
||||
lastLog: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface GroupedLogsResponse {
|
||||
grouped: boolean;
|
||||
groups: { [reqId: string]: LogEntry[] };
|
||||
summary: {
|
||||
totalRequests: number;
|
||||
totalLogs: number;
|
||||
requests: LogGroupSummary[];
|
||||
};
|
||||
}
|
||||
|
||||
export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [logFiles, setLogFiles] = useState<LogFile[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<LogFile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [groupByReqId, setGroupByReqId] = useState(false);
|
||||
const [groupedLogs, setGroupedLogs] = useState<GroupedLogsResponse | null>(null);
|
||||
const [selectedReqId, setSelectedReqId] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const refreshInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadLogFiles();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 创建内联 Web Worker
|
||||
const createInlineWorker = (): Worker => {
|
||||
const workerCode = `
|
||||
// 日志聚合Web Worker
|
||||
self.onmessage = function(event) {
|
||||
const { type, data } = event.data;
|
||||
|
||||
if (type === 'groupLogsByReqId') {
|
||||
try {
|
||||
const { logs } = data;
|
||||
|
||||
// 按reqId聚合日志
|
||||
const groupedLogs = {};
|
||||
|
||||
logs.forEach((log, index) => {
|
||||
log = JSON.parse(log);
|
||||
let reqId = log.reqId || 'no-req-id';
|
||||
|
||||
if (!groupedLogs[reqId]) {
|
||||
groupedLogs[reqId] = [];
|
||||
}
|
||||
groupedLogs[reqId].push(log);
|
||||
});
|
||||
|
||||
// 按时间戳排序每个组的日志
|
||||
Object.keys(groupedLogs).forEach(reqId => {
|
||||
groupedLogs[reqId].sort((a, b) => a.time - b.time);
|
||||
});
|
||||
|
||||
// 提取model信息
|
||||
const extractModelInfo = (reqId) => {
|
||||
const logGroup = groupedLogs[reqId];
|
||||
for (const log of logGroup) {
|
||||
try {
|
||||
// 尝试从message字段解析JSON
|
||||
if (log.type === 'request body' && log.data && log.data.model) {
|
||||
return log.data.model;
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,继续尝试下一条日志
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 生成摘要信息
|
||||
const summary = {
|
||||
totalRequests: Object.keys(groupedLogs).length,
|
||||
totalLogs: logs.length,
|
||||
requests: Object.keys(groupedLogs).map(reqId => ({
|
||||
reqId,
|
||||
logCount: groupedLogs[reqId].length,
|
||||
firstLog: groupedLogs[reqId][0]?.time,
|
||||
lastLog: groupedLogs[reqId][groupedLogs[reqId].length - 1]?.time,
|
||||
model: extractModelInfo(reqId)
|
||||
}))
|
||||
};
|
||||
|
||||
const response = {
|
||||
grouped: true,
|
||||
groups: groupedLogs,
|
||||
summary
|
||||
};
|
||||
|
||||
// 发送结果回主线程
|
||||
self.postMessage({
|
||||
type: 'groupLogsResult',
|
||||
data: response
|
||||
});
|
||||
} catch (error) {
|
||||
// 发送错误回主线程
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
const blob = new Blob([workerCode], { type: 'application/javascript' });
|
||||
const workerUrl = URL.createObjectURL(blob);
|
||||
return new Worker(workerUrl);
|
||||
};
|
||||
|
||||
// 初始化Web Worker
|
||||
useEffect(() => {
|
||||
if (typeof Worker !== 'undefined') {
|
||||
try {
|
||||
// 创建内联Web Worker
|
||||
workerRef.current = createInlineWorker();
|
||||
|
||||
// 监听Worker消息
|
||||
workerRef.current.onmessage = (event) => {
|
||||
const { type, data, error } = event.data;
|
||||
|
||||
if (type === 'groupLogsResult') {
|
||||
setGroupedLogs(data);
|
||||
} else if (type === 'error') {
|
||||
console.error('Worker error:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.worker_error') + ': ' + error, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听Worker错误
|
||||
workerRef.current.onerror = (error) => {
|
||||
console.error('Worker error:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.worker_init_failed'), 'error');
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create worker:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.worker_init_failed'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理Worker
|
||||
return () => {
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
workerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefresh && open && selectedFile) {
|
||||
refreshInterval.current = setInterval(() => {
|
||||
loadLogs();
|
||||
}, 5000); // Refresh every 5 seconds
|
||||
} else if (refreshInterval.current) {
|
||||
clearInterval(refreshInterval.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refreshInterval.current) {
|
||||
clearInterval(refreshInterval.current);
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, open, selectedFile]);
|
||||
|
||||
// Load logs when selected file changes
|
||||
useEffect(() => {
|
||||
if (selectedFile && open) {
|
||||
setLogs([]); // Clear existing logs
|
||||
loadLogs();
|
||||
}
|
||||
}, [selectedFile, open]);
|
||||
|
||||
// Handle open/close animations
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setIsVisible(true);
|
||||
// Trigger the animation after a small delay to ensure the element is rendered
|
||||
requestAnimationFrame(() => {
|
||||
setIsAnimating(true);
|
||||
});
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
// Wait for the animation to complete before hiding
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadLogFiles = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.getLogFiles();
|
||||
|
||||
if (response && Array.isArray(response)) {
|
||||
setLogFiles(response);
|
||||
setSelectedFile(null);
|
||||
setLogs([]);
|
||||
} else {
|
||||
setLogFiles([]);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.no_log_files_available'), 'warning');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load log files:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.load_files_failed') + ': ' + (error as Error).message, 'error');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setGroupedLogs(null);
|
||||
setSelectedReqId(null);
|
||||
|
||||
// 始终加载原始日志数据
|
||||
const response = await api.getLogs(selectedFile.path);
|
||||
|
||||
if (response && Array.isArray(response)) {
|
||||
// 现在接口返回的是原始日志字符串数组,直接存储
|
||||
setLogs(response);
|
||||
|
||||
// 如果启用了分组,使用Web Worker进行聚合(需要转换为LogEntry格式供Worker使用)
|
||||
if (groupByReqId && workerRef.current) {
|
||||
// const workerLogs: LogEntry[] = response.map((logLine, index) => ({
|
||||
// timestamp: new Date().toISOString(),
|
||||
// level: 'info',
|
||||
// message: logLine,
|
||||
// source: undefined,
|
||||
// reqId: undefined
|
||||
// }));
|
||||
|
||||
workerRef.current.postMessage({
|
||||
type: 'groupLogsByReqId',
|
||||
data: { logs: response }
|
||||
});
|
||||
} else {
|
||||
setGroupedLogs(null);
|
||||
}
|
||||
} else {
|
||||
setLogs([]);
|
||||
setGroupedLogs(null);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.no_logs_available'), 'warning');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.load_failed') + ': ' + (error as Error).message, 'error');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
try {
|
||||
await api.clearLogs(selectedFile.path);
|
||||
setLogs([]);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.logs_cleared'), 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear logs:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.clear_failed') + ': ' + (error as Error).message, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectFile = (file: LogFile) => {
|
||||
setSelectedFile(file);
|
||||
setAutoRefresh(false); // Reset auto refresh when changing files
|
||||
};
|
||||
|
||||
|
||||
const toggleGroupByReqId = () => {
|
||||
const newValue = !groupByReqId;
|
||||
setGroupByReqId(newValue);
|
||||
|
||||
if (newValue && selectedFile && logs.length > 0) {
|
||||
// 启用聚合时,如果已有日志,则使用Worker进行聚合
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({
|
||||
type: 'groupLogsByReqId',
|
||||
data: { logs }
|
||||
});
|
||||
}
|
||||
} else if (!newValue) {
|
||||
// 禁用聚合时,清除聚合结果
|
||||
setGroupedLogs(null);
|
||||
setSelectedReqId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const selectReqId = (reqId: string) => {
|
||||
setSelectedReqId(reqId);
|
||||
};
|
||||
|
||||
|
||||
const getDisplayLogs = () => {
|
||||
if (groupByReqId && groupedLogs) {
|
||||
if (selectedReqId && groupedLogs.groups[selectedReqId]) {
|
||||
return groupedLogs.groups[selectedReqId];
|
||||
}
|
||||
// 当在分组模式但没有选中具体请求时,显示原始日志字符串数组
|
||||
return logs.map(logLine => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: logLine,
|
||||
source: undefined,
|
||||
reqId: undefined
|
||||
}));
|
||||
}
|
||||
// 当不在分组模式时,显示原始日志字符串数组
|
||||
return logs.map(logLine => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: logLine,
|
||||
source: undefined,
|
||||
reqId: undefined
|
||||
}));
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
if (!selectedFile || logs.length === 0) return;
|
||||
|
||||
// 直接下载原始日志字符串,每行一个日志
|
||||
const logText = logs.join('\n');
|
||||
|
||||
const blob = new Blob([logText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedFile.name}-${new Date().toISOString().split('T')[0]}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.logs_downloaded'), 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
// 面包屑导航项类型
|
||||
interface BreadcrumbItem {
|
||||
id: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// 获取面包屑导航项
|
||||
const getBreadcrumbs = (): BreadcrumbItem[] => {
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
id: 'root',
|
||||
label: t('log_viewer.title'),
|
||||
onClick: () => {
|
||||
setSelectedFile(null);
|
||||
setAutoRefresh(false);
|
||||
setLogs([]);
|
||||
setGroupedLogs(null);
|
||||
setSelectedReqId(null);
|
||||
setGroupByReqId(false);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (selectedFile) {
|
||||
breadcrumbs.push({
|
||||
id: 'file',
|
||||
label: selectedFile.name,
|
||||
onClick: () => {
|
||||
if (groupByReqId) {
|
||||
// 如果在分组模式下,点击文件层级应该返回到分组列表
|
||||
setSelectedReqId(null);
|
||||
} else {
|
||||
// 如果不在分组模式下,点击文件层级关闭分组功能
|
||||
setSelectedReqId(null);
|
||||
setGroupedLogs(null);
|
||||
setGroupByReqId(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedReqId) {
|
||||
breadcrumbs.push({
|
||||
id: 'req',
|
||||
label: `${t('log_viewer.request')} ${selectedReqId}`,
|
||||
onClick: () => {
|
||||
// 点击当前层级时不做任何操作
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
// 获取返回按钮的处理函数
|
||||
const getBackAction = (): (() => void) | null => {
|
||||
if (selectedReqId) {
|
||||
return () => {
|
||||
setSelectedReqId(null);
|
||||
};
|
||||
} else if (selectedFile) {
|
||||
return () => {
|
||||
setSelectedFile(null);
|
||||
setAutoRefresh(false);
|
||||
setLogs([]);
|
||||
setGroupedLogs(null);
|
||||
setSelectedReqId(null);
|
||||
setGroupByReqId(false);
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatLogsForEditor = () => {
|
||||
// 如果在分组模式且选中了具体请求,显示该请求的日志
|
||||
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
|
||||
const requestLogs = groupedLogs.groups[selectedReqId];
|
||||
console.log(requestLogs)
|
||||
// 提取原始JSON字符串并每行一个
|
||||
return requestLogs.map(log => JSON.stringify(log)).join('\n');
|
||||
}
|
||||
|
||||
// 其他情况,直接显示原始日志字符串数组,每行一个
|
||||
return logs.join('\n');
|
||||
};
|
||||
|
||||
// 解析日志行,获取final request的行号
|
||||
const getFinalRequestLines = () => {
|
||||
const lines: number[] = [];
|
||||
|
||||
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
|
||||
// 分组模式下,检查选中的请求日志
|
||||
const requestLogs = groupedLogs.groups[selectedReqId];
|
||||
requestLogs.forEach((log, index) => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
log = JSON.parse(log)
|
||||
// 检查日志的msg字段是否等于"final request"
|
||||
if (log.msg === "final request") {
|
||||
lines.push(index + 1); // 行号从1开始
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,跳过
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 非分组模式下,检查原始日志
|
||||
logs.forEach((logLine, index) => {
|
||||
try {
|
||||
const log = JSON.parse(logLine);
|
||||
// 检查日志的msg字段是否等于"final request"
|
||||
if (log.msg === "final request") {
|
||||
lines.push(index + 1); // 行号从1开始
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,跳过
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
// 处理调试按钮点击
|
||||
const handleDebugClick = (lineNumber: number) => {
|
||||
console.log('handleDebugClick called with lineNumber:', lineNumber);
|
||||
console.log('Current state:', { groupByReqId, selectedReqId, logsLength: logs.length });
|
||||
|
||||
let logData = null;
|
||||
|
||||
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
|
||||
// 分组模式下获取日志数据
|
||||
const requestLogs = groupedLogs.groups[selectedReqId];
|
||||
console.log('Group mode - requestLogs length:', requestLogs.length);
|
||||
logData = requestLogs[lineNumber - 1]; // 行号转换为数组索引
|
||||
console.log('Group mode - logData:', logData);
|
||||
} else {
|
||||
// 非分组模式下获取日志数据
|
||||
console.log('Non-group mode - logs length:', logs.length);
|
||||
try {
|
||||
const logLine = logs[lineNumber - 1];
|
||||
console.log('Log line:', logLine);
|
||||
logData = JSON.parse(logLine);
|
||||
console.log('Parsed logData:', logData);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse log data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (logData) {
|
||||
console.log('Navigating to debug page with logData:', logData);
|
||||
// 导航到调试页面,并传递日志数据作为URL参数
|
||||
const logDataParam = encodeURIComponent(JSON.stringify(logData));
|
||||
console.log('Encoded logDataParam length:', logDataParam.length);
|
||||
navigate(`/debug?logData=${logDataParam}`);
|
||||
} else {
|
||||
console.error('No log data found for line:', lineNumber);
|
||||
}
|
||||
};
|
||||
|
||||
// 配置Monaco Editor
|
||||
const configureEditor = (editor: any) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// 启用glyph margin
|
||||
editor.updateOptions({
|
||||
glyphMargin: true,
|
||||
});
|
||||
|
||||
// 存储当前的装饰ID
|
||||
let currentDecorations: string[] = [];
|
||||
|
||||
// 添加glyph margin装饰
|
||||
const updateDecorations = () => {
|
||||
const finalRequestLines = getFinalRequestLines();
|
||||
const decorations = finalRequestLines.map(lineNumber => ({
|
||||
range: {
|
||||
startLineNumber: lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: lineNumber,
|
||||
endColumn: 1
|
||||
},
|
||||
options: {
|
||||
glyphMarginClassName: 'debug-button-glyph',
|
||||
glyphMarginHoverMessage: { value: '点击调试此请求' }
|
||||
}
|
||||
}));
|
||||
|
||||
// 使用deltaDecorations正确更新装饰,清理旧的装饰
|
||||
currentDecorations = editor.deltaDecorations(currentDecorations, decorations);
|
||||
};
|
||||
|
||||
// 初始更新装饰
|
||||
updateDecorations();
|
||||
|
||||
// 监听glyph margin点击 - 使用正确的事件监听方式
|
||||
editor.onMouseDown((e: any) => {
|
||||
console.log('Mouse down event:', e.target);
|
||||
console.log('Event details:', {
|
||||
type: e.target.type,
|
||||
hasDetail: !!e.target.detail,
|
||||
glyphMarginLane: e.target.detail?.glyphMarginLane,
|
||||
offsetX: e.target.detail?.offsetX,
|
||||
glyphMarginLeft: e.target.detail?.glyphMarginLeft,
|
||||
glyphMarginWidth: e.target.detail?.glyphMarginWidth
|
||||
});
|
||||
|
||||
// 检查是否点击在glyph margin区域
|
||||
const isGlyphMarginClick = e.target.detail &&
|
||||
e.target.detail.glyphMarginLane !== undefined &&
|
||||
e.target.detail.offsetX !== undefined &&
|
||||
e.target.detail.offsetX <= e.target.detail.glyphMarginLeft + e.target.detail.glyphMarginWidth;
|
||||
|
||||
console.log('Is glyph margin click:', isGlyphMarginClick);
|
||||
|
||||
if (e.target.position && isGlyphMarginClick) {
|
||||
const finalRequestLines = getFinalRequestLines();
|
||||
console.log('Final request lines:', finalRequestLines);
|
||||
console.log('Clicked line number:', e.target.position.lineNumber);
|
||||
if (finalRequestLines.includes(e.target.position.lineNumber)) {
|
||||
console.log('Opening debug page for line:', e.target.position.lineNumber);
|
||||
handleDebugClick(e.target.position.lineNumber);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 尝试使用 onGlyphMarginClick 如果可用
|
||||
if (typeof editor.onGlyphMarginClick === 'function') {
|
||||
editor.onGlyphMarginClick((e: any) => {
|
||||
console.log('Glyph margin click event:', e);
|
||||
const finalRequestLines = getFinalRequestLines();
|
||||
if (finalRequestLines.includes(e.target.position.lineNumber)) {
|
||||
console.log('Opening debug page for line (glyph):', e.target.position.lineNumber);
|
||||
handleDebugClick(e.target.position.lineNumber);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 添加鼠标移动事件来检测悬停在调试按钮上
|
||||
editor.onMouseMove((e: any) => {
|
||||
if (e.target.position && (e.target.type === 4 || e.target.type === 'glyph-margin')) {
|
||||
const finalRequestLines = getFinalRequestLines();
|
||||
if (finalRequestLines.includes(e.target.position.lineNumber)) {
|
||||
// 可以在这里添加悬停效果
|
||||
editor.updateOptions({
|
||||
glyphMargin: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 当日志变化时更新装饰
|
||||
const interval = setInterval(updateDecorations, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
// 清理装饰
|
||||
if (editorRef.current) {
|
||||
editorRef.current.deltaDecorations(currentDecorations, []);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (!isVisible && !open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isVisible || open) && (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 transition-all duration-300 ease-out ${
|
||||
isAnimating && open ? 'bg-black/50 opacity-100' : 'bg-black/0 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-white shadow-2xl transition-all duration-300 ease-out transform ${
|
||||
isAnimating && open ? 'translate-y-0' : 'translate-y-full'
|
||||
}`}
|
||||
style={{
|
||||
height: '100vh',
|
||||
maxHeight: '100vh'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getBackAction() && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={getBackAction()!}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
{t('log_viewer.back')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 面包屑导航 */}
|
||||
<nav className="flex items-center space-x-1 text-sm">
|
||||
{getBreadcrumbs().map((breadcrumb, index) => (
|
||||
<React.Fragment key={breadcrumb.id}>
|
||||
{index > 0 && (
|
||||
<span className="text-gray-400 mx-1">/</span>
|
||||
)}
|
||||
{index === getBreadcrumbs().length - 1 ? (
|
||||
<span className="text-gray-900 font-medium">
|
||||
{breadcrumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={breadcrumb.onClick}
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedFile && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleGroupByReqId}
|
||||
className={groupByReqId ? 'bg-blue-100 text-blue-700' : ''}
|
||||
>
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
{groupByReqId ? t('log_viewer.grouped_on') : t('log_viewer.group_by_req_id')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={autoRefresh ? 'bg-blue-100 text-blue-700' : ''}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${autoRefresh ? 'animate-spin' : ''}`} />
|
||||
{autoRefresh ? t('log_viewer.auto_refresh_on') : t('log_viewer.auto_refresh_off')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadLogs}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t('log_viewer.download')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearLogs}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('log_viewer.clear')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
{t('log_viewer.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 bg-gray-50">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : selectedFile ? (
|
||||
<>
|
||||
{groupByReqId && groupedLogs && !selectedReqId ? (
|
||||
// 显示日志组列表
|
||||
<div className="flex flex-col h-full p-6">
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<h3 className="text-lg font-medium mb-2">{t('log_viewer.request_groups')}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t('log_viewer.total_requests')}: {groupedLogs.summary.totalRequests} |
|
||||
{t('log_viewer.total_logs')}: {groupedLogs.summary.totalLogs}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-3">
|
||||
{groupedLogs.summary.requests.map((request) => (
|
||||
<div
|
||||
key={request.reqId}
|
||||
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => selectReqId(request.reqId)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-5 w-5 text-blue-600" />
|
||||
<span className="font-medium text-sm">{request.reqId}</span>
|
||||
{request.model && (
|
||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
|
||||
{request.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||
{request.logCount} {t('log_viewer.logs')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div>{t('log_viewer.first_log')}: {formatDate(request.firstLog)}</div>
|
||||
<div>{t('log_viewer.last_log')}: {formatDate(request.lastLog)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 显示日志内容
|
||||
<div className="relative h-full">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="json"
|
||||
value={formatLogsForEditor()}
|
||||
theme="vs"
|
||||
options={{
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
wordWrap: 'on',
|
||||
readOnly: true,
|
||||
lineNumbers: 'on',
|
||||
folding: true,
|
||||
renderWhitespace: 'all',
|
||||
glyphMargin: true,
|
||||
}}
|
||||
onMount={configureEditor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium mb-4">{t('log_viewer.select_file')}</h3>
|
||||
{logFiles.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-8">
|
||||
<File className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||
<p>{t('log_viewer.no_log_files_available')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{logFiles.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => selectFile(file)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-5 w-5 text-blue-600" />
|
||||
<span className="font-medium text-sm">{file.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div>{formatFileSize(file.size)}</div>
|
||||
<div>{formatDate(file.lastModified)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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, Search, XCircle } 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,33 @@ 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 [searchTerm, setSearchTerm] = useState<string>("");
|
||||
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 +73,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 +183,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 +204,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 +315,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 +351,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 +393,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 +429,117 @@ 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);
|
||||
|
||||
// Filter providers based on search term
|
||||
const filteredProviders = validProviders.filter(provider => {
|
||||
if (!searchTerm) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
// Check provider name and URL
|
||||
if (
|
||||
(provider.name && provider.name.toLowerCase().includes(term)) ||
|
||||
(provider.api_base_url && provider.api_base_url.toLowerCase().includes(term))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Check models
|
||||
if (provider.models && Array.isArray(provider.models)) {
|
||||
return provider.models.some(model =>
|
||||
model && model.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
|
||||
<CardTitle className="text-lg">{t("providers.title")} <span className="text-sm font-normal text-gray-500">({config.Providers.length})</span></CardTitle>
|
||||
<Button onClick={handleAddProvider}>{t("providers.add")}</Button>
|
||||
<CardHeader className="flex flex-col border-b p-4 gap-3">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">{t("providers.title")} <span className="text-sm font-normal text-gray-500">({filteredProviders.length}/{validProviders.length})</span></CardTitle>
|
||||
<Button onClick={handleAddProvider}>{t("providers.add")}</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||
<Input
|
||||
placeholder={t("providers.search")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSearchTerm("")}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow overflow-y-auto p-4">
|
||||
<ProviderList
|
||||
providers={config.Providers}
|
||||
onEdit={setEditingProviderIndex}
|
||||
providers={filteredProviders}
|
||||
onEdit={handleEditProvider}
|
||||
onRemove={setDeletingProviderIndex}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -354,17 +556,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 +632,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 +661,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 +689,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 +731,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 +854,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 +885,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;
|
||||
169
ui/src/components/RequestHistoryDrawer.tsx
Normal file
169
ui/src/components/RequestHistoryDrawer.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { History, Trash2, Clock, X } from 'lucide-react';
|
||||
import { requestHistoryDB, type RequestHistoryItem } from '@/lib/db';
|
||||
|
||||
interface RequestHistoryDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectRequest: (request: RequestHistoryItem) => void;
|
||||
}
|
||||
|
||||
export function RequestHistoryDrawer({ isOpen, onClose, onSelectRequest }: RequestHistoryDrawerProps) {
|
||||
const [requests, setRequests] = useState<RequestHistoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadRequests();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadRequests = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const history = await requestHistoryDB.getRequests();
|
||||
setRequests(history);
|
||||
} catch (error) {
|
||||
console.error('Failed to load request history:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await requestHistoryDB.deleteRequest(id);
|
||||
setRequests(prev => prev.filter(req => req.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete request:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (window.confirm('确定要清空所有请求历史吗?')) {
|
||||
try {
|
||||
await requestHistoryDB.clearAllRequests();
|
||||
setRequests([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear request history:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
if (minutes < 1440) return `${Math.floor(minutes / 60)}小时前`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* 遮罩层 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 抽屉 */}
|
||||
<div className="absolute right-0 top-0 h-full w-96 bg-white shadow-xl flex flex-col">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">请求历史</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
disabled={requests.length === 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
清空
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">
|
||||
加载中...
|
||||
</div>
|
||||
) : requests.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{requests.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-3 bg-gray-50 rounded-lg border cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => {
|
||||
onSelectRequest(item);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs bg-gray-200 px-2 py-1 rounded">
|
||||
{item.method}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{item.url}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => handleDelete(item.id, e)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono px-1 rounded ${
|
||||
item.status >= 200 && item.status < 300
|
||||
? 'bg-green-100 text-green-800'
|
||||
: item.status >= 400
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
<span>{item.responseTime}ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTime(item.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<History className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>暂无请求历史</p>
|
||||
<p className="text-sm mt-2">发送请求后会在此显示历史记录</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,60 @@ 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: "",
|
||||
image: ""
|
||||
};
|
||||
|
||||
const handleRouterChange = (field: string, value: string | number) => {
|
||||
// Handle case where config.Router might be null or undefined
|
||||
const currentRouter = config.Router || {};
|
||||
const newRouter = { ...currentRouter, [field]: value };
|
||||
setConfig({ ...config, Router: newRouter });
|
||||
};
|
||||
|
||||
const modelOptions = config.Providers.flatMap((provider) =>
|
||||
provider.models.map((model) => ({
|
||||
value: `${provider.name},${model}`,
|
||||
label: `${provider.name}, ${model}`,
|
||||
}))
|
||||
);
|
||||
const handleForceUseImageAgentChange = (value: boolean) => {
|
||||
setConfig({ ...config, forceUseImageAgent: value });
|
||||
};
|
||||
|
||||
// Handle case where config.Providers might be null or undefined
|
||||
const providers = Array.isArray(config.Providers) ? config.Providers : [];
|
||||
|
||||
const modelOptions = providers.flatMap((provider) => {
|
||||
// Handle case where individual provider might be null or undefined
|
||||
if (!provider) return [];
|
||||
|
||||
// Handle case where provider.models might be null or undefined
|
||||
const models = Array.isArray(provider.models) ? provider.models : [];
|
||||
|
||||
// Handle case where provider.name might be null or undefined
|
||||
const providerName = provider.name || "Unknown Provider";
|
||||
|
||||
return models.map((model) => ({
|
||||
value: `${providerName},${model || "Unknown Model"}`,
|
||||
label: `${providerName}, ${model || "Unknown Model"}`,
|
||||
}));
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
|
||||
@@ -34,7 +74,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 +85,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 +96,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,27 +104,67 @@ 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")}
|
||||
emptyPlaceholder={t("router.noModelFound")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<Label>{t("router.image")} (beta)</Label>
|
||||
<Combobox
|
||||
options={modelOptions}
|
||||
value={routerConfig.image || ""}
|
||||
onChange={(value) => handleRouterChange("image", value)}
|
||||
placeholder={t("router.selectModel")}
|
||||
searchPlaceholder={t("router.searchModel")}
|
||||
emptyPlaceholder={t("router.noModelFound")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<Label htmlFor="forceUseImageAgent">{t("router.forceUseImageAgent")}</Label>
|
||||
<select
|
||||
id="forceUseImageAgent"
|
||||
value={config.forceUseImageAgent ? "true" : "false"}
|
||||
onChange={(e) => handleForceUseImageAgentChange(e.target.value === "true")}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="false">{t("common.no")}</option>
|
||||
<option value="true">{t("common.yes")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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,211 @@ 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>
|
||||
<DialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange} >
|
||||
<DialogContent data-testid="settings-dialog" className="max-h-[80vh] flex flex-col p-0">
|
||||
<DialogHeader className="p-4 pb-0">
|
||||
<DialogTitle>{t("toplevel.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-4 p-4 px-8 overflow-y-auto flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="log" checked={config.LOG} onCheckedChange={handleLogChange} />
|
||||
<Label htmlFor="log" className="transition-all-ease hover:scale-[1.02] cursor-pointer">{t("toplevel.log")}</Label>
|
||||
<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 className="space-y-2">
|
||||
<Label
|
||||
htmlFor="custom-router-path"
|
||||
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||
>
|
||||
{t("toplevel.custom_router_path")}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-router-path"
|
||||
value={config.CUSTOM_ROUTER_PATH || ""}
|
||||
onChange={(e) => setConfig({ ...config, CUSTOM_ROUTER_PATH: e.target.value })}
|
||||
placeholder={t("toplevel.custom_router_path_placeholder")}
|
||||
className="transition-all-ease focus:scale-[1.01]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]">{t("app.save")}</Button>
|
||||
<DialogFooter className="p-4 pt-0">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
{t("app.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<StatusLineConfigDialog
|
||||
isOpen={isStatusLineConfigOpen}
|
||||
onOpenChange={setIsStatusLineConfigOpen}
|
||||
data-testid="statusline-config-dialog"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
1233
ui/src/components/StatusLineConfigDialog.tsx
Normal file
1233
ui/src/components/StatusLineConfigDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
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 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
165
ui/src/components/ui/color-picker.tsx
Normal file
165
ui/src/components/ui/color-picker.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { HexColorPicker } from "react-colorful"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
|
||||
interface ColorPickerProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
showPreview?: boolean;
|
||||
}
|
||||
|
||||
// 获取颜色值的函数
|
||||
const getColorValue = (color: string): string => {
|
||||
// 如果是十六进制颜色
|
||||
if (color.startsWith("#")) {
|
||||
return color
|
||||
}
|
||||
|
||||
// 默认返回黑色
|
||||
return "#000000"
|
||||
}
|
||||
|
||||
export function ColorPicker({
|
||||
value = "",
|
||||
onChange,
|
||||
placeholder = "选择颜色...",
|
||||
showPreview = true
|
||||
}: ColorPickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [customColor, setCustomColor] = React.useState("")
|
||||
|
||||
// 当value变化时更新customColor
|
||||
React.useEffect(() => {
|
||||
if (value.startsWith("#")) {
|
||||
setCustomColor(value)
|
||||
} else {
|
||||
setCustomColor("")
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
onChange(color)
|
||||
}
|
||||
|
||||
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const color = e.target.value
|
||||
setCustomColor(color)
|
||||
// 验证十六进制颜色格式
|
||||
if (/^#[0-9A-F]{6}$/i.test(color)) {
|
||||
handleColorChange(color)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const selectedColorValue = getColorValue(value)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal h-10 transition-all hover:scale-[1.02] active:scale-[0.98]",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{showPreview && (
|
||||
<div
|
||||
className="h-5 w-5 rounded border shadow-sm"
|
||||
style={{ backgroundColor: selectedColorValue }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate flex-1">
|
||||
{value || placeholder}
|
||||
</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m7 15 5 5 5-5"/>
|
||||
<path d="m7 9 5-5 5 5"/>
|
||||
</svg>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-3" align="start">
|
||||
<div className="space-y-4">
|
||||
{/* 颜色选择器标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">颜色选择器</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => handleColorChange("")}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 颜色预览 */}
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary">
|
||||
<div
|
||||
className="h-8 w-8 rounded border shadow-sm"
|
||||
style={{ backgroundColor: selectedColorValue }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{value || "未选择颜色"}
|
||||
</div>
|
||||
{value && value.startsWith("#") && (
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{value.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 颜色选择器 */}
|
||||
<div className="rounded-md overflow-hidden border">
|
||||
<HexColorPicker
|
||||
color={selectedColorValue}
|
||||
onChange={handleColorChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 自定义颜色输入 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">自定义颜色</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={customColor}
|
||||
onChange={handleCustomColorChange}
|
||||
placeholder="#RRGGBB"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) {
|
||||
handleColorChange(customColor)
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
disabled={!customColor || !/^#[0-9A-F]{6}$/i.test(customColor)}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
输入十六进制颜色值 (例如: #FF0000)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,15 +4,72 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
const isNumeric = type === "number";
|
||||
const [tempValue, setTempValue] = React.useState(props.value?.toString() || '');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.value !== undefined) {
|
||||
setTempValue(props.value.toString());
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (isNumeric) {
|
||||
// Only allow empty string or numbers for numeric input
|
||||
if (newValue === '' || /^\d+$/.test(newValue)) {
|
||||
setTempValue(newValue);
|
||||
// Only call onChange if the value is not empty
|
||||
if (props.onChange && newValue !== '') {
|
||||
props.onChange(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setTempValue(newValue);
|
||||
if (props.onChange) {
|
||||
props.onChange(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (isNumeric && tempValue === '') {
|
||||
const defaultValue = props.placeholder || "1";
|
||||
setTempValue(defaultValue);
|
||||
|
||||
// Create a synthetic event for the corrected value
|
||||
if (props.onChange) {
|
||||
const syntheticEvent = {
|
||||
...e,
|
||||
target: { ...e.target, value: defaultValue }
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
props.onChange(syntheticEvent);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.onBlur) {
|
||||
props.onBlur(e);
|
||||
}
|
||||
};
|
||||
|
||||
// For numeric inputs, use text type and manage value internally
|
||||
const inputType = isNumeric ? "text" : type;
|
||||
const inputValue = isNumeric ? tempValue : props.value;
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
{...props}
|
||||
type={inputType}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
53
ui/src/components/ui/tabs.tsx
Normal file
53
ui/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -119,4 +119,59 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* 美化滚动条 - WebKit浏览器 (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-muted-foreground/30;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-muted-foreground/50;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.556 0 0) oklch(0.97 0 0);
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: oklch(0.708 0 0) oklch(0.269 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Monaco Editor 调试按钮样式 */
|
||||
.debug-button-glyph {
|
||||
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="%23056bfe" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v-9"/><path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z"/><path d="M14.12 3.88 16 2"/><path d="M21 21a4 4 0 0 0-3.81-4"/><path d="M21 5a4 4 0 0 1-3.55 3.97"/><path d="M22 13h-4"/><path d="M3 21a4 4 0 0 1 3.81-4"/><path d="M3 5a4 4 0 0 0 3.55 3.97"/><path d="M6 13H2"/><path d="m8 2 1.88 1.88"/><path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/></svg>') center center no-repeat;
|
||||
background-size: 14px 14px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.debug-button-glyph:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 确保调试按钮在glyph margin中可见 */
|
||||
.monaco-editor .margin-view-overlays .debug-button-glyph {
|
||||
display: block !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import type { Config, Provider, Transformer } from '@/components/ConfigProvider';
|
||||
import type { Config, Provider, Transformer } from '@/types';
|
||||
|
||||
// 日志聚合响应类型
|
||||
interface GroupedLogsResponse {
|
||||
grouped: boolean;
|
||||
groups: { [reqId: string]: Array<{ timestamp: string; level: string; message: string; source?: string; reqId?: string }> };
|
||||
summary: {
|
||||
totalRequests: number;
|
||||
totalLogs: number;
|
||||
requests: Array<{
|
||||
reqId: string;
|
||||
logCount: number;
|
||||
firstLog: string;
|
||||
lastLog: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// API Client Class for handling requests with baseUrl and apikey authentication
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private apiKey: string;
|
||||
private tempApiKey: string | null;
|
||||
|
||||
constructor(baseUrl: string = '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 +45,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 +210,31 @@ 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', {});
|
||||
}
|
||||
|
||||
// Get log files list
|
||||
async getLogFiles(): Promise<Array<{ name: string; path: string; size: number; lastModified: string }>> {
|
||||
return this.get<Array<{ name: string; path: string; size: number; lastModified: string }>>('/logs/files');
|
||||
}
|
||||
|
||||
// Get logs from specific file
|
||||
async getLogs(filePath: string): Promise<string[]> {
|
||||
return this.get<string[]>(`/logs?file=${encodeURIComponent(filePath)}`);
|
||||
}
|
||||
|
||||
// Clear logs from specific file
|
||||
async clearLogs(filePath: string): Promise<void> {
|
||||
return this.delete<void>(`/logs?file=${encodeURIComponent(filePath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default instance of the API client
|
||||
|
||||
106
ui/src/lib/db.ts
Normal file
106
ui/src/lib/db.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export interface RequestHistoryItem {
|
||||
id: string;
|
||||
url: string;
|
||||
method: string;
|
||||
headers: string;
|
||||
body: string;
|
||||
timestamp: string;
|
||||
status: number;
|
||||
responseTime: number;
|
||||
responseBody: string;
|
||||
responseHeaders: string;
|
||||
}
|
||||
|
||||
class RequestHistoryDB {
|
||||
private readonly DB_NAME = 'RequestHistoryDB';
|
||||
private readonly STORE_NAME = 'requests';
|
||||
private readonly VERSION = 1;
|
||||
|
||||
async openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.DB_NAME, this.VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!db.objectStoreNames.contains(this.STORE_NAME)) {
|
||||
const store = db.createObjectStore(this.STORE_NAME, { keyPath: 'id' });
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
store.createIndex('url', 'url', { unique: false });
|
||||
store.createIndex('method', 'method', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async saveRequest(request: Omit<RequestHistoryItem, 'id' | 'timestamp'>): Promise<void> {
|
||||
const db = await this.openDB();
|
||||
const item: RequestHistoryItem = {
|
||||
...request,
|
||||
id: Date.now().toString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(this.STORE_NAME);
|
||||
const request = store.add(item);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getRequests(limit: number = 50): Promise<RequestHistoryItem[]> {
|
||||
const db = await this.openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(this.STORE_NAME);
|
||||
const index = store.index('timestamp');
|
||||
const request = index.openCursor(null, 'prev');
|
||||
|
||||
const results: RequestHistoryItem[] = [];
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor && results.length < limit) {
|
||||
results.push(cursor.value);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(results);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRequest(id: string): Promise<void> {
|
||||
const db = await this.openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(this.STORE_NAME);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearAllRequests(): Promise<void> {
|
||||
const db = await this.openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(this.STORE_NAME);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const requestHistoryDB = new RequestHistoryDB();
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code Router",
|
||||
"save": "Save",
|
||||
@@ -12,7 +16,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 +40,15 @@
|
||||
"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",
|
||||
"custom_router_path": "Custom Router Script Path",
|
||||
"custom_router_path_placeholder": "Enter absolute path to custom router script file"
|
||||
},
|
||||
"transformers": {
|
||||
"title": "Custom Transformers",
|
||||
@@ -46,7 +64,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 +93,15 @@
|
||||
"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",
|
||||
"search": "Search providers..."
|
||||
|
||||
},
|
||||
"router": {
|
||||
"title": "Router",
|
||||
@@ -83,7 +109,10 @@
|
||||
"background": "Background",
|
||||
"think": "Think",
|
||||
"longContext": "Long Context",
|
||||
"longContextThreshold": "Context Threshold",
|
||||
"webSearch": "Web Search",
|
||||
"image": "Image",
|
||||
"forceUseImageAgent": "Force Use Image Agent",
|
||||
"selectModel": "Select a model...",
|
||||
"searchModel": "Search model...",
|
||||
"noModelFound": "No model found."
|
||||
@@ -95,5 +124,105 @@
|
||||
"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",
|
||||
"module_text_description": "Enter display text, variables can be used:",
|
||||
"module_color_description": "Select text color",
|
||||
"module_background_description": "Select background color (optional)",
|
||||
"module_script_path": "Script Path",
|
||||
"module_script_path_description": "Enter the absolute path of the Node.js script file",
|
||||
"add_module": "Add Module",
|
||||
"remove_module": "Remove Module",
|
||||
"delete_module": "Delete Module",
|
||||
"preview": "Preview",
|
||||
"components": "Components",
|
||||
"properties": "Properties",
|
||||
"workDir": "Working Directory",
|
||||
"gitBranch": "Git Branch",
|
||||
"model": "Model",
|
||||
"usage": "Usage",
|
||||
"script": "Script",
|
||||
"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",
|
||||
"font_placeholder": "Select Font",
|
||||
"theme_placeholder": "Select Theme Style",
|
||||
"icon_placeholder": "Paste icon or search by name...",
|
||||
"icon_description": "Enter icon character, paste icon, or search icons (optional)",
|
||||
"text_placeholder": "e.g.: {{workDirName}}",
|
||||
"script_placeholder": "e.g.: /path/to/your/script.js",
|
||||
"drag_hint": "Drag components here to configure",
|
||||
"select_hint": "Select a component to configure",
|
||||
"no_icons_found": "No icons found",
|
||||
"no_icons_available": "No icons available",
|
||||
"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"
|
||||
},
|
||||
"log_viewer": {
|
||||
"title": "Log Viewer",
|
||||
"close": "Close",
|
||||
"download": "Download",
|
||||
"clear": "Clear",
|
||||
"auto_refresh_on": "Auto Refresh On",
|
||||
"auto_refresh_off": "Auto Refresh Off",
|
||||
"load_failed": "Failed to load logs",
|
||||
"no_logs_available": "No logs available",
|
||||
"logs_cleared": "Logs cleared successfully",
|
||||
"clear_failed": "Failed to clear logs",
|
||||
"logs_downloaded": "Logs downloaded successfully",
|
||||
"back_to_files": "Back to Files",
|
||||
"select_file": "Select a log file to view",
|
||||
"no_log_files_available": "No log files available",
|
||||
"load_files_failed": "Failed to load log files",
|
||||
"group_by_req_id": "Group by Request ID",
|
||||
"grouped_on": "Grouped",
|
||||
"request_groups": "Request Groups",
|
||||
"total_requests": "Total Requests",
|
||||
"total_logs": "Total Logs",
|
||||
"request": "Request",
|
||||
"logs": "logs",
|
||||
"first_log": "First Log",
|
||||
"last_log": "Last Log",
|
||||
"back_to_all_logs": "Back to All Logs",
|
||||
"worker_error": "Worker error",
|
||||
"worker_init_failed": "Failed to initialize worker",
|
||||
"grouping_not_supported": "Log grouping not supported by server",
|
||||
"back": "Back"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"yes": "是",
|
||||
"no": "否"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code Router",
|
||||
"save": "保存",
|
||||
@@ -12,7 +16,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 +40,15 @@
|
||||
"toplevel": {
|
||||
"title": "通用设置",
|
||||
"log": "启用日志",
|
||||
"log_level": "日志级别",
|
||||
"claude_path": "Claude 路径",
|
||||
"host": "主机",
|
||||
"port": "端口",
|
||||
"apikey": "API 密钥"
|
||||
"apikey": "API 密钥",
|
||||
"timeout": "API 超时时间 (毫秒)",
|
||||
"proxy_url": "代理地址",
|
||||
"custom_router_path": "自定义路由脚本路径",
|
||||
"custom_router_path_placeholder": "输入自定义路由脚本文件的绝对路径"
|
||||
},
|
||||
"transformers": {
|
||||
"title": "自定义转换器",
|
||||
@@ -46,7 +64,7 @@
|
||||
"providers": {
|
||||
"title": "供应商",
|
||||
"name": "名称",
|
||||
"api_base_url": "API 基础地址",
|
||||
"api_base_url": "API 完整地址",
|
||||
"api_key": "API 密钥",
|
||||
"models": "模型",
|
||||
"models_placeholder": "输入模型名称并按回车键添加",
|
||||
@@ -75,7 +93,15 @@
|
||||
"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": "已存在同名供应商",
|
||||
"search": "搜索供应商..."
|
||||
|
||||
},
|
||||
"router": {
|
||||
"title": "路由",
|
||||
@@ -83,7 +109,10 @@
|
||||
"background": "后台",
|
||||
"think": "思考",
|
||||
"longContext": "长上下文",
|
||||
"longContextThreshold": "上下文阈值",
|
||||
"webSearch": "网络搜索",
|
||||
"image": "图像",
|
||||
"forceUseImageAgent": "强制使用图像代理",
|
||||
"selectModel": "选择一个模型...",
|
||||
"searchModel": "搜索模型...",
|
||||
"noModelFound": "未找到模型."
|
||||
@@ -95,5 +124,105 @@
|
||||
"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": "背景",
|
||||
"module_text_description": "输入显示文本,可使用变量:",
|
||||
"module_color_description": "选择文字颜色",
|
||||
"module_background_description": "选择背景颜色(可选)",
|
||||
"module_script_path": "脚本路径",
|
||||
"module_script_path_description": "输入Node.js脚本文件的绝对路径",
|
||||
"add_module": "添加模块",
|
||||
"remove_module": "移除模块",
|
||||
"delete_module": "删除组件",
|
||||
"preview": "预览",
|
||||
"components": "组件",
|
||||
"properties": "属性",
|
||||
"workDir": "工作目录",
|
||||
"gitBranch": "Git分支",
|
||||
"model": "模型",
|
||||
"usage": "使用情况",
|
||||
"script": "脚本",
|
||||
"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": "亮白色",
|
||||
"font_placeholder": "选择字体",
|
||||
"theme_placeholder": "选择主题样式",
|
||||
"icon_placeholder": "粘贴图标或输入名称搜索...",
|
||||
"icon_description": "输入图标字符、粘贴图标或搜索图标(可选)",
|
||||
"text_placeholder": "例如: {{workDirName}}",
|
||||
"script_placeholder": "例如: /path/to/your/script.js",
|
||||
"drag_hint": "拖拽组件到此处进行配置",
|
||||
"select_hint": "选择一个组件进行配置",
|
||||
"no_icons_found": "未找到图标",
|
||||
"no_icons_available": "暂无可用图标",
|
||||
"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": "模板下载失败"
|
||||
},
|
||||
"log_viewer": {
|
||||
"title": "日志查看器",
|
||||
"close": "关闭",
|
||||
"download": "下载",
|
||||
"clear": "清除",
|
||||
"auto_refresh_on": "自动刷新开启",
|
||||
"auto_refresh_off": "自动刷新关闭",
|
||||
"load_failed": "加载日志失败",
|
||||
"no_logs_available": "暂无日志",
|
||||
"logs_cleared": "日志清除成功",
|
||||
"clear_failed": "清除日志失败",
|
||||
"logs_downloaded": "日志下载成功",
|
||||
"back_to_files": "返回文件列表",
|
||||
"select_file": "选择要查看的日志文件",
|
||||
"no_log_files_available": "暂无日志文件",
|
||||
"load_files_failed": "加载日志文件失败",
|
||||
"group_by_req_id": "按请求ID分组",
|
||||
"grouped_on": "已分组",
|
||||
"request_groups": "请求组",
|
||||
"total_requests": "总请求数",
|
||||
"total_logs": "总日志数",
|
||||
"request": "请求",
|
||||
"logs": "条日志",
|
||||
"first_log": "首条日志",
|
||||
"last_log": "末条日志",
|
||||
"back_to_all_logs": "返回所有日志",
|
||||
"worker_error": "Worker错误",
|
||||
"worker_init_failed": "Worker初始化失败",
|
||||
"grouping_not_supported": "服务器不支持日志分组",
|
||||
"back": "返回"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
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 { DebugPage } from '@/components/DebugPage';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import PublicRoute from '@/components/PublicRoute';
|
||||
|
||||
export const router = createMemoryRouter([
|
||||
{
|
||||
@@ -27,6 +18,10 @@ export const router = createMemoryRouter([
|
||||
path: '/dashboard',
|
||||
element: <ProtectedRoute><App /></ProtectedRoute>,
|
||||
},
|
||||
{
|
||||
path: '/debug',
|
||||
element: <ProtectedRoute><DebugPage /></ProtectedRoute>,
|
||||
},
|
||||
], {
|
||||
initialEntries: ['/dashboard']
|
||||
});
|
||||
70
ui/src/types.ts
Normal file
70
ui/src/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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;
|
||||
image: 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;
|
||||
scriptPath?: string; // 用于script类型的模块,指定要执行的Node.js脚本文件路径
|
||||
}
|
||||
|
||||
export interface StatusLineThemeConfig {
|
||||
modules: StatusLineModuleConfig[];
|
||||
}
|
||||
|
||||
export interface StatusLineConfig {
|
||||
enabled: boolean;
|
||||
currentStyle: string;
|
||||
default: StatusLineThemeConfig;
|
||||
powerline: StatusLineThemeConfig;
|
||||
fontFamily?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
Providers: Provider[];
|
||||
Router: RouterConfig;
|
||||
transformers: Transformer[];
|
||||
StatusLine?: StatusLineConfig;
|
||||
forceUseImageAgent?: boolean;
|
||||
// Top-level settings
|
||||
LOG: boolean;
|
||||
LOG_LEVEL: string;
|
||||
CLAUDE_PATH: string;
|
||||
HOST: string;
|
||||
PORT: number;
|
||||
APIKEY: string;
|
||||
API_TIMEOUT_MS: string;
|
||||
PROXY_URL: string;
|
||||
CUSTOM_ROUTER_PATH?: 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/debugpage.tsx","./src/components/jsoneditor.tsx","./src/components/logviewer.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/requesthistorydrawer.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/tabs.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/db.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}
|
||||
Reference in New Issue
Block a user