28 Commits

Author SHA1 Message Date
musi
5cd21c570f Merge pull request #798 from SaseQ/main
Add ccr logo and badges (README.md edit)
2025-09-10 20:39:37 +08:00
SaseQ
c5e97709a5 Add ccr logo and badges (README.md edit) 2025-09-10 14:32:02 +02:00
musistudio
f7adb7b28e release v1.0.49 2025-09-09 22:43:01 +08:00
musistudio
7964fff175 release v1.0.48 2025-09-09 21:47:59 +08:00
musistudio
fe06b57032 fix llms version 2025-09-09 21:21:45 +08:00
musistudio
1b3a8f8803 fix logviewer 2025-09-09 21:06:19 +08:00
musistudio
cec8421dd9 change logviewer 2025-09-06 22:19:40 +08:00
musistudio
1a7e90df39 remove log util 2025-09-06 09:06:18 +08:00
musistudio
e5741ae470 add logger vireer 2025-09-05 21:36:21 +08:00
musistudio
0152af5db9 update sponsors 2025-09-04 22:07:40 +08:00
musistudio
e6b3e2a194 release v1.0.47 2025-09-04 22:04:31 +08:00
musistudio
f7058dcdb5 fix log file path 2025-09-04 22:04:03 +08:00
musistudio
e670302e9e optimize docker deployment 2025-09-03 09:58:09 +08:00
musistudio
5761e165fd release v1.0.46 2025-09-02 21:23:53 +08:00
musistudio
8c4fec4f5f fix stream handler error 2025-09-02 21:23:21 +08:00
musistudio
5d53571fe6 release v1.0.45 2025-09-02 19:55:04 +08:00
musistudio
35fc4505b2 release v1.0.44 2025-09-02 12:16:31 +08:00
musistudio
c7303775ad update document 2025-09-02 12:13:54 +08:00
musistudio
f7981b16cd Merge branch 'dev/agents' 2025-09-01 21:10:18 +08:00
musistudio
b54687c4d5 update sponsors 2025-09-01 21:09:52 +08:00
musi
0be4c3753f Merge pull request #691 from zoyopei/fix/ui-custom-router
fix(ui): add CUSTOM_ROUTER_PATH support in general settings
2025-09-01 20:53:07 +08:00
musi
668e855a2d Merge pull request #699 from geocine/numeric-input
Fix numeric input UX: allow complete deletion with smart defaults
2025-09-01 20:52:27 +08:00
musi
41108cea1d Merge pull request #706 from vitobotta/chutes-gllm
Add link to unofficial GLM 4.5 transformer for Chutes provider
2025-09-01 20:50:56 +08:00
musistudio
3b9e58a823 update readme 2025-08-26 22:14:40 +08:00
musistudio
615fe7629e update readme 2025-08-26 22:05:25 +08:00
Vito Botta
656a5f9a97 Add link to unofficial GLM 4.5 transformer for Chutes provider 2025-08-25 18:44:24 +03:00
Aivan Monceller
d2a0815cb7 enhance Input component to manage numeric values and improve onChange handling 2025-08-25 13:27:58 +08:00
zoyopei
7cc41d83cf fix(ui): add CUSTOM_ROUTER_PATH support in general settings
- Add CUSTOM_ROUTER_PATH field to UI configuration
  - Fix configuration preservation during save operations
2025-08-24 19:52:14 +08:00
29 changed files with 1554 additions and 297 deletions

7
Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM node:20-alpine
RUN npm install -g @musistudio/claude-code-router
EXPOSE 3456
CMD ["ccr", "start"]

View File

@@ -1,14 +1,23 @@
# Claude Code Router ![](blog/images/claude-code-router-img.png)
[![](https://img.shields.io/badge/%F0%9F%87%A8%F0%9F%87%B3-%E4%B8%AD%E6%96%87%E7%89%88-ff0000?style=flat)](README_zh.md)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white)](https://discord.gg/rdftVMaUcS)
[![](https://img.shields.io/github/license/musistudio/claude-code-router)](https://github.com/musistudio/claude-code-router/blob/main/LICENSE)
<hr>
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) 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. > 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. Its worth noting that iFlow limits each user to a concurrency of 1, which means youll need to route background requests to other models.
> If youd like a better experience, you can try [iFlow CLI](https://cli.iflow.cn).
![](blog/images/claude-code.png) ![](blog/images/claude-code.png)
![](blog/images/roadmap.svg)
## ✨ Features ## ✨ Features
- **Model Routing**: Route requests to different models based on your needs (e.g., background tasks, thinking, long context). - **Model Routing**: Route requests to different models based on your needs (e.g., background tasks, thinking, long context).
@@ -315,6 +324,7 @@ Transformers allow you to modify the request and response payloads to ensure com
- `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). - `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. - `cleancache`: Clears the `cache_control` field from requests.
- `vertex-gemini`: Handles the Gemini API using Vertex authentication. - `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). - `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). - `rovo-cli` (experimental): Unofficial support for gpt-5 via Atlassian Rovo Dev CLI [rovo-cli.js](https://gist.github.com/SaseQ/c2a20a38b11276537ec5332d1f7a5e53).
@@ -345,8 +355,9 @@ The `Router` object defines which model to use for different scenarios:
- `longContext`: A model for handling long contexts (e.g., > 60K tokens). - `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. - `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. - `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 CCRs 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` `/model provider_name,model_name`
Example: `/model openrouter,anthropic/claude-3.5-sonnet` Example: `/model openrouter,anthropic/claude-3.5-sonnet`
@@ -544,7 +555,7 @@ A huge thank you to all our sponsors for their generous support!
- @\*光 - @\*光
- @W\*l - @W\*l
- [@kesku](https://github.com/kesku) - [@kesku](https://github.com/kesku)
- @水\*丫 - [@biguncle](https://github.com/biguncle)
- @二吉吉 - @二吉吉
- @a\*g - @a\*g
- @\*林 - @\*林
@@ -557,5 +568,17 @@ A huge thank you to all our sponsors for their generous support!
- @r\*c - @r\*c
- [@qierkang](http://github.com/qierkang) - [@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.) (If your name is masked, please contact me via my homepage email to update it with your GitHub username.)

View File

@@ -1,11 +1,24 @@
# Claude Code Router ![](blog/images/claude-code-router-img.png)
[![](https://img.shields.io/badge/%F0%9F%87%AC%F0%9F%87%A7-English-000aff?style=flat)](README.md)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white)](https://discord.gg/rdftVMaUcS)
[![](https://img.shields.io/github/license/musistudio/claude-code-router)](https://github.com/musistudio/claude-code-router/blob/main/LICENSE)
<hr>
我正在为该项目寻求资金支持,以更好地维持其发展。如果您有任何想法,请随时与我联系: [m@musiiot.top](mailto:m@musiiot.top) 我正在为该项目寻求资金支持,以更好地维持其发展。如果您有任何想法,请随时与我联系: [m@musiiot.top](mailto:m@musiiot.top)
> 一款强大的工具,可将 Claude Code 请求路由到不同的模型,并自定义任何请求。 > 一款强大的工具,可将 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)。
![](blog/images/claude-code.png) ![](blog/images/claude-code.png)
![](blog/images/roadmap.svg)
## ✨ 功能 ## ✨ 功能
- **模型路由**: 根据您的需求将请求路由到不同的模型(例如,后台任务、思考、长上下文)。 - **模型路由**: 根据您的需求将请求路由到不同的模型(例如,后台任务、思考、长上下文)。
@@ -318,6 +331,7 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商
- `longContext`: 用于处理长上下文(例如,> 60K 令牌)的模型。 - `longContext`: 用于处理长上下文(例如,> 60K 令牌)的模型。
- `longContextThreshold` (可选): 触发长上下文模型的令牌数阈值。如果未指定,默认为 60000。 - `longContextThreshold` (可选): 触发长上下文模型的令牌数阈值。如果未指定,默认为 60000。
- `webSearch`: 用于处理网络搜索任务,需要模型本身支持。如果使用`openrouter`需要在模型后面加上`:online`后缀。 - `webSearch`: 用于处理网络搜索任务,需要模型本身支持。如果使用`openrouter`需要在模型后面加上`:online`后缀。
- `image`(测试版): 用于处理图片类任务采用CCR内置的agent支持如果该模型不支持工具调用需要将`config.forceUseImageAgent`属性设置为`true`。
您还可以使用 `/model` 命令在 Claude Code 中动态切换模型: 您还可以使用 `/model` 命令在 Claude Code 中动态切换模型:
`/model provider_name,model_name` `/model provider_name,model_name`
@@ -499,6 +513,7 @@ jobs:
- @*琢 - @*琢
- @*成 - @*成
- @Z*o - @Z*o
- @\*琨
- [@congzhangzh](https://github.com/congzhangzh) - [@congzhangzh](https://github.com/congzhangzh)
- @*_ - @*_
- @Z\*m - @Z\*m
@@ -513,7 +528,7 @@ jobs:
- @\*光 - @\*光
- @W\*l - @W\*l
- [@kesku](https://github.com/kesku) - [@kesku](https://github.com/kesku)
- @水\*丫 - [@biguncle](https://github.com/biguncle)
- @二吉吉 - @二吉吉
- @a\*g - @a\*g
- @\*林 - @\*林
@@ -526,6 +541,18 @@ jobs:
- @r\*c - @r\*c
- [@qierkang](http://github.com/qierkang) - [@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 用户名进行更新。) (如果您的名字被屏蔽,请通过我的主页电子邮件与我联系,以便使用您的 GitHub 用户名进行更新。)

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

67
blog/images/roadmap.svg Normal file
View File

@@ -0,0 +1,67 @@
<svg viewBox="0 0 1200 420" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.road { stroke: #7aa2ff; stroke-width: 6; fill: none; filter: drop-shadow(0 6px 18px rgba(122,162,255,0.25)); }
.dash { stroke: rgba(122,162,255,0.25); stroke-width: 6; fill: none; stroke-dasharray: 2 18; }
.node { filter: drop-shadow(0 3px 10px rgba(126,240,193,0.35)); }
.node-circle { fill: #7ef0c1; }
.node-core { fill: #181b22; stroke: white; stroke-width: 1.5; }
.label-bg { fill: rgba(24,27,34,0.8); stroke: rgba(255,255,255,0.12); rx: 12; }
.label-text { fill: #e8ecf1; font-weight: 700; font-size: 14px; font-family: Arial, sans-serif; }
.label-sub { fill: #9aa6b2; font-weight: 500; font-size: 12px; font-family: Arial, sans-serif; }
.spark { fill: none; stroke: #ffd36e; stroke-width: 1.6; stroke-linecap: round; }
</style>
</defs>
<!-- Background road with dash -->
<path class="dash" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>
<!-- Main road -->
<path class="road" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>
<!-- New Documentation Node -->
<g class="node" transform="translate(200,280)">
<circle class="node-circle" r="10"/>
<circle class="node-core" r="6"/>
</g>
<!-- New Documentation Label -->
<g transform="translate(80,120)">
<rect class="label-bg" width="260" height="92"/>
<text class="label-text" x="16" y="34">New Documentation</text>
<text class="label-sub" x="16" y="58">Clear structure, examples &amp; best practices</text>
</g>
<!-- Plugin Marketplace Node -->
<g class="node" transform="translate(640,150)">
<circle class="node-circle" r="10"/>
<circle class="node-core" r="6"/>
</g>
<!-- Plugin Marketplace Label -->
<g transform="translate(560,20)">
<rect class="label-bg" width="320" height="100"/>
<text class="label-text" x="16" y="34">Plugin Marketplace</text>
<text class="label-sub" x="16" y="58">Community submissions, ratings &amp; version constraints</text>
</g>
<!-- One More Thing Node -->
<g class="node" transform="translate(1080,255)">
<circle class="node-circle" r="10"/>
<circle class="node-core" r="6"/>
</g>
<!-- One More Thing Label -->
<g transform="translate(940,300)">
<rect class="label-bg" width="250" height="86"/>
<text class="label-text" x="16" y="34">One More Thing</text>
<text class="label-sub" x="16" y="58">🚀 Confidential project · Revealing soon</text>
</g>
<!-- Spark decorations -->
<g transform="translate(1125,290)">
<path class="spark" d="M0 0 L8 0 M4 -4 L4 4"/>
<path class="spark" d="M14 -2 L22 -2 M18 -6 L18 2"/>
<path class="spark" d="M-10 6 L-2 6 M-6 2 L-6 10"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@musistudio/claude-code-router", "name": "@musistudio/claude-code-router",
"version": "1.0.43", "version": "1.0.49",
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider", "description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
"bin": { "bin": {
"ccr": "./dist/cli.js" "ccr": "./dist/cli.js"
@@ -20,11 +20,12 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@musistudio/llms": "^1.0.28", "@musistudio/llms": "^1.0.32",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"find-process": "^2.0.0",
"json5": "^2.2.3", "json5": "^2.2.3",
"openurl": "^1.1.1", "openurl": "^1.1.1",
"pino-rotating-file-stream": "^0.0.2", "rotating-file-stream": "^3.2.7",
"tiktoken": "^1.0.21", "tiktoken": "^1.0.21",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },

111
pnpm-lock.yaml generated
View File

@@ -12,20 +12,23 @@ importers:
specifier: ^8.2.0 specifier: ^8.2.0
version: 8.2.0 version: 8.2.0
'@musistudio/llms': '@musistudio/llms':
specifier: ^1.0.28 specifier: ^1.0.32
version: 1.0.28(ws@8.18.3) version: 1.0.32(ws@8.18.3)
dotenv: dotenv:
specifier: ^16.4.7 specifier: ^16.4.7
version: 16.6.1 version: 16.6.1
find-process:
specifier: ^2.0.0
version: 2.0.0
json5: json5:
specifier: ^2.2.3 specifier: ^2.2.3
version: 2.2.3 version: 2.2.3
openurl: openurl:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
pino-rotating-file-stream: rotating-file-stream:
specifier: ^0.0.2 specifier: ^3.2.7
version: 0.0.2 version: 3.2.7
tiktoken: tiktoken:
specifier: ^1.0.21 specifier: ^1.0.21
version: 1.0.22 version: 1.0.22
@@ -241,11 +244,11 @@ packages:
'@fastify/static@8.2.0': '@fastify/static@8.2.0':
resolution: {integrity: sha512-PejC/DtT7p1yo3p+W7LiUtLMsV8fEvxAK15sozHy9t8kwo5r0uLYmhV/inURmGz1SkHZFz/8CNtHLPyhKcx4SQ==} resolution: {integrity: sha512-PejC/DtT7p1yo3p+W7LiUtLMsV8fEvxAK15sozHy9t8kwo5r0uLYmhV/inURmGz1SkHZFz/8CNtHLPyhKcx4SQ==}
'@google/genai@1.14.0': '@google/genai@1.16.0':
resolution: {integrity: sha512-jirYprAAJU1svjwSDVCzyVq+FrJpJd5CSxR/g2Ga/gZ0ZYZpcWjMS75KJl9y71K1mDN+tcx6s21CzCbB2R840g==} resolution: {integrity: sha512-hdTYu39QgDFxv+FB6BK2zi4UIJGWhx2iPc0pHQ0C5Q/RCi+m+4gsryIzTGO+riqWcUA8/WGYp6hpqckdOBNysw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
peerDependencies: peerDependencies:
'@modelcontextprotocol/sdk': ^1.11.0 '@modelcontextprotocol/sdk': ^1.11.4
peerDependenciesMeta: peerDependenciesMeta:
'@modelcontextprotocol/sdk': '@modelcontextprotocol/sdk':
optional: true optional: true
@@ -266,8 +269,8 @@ packages:
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'} engines: {node: '>=8'}
'@musistudio/llms@1.0.28': '@musistudio/llms@1.0.32':
resolution: {integrity: sha512-rHvcJTtrFsRC7ayxz7ZXVoC7lZUwLtAHubdouUj+LYkv35Hr8S6K3lpOMXKYyqcKCtMvxbpjvM9MiwjCaleGEA==} resolution: {integrity: sha512-i+dB7x4qxZ8oOM3TLijjJ2rwIOje6/ovyHdU8A5h6d2wcTKOd0JUpNixUgBO3dPJp2dYVXz0SSfhw7gzmt1Kkg==}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
@@ -338,6 +341,10 @@ packages:
buffer-equal-constant-time@1.0.1: buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -345,6 +352,10 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
commander@12.1.0:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
content-disposition@0.5.4: content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -460,6 +471,10 @@ packages:
resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==}
engines: {node: '>=20'} engines: {node: '>=20'}
find-process@2.0.0:
resolution: {integrity: sha512-YUBQnteWGASJoEVVsOXy6XtKAY2O1FCsWnnvQ8y0YwgY1rZiKeVptnFvMu6RSELZAJOGklqseTnUGGs5D0bKmg==}
hasBin: true
foreground-child@3.3.1: foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -500,8 +515,8 @@ packages:
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
hasBin: true hasBin: true
google-auth-library@10.2.1: google-auth-library@10.3.0:
resolution: {integrity: sha512-HMxFl2NfeHYnaL1HoRIN1XgorKS+6CDaM+z9LSSN+i/nKDDL4KFFEWogMXu7jV4HZQy2MsxpY+wA5XIf3w410A==} resolution: {integrity: sha512-ylSE3RlCRZfZB56PFJSfUCuiuPq83Fx8hqu1KPWGK8FVdSaxlp/qkeMMX/DT/18xkwXIHvXEXkZsljRwfrdEfQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
google-auth-library@9.15.1: google-auth-library@9.15.1:
@@ -524,6 +539,10 @@ packages:
resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
engines: {node: '>=18'} engines: {node: '>=18'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hasown@2.0.2: hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -609,6 +628,10 @@ packages:
light-my-request@6.6.0: light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
lru-cache@11.1.0: lru-cache@11.1.0:
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -672,8 +695,8 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
openai@5.12.2: openai@5.16.0:
resolution: {integrity: sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==} resolution: {integrity: sha512-hoEH8ZNvg1HXjU9mp88L/ZH8O082Z8r6FHCXGiWAzVRrEv443aI57qhch4snu07yQydj+AUAWLenAiBXhu89Tw==}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
ws: ^8.18.0 ws: ^8.18.0
@@ -716,9 +739,6 @@ packages:
pino-abstract-transport@2.0.0: pino-abstract-transport@2.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
pino-rotating-file-stream@0.0.2:
resolution: {integrity: sha512-knF+ReDBMQMB7gzBfuFpUmCrXpRen6YYh5Q9Ymmj//dDHeH4QEMwAV7VoGEEM+30s7VHqfbabazs9wxkMO2BIQ==}
pino-std-serializers@7.0.0: pino-std-serializers@7.0.0:
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
@@ -769,8 +789,8 @@ packages:
rfdc@1.4.1: rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rotating-file-stream@3.2.6: rotating-file-stream@3.2.7:
resolution: {integrity: sha512-r8yShzMWUvWXkRzbOXDM1fEaMpc3qo2PzK7bBH/0p0Nl/uz8Mud/Y+0XTQxe3kbSnDF7qBH2tSe83WDKA7o3ww==} resolution: {integrity: sha512-SVquhBEVvRFY+nWLUc791Y0MIlyZrEClRZwZFLLRgJKldHyV1z4e2e/dp9LPqCS3AM//uq/c3PnOFgjqnm5P+A==}
engines: {node: '>=14.0'} engines: {node: '>=14.0'}
run-parallel@1.2.0: run-parallel@1.2.0:
@@ -868,6 +888,10 @@ packages:
resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-preserve-symlinks-flag@1.0.0: supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -901,8 +925,8 @@ packages:
undici-types@7.10.0: undici-types@7.10.0:
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
undici@7.13.0: undici@7.15.0:
resolution: {integrity: sha512-l+zSMssRqrzDcb3fjMkjjLGmuiiK2pMIcV++mJaAc9vhjSGpvM7h43QgP+OAMb1GImHmbPyG2tBXeuyG5iY4gA==} resolution: {integrity: sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
uuid@11.1.0: uuid@11.1.0:
@@ -1084,7 +1108,7 @@ snapshots:
fastq: 1.19.1 fastq: 1.19.1
glob: 11.0.3 glob: 11.0.3
'@google/genai@1.14.0': '@google/genai@1.16.0':
dependencies: dependencies:
google-auth-library: 9.15.1 google-auth-library: 9.15.1
ws: 8.18.3 ws: 8.18.3
@@ -1111,18 +1135,18 @@ snapshots:
'@lukeed/ms@2.0.2': {} '@lukeed/ms@2.0.2': {}
'@musistudio/llms@1.0.28(ws@8.18.3)': '@musistudio/llms@1.0.32(ws@8.18.3)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.54.0 '@anthropic-ai/sdk': 0.54.0
'@fastify/cors': 11.1.0 '@fastify/cors': 11.1.0
'@google/genai': 1.14.0 '@google/genai': 1.16.0
dotenv: 16.6.1 dotenv: 16.6.1
fastify: 5.5.0 fastify: 5.5.0
google-auth-library: 10.2.1 google-auth-library: 10.3.0
json5: 2.2.3 json5: 2.2.3
jsonrepair: 3.13.0 jsonrepair: 3.13.0
openai: 5.12.2(ws@8.18.3) openai: 5.16.0(ws@8.18.3)
undici: 7.13.0 undici: 7.15.0
uuid: 11.1.0 uuid: 11.1.0
transitivePeerDependencies: transitivePeerDependencies:
- '@modelcontextprotocol/sdk' - '@modelcontextprotocol/sdk'
@@ -1191,12 +1215,19 @@ snapshots:
buffer-equal-constant-time@1.0.1: {} buffer-equal-constant-time@1.0.1: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
color-name@1.1.4: {} color-name@1.1.4: {}
commander@12.1.0: {}
content-disposition@0.5.4: content-disposition@0.5.4:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -1354,6 +1385,12 @@ snapshots:
fast-querystring: 1.1.2 fast-querystring: 1.1.2
safe-regex2: 5.0.0 safe-regex2: 5.0.0
find-process@2.0.0:
dependencies:
chalk: 4.1.2
commander: 12.1.0
loglevel: 1.9.2
foreground-child@3.3.1: foreground-child@3.3.1:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@@ -1418,7 +1455,7 @@ snapshots:
package-json-from-dist: 1.0.1 package-json-from-dist: 1.0.1
path-scurry: 2.0.0 path-scurry: 2.0.0
google-auth-library@10.2.1: google-auth-library@10.3.0:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1
ecdsa-sig-formatter: 1.0.11 ecdsa-sig-formatter: 1.0.11
@@ -1461,6 +1498,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
has-flag@4.0.0: {}
hasown@2.0.2: hasown@2.0.2:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
@@ -1541,6 +1580,8 @@ snapshots:
process-warning: 4.0.1 process-warning: 4.0.1
set-cookie-parser: 2.7.1 set-cookie-parser: 2.7.1
loglevel@1.9.2: {}
lru-cache@11.1.0: {} lru-cache@11.1.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
@@ -1586,7 +1627,7 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
openai@5.12.2(ws@8.18.3): openai@5.16.0(ws@8.18.3):
optionalDependencies: optionalDependencies:
ws: 8.18.3 ws: 8.18.3
@@ -1613,10 +1654,6 @@ snapshots:
dependencies: dependencies:
split2: 4.2.0 split2: 4.2.0
pino-rotating-file-stream@0.0.2:
dependencies:
rotating-file-stream: 3.2.6
pino-std-serializers@7.0.0: {} pino-std-serializers@7.0.0: {}
pino@9.9.0: pino@9.9.0:
@@ -1666,7 +1703,7 @@ snapshots:
rfdc@1.4.1: {} rfdc@1.4.1: {}
rotating-file-stream@3.2.6: {} rotating-file-stream@3.2.7: {}
run-parallel@1.2.0: run-parallel@1.2.0:
dependencies: dependencies:
@@ -1748,6 +1785,10 @@ snapshots:
strip-eof@1.0.0: {} strip-eof@1.0.0: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
thread-stream@3.1.0: thread-stream@3.1.0:
@@ -1770,7 +1811,7 @@ snapshots:
undici-types@7.10.0: {} undici-types@7.10.0: {}
undici@7.13.0: {} undici@7.15.0: {}
uuid@11.1.0: {} uuid@11.1.0: {}

View File

@@ -17,15 +17,8 @@ class ImageCache {
}); });
} }
calculateHash(base64Image: string): string {
const hash = createHash('sha256');
hash.update(base64Image);
return hash.digest('hex');
}
storeImage(id: string, source: any): void { storeImage(id: string, source: any): void {
if (this.hasImage(id)) return; if (this.hasImage(id)) return;
const base64Image = source.data
this.cache.set(id, { this.cache.set(id, {
source, source,
timestamp: Date.now(), timestamp: Date.now(),
@@ -62,12 +55,10 @@ export class ImageAgent implements IAgent {
} }
shouldHandle(req: any, config: any): boolean { shouldHandle(req: any, config: any): boolean {
if (!config.Router.image) return false; if (!config.Router.image || req.body.model === config.Router.image) return false;
const lastMessage = req.body.messages[req.body.messages.length - 1] const lastMessage = req.body.messages[req.body.messages.length - 1]
if (lastMessage.role === 'user' && Array.isArray(lastMessage.content) &&lastMessage.content.find((item: any) => item.type === 'image')) { if (!config.forceUseImageAgent && lastMessage.role === 'user' && Array.isArray(lastMessage.content) &&lastMessage.content.find((item: any) => item.type === 'image')) {
if (config.Router.image) {
req.body.model = config.Router.image req.body.model = config.Router.image
}
return false; return false;
} }
return req.body.messages.some((msg: any) => msg.role === 'user' && Array.isArray(msg.content) && msg.content.some((item: any) => item.type === 'image')) return req.body.messages.some((msg: any) => msg.role === 'user' && Array.isArray(msg.content) && msg.content.some((item: any) => item.type === 'image'))
@@ -111,6 +102,7 @@ export class ImageAgent implements IAgent {
"required": ["imageId", "task"] "required": ["imageId", "task"]
}, },
handler: async (args, context) => { handler: async (args, context) => {
console.log('args', JSON.stringify(args, null, 2))
const imageMessages = []; const imageMessages = [];
let imageId; let imageId;
@@ -129,7 +121,6 @@ export class ImageAgent implements IAgent {
delete args.imageId; delete args.imageId;
} }
// Add text message with the response
if (Object.keys(args).length > 0) { if (Object.keys(args).length > 0) {
imageMessages.push({ imageMessages.push({
type: "text", type: "text",
@@ -148,7 +139,10 @@ export class ImageAgent implements IAgent {
model: context.config.Router.image, model: context.config.Router.image,
system: [{ system: [{
type: 'text', type: 'text',
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 users 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: [ messages: [
{ {
@@ -161,6 +155,7 @@ export class ImageAgent implements IAgent {
}).then(res => res.json()).catch(err => { }).then(res => res.json()).catch(err => {
return null; return null;
}); });
console.log(agentResponse.content);
if (!agentResponse || !agentResponse.content) { if (!agentResponse || !agentResponse.content) {
return 'analyzeImage Error'; return 'analyzeImage Error';
} }

View File

@@ -45,7 +45,8 @@ async function waitForService(
const startTime = Date.now(); const startTime = Date.now();
while (Date.now() - startTime < timeout) { while (Date.now() - startTime < timeout) {
if (isServiceRunning()) { const isRunning = await isServiceRunning()
if (isRunning) {
// Wait for an additional short period to ensure service is fully ready // Wait for an additional short period to ensure service is fully ready
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
return true; return true;
@@ -56,6 +57,7 @@ async function waitForService(
} }
async function main() { async function main() {
const isRunning = await isServiceRunning()
switch (command) { switch (command) {
case "start": case "start":
run(); run();
@@ -108,7 +110,7 @@ async function main() {
}); });
break; break;
case "code": case "code":
if (!isServiceRunning()) { if (!isRunning) {
console.log("Service not running, starting service..."); console.log("Service not running, starting service...");
const cliPath = join(__dirname, "cli.js"); const cliPath = join(__dirname, "cli.js");
const startProcess = spawn("node", [cliPath, "start"], { const startProcess = spawn("node", [cliPath, "start"], {
@@ -153,7 +155,7 @@ async function main() {
break; break;
case "ui": case "ui":
// Check if service is running // Check if service is running
if (!isServiceRunning()) { if (!isRunning) {
console.log("Service not running, starting service..."); console.log("Service not running, starting service...");
const cliPath = join(__dirname, "cli.js"); const cliPath = join(__dirname, "cli.js");
const startProcess = spawn("node", [cliPath, "start"], { const startProcess = spawn("node", [cliPath, "start"], {

View File

@@ -12,9 +12,8 @@ import {
savePid, savePid,
} from "./utils/processCheck"; } from "./utils/processCheck";
import { CONFIG_FILE } from "./constants"; import { CONFIG_FILE } from "./constants";
import createWriteStream from "pino-rotating-file-stream"; import { createStream } from 'rotating-file-stream';
import { HOME_DIR } from "./constants"; import { HOME_DIR } from "./constants";
import { configureLogging } from "./utils/log";
import { sessionUsageCache } from "./utils/cache"; import { sessionUsageCache } from "./utils/cache";
import {SSEParserTransform} from "./utils/SSEParser.transform"; import {SSEParserTransform} from "./utils/SSEParser.transform";
import {SSESerializerTransform} from "./utils/SSESerializer.transform"; import {SSESerializerTransform} from "./utils/SSESerializer.transform";
@@ -22,7 +21,9 @@ import {rewriteStream} from "./utils/rewriteStream";
import JSON5 from "json5"; import JSON5 from "json5";
import { IAgent } from "./agents/type"; import { IAgent } from "./agents/type";
import agentsManager from "./agents"; import agentsManager from "./agents";
import { EventEmitter } from "node:events";
const event = new EventEmitter()
async function initializeClaudeConfig() { async function initializeClaudeConfig() {
const homeDir = homedir(); const homeDir = homedir();
@@ -50,7 +51,8 @@ interface RunOptions {
async function run(options: RunOptions = {}) { async function run(options: RunOptions = {}) {
// Check if service is already running // Check if service is already running
if (isServiceRunning()) { const isRunning = await isServiceRunning()
if (isRunning) {
console.log("✅ Service is already running in the background."); console.log("✅ Service is already running in the background.");
return; return;
} }
@@ -61,8 +63,6 @@ async function run(options: RunOptions = {}) {
await cleanupLogFiles(); await cleanupLogFiles();
const config = await initConfig(); const config = await initConfig();
// Configure logging based on config
configureLogging(config);
let HOST = config.HOST || "127.0.0.1"; let HOST = config.HOST || "127.0.0.1";
@@ -95,15 +95,29 @@ async function run(options: RunOptions = {}) {
: port; : port;
// Configure logger based on config settings // 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 = const loggerConfig =
config.LOG !== false config.LOG !== false
? { ? {
level: config.LOG_LEVEL || "debug", level: config.LOG_LEVEL || "debug",
stream: createWriteStream({ stream: createStream(generator, {
path: HOME_DIR, path: HOME_DIR,
filename: config.LOGNAME || `./logs/ccr-${+new Date()}.log`,
maxFiles: 3, maxFiles: 3,
interval: "1d", interval: "1d",
compress: false,
maxSize: "50M"
}), }),
} }
: false; : false;
@@ -123,6 +137,15 @@ async function run(options: RunOptions = {}) {
}, },
logger: loggerConfig, logger: loggerConfig,
}); });
// Add global error handlers to prevent the service from crashing
process.on("uncaughtException", (err) => {
server.log.error("Uncaught exception:", err);
});
process.on("unhandledRejection", (reason, promise) => {
server.log.error("Unhandled rejection at:", promise, "reason:", reason);
});
// Add async preHandler hook for authentication // Add async preHandler hook for authentication
server.addHook("preHandler", async (req, reply) => { server.addHook("preHandler", async (req, reply) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -148,6 +171,9 @@ async function run(options: RunOptions = {}) {
// append agent tools // append agent tools
if (agent.tools.size) { if (agent.tools.size) {
if (!req.body?.tools?.length) {
req.body.tools = []
}
req.body.tools.unshift(...Array.from(agent.tools.values()).map(item => { req.body.tools.unshift(...Array.from(agent.tools.values()).map(item => {
return { return {
name: item.name, name: item.name,
@@ -162,13 +188,20 @@ async function run(options: RunOptions = {}) {
if (useAgents.length) { if (useAgents.length) {
req.agents = useAgents; req.agents = useAgents;
} }
await router(req, reply, config); await router(req, reply, {
config,
event
});
} }
}); });
server.addHook("onSend", async (req, reply, payload) => { 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 (req.sessionId && req.url.startsWith("/v1/messages")) {
if (payload instanceof ReadableStream) { if (payload instanceof ReadableStream) {
if (req.agents) { if (req.agents) {
const abortController = new AbortController();
const eventStream = payload.pipeThrough(new SSEParserTransform()) const eventStream = payload.pipeThrough(new SSEParserTransform())
let currentAgent: undefined | IAgent; let currentAgent: undefined | IAgent;
let currentToolIndex = -1 let currentToolIndex = -1
@@ -178,7 +211,8 @@ async function run(options: RunOptions = {}) {
const toolMessages: any[] = [] const toolMessages: any[] = []
const assistantMessages: any[] = [] const assistantMessages: any[] = []
// 存储Anthropic格式的消息体区分文本和工具类型 // 存储Anthropic格式的消息体区分文本和工具类型
return rewriteStream(eventStream, async (data, controller) => { return done(null, rewriteStream(eventStream, async (data, controller) => {
try {
// 检测工具调用开始 // 检测工具调用开始
if (data.event === 'content_block_start' && data?.data?.content_block?.name) { 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)) const agent = req.agents.find((name: string) => agentsManager.getAgent(name)?.tools.get(data.data.content_block.name))
@@ -250,6 +284,7 @@ async function run(options: RunOptions = {}) {
const stream = response.body!.pipeThrough(new SSEParserTransform()) const stream = response.body!.pipeThrough(new SSEParserTransform())
const reader = stream.getReader() const reader = stream.getReader()
while (true) { while (true) {
try {
const {value, done} = await reader.read(); const {value, done} = await reader.read();
if (done) { if (done) {
break; break;
@@ -257,17 +292,44 @@ async function run(options: RunOptions = {}) {
if (['message_start', 'message_stop'].includes(value.event)) { if (['message_start', 'message_stop'].includes(value.event)) {
continue continue
} }
// 检查流是否仍然可写
if (!controller.desiredSize) {
break;
}
controller.enqueue(value) controller.enqueue(value)
}catch (readError: any) {
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
abortController.abort(); // 中止所有相关操作
break;
}
throw readError;
}
} }
return undefined return undefined
} }
return data return data
}).pipeThrough(new SSESerializerTransform()) }catch (error: any) {
console.error('Unexpected error in stream processing:', error);
// 处理流提前关闭的错误
if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
abortController.abort();
return undefined;
}
// 其他错误仍然抛出
throw error;
}
}).pipeThrough(new SSESerializerTransform()))
} }
const [originalStream, clonedStream] = payload.tee(); const [originalStream, clonedStream] = payload.tee();
const read = async (stream: ReadableStream) => { const read = async (stream: ReadableStream) => {
const reader = stream.getReader(); const reader = stream.getReader();
try {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
@@ -282,14 +344,38 @@ async function run(options: RunOptions = {}) {
sessionUsageCache.put(req.sessionId, message.usage); sessionUsageCache.put(req.sessionId, message.usage);
} catch {} } catch {}
} }
} catch (readError: any) {
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
console.error('Background read stream closed prematurely');
} else {
console.error('Error in background stream reading:', readError);
}
} finally {
reader.releaseLock();
}
} }
read(clonedStream); read(clonedStream);
return originalStream return done(null, originalStream)
} }
sessionUsageCache.put(req.sessionId, payload.usage); sessionUsageCache.put(req.sessionId, payload.usage);
if (typeof payload ==='object') {
if (payload.error) {
return done(payload.error, null)
} else {
return done(payload, null)
} }
return payload; }
}
if (typeof payload ==='object' && payload.error) {
return done(payload.error, null)
}
done(null, payload)
}); });
server.addHook("onSend", async (req, reply, payload) => {
event.emit('onSend', req, reply, payload);
return payload;
})
server.start(); server.start();
} }

View File

@@ -3,6 +3,8 @@ import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
import { checkForUpdates, performUpdate } from "./utils"; import { checkForUpdates, performUpdate } from "./utils";
import { join } from "path"; import { join } from "path";
import fastifyStatic from "@fastify/static"; import fastifyStatic from "@fastify/static";
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
import { homedir } from "os";
export const createServer = (config: any): Server => { export const createServer = (config: any): Server => {
const server = new Server(config); const server = new Server(config);
@@ -102,5 +104,92 @@ export const createServer = (config: any): Server => {
} }
}); });
// 获取日志文件列表端点
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; return server;
}; };

View File

@@ -5,8 +5,9 @@ import { join } from 'path';
export async function closeService() { export async function closeService() {
const PID_FILE = join(HOME_DIR, '.claude-code-router.pid'); const PID_FILE = join(HOME_DIR, '.claude-code-router.pid');
const isRunning = await isServiceRunning()
if (!isServiceRunning()) { if (!isRunning) {
console.log("No service is currently running."); console.log("No service is currently running.");
return; return;
} }

View File

@@ -11,11 +11,15 @@ import {join} from "path";
export async function executeCodeCommand(args: string[] = []) { export async function executeCodeCommand(args: string[] = []) {
// Set environment variables // Set environment variables
const config = await readConfigFile(); const config = await readConfigFile();
const port = config.PORT || 3456;
const env: Record<string, string> = { const env: Record<string, string> = {
...process.env, ...process.env,
ANTHROPIC_AUTH_TOKEN: config?.APIKEY || "test", ANTHROPIC_AUTH_TOKEN: config?.APIKEY || "test",
ANTHROPIC_API_KEY: '', ANTHROPIC_API_KEY: '',
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.PORT || 3456}`, 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 API_TIMEOUT_MS: String(config.API_TIMEOUT_MS ?? 600000), // Default to 10 minutes if not set
}; };
let settingsFlag: Record<string, any> | undefined; let settingsFlag: Record<string, any> | undefined;
@@ -65,7 +69,6 @@ export async function executeCodeCommand(args: string[] = []) {
const stdioConfig: StdioOptions = config.NON_INTERACTIVE_MODE const stdioConfig: StdioOptions = config.NON_INTERACTIVE_MODE
? ["pipe", "inherit", "inherit"] // Pipe stdin for non-interactive ? ["pipe", "inherit", "inherit"] // Pipe stdin for non-interactive
: "inherit"; // Default inherited behavior : "inherit"; // Default inherited behavior
console.log(joinedArgs)
const claudeProcess = spawn( const claudeProcess = spawn(
claudePath + (joinedArgs ? ` ${joinedArgs}` : ""), claudePath + (joinedArgs ? ` ${joinedArgs}` : ""),
[], [],

View File

@@ -83,25 +83,38 @@ export const readConfigFile = async () => {
} catch (readError: any) { } catch (readError: any) {
if (readError.code === "ENOENT") { if (readError.code === "ENOENT") {
// Config file doesn't exist, prompt user for initial setup // Config file doesn't exist, prompt user for initial setup
const name = await question("Enter Provider Name: "); try {
const APIKEY = await question("Enter Provider API KEY: "); // Initialize directories
const baseUrl = await question("Enter Provider URL: "); await initDir();
const model = await question("Enter MODEL Name: ");
const config = Object.assign({}, DEFAULT_CONFIG, { // Backup existing config file if it exists
Providers: [ const backupPath = await backupConfigFile();
{ if (backupPath) {
name, console.log(
api_base_url: baseUrl, `Backed up existing configuration file to ${backupPath}`
api_key: APIKEY, );
models: [model], }
}, const config = {
], PORT: 3456,
Router: { Providers: [],
default: `${name},${model}`, Router: {},
}, }
}); // Create a minimal default config file
await writeConfigFile(config); await writeConfigFile(config);
return 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 { } else {
console.error(`Failed to read config file at ${CONFIG_FILE}`); console.error(`Failed to read config file at ${CONFIG_FILE}`);
console.error("Error details:", readError.message); console.error("Error details:", readError.message);

View File

@@ -1,45 +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 });
}
// Global variable to store the logging configuration
let isLogEnabled: boolean | null = null;
let logLevel: string = "info";
// Function to configure logging
export function configureLogging(config: { LOG?: boolean; LOG_LEVEL?: string }) {
isLogEnabled = config.LOG !== false; // Default to true if not explicitly set to false
logLevel = config.LOG_LEVEL || "debug";
}
export function log(...args: any[]) {
// If logging configuration hasn't been set, default to enabled
if (isLogEnabled === null) {
isLogEnabled = 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");
}

View File

@@ -1,6 +1,16 @@
import { existsSync, readFileSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, writeFileSync } from 'fs';
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants'; import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
import { readConfigFile } from '.'; import { readConfigFile } from '.';
import find from 'find-process';
export async function isProcessRunning(pid: number): Promise<boolean> {
try {
const processes = await find('pid', pid);
return processes.length > 0;
} catch (error) {
return false;
}
}
export function incrementReferenceCount() { export function incrementReferenceCount() {
let count = 0; let count = 0;
@@ -27,15 +37,14 @@ export function getReferenceCount(): number {
return parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0; return parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
} }
export function isServiceRunning(): boolean { export async function isServiceRunning(): Promise<boolean> {
if (!existsSync(PID_FILE)) { if (!existsSync(PID_FILE)) {
return false; return false;
} }
try { try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8')); const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
process.kill(pid, 0); return await isProcessRunning(pid);
return true;
} catch (e) { } catch (e) {
// Process not running, clean up pid file // Process not running, clean up pid file
cleanupPidFile(); cleanupPidFile();
@@ -73,7 +82,7 @@ export function getServicePid(): number | null {
export async function getServiceInfo() { export async function getServiceInfo() {
const pid = getServicePid(); const pid = getServicePid();
const running = isServiceRunning(); const running = await isServiceRunning();
const config = await readConfigFile(); const config = await readConfigFile();
const port = config.PORT || 3456; const port = config.PORT || 3456;

View File

@@ -4,8 +4,8 @@ import {
Tool, Tool,
} from "@anthropic-ai/sdk/resources/messages"; } from "@anthropic-ai/sdk/resources/messages";
import { get_encoding } from "tiktoken"; import { get_encoding } from "tiktoken";
import { log } from "./log";
import { sessionUsageCache, Usage } from "./cache"; import { sessionUsageCache, Usage } from "./cache";
import { readFile } from 'fs/promises'
const enc = get_encoding("cl100k_base"); const enc = get_encoding("cl100k_base");
@@ -94,11 +94,8 @@ const getUseModel = async (
(lastUsageThreshold || tokenCountThreshold) && (lastUsageThreshold || tokenCountThreshold) &&
config.Router.longContext config.Router.longContext
) { ) {
log( req.log.info(
"Using long context model due to token count:", `Using long context model due to token count: ${tokenCount}, threshold: ${longContextThreshold}`
tokenCount,
"threshold:",
longContextThreshold
); );
return config.Router.longContext; return config.Router.longContext;
} }
@@ -122,12 +119,12 @@ const getUseModel = async (
req.body.model?.startsWith("claude-3-5-haiku") && req.body.model?.startsWith("claude-3-5-haiku") &&
config.Router.background 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; return config.Router.background;
} }
// if exits thinking, use the think model // if exits thinking, use the think model
if (req.body.thinking && config.Router.think) { 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; return config.Router.think;
} }
if ( if (
@@ -140,7 +137,8 @@ const getUseModel = async (
return config.Router!.default; 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 // Parse sessionId from metadata.user_id
if (req.body.metadata?.user_id) { if (req.body.metadata?.user_id) {
const parts = req.body.metadata.user_id.split("_session_"); const parts = req.body.metadata.user_id.split("_session_");
@@ -150,6 +148,11 @@ export const router = async (req: any, _res: any, config: any) => {
} }
const lastMessageUsage = sessionUsageCache.get(req.sessionId); const lastMessageUsage = sessionUsageCache.get(req.sessionId);
const { messages, system = [], tools }: MessageCreateParamsBase = req.body; const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
if (config.REWRITE_SYSTEM_PROMPT && system.length > 1 && system[1]?.text?.includes('<env>')) {
const prompt = await readFile(config.REWRITE_SYSTEM_PROMPT, 'utf-8');
system[1].text = `${prompt}<env>${system[1].text.split('<env>').pop()}`
}
try { try {
const tokenCount = calculateTokenCount( const tokenCount = calculateTokenCount(
messages as MessageParam[], messages as MessageParam[],
@@ -162,9 +165,11 @@ export const router = async (req: any, _res: any, config: any) => {
try { try {
const customRouter = require(config.CUSTOM_ROUTER_PATH); const customRouter = require(config.CUSTOM_ROUTER_PATH);
req.tokenCount = tokenCount; // Pass token count to custom router req.tokenCount = tokenCount; // Pass token count to custom router
model = await customRouter(req, config); model = await customRouter(req, config, {
event
});
} catch (e: any) { } catch (e: any) {
log("failed to load custom router", e.message); req.log.error(`failed to load custom router: ${e.message}`);
} }
} }
if (!model) { if (!model) {
@@ -172,7 +177,7 @@ export const router = async (req: any, _res: any, config: any) => {
} }
req.body.model = model; req.body.model = model;
} catch (error: any) { } 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; req.body.model = config.Router!.default;
} }
return; return;

View File

@@ -6,10 +6,11 @@ import { Transformers } from "@/components/Transformers";
import { Providers } from "@/components/Providers"; import { Providers } from "@/components/Providers";
import { Router } from "@/components/Router"; import { Router } from "@/components/Router";
import { JsonEditor } from "@/components/JsonEditor"; import { JsonEditor } from "@/components/JsonEditor";
import { LogViewer } from "@/components/LogViewer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useConfig } from "@/components/ConfigProvider"; import { useConfig } from "@/components/ConfigProvider";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp } from "lucide-react"; import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText } from "lucide-react";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -32,6 +33,7 @@ function App() {
const { config, error } = useConfig(); const { config, error } = useConfig();
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState(false); const [isJsonEditorOpen, setIsJsonEditorOpen] = useState(false);
const [isLogViewerOpen, setIsLogViewerOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(true); const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null); const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
// 版本检查状态 // 版本检查状态
@@ -276,6 +278,9 @@ function App() {
<Button variant="ghost" size="icon" onClick={() => setIsJsonEditorOpen(true)} className="transition-all-ease hover:scale-110"> <Button variant="ghost" size="icon" onClick={() => setIsJsonEditorOpen(true)} className="transition-all-ease hover:scale-110">
<FileJson className="h-5 w-5" /> <FileJson className="h-5 w-5" />
</Button> </Button>
<Button variant="ghost" size="icon" onClick={() => setIsLogViewerOpen(true)} className="transition-all-ease hover:scale-110">
<FileText className="h-5 w-5" />
</Button>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="transition-all-ease hover:scale-110"> <Button variant="ghost" size="icon" className="transition-all-ease hover:scale-110">
@@ -350,6 +355,11 @@ function App() {
onOpenChange={setIsJsonEditorOpen} onOpenChange={setIsJsonEditorOpen}
showToast={(message, type) => setToast({ message, type })} showToast={(message, type) => setToast({ message, type })}
/> />
<LogViewer
open={isLogViewerOpen}
onOpenChange={setIsLogViewerOpen}
showToast={(message, type) => setToast({ message, type })}
/>
{/* 版本更新对话框 */} {/* 版本更新对话框 */}
<Dialog open={isUpdateDialogOpen} onOpenChange={setIsUpdateDialogOpen}> <Dialog open={isUpdateDialogOpen} onOpenChange={setIsUpdateDialogOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">

View File

@@ -95,15 +95,18 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
think: typeof data.Router.think === 'string' ? data.Router.think : '', think: typeof data.Router.think === 'string' ? data.Router.think : '',
longContext: typeof data.Router.longContext === 'string' ? data.Router.longContext : '', longContext: typeof data.Router.longContext === 'string' ? data.Router.longContext : '',
longContextThreshold: typeof data.Router.longContextThreshold === 'number' ? data.Router.longContextThreshold : 60000, longContextThreshold: typeof data.Router.longContextThreshold === 'number' ? data.Router.longContextThreshold : 60000,
webSearch: typeof data.Router.webSearch === 'string' ? data.Router.webSearch : '' webSearch: typeof data.Router.webSearch === 'string' ? data.Router.webSearch : '',
image: typeof data.Router.image === 'string' ? data.Router.image : ''
} : { } : {
default: '', default: '',
background: '', background: '',
think: '', think: '',
longContext: '', longContext: '',
longContextThreshold: 60000, longContextThreshold: 60000,
webSearch: '' webSearch: '',
} image: ''
},
CUSTOM_ROUTER_PATH: typeof data.CUSTOM_ROUTER_PATH === 'string' ? data.CUSTOM_ROUTER_PATH : ''
}; };
setConfig(validConfig); setConfig(validConfig);
@@ -131,8 +134,10 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
think: '', think: '',
longContext: '', longContext: '',
longContextThreshold: 60000, longContextThreshold: 60000,
webSearch: '' webSearch: '',
} image: ''
},
CUSTOM_ROUTER_PATH: ''
}); });
setError(err as Error); setError(err as Error);
} }

View File

@@ -0,0 +1,726 @@
import React, { useState, useEffect, useRef } from 'react';
import Editor from '@monaco-editor/react';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
import { useTranslation } from 'react-i18next';
import { X, RefreshCw, Download, Trash2, ArrowLeft, File, Layers } from 'lucide-react';
interface LogViewerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
showToast?: (message: string, type: 'success' | 'error' | 'warning') => void;
}
interface LogEntry {
timestamp: string;
level: 'info' | 'warn' | 'error' | 'debug';
message: string; // 现在这个字段直接包含原始JSON字符串
source?: string;
reqId?: string;
}
interface LogFile {
name: string;
path: string;
size: number;
lastModified: string;
}
interface GroupedLogs {
[reqId: string]: LogEntry[];
}
interface LogGroupSummary {
reqId: string;
logCount: number;
firstLog: string;
lastLog: string;
model?: string;
}
interface GroupedLogsResponse {
grouped: boolean;
groups: { [reqId: string]: LogEntry[] };
summary: {
totalRequests: number;
totalLogs: number;
requests: LogGroupSummary[];
};
}
export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
const { t } = useTranslation();
const [logs, setLogs] = useState<string[]>([]);
const [logFiles, setLogFiles] = useState<LogFile[]>([]);
const [selectedFile, setSelectedFile] = useState<LogFile | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
const [groupByReqId, setGroupByReqId] = useState(false);
const [groupedLogs, setGroupedLogs] = useState<GroupedLogsResponse | null>(null);
const [selectedReqId, setSelectedReqId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const refreshInterval = useRef<NodeJS.Timeout | null>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
if (open) {
loadLogFiles();
}
}, [open]);
// 创建内联 Web Worker
const createInlineWorker = (): Worker => {
const workerCode = `
// 日志聚合Web Worker
self.onmessage = function(event) {
const { type, data } = event.data;
if (type === 'groupLogsByReqId') {
try {
const { logs } = data;
// 按reqId聚合日志
const groupedLogs = {};
logs.forEach((log, index) => {
log = JSON.parse(log);
let reqId = log.reqId || 'no-req-id';
if (!groupedLogs[reqId]) {
groupedLogs[reqId] = [];
}
groupedLogs[reqId].push(log);
});
// 按时间戳排序每个组的日志
Object.keys(groupedLogs).forEach(reqId => {
groupedLogs[reqId].sort((a, b) => a.time - b.time);
});
// 提取model信息
const extractModelInfo = (reqId) => {
const logGroup = groupedLogs[reqId];
for (const log of logGroup) {
try {
// 尝试从message字段解析JSON
if (log.type === 'request body' && log.data && log.data.model) {
return log.data.model;
}
} catch (e) {
// 解析失败,继续尝试下一条日志
}
}
return undefined;
};
// 生成摘要信息
const summary = {
totalRequests: Object.keys(groupedLogs).length,
totalLogs: logs.length,
requests: Object.keys(groupedLogs).map(reqId => ({
reqId,
logCount: groupedLogs[reqId].length,
firstLog: groupedLogs[reqId][0]?.time,
lastLog: groupedLogs[reqId][groupedLogs[reqId].length - 1]?.time,
model: extractModelInfo(reqId)
}))
};
const response = {
grouped: true,
groups: groupedLogs,
summary
};
// 发送结果回主线程
self.postMessage({
type: 'groupLogsResult',
data: response
});
} catch (error) {
// 发送错误回主线程
self.postMessage({
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error occurred'
});
}
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
return new Worker(workerUrl);
};
// 初始化Web Worker
useEffect(() => {
if (typeof Worker !== 'undefined') {
try {
// 创建内联Web Worker
workerRef.current = createInlineWorker();
// 监听Worker消息
workerRef.current.onmessage = (event) => {
const { type, data, error } = event.data;
if (type === 'groupLogsResult') {
setGroupedLogs(data);
} else if (type === 'error') {
console.error('Worker error:', error);
if (showToast) {
showToast(t('log_viewer.worker_error') + ': ' + error, 'error');
}
}
};
// 监听Worker错误
workerRef.current.onerror = (error) => {
console.error('Worker error:', error);
if (showToast) {
showToast(t('log_viewer.worker_init_failed'), 'error');
}
};
} catch (error) {
console.error('Failed to create worker:', error);
if (showToast) {
showToast(t('log_viewer.worker_init_failed'), 'error');
}
}
}
// 清理Worker
return () => {
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
}
};
}, [showToast, t]);
useEffect(() => {
if (autoRefresh && open && selectedFile) {
refreshInterval.current = setInterval(() => {
loadLogs();
}, 5000); // Refresh every 5 seconds
} else if (refreshInterval.current) {
clearInterval(refreshInterval.current);
}
return () => {
if (refreshInterval.current) {
clearInterval(refreshInterval.current);
}
};
}, [autoRefresh, open, selectedFile]);
// Load logs when selected file changes
useEffect(() => {
if (selectedFile && open) {
setLogs([]); // Clear existing logs
loadLogs();
}
}, [selectedFile, open]);
// Handle open/close animations
useEffect(() => {
if (open) {
setIsVisible(true);
// Trigger the animation after a small delay to ensure the element is rendered
requestAnimationFrame(() => {
setIsAnimating(true);
});
} else {
setIsAnimating(false);
// Wait for the animation to complete before hiding
const timer = setTimeout(() => {
setIsVisible(false);
}, 300);
return () => clearTimeout(timer);
}
}, [open]);
const loadLogFiles = async () => {
try {
setIsLoading(true);
const response = await api.getLogFiles();
if (response && Array.isArray(response)) {
setLogFiles(response);
setSelectedFile(null);
setLogs([]);
} else {
setLogFiles([]);
if (showToast) {
showToast(t('log_viewer.no_log_files_available'), 'warning');
}
}
} catch (error) {
console.error('Failed to load log files:', error);
if (showToast) {
showToast(t('log_viewer.load_files_failed') + ': ' + (error as Error).message, 'error');
}
} finally {
setIsLoading(false);
}
};
const loadLogs = async () => {
if (!selectedFile) return;
try {
setIsLoading(true);
setGroupedLogs(null);
setSelectedReqId(null);
// 始终加载原始日志数据
const response = await api.getLogs(selectedFile.path);
if (response && Array.isArray(response)) {
// 现在接口返回的是原始日志字符串数组,直接存储
setLogs(response);
// 如果启用了分组使用Web Worker进行聚合需要转换为LogEntry格式供Worker使用
if (groupByReqId && workerRef.current) {
// const workerLogs: LogEntry[] = response.map((logLine, index) => ({
// timestamp: new Date().toISOString(),
// level: 'info',
// message: logLine,
// source: undefined,
// reqId: undefined
// }));
workerRef.current.postMessage({
type: 'groupLogsByReqId',
data: { logs: response }
});
} else {
setGroupedLogs(null);
}
} else {
setLogs([]);
setGroupedLogs(null);
if (showToast) {
showToast(t('log_viewer.no_logs_available'), 'warning');
}
}
} catch (error) {
console.error('Failed to load logs:', error);
if (showToast) {
showToast(t('log_viewer.load_failed') + ': ' + (error as Error).message, 'error');
}
} finally {
setIsLoading(false);
}
};
const clearLogs = async () => {
if (!selectedFile) return;
try {
await api.clearLogs(selectedFile.path);
setLogs([]);
if (showToast) {
showToast(t('log_viewer.logs_cleared'), 'success');
}
} catch (error) {
console.error('Failed to clear logs:', error);
if (showToast) {
showToast(t('log_viewer.clear_failed') + ': ' + (error as Error).message, 'error');
}
}
};
const selectFile = (file: LogFile) => {
setSelectedFile(file);
setAutoRefresh(false); // Reset auto refresh when changing files
};
const toggleGroupByReqId = () => {
const newValue = !groupByReqId;
setGroupByReqId(newValue);
if (newValue && selectedFile && logs.length > 0) {
// 启用聚合时如果已有日志则使用Worker进行聚合
if (workerRef.current) {
workerRef.current.postMessage({
type: 'groupLogsByReqId',
data: { logs }
});
}
} else if (!newValue) {
// 禁用聚合时,清除聚合结果
setGroupedLogs(null);
setSelectedReqId(null);
}
};
const selectReqId = (reqId: string) => {
setSelectedReqId(reqId);
};
const getDisplayLogs = () => {
if (groupByReqId && groupedLogs) {
if (selectedReqId && groupedLogs.groups[selectedReqId]) {
return groupedLogs.groups[selectedReqId];
}
// 当在分组模式但没有选中具体请求时,显示原始日志字符串数组
return logs.map(logLine => ({
timestamp: new Date().toISOString(),
level: 'info',
message: logLine,
source: undefined,
reqId: undefined
}));
}
// 当不在分组模式时,显示原始日志字符串数组
return logs.map(logLine => ({
timestamp: new Date().toISOString(),
level: 'info',
message: logLine,
source: undefined,
reqId: undefined
}));
};
const downloadLogs = () => {
if (!selectedFile || logs.length === 0) return;
// 直接下载原始日志字符串,每行一个日志
const logText = logs.join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedFile.name}-${new Date().toISOString().split('T')[0]}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (showToast) {
showToast(t('log_viewer.logs_downloaded'), 'success');
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// 面包屑导航项类型
interface BreadcrumbItem {
id: string;
label: string;
onClick: () => void;
}
// 获取面包屑导航项
const getBreadcrumbs = (): BreadcrumbItem[] => {
const breadcrumbs: BreadcrumbItem[] = [
{
id: 'root',
label: t('log_viewer.title'),
onClick: () => {
setSelectedFile(null);
setAutoRefresh(false);
setLogs([]);
setGroupedLogs(null);
setSelectedReqId(null);
setGroupByReqId(false);
}
}
];
if (selectedFile) {
breadcrumbs.push({
id: 'file',
label: selectedFile.name,
onClick: () => {
if (groupByReqId) {
// 如果在分组模式下,点击文件层级应该返回到分组列表
setSelectedReqId(null);
} else {
// 如果不在分组模式下,点击文件层级关闭分组功能
setSelectedReqId(null);
setGroupedLogs(null);
setGroupByReqId(false);
}
}
});
}
if (selectedReqId) {
breadcrumbs.push({
id: 'req',
label: `${t('log_viewer.request')} ${selectedReqId}`,
onClick: () => {
// 点击当前层级时不做任何操作
}
});
}
return breadcrumbs;
};
// 获取返回按钮的处理函数
const getBackAction = (): (() => void) | null => {
if (selectedReqId) {
return () => {
setSelectedReqId(null);
};
} else if (selectedFile) {
return () => {
setSelectedFile(null);
setAutoRefresh(false);
setLogs([]);
setGroupedLogs(null);
setSelectedReqId(null);
setGroupByReqId(false);
};
}
return null;
};
const formatLogsForEditor = () => {
// 如果在分组模式且选中了具体请求,显示该请求的日志
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
const requestLogs = groupedLogs.groups[selectedReqId];
console.log(requestLogs)
// 提取原始JSON字符串并每行一个
return requestLogs.map(log => JSON.stringify(log)).join('\n');
}
// 其他情况,直接显示原始日志字符串数组,每行一个
return logs.join('\n');
};
if (!isVisible && !open) {
return null;
}
return (
<>
{(isVisible || open) && (
<div
className={`fixed inset-0 z-50 transition-all duration-300 ease-out ${
isAnimating && open ? 'bg-black/50 opacity-100' : 'bg-black/0 opacity-0 pointer-events-none'
}`}
onClick={() => onOpenChange(false)}
/>
)}
<div
ref={containerRef}
className={`fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-white shadow-2xl transition-all duration-300 ease-out transform ${
isAnimating && open ? 'translate-y-0' : 'translate-y-full'
}`}
style={{
height: '100vh',
maxHeight: '100vh'
}}
>
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-2">
{getBackAction() && (
<Button
variant="ghost"
size="sm"
onClick={getBackAction()!}
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t('log_viewer.back')}
</Button>
)}
{/* 面包屑导航 */}
<nav className="flex items-center space-x-1 text-sm">
{getBreadcrumbs().map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.id}>
{index > 0 && (
<span className="text-gray-400 mx-1">/</span>
)}
{index === getBreadcrumbs().length - 1 ? (
<span className="text-gray-900 font-medium">
{breadcrumb.label}
</span>
) : (
<button
onClick={breadcrumb.onClick}
className="text-blue-600 hover:text-blue-800 transition-colors"
>
{breadcrumb.label}
</button>
)}
</React.Fragment>
))}
</nav>
</div>
<div className="flex gap-2">
{selectedFile && (
<>
<Button
variant="ghost"
size="sm"
onClick={toggleGroupByReqId}
className={groupByReqId ? 'bg-blue-100 text-blue-700' : ''}
>
<Layers className="h-4 w-4 mr-2" />
{groupByReqId ? t('log_viewer.grouped_on') : t('log_viewer.group_by_req_id')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
className={autoRefresh ? 'bg-blue-100 text-blue-700' : ''}
>
<RefreshCw className={`h-4 w-4 mr-2 ${autoRefresh ? 'animate-spin' : ''}`} />
{autoRefresh ? t('log_viewer.auto_refresh_on') : t('log_viewer.auto_refresh_off')}
</Button>
<Button
variant="outline"
size="sm"
onClick={downloadLogs}
disabled={logs.length === 0}
>
<Download className="h-4 w-4 mr-2" />
{t('log_viewer.download')}
</Button>
<Button
variant="outline"
size="sm"
onClick={clearLogs}
disabled={logs.length === 0}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('log_viewer.clear')}
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4 mr-2" />
{t('log_viewer.close')}
</Button>
</div>
</div>
<div className="flex-1 min-h-0 bg-gray-50">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : selectedFile ? (
<>
{groupByReqId && groupedLogs && !selectedReqId ? (
// 显示日志组列表
<div className="flex flex-col h-full p-6">
<div className="mb-4 flex-shrink-0">
<h3 className="text-lg font-medium mb-2">{t('log_viewer.request_groups')}</h3>
<p className="text-sm text-gray-600">
{t('log_viewer.total_requests')}: {groupedLogs.summary.totalRequests} |
{t('log_viewer.total_logs')}: {groupedLogs.summary.totalLogs}
</p>
</div>
<div className="flex-1 min-h-0 overflow-y-auto space-y-3">
{groupedLogs.summary.requests.map((request) => (
<div
key={request.reqId}
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => selectReqId(request.reqId)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<File className="h-5 w-5 text-blue-600" />
<span className="font-medium text-sm">{request.reqId}</span>
{request.model && (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
{request.model}
</span>
)}
</div>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
{request.logCount} {t('log_viewer.logs')}
</span>
</div>
<div className="text-xs text-gray-500 space-y-1">
<div>{t('log_viewer.first_log')}: {formatDate(request.firstLog)}</div>
<div>{t('log_viewer.last_log')}: {formatDate(request.lastLog)}</div>
</div>
</div>
))}
</div>
</div>
) : (
// 显示日志内容
<Editor
height="100%"
defaultLanguage="json"
value={formatLogsForEditor()}
theme="vs"
options={{
minimap: { enabled: true },
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: 'on',
readOnly: true,
lineNumbers: 'on',
folding: true,
renderWhitespace: 'all',
}}
/>
)}
</>
) : (
<div className="p-6">
<h3 className="text-lg font-medium mb-4">{t('log_viewer.select_file')}</h3>
{logFiles.length === 0 ? (
<div className="text-gray-500 text-center py-8">
<File className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p>{t('log_viewer.no_log_files_available')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{logFiles.map((file) => (
<div
key={file.path}
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => selectFile(file)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<File className="h-5 w-5 text-blue-600" />
<span className="font-medium text-sm">{file.name}</span>
</div>
</div>
<div className="text-xs text-gray-500 space-y-1">
<div>{formatFileSize(file.size)}</div>
<div>{formatDate(file.lastModified)}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -30,7 +30,8 @@ export function Router() {
think: "", think: "",
longContext: "", longContext: "",
longContextThreshold: 60000, longContextThreshold: 60000,
webSearch: "" webSearch: "",
image: ""
}; };
const handleRouterChange = (field: string, value: string | number) => { const handleRouterChange = (field: string, value: string | number) => {
@@ -40,6 +41,10 @@ export function Router() {
setConfig({ ...config, Router: newRouter }); setConfig({ ...config, Router: newRouter });
}; };
const handleForceUseImageAgentChange = (value: boolean) => {
setConfig({ ...config, forceUseImageAgent: value });
};
// Handle case where config.Providers might be null or undefined // Handle case where config.Providers might be null or undefined
const providers = Array.isArray(config.Providers) ? config.Providers : []; const providers = Array.isArray(config.Providers) ? config.Providers : [];
@@ -133,6 +138,33 @@ export function Router() {
emptyPlaceholder={t("router.noModelFound")} emptyPlaceholder={t("router.noModelFound")}
/> />
</div> </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> </CardContent>
</Card> </Card>
); );

View File

@@ -212,6 +212,21 @@ export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
className="transition-all-ease focus:scale-[1.01]" className="transition-all-ease focus:scale-[1.01]"
/> />
</div> </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> </div>
<DialogFooter className="p-4 pt-0"> <DialogFooter className="p-4 pt-0">
<Button <Button

View File

@@ -4,15 +4,72 @@ import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ 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 ( return (
<input <input
type={type} {...props}
type={inputType}
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
className={cn( 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", "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 className
)} )}
ref={ref} ref={ref}
{...props}
/> />
) )
} }

View File

@@ -1,5 +1,21 @@
import type { Config, Provider, Transformer } from '@/types'; 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 // API Client Class for handling requests with baseUrl and apikey authentication
class ApiClient { class ApiClient {
private baseUrl: string; private baseUrl: string;
@@ -204,6 +220,21 @@ class ApiClient {
async performUpdate(): Promise<{ success: boolean; message: string }> { async performUpdate(): Promise<{ success: boolean; message: string }> {
return this.post<{ success: boolean; message: string }>('/api/update/perform', {}); 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 // Create a default instance of the API client

View File

@@ -1,4 +1,8 @@
{ {
"common": {
"yes": "Yes",
"no": "No"
},
"app": { "app": {
"title": "Claude Code Router", "title": "Claude Code Router",
"save": "Save", "save": "Save",
@@ -42,7 +46,9 @@
"port": "Port", "port": "Port",
"apikey": "API Key", "apikey": "API Key",
"timeout": "API Timeout (ms)", "timeout": "API Timeout (ms)",
"proxy_url": "Proxy URL" "proxy_url": "Proxy URL",
"custom_router_path": "Custom Router Script Path",
"custom_router_path_placeholder": "Enter absolute path to custom router script file"
}, },
"transformers": { "transformers": {
"title": "Custom Transformers", "title": "Custom Transformers",
@@ -105,6 +111,8 @@
"longContext": "Long Context", "longContext": "Long Context",
"longContextThreshold": "Context Threshold", "longContextThreshold": "Context Threshold",
"webSearch": "Web Search", "webSearch": "Web Search",
"image": "Image",
"forceUseImageAgent": "Force Use Image Agent",
"selectModel": "Select a model...", "selectModel": "Select a model...",
"searchModel": "Search model...", "searchModel": "Search model...",
"noModelFound": "No model found." "noModelFound": "No model found."
@@ -185,5 +193,36 @@
"template_download_success": "Template downloaded successfully", "template_download_success": "Template downloaded successfully",
"template_download_success_desc": "Configuration template has been downloaded to your device", "template_download_success_desc": "Configuration template has been downloaded to your device",
"template_download_failed": "Failed to download template" "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"
} }
} }

View File

@@ -1,4 +1,8 @@
{ {
"common": {
"yes": "是",
"no": "否"
},
"app": { "app": {
"title": "Claude Code Router", "title": "Claude Code Router",
"save": "保存", "save": "保存",
@@ -42,7 +46,9 @@
"port": "端口", "port": "端口",
"apikey": "API 密钥", "apikey": "API 密钥",
"timeout": "API 超时时间 (毫秒)", "timeout": "API 超时时间 (毫秒)",
"proxy_url": "代理地址" "proxy_url": "代理地址",
"custom_router_path": "自定义路由脚本路径",
"custom_router_path_placeholder": "输入自定义路由脚本文件的绝对路径"
}, },
"transformers": { "transformers": {
"title": "自定义转换器", "title": "自定义转换器",
@@ -105,6 +111,8 @@
"longContext": "长上下文", "longContext": "长上下文",
"longContextThreshold": "上下文阈值", "longContextThreshold": "上下文阈值",
"webSearch": "网络搜索", "webSearch": "网络搜索",
"image": "图像",
"forceUseImageAgent": "强制使用图像代理",
"selectModel": "选择一个模型...", "selectModel": "选择一个模型...",
"searchModel": "搜索模型...", "searchModel": "搜索模型...",
"noModelFound": "未找到模型." "noModelFound": "未找到模型."
@@ -185,5 +193,36 @@
"template_download_success": "模板下载成功", "template_download_success": "模板下载成功",
"template_download_success_desc": "配置模板已下载到您的设备", "template_download_success_desc": "配置模板已下载到您的设备",
"template_download_failed": "模板下载失败" "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": "返回"
} }
} }

View File

@@ -18,6 +18,7 @@ export interface RouterConfig {
longContext: string; longContext: string;
longContextThreshold: number; longContextThreshold: number;
webSearch: string; webSearch: string;
image: string;
custom?: any; custom?: any;
} }
@@ -53,6 +54,7 @@ export interface Config {
Router: RouterConfig; Router: RouterConfig;
transformers: Transformer[]; transformers: Transformer[];
StatusLine?: StatusLineConfig; StatusLine?: StatusLineConfig;
forceUseImageAgent?: boolean;
// Top-level settings // Top-level settings
LOG: boolean; LOG: boolean;
LOG_LEVEL: string; LOG_LEVEL: string;
@@ -62,6 +64,7 @@ export interface Config {
APIKEY: string; APIKEY: string;
API_TIMEOUT_MS: string; API_TIMEOUT_MS: string;
PROXY_URL: string; PROXY_URL: string;
CUSTOM_ROUTER_PATH?: string;
} }
export type AccessLevel = 'restricted' | 'full'; export type AccessLevel = 'restricted' | 'full';

View File

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