Compare commits
28 Commits
dev/agents
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cd21c570f | ||
|
|
c5e97709a5 | ||
|
|
f7adb7b28e | ||
|
|
7964fff175 | ||
|
|
fe06b57032 | ||
|
|
1b3a8f8803 | ||
|
|
cec8421dd9 | ||
|
|
1a7e90df39 | ||
|
|
e5741ae470 | ||
|
|
0152af5db9 | ||
|
|
e6b3e2a194 | ||
|
|
f7058dcdb5 | ||
|
|
e670302e9e | ||
|
|
5761e165fd | ||
|
|
8c4fec4f5f | ||
|
|
5d53571fe6 | ||
|
|
35fc4505b2 | ||
|
|
c7303775ad | ||
|
|
f7981b16cd | ||
|
|
b54687c4d5 | ||
|
|
0be4c3753f | ||
|
|
668e855a2d | ||
|
|
41108cea1d | ||
|
|
3b9e58a823 | ||
|
|
615fe7629e | ||
|
|
656a5f9a97 | ||
|
|
d2a0815cb7 | ||
|
|
7cc41d83cf |
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN npm install -g @musistudio/claude-code-router
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
CMD ["ccr", "start"]
|
||||
35
README.md
35
README.md
@@ -1,14 +1,23 @@
|
||||
# Claude Code Router
|
||||

|
||||
|
||||
[](README_zh.md)
|
||||
[](https://discord.gg/rdftVMaUcS)
|
||||
[](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)
|
||||
|
||||
|
||||
[中文版](README_zh.md)
|
||||
|
||||
> A powerful tool to route Claude Code requests to different models and customize any request.
|
||||
|
||||
> Now you can use models such as `GLM-4.5`, `Kimi-K2`, `Qwen3-Coder-480B-A35B`, and `DeepSeek v3.1` for free through the [iFlow Platform](https://platform.iflow.cn/docs/api-mode).
|
||||
> You can use the `ccr ui` command to directly import the `iflow` template in the UI. It’s worth noting that iFlow limits each user to a concurrency of 1, which means you’ll need to route background requests to other models.
|
||||
> If you’d like a better experience, you can try [iFlow CLI](https://cli.iflow.cn).
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **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).
|
||||
- `cleancache`: Clears the `cache_control` field from requests.
|
||||
- `vertex-gemini`: Handles the Gemini API using Vertex authentication.
|
||||
- `chutes-glm` Unofficial support for GLM 4.5 model via Chutes [chutes-glm-transformer.js](https://gist.github.com/vitobotta/2be3f33722e05e8d4f9d2b0138b8c863).
|
||||
- `qwen-cli` (experimental): Unofficial support for qwen3-coder-plus model via Qwen CLI [qwen-cli.js](https://gist.github.com/musistudio/f5a67841ced39912fd99e42200d5ca8b).
|
||||
- `rovo-cli` (experimental): Unofficial support for gpt-5 via Atlassian Rovo Dev CLI [rovo-cli.js](https://gist.github.com/SaseQ/c2a20a38b11276537ec5332d1f7a5e53).
|
||||
|
||||
@@ -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).
|
||||
- `longContextThreshold` (optional): The token count threshold for triggering the long context model. Defaults to 60000 if not specified.
|
||||
- `webSearch`: Used for handling web search tasks and this requires the model itself to support the feature. If you're using openrouter, you need to add the `:online` suffix after the model name.
|
||||
- `image` (beta): Used for handling image-related tasks (supported by CCR’s built-in agent). If the model does not support tool calling, you need to set the `config.forceUseImageAgent` property to `true`.
|
||||
|
||||
You can also switch models dynamically in Claude Code with the `/model` command:
|
||||
- You can also switch models dynamically in Claude Code with the `/model` command:
|
||||
`/model provider_name,model_name`
|
||||
Example: `/model openrouter,anthropic/claude-3.5-sonnet`
|
||||
|
||||
@@ -544,7 +555,7 @@ A huge thank you to all our sponsors for their generous support!
|
||||
- @\*光
|
||||
- @W\*l
|
||||
- [@kesku](https://github.com/kesku)
|
||||
- @水\*丫
|
||||
- [@biguncle](https://github.com/biguncle)
|
||||
- @二吉吉
|
||||
- @a\*g
|
||||
- @\*林
|
||||
@@ -557,5 +568,17 @@ A huge thank you to all our sponsors for their generous support!
|
||||
- @r\*c
|
||||
- [@qierkang](http://github.com/qierkang)
|
||||
- @\*军
|
||||
- [@snrise-z](http://github.com/snrise-z)
|
||||
- @\*王
|
||||
- [@greatheart1000](http://github.com/greatheart1000)
|
||||
- @\*王
|
||||
- @zcutlip
|
||||
- [@Peng-YM](http://github.com/Peng-YM)
|
||||
- @\*更
|
||||
- @\*.
|
||||
- @F\*t
|
||||
- @\*政
|
||||
- @\*铭
|
||||
- @\*叶
|
||||
|
||||
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)
|
||||
|
||||
31
README_zh.md
31
README_zh.md
@@ -1,11 +1,24 @@
|
||||
# Claude Code Router
|
||||

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

|
||||
|
||||

|
||||
|
||||
|
||||
## ✨ 功能
|
||||
|
||||
- **模型路由**: 根据您的需求将请求路由到不同的模型(例如,后台任务、思考、长上下文)。
|
||||
@@ -318,6 +331,7 @@ Transformers 允许您修改请求和响应负载,以确保与不同提供商
|
||||
- `longContext`: 用于处理长上下文(例如,> 60K 令牌)的模型。
|
||||
- `longContextThreshold` (可选): 触发长上下文模型的令牌数阈值。如果未指定,默认为 60000。
|
||||
- `webSearch`: 用于处理网络搜索任务,需要模型本身支持。如果使用`openrouter`需要在模型后面加上`:online`后缀。
|
||||
- `image`(测试版): 用于处理图片类任务(采用CCR内置的agent支持),如果该模型不支持工具调用,需要将`config.forceUseImageAgent`属性设置为`true`。
|
||||
|
||||
您还可以使用 `/model` 命令在 Claude Code 中动态切换模型:
|
||||
`/model provider_name,model_name`
|
||||
@@ -499,6 +513,7 @@ jobs:
|
||||
- @*琢
|
||||
- @*成
|
||||
- @Z*o
|
||||
- @\*琨
|
||||
- [@congzhangzh](https://github.com/congzhangzh)
|
||||
- @*_
|
||||
- @Z\*m
|
||||
@@ -513,7 +528,7 @@ jobs:
|
||||
- @\*光
|
||||
- @W\*l
|
||||
- [@kesku](https://github.com/kesku)
|
||||
- @水\*丫
|
||||
- [@biguncle](https://github.com/biguncle)
|
||||
- @二吉吉
|
||||
- @a\*g
|
||||
- @\*林
|
||||
@@ -526,6 +541,18 @@ jobs:
|
||||
- @r\*c
|
||||
- [@qierkang](http://github.com/qierkang)
|
||||
- @\*军
|
||||
- [@snrise-z](http://github.com/snrise-z)
|
||||
- @\*王
|
||||
- [@greatheart1000](http://github.com/greatheart1000)
|
||||
- @\*王
|
||||
- @zcutlip
|
||||
- [@Peng-YM](http://github.com/Peng-YM)
|
||||
- @\*更
|
||||
- @\*.
|
||||
- @F\*t
|
||||
- @\*政
|
||||
- @\*铭
|
||||
- @\*叶
|
||||
|
||||
(如果您的名字被屏蔽,请通过我的主页电子邮件与我联系,以便使用您的 GitHub 用户名进行更新。)
|
||||
|
||||
|
||||
BIN
blog/images/claude-code-router-img.png
Normal file
BIN
blog/images/claude-code-router-img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
67
blog/images/roadmap.svg
Normal file
67
blog/images/roadmap.svg
Normal file
@@ -0,0 +1,67 @@
|
||||
<svg viewBox="0 0 1200 420" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style>
|
||||
.road { stroke: #7aa2ff; stroke-width: 6; fill: none; filter: drop-shadow(0 6px 18px rgba(122,162,255,0.25)); }
|
||||
.dash { stroke: rgba(122,162,255,0.25); stroke-width: 6; fill: none; stroke-dasharray: 2 18; }
|
||||
.node { filter: drop-shadow(0 3px 10px rgba(126,240,193,0.35)); }
|
||||
.node-circle { fill: #7ef0c1; }
|
||||
.node-core { fill: #181b22; stroke: white; stroke-width: 1.5; }
|
||||
.label-bg { fill: rgba(24,27,34,0.8); stroke: rgba(255,255,255,0.12); rx: 12; }
|
||||
.label-text { fill: #e8ecf1; font-weight: 700; font-size: 14px; font-family: Arial, sans-serif; }
|
||||
.label-sub { fill: #9aa6b2; font-weight: 500; font-size: 12px; font-family: Arial, sans-serif; }
|
||||
.spark { fill: none; stroke: #ffd36e; stroke-width: 1.6; stroke-linecap: round; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- Background road with dash -->
|
||||
<path class="dash" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>
|
||||
|
||||
<!-- Main road -->
|
||||
<path class="road" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>
|
||||
|
||||
<!-- New Documentation Node -->
|
||||
<g class="node" transform="translate(200,280)">
|
||||
<circle class="node-circle" r="10"/>
|
||||
<circle class="node-core" r="6"/>
|
||||
</g>
|
||||
|
||||
<!-- New Documentation Label -->
|
||||
<g transform="translate(80,120)">
|
||||
<rect class="label-bg" width="260" height="92"/>
|
||||
<text class="label-text" x="16" y="34">New Documentation</text>
|
||||
<text class="label-sub" x="16" y="58">Clear structure, examples & best practices</text>
|
||||
</g>
|
||||
|
||||
<!-- Plugin Marketplace Node -->
|
||||
<g class="node" transform="translate(640,150)">
|
||||
<circle class="node-circle" r="10"/>
|
||||
<circle class="node-core" r="6"/>
|
||||
</g>
|
||||
|
||||
<!-- Plugin Marketplace Label -->
|
||||
<g transform="translate(560,20)">
|
||||
<rect class="label-bg" width="320" height="100"/>
|
||||
<text class="label-text" x="16" y="34">Plugin Marketplace</text>
|
||||
<text class="label-sub" x="16" y="58">Community submissions, ratings & version constraints</text>
|
||||
</g>
|
||||
|
||||
<!-- One More Thing Node -->
|
||||
<g class="node" transform="translate(1080,255)">
|
||||
<circle class="node-circle" r="10"/>
|
||||
<circle class="node-core" r="6"/>
|
||||
</g>
|
||||
|
||||
<!-- One More Thing Label -->
|
||||
<g transform="translate(940,300)">
|
||||
<rect class="label-bg" width="250" height="86"/>
|
||||
<text class="label-text" x="16" y="34">One More Thing</text>
|
||||
<text class="label-sub" x="16" y="58">🚀 Confidential project · Revealing soon</text>
|
||||
</g>
|
||||
|
||||
<!-- Spark decorations -->
|
||||
<g transform="translate(1125,290)">
|
||||
<path class="spark" d="M0 0 L8 0 M4 -4 L4 4"/>
|
||||
<path class="spark" d="M14 -2 L22 -2 M18 -6 L18 2"/>
|
||||
<path class="spark" d="M-10 6 L-2 6 M-6 2 L-6 10"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
24
dockerfile
24
dockerfile
@@ -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"]
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"bin": {
|
||||
"ccr": "./dist/cli.js"
|
||||
@@ -20,11 +20,12 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@musistudio/llms": "^1.0.28",
|
||||
"@musistudio/llms": "^1.0.32",
|
||||
"dotenv": "^16.4.7",
|
||||
"find-process": "^2.0.0",
|
||||
"json5": "^2.2.3",
|
||||
"openurl": "^1.1.1",
|
||||
"pino-rotating-file-stream": "^0.0.2",
|
||||
"rotating-file-stream": "^3.2.7",
|
||||
"tiktoken": "^1.0.21",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
|
||||
111
pnpm-lock.yaml
generated
111
pnpm-lock.yaml
generated
@@ -12,20 +12,23 @@ importers:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
'@musistudio/llms':
|
||||
specifier: ^1.0.28
|
||||
version: 1.0.28(ws@8.18.3)
|
||||
specifier: ^1.0.32
|
||||
version: 1.0.32(ws@8.18.3)
|
||||
dotenv:
|
||||
specifier: ^16.4.7
|
||||
version: 16.6.1
|
||||
find-process:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
openurl:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
pino-rotating-file-stream:
|
||||
specifier: ^0.0.2
|
||||
version: 0.0.2
|
||||
rotating-file-stream:
|
||||
specifier: ^3.2.7
|
||||
version: 3.2.7
|
||||
tiktoken:
|
||||
specifier: ^1.0.21
|
||||
version: 1.0.22
|
||||
@@ -241,11 +244,11 @@ packages:
|
||||
'@fastify/static@8.2.0':
|
||||
resolution: {integrity: sha512-PejC/DtT7p1yo3p+W7LiUtLMsV8fEvxAK15sozHy9t8kwo5r0uLYmhV/inURmGz1SkHZFz/8CNtHLPyhKcx4SQ==}
|
||||
|
||||
'@google/genai@1.14.0':
|
||||
resolution: {integrity: sha512-jirYprAAJU1svjwSDVCzyVq+FrJpJd5CSxR/g2Ga/gZ0ZYZpcWjMS75KJl9y71K1mDN+tcx6s21CzCbB2R840g==}
|
||||
'@google/genai@1.16.0':
|
||||
resolution: {integrity: sha512-hdTYu39QgDFxv+FB6BK2zi4UIJGWhx2iPc0pHQ0C5Q/RCi+m+4gsryIzTGO+riqWcUA8/WGYp6hpqckdOBNysw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@modelcontextprotocol/sdk': ^1.11.0
|
||||
'@modelcontextprotocol/sdk': ^1.11.4
|
||||
peerDependenciesMeta:
|
||||
'@modelcontextprotocol/sdk':
|
||||
optional: true
|
||||
@@ -266,8 +269,8 @@ packages:
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@musistudio/llms@1.0.28':
|
||||
resolution: {integrity: sha512-rHvcJTtrFsRC7ayxz7ZXVoC7lZUwLtAHubdouUj+LYkv35Hr8S6K3lpOMXKYyqcKCtMvxbpjvM9MiwjCaleGEA==}
|
||||
'@musistudio/llms@1.0.32':
|
||||
resolution: {integrity: sha512-i+dB7x4qxZ8oOM3TLijjJ2rwIOje6/ovyHdU8A5h6d2wcTKOd0JUpNixUgBO3dPJp2dYVXz0SSfhw7gzmt1Kkg==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
@@ -338,6 +341,10 @@ packages:
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -345,6 +352,10 @@ packages:
|
||||
color-name@1.1.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -460,6 +471,10 @@ packages:
|
||||
resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
find-process@2.0.0:
|
||||
resolution: {integrity: sha512-YUBQnteWGASJoEVVsOXy6XtKAY2O1FCsWnnvQ8y0YwgY1rZiKeVptnFvMu6RSELZAJOGklqseTnUGGs5D0bKmg==}
|
||||
hasBin: true
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -500,8 +515,8 @@ packages:
|
||||
engines: {node: 20 || >=22}
|
||||
hasBin: true
|
||||
|
||||
google-auth-library@10.2.1:
|
||||
resolution: {integrity: sha512-HMxFl2NfeHYnaL1HoRIN1XgorKS+6CDaM+z9LSSN+i/nKDDL4KFFEWogMXu7jV4HZQy2MsxpY+wA5XIf3w410A==}
|
||||
google-auth-library@10.3.0:
|
||||
resolution: {integrity: sha512-ylSE3RlCRZfZB56PFJSfUCuiuPq83Fx8hqu1KPWGK8FVdSaxlp/qkeMMX/DT/18xkwXIHvXEXkZsljRwfrdEfQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
google-auth-library@9.15.1:
|
||||
@@ -524,6 +539,10 @@ packages:
|
||||
resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -609,6 +628,10 @@ packages:
|
||||
light-my-request@6.6.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
|
||||
engines: {node: 20 || >=22}
|
||||
@@ -672,8 +695,8 @@ packages:
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
openai@5.12.2:
|
||||
resolution: {integrity: sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==}
|
||||
openai@5.16.0:
|
||||
resolution: {integrity: sha512-hoEH8ZNvg1HXjU9mp88L/ZH8O082Z8r6FHCXGiWAzVRrEv443aI57qhch4snu07yQydj+AUAWLenAiBXhu89Tw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
@@ -716,9 +739,6 @@ packages:
|
||||
pino-abstract-transport@2.0.0:
|
||||
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
|
||||
|
||||
pino-rotating-file-stream@0.0.2:
|
||||
resolution: {integrity: sha512-knF+ReDBMQMB7gzBfuFpUmCrXpRen6YYh5Q9Ymmj//dDHeH4QEMwAV7VoGEEM+30s7VHqfbabazs9wxkMO2BIQ==}
|
||||
|
||||
pino-std-serializers@7.0.0:
|
||||
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
|
||||
|
||||
@@ -769,8 +789,8 @@ packages:
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rotating-file-stream@3.2.6:
|
||||
resolution: {integrity: sha512-r8yShzMWUvWXkRzbOXDM1fEaMpc3qo2PzK7bBH/0p0Nl/uz8Mud/Y+0XTQxe3kbSnDF7qBH2tSe83WDKA7o3ww==}
|
||||
rotating-file-stream@3.2.7:
|
||||
resolution: {integrity: sha512-SVquhBEVvRFY+nWLUc791Y0MIlyZrEClRZwZFLLRgJKldHyV1z4e2e/dp9LPqCS3AM//uq/c3PnOFgjqnm5P+A==}
|
||||
engines: {node: '>=14.0'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
@@ -868,6 +888,10 @@ packages:
|
||||
resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==}
|
||||
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:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -901,8 +925,8 @@ packages:
|
||||
undici-types@7.10.0:
|
||||
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
|
||||
|
||||
undici@7.13.0:
|
||||
resolution: {integrity: sha512-l+zSMssRqrzDcb3fjMkjjLGmuiiK2pMIcV++mJaAc9vhjSGpvM7h43QgP+OAMb1GImHmbPyG2tBXeuyG5iY4gA==}
|
||||
undici@7.15.0:
|
||||
resolution: {integrity: sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
uuid@11.1.0:
|
||||
@@ -1084,7 +1108,7 @@ snapshots:
|
||||
fastq: 1.19.1
|
||||
glob: 11.0.3
|
||||
|
||||
'@google/genai@1.14.0':
|
||||
'@google/genai@1.16.0':
|
||||
dependencies:
|
||||
google-auth-library: 9.15.1
|
||||
ws: 8.18.3
|
||||
@@ -1111,18 +1135,18 @@ snapshots:
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@musistudio/llms@1.0.28(ws@8.18.3)':
|
||||
'@musistudio/llms@1.0.32(ws@8.18.3)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.54.0
|
||||
'@fastify/cors': 11.1.0
|
||||
'@google/genai': 1.14.0
|
||||
'@google/genai': 1.16.0
|
||||
dotenv: 16.6.1
|
||||
fastify: 5.5.0
|
||||
google-auth-library: 10.2.1
|
||||
google-auth-library: 10.3.0
|
||||
json5: 2.2.3
|
||||
jsonrepair: 3.13.0
|
||||
openai: 5.12.2(ws@8.18.3)
|
||||
undici: 7.13.0
|
||||
openai: 5.16.0(ws@8.18.3)
|
||||
undici: 7.15.0
|
||||
uuid: 11.1.0
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
@@ -1191,12 +1215,19 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
commander@12.1.0: {}
|
||||
|
||||
content-disposition@0.5.4:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -1354,6 +1385,12 @@ snapshots:
|
||||
fast-querystring: 1.1.2
|
||||
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:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
@@ -1418,7 +1455,7 @@ snapshots:
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 2.0.0
|
||||
|
||||
google-auth-library@10.2.1:
|
||||
google-auth-library@10.3.0:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
@@ -1461,6 +1498,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
@@ -1541,6 +1580,8 @@ snapshots:
|
||||
process-warning: 4.0.1
|
||||
set-cookie-parser: 2.7.1
|
||||
|
||||
loglevel@1.9.2: {}
|
||||
|
||||
lru-cache@11.1.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
@@ -1586,7 +1627,7 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
openai@5.12.2(ws@8.18.3):
|
||||
openai@5.16.0(ws@8.18.3):
|
||||
optionalDependencies:
|
||||
ws: 8.18.3
|
||||
|
||||
@@ -1613,10 +1654,6 @@ snapshots:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pino-rotating-file-stream@0.0.2:
|
||||
dependencies:
|
||||
rotating-file-stream: 3.2.6
|
||||
|
||||
pino-std-serializers@7.0.0: {}
|
||||
|
||||
pino@9.9.0:
|
||||
@@ -1666,7 +1703,7 @@ snapshots:
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rotating-file-stream@3.2.6: {}
|
||||
rotating-file-stream@3.2.7: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
@@ -1748,6 +1785,10 @@ snapshots:
|
||||
|
||||
strip-eof@1.0.0: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
thread-stream@3.1.0:
|
||||
@@ -1770,7 +1811,7 @@ snapshots:
|
||||
|
||||
undici-types@7.10.0: {}
|
||||
|
||||
undici@7.13.0: {}
|
||||
undici@7.15.0: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
if (this.hasImage(id)) return;
|
||||
const base64Image = source.data
|
||||
this.cache.set(id, {
|
||||
source,
|
||||
timestamp: Date.now(),
|
||||
@@ -62,12 +55,10 @@ export class ImageAgent implements IAgent {
|
||||
}
|
||||
|
||||
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]
|
||||
if (lastMessage.role === 'user' && Array.isArray(lastMessage.content) &&lastMessage.content.find((item: any) => item.type === 'image')) {
|
||||
if (config.Router.image) {
|
||||
if (!config.forceUseImageAgent && lastMessage.role === 'user' && Array.isArray(lastMessage.content) &&lastMessage.content.find((item: any) => item.type === 'image')) {
|
||||
req.body.model = config.Router.image
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return req.body.messages.some((msg: any) => msg.role === 'user' && Array.isArray(msg.content) && msg.content.some((item: any) => item.type === 'image'))
|
||||
@@ -111,6 +102,7 @@ export class ImageAgent implements IAgent {
|
||||
"required": ["imageId", "task"]
|
||||
},
|
||||
handler: async (args, context) => {
|
||||
console.log('args', JSON.stringify(args, null, 2))
|
||||
const imageMessages = [];
|
||||
let imageId;
|
||||
|
||||
@@ -129,7 +121,6 @@ export class ImageAgent implements IAgent {
|
||||
delete args.imageId;
|
||||
}
|
||||
|
||||
// Add text message with the response
|
||||
if (Object.keys(args).length > 0) {
|
||||
imageMessages.push({
|
||||
type: "text",
|
||||
@@ -148,7 +139,10 @@ export class ImageAgent implements IAgent {
|
||||
model: context.config.Router.image,
|
||||
system: [{
|
||||
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 user’s instructions.
|
||||
Do not ignore or deviate from the task.
|
||||
Always ensure that your response reflects a clear, accurate interpretation of the image aligned with the given objective.`
|
||||
}],
|
||||
messages: [
|
||||
{
|
||||
@@ -161,6 +155,7 @@ export class ImageAgent implements IAgent {
|
||||
}).then(res => res.json()).catch(err => {
|
||||
return null;
|
||||
});
|
||||
console.log(agentResponse.content);
|
||||
if (!agentResponse || !agentResponse.content) {
|
||||
return 'analyzeImage Error';
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ async function waitForService(
|
||||
|
||||
const startTime = Date.now();
|
||||
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
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return true;
|
||||
@@ -56,6 +57,7 @@ async function waitForService(
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const isRunning = await isServiceRunning()
|
||||
switch (command) {
|
||||
case "start":
|
||||
run();
|
||||
@@ -108,7 +110,7 @@ async function main() {
|
||||
});
|
||||
break;
|
||||
case "code":
|
||||
if (!isServiceRunning()) {
|
||||
if (!isRunning) {
|
||||
console.log("Service not running, starting service...");
|
||||
const cliPath = join(__dirname, "cli.js");
|
||||
const startProcess = spawn("node", [cliPath, "start"], {
|
||||
@@ -153,7 +155,7 @@ async function main() {
|
||||
break;
|
||||
case "ui":
|
||||
// Check if service is running
|
||||
if (!isServiceRunning()) {
|
||||
if (!isRunning) {
|
||||
console.log("Service not running, starting service...");
|
||||
const cliPath = join(__dirname, "cli.js");
|
||||
const startProcess = spawn("node", [cliPath, "start"], {
|
||||
|
||||
112
src/index.ts
112
src/index.ts
@@ -12,9 +12,8 @@ import {
|
||||
savePid,
|
||||
} from "./utils/processCheck";
|
||||
import { CONFIG_FILE } from "./constants";
|
||||
import createWriteStream from "pino-rotating-file-stream";
|
||||
import { createStream } from 'rotating-file-stream';
|
||||
import { HOME_DIR } from "./constants";
|
||||
import { configureLogging } from "./utils/log";
|
||||
import { sessionUsageCache } from "./utils/cache";
|
||||
import {SSEParserTransform} from "./utils/SSEParser.transform";
|
||||
import {SSESerializerTransform} from "./utils/SSESerializer.transform";
|
||||
@@ -22,7 +21,9 @@ import {rewriteStream} from "./utils/rewriteStream";
|
||||
import JSON5 from "json5";
|
||||
import { IAgent } from "./agents/type";
|
||||
import agentsManager from "./agents";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
const event = new EventEmitter()
|
||||
|
||||
async function initializeClaudeConfig() {
|
||||
const homeDir = homedir();
|
||||
@@ -50,7 +51,8 @@ interface RunOptions {
|
||||
|
||||
async function run(options: RunOptions = {}) {
|
||||
// Check if service is already running
|
||||
if (isServiceRunning()) {
|
||||
const isRunning = await isServiceRunning()
|
||||
if (isRunning) {
|
||||
console.log("✅ Service is already running in the background.");
|
||||
return;
|
||||
}
|
||||
@@ -61,8 +63,6 @@ async function run(options: RunOptions = {}) {
|
||||
await cleanupLogFiles();
|
||||
const config = await initConfig();
|
||||
|
||||
// Configure logging based on config
|
||||
configureLogging(config);
|
||||
|
||||
let HOST = config.HOST || "127.0.0.1";
|
||||
|
||||
@@ -95,15 +95,29 @@ async function run(options: RunOptions = {}) {
|
||||
: port;
|
||||
|
||||
// Configure logger based on config settings
|
||||
const pad = num => (num > 9 ? "" : "0") + num;
|
||||
const generator = (time, index) => {
|
||||
if (!time) {
|
||||
time = new Date()
|
||||
}
|
||||
|
||||
var month = time.getFullYear() + "" + pad(time.getMonth() + 1);
|
||||
var day = pad(time.getDate());
|
||||
var hour = pad(time.getHours());
|
||||
var minute = pad(time.getMinutes());
|
||||
|
||||
return `./logs/ccr-${month}${day}${hour}${minute}${pad(time.getSeconds())}${index ? `_${index}` : ''}.log`;
|
||||
};
|
||||
const loggerConfig =
|
||||
config.LOG !== false
|
||||
? {
|
||||
level: config.LOG_LEVEL || "debug",
|
||||
stream: createWriteStream({
|
||||
stream: createStream(generator, {
|
||||
path: HOME_DIR,
|
||||
filename: config.LOGNAME || `./logs/ccr-${+new Date()}.log`,
|
||||
maxFiles: 3,
|
||||
interval: "1d",
|
||||
compress: false,
|
||||
maxSize: "50M"
|
||||
}),
|
||||
}
|
||||
: false;
|
||||
@@ -123,6 +137,15 @@ async function run(options: RunOptions = {}) {
|
||||
},
|
||||
logger: loggerConfig,
|
||||
});
|
||||
|
||||
// Add global error handlers to prevent the service from crashing
|
||||
process.on("uncaughtException", (err) => {
|
||||
server.log.error("Uncaught exception:", err);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
server.log.error("Unhandled rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
// Add async preHandler hook for authentication
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -148,6 +171,9 @@ async function run(options: RunOptions = {}) {
|
||||
|
||||
// append agent tools
|
||||
if (agent.tools.size) {
|
||||
if (!req.body?.tools?.length) {
|
||||
req.body.tools = []
|
||||
}
|
||||
req.body.tools.unshift(...Array.from(agent.tools.values()).map(item => {
|
||||
return {
|
||||
name: item.name,
|
||||
@@ -162,13 +188,20 @@ async function run(options: RunOptions = {}) {
|
||||
if (useAgents.length) {
|
||||
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 (payload instanceof ReadableStream) {
|
||||
if (req.agents) {
|
||||
const abortController = new AbortController();
|
||||
const eventStream = payload.pipeThrough(new SSEParserTransform())
|
||||
let currentAgent: undefined | IAgent;
|
||||
let currentToolIndex = -1
|
||||
@@ -178,7 +211,8 @@ async function run(options: RunOptions = {}) {
|
||||
const toolMessages: any[] = []
|
||||
const assistantMessages: any[] = []
|
||||
// 存储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) {
|
||||
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 reader = stream.getReader()
|
||||
while (true) {
|
||||
try {
|
||||
const {value, done} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
@@ -257,17 +292,44 @@ async function run(options: RunOptions = {}) {
|
||||
if (['message_start', 'message_stop'].includes(value.event)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查流是否仍然可写
|
||||
if (!controller.desiredSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
controller.enqueue(value)
|
||||
}catch (readError: any) {
|
||||
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
abortController.abort(); // 中止所有相关操作
|
||||
break;
|
||||
}
|
||||
throw readError;
|
||||
}
|
||||
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return data
|
||||
}).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 read = async (stream: ReadableStream) => {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
@@ -282,14 +344,38 @@ async function run(options: RunOptions = {}) {
|
||||
sessionUsageCache.put(req.sessionId, message.usage);
|
||||
} catch {}
|
||||
}
|
||||
} catch (readError: any) {
|
||||
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
console.error('Background read stream closed prematurely');
|
||||
} else {
|
||||
console.error('Error in background stream reading:', readError);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
read(clonedStream);
|
||||
return originalStream
|
||||
return done(null, originalStream)
|
||||
}
|
||||
sessionUsageCache.put(req.sessionId, payload.usage);
|
||||
if (typeof payload ==='object') {
|
||||
if (payload.error) {
|
||||
return done(payload.error, null)
|
||||
} else {
|
||||
return done(payload, null)
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils";
|
||||
import { checkForUpdates, performUpdate } from "./utils";
|
||||
import { join } from "path";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
|
||||
export const createServer = (config: any): Server => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -5,8 +5,9 @@ import { join } from 'path';
|
||||
|
||||
export async function closeService() {
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,15 @@ import {join} from "path";
|
||||
export async function executeCodeCommand(args: string[] = []) {
|
||||
// Set environment variables
|
||||
const config = await readConfigFile();
|
||||
const port = config.PORT || 3456;
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
ANTHROPIC_AUTH_TOKEN: config?.APIKEY || "test",
|
||||
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
|
||||
};
|
||||
let settingsFlag: Record<string, any> | undefined;
|
||||
@@ -65,7 +69,6 @@ export async function executeCodeCommand(args: string[] = []) {
|
||||
const stdioConfig: StdioOptions = config.NON_INTERACTIVE_MODE
|
||||
? ["pipe", "inherit", "inherit"] // Pipe stdin for non-interactive
|
||||
: "inherit"; // Default inherited behavior
|
||||
console.log(joinedArgs)
|
||||
const claudeProcess = spawn(
|
||||
claudePath + (joinedArgs ? ` ${joinedArgs}` : ""),
|
||||
[],
|
||||
|
||||
@@ -83,25 +83,38 @@ export const readConfigFile = async () => {
|
||||
} catch (readError: any) {
|
||||
if (readError.code === "ENOENT") {
|
||||
// Config file doesn't exist, prompt user for initial setup
|
||||
const name = await question("Enter Provider Name: ");
|
||||
const APIKEY = await question("Enter Provider API KEY: ");
|
||||
const baseUrl = await question("Enter Provider URL: ");
|
||||
const model = await question("Enter MODEL Name: ");
|
||||
const config = Object.assign({}, DEFAULT_CONFIG, {
|
||||
Providers: [
|
||||
{
|
||||
name,
|
||||
api_base_url: baseUrl,
|
||||
api_key: APIKEY,
|
||||
models: [model],
|
||||
},
|
||||
],
|
||||
Router: {
|
||||
default: `${name},${model}`,
|
||||
},
|
||||
});
|
||||
try {
|
||||
// Initialize directories
|
||||
await initDir();
|
||||
|
||||
// Backup existing config file if it exists
|
||||
const backupPath = await backupConfigFile();
|
||||
if (backupPath) {
|
||||
console.log(
|
||||
`Backed up existing configuration file to ${backupPath}`
|
||||
);
|
||||
}
|
||||
const config = {
|
||||
PORT: 3456,
|
||||
Providers: [],
|
||||
Router: {},
|
||||
}
|
||||
// Create a minimal default config file
|
||||
await writeConfigFile(config);
|
||||
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 {
|
||||
console.error(`Failed to read config file at ${CONFIG_FILE}`);
|
||||
console.error("Error details:", readError.message);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
|
||||
import { readConfigFile } from '.';
|
||||
import find from 'find-process';
|
||||
|
||||
export async function isProcessRunning(pid: number): Promise<boolean> {
|
||||
try {
|
||||
const processes = await find('pid', pid);
|
||||
return processes.length > 0;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementReferenceCount() {
|
||||
let count = 0;
|
||||
@@ -27,15 +37,14 @@ export function getReferenceCount(): number {
|
||||
return parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
|
||||
}
|
||||
|
||||
export function isServiceRunning(): boolean {
|
||||
export async function isServiceRunning(): Promise<boolean> {
|
||||
if (!existsSync(PID_FILE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
return await isProcessRunning(pid);
|
||||
} catch (e) {
|
||||
// Process not running, clean up pid file
|
||||
cleanupPidFile();
|
||||
@@ -73,7 +82,7 @@ export function getServicePid(): number | null {
|
||||
|
||||
export async function getServiceInfo() {
|
||||
const pid = getServicePid();
|
||||
const running = isServiceRunning();
|
||||
const running = await isServiceRunning();
|
||||
const config = await readConfigFile();
|
||||
const port = config.PORT || 3456;
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
Tool,
|
||||
} from "@anthropic-ai/sdk/resources/messages";
|
||||
import { get_encoding } from "tiktoken";
|
||||
import { log } from "./log";
|
||||
import { sessionUsageCache, Usage } from "./cache";
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
const enc = get_encoding("cl100k_base");
|
||||
|
||||
@@ -94,11 +94,8 @@ const getUseModel = async (
|
||||
(lastUsageThreshold || tokenCountThreshold) &&
|
||||
config.Router.longContext
|
||||
) {
|
||||
log(
|
||||
"Using long context model due to token count:",
|
||||
tokenCount,
|
||||
"threshold:",
|
||||
longContextThreshold
|
||||
req.log.info(
|
||||
`Using long context model due to token count: ${tokenCount}, threshold: ${longContextThreshold}`
|
||||
);
|
||||
return config.Router.longContext;
|
||||
}
|
||||
@@ -122,12 +119,12 @@ const getUseModel = async (
|
||||
req.body.model?.startsWith("claude-3-5-haiku") &&
|
||||
config.Router.background
|
||||
) {
|
||||
log("Using background model for ", req.body.model);
|
||||
req.log.info(`Using background model for ${req.body.model}`);
|
||||
return config.Router.background;
|
||||
}
|
||||
// if exits thinking, use the think model
|
||||
if (req.body.thinking && config.Router.think) {
|
||||
log("Using think model for ", req.body.thinking);
|
||||
req.log.info(`Using think model for ${req.body.thinking}`);
|
||||
return config.Router.think;
|
||||
}
|
||||
if (
|
||||
@@ -140,7 +137,8 @@ const getUseModel = async (
|
||||
return config.Router!.default;
|
||||
};
|
||||
|
||||
export const router = async (req: any, _res: any, config: any) => {
|
||||
export const router = async (req: any, _res: any, context: any) => {
|
||||
const { config, event } = context;
|
||||
// Parse sessionId from metadata.user_id
|
||||
if (req.body.metadata?.user_id) {
|
||||
const parts = req.body.metadata.user_id.split("_session_");
|
||||
@@ -150,6 +148,11 @@ export const router = async (req: any, _res: any, config: any) => {
|
||||
}
|
||||
const lastMessageUsage = sessionUsageCache.get(req.sessionId);
|
||||
const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
|
||||
if (config.REWRITE_SYSTEM_PROMPT && system.length > 1 && system[1]?.text?.includes('<env>')) {
|
||||
const prompt = await readFile(config.REWRITE_SYSTEM_PROMPT, 'utf-8');
|
||||
system[1].text = `${prompt}<env>${system[1].text.split('<env>').pop()}`
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenCount = calculateTokenCount(
|
||||
messages as MessageParam[],
|
||||
@@ -162,9 +165,11 @@ export const router = async (req: any, _res: any, config: any) => {
|
||||
try {
|
||||
const customRouter = require(config.CUSTOM_ROUTER_PATH);
|
||||
req.tokenCount = tokenCount; // Pass token count to custom router
|
||||
model = await customRouter(req, config);
|
||||
model = await customRouter(req, config, {
|
||||
event
|
||||
});
|
||||
} catch (e: any) {
|
||||
log("failed to load custom router", e.message);
|
||||
req.log.error(`failed to load custom router: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
@@ -172,7 +177,7 @@ export const router = async (req: any, _res: any, config: any) => {
|
||||
}
|
||||
req.body.model = model;
|
||||
} catch (error: any) {
|
||||
log("Error in router middleware:", error.message);
|
||||
req.log.error(`Error in router middleware: ${error.message}`);
|
||||
req.body.model = config.Router!.default;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -6,10 +6,11 @@ import { Transformers } from "@/components/Transformers";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import { Router } from "@/components/Router";
|
||||
import { JsonEditor } from "@/components/JsonEditor";
|
||||
import { LogViewer } from "@/components/LogViewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/components/ConfigProvider";
|
||||
import { api } from "@/lib/api";
|
||||
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp } from "lucide-react";
|
||||
import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -32,6 +33,7 @@ function App() {
|
||||
const { config, error } = useConfig();
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState(false);
|
||||
const [isLogViewerOpen, setIsLogViewerOpen] = useState(false);
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
|
||||
// 版本检查状态
|
||||
@@ -276,6 +278,9 @@ function App() {
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsJsonEditorOpen(true)} className="transition-all-ease hover:scale-110">
|
||||
<FileJson className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsLogViewerOpen(true)} className="transition-all-ease hover:scale-110">
|
||||
<FileText className="h-5 w-5" />
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="transition-all-ease hover:scale-110">
|
||||
@@ -350,6 +355,11 @@ function App() {
|
||||
onOpenChange={setIsJsonEditorOpen}
|
||||
showToast={(message, type) => setToast({ message, type })}
|
||||
/>
|
||||
<LogViewer
|
||||
open={isLogViewerOpen}
|
||||
onOpenChange={setIsLogViewerOpen}
|
||||
showToast={(message, type) => setToast({ message, type })}
|
||||
/>
|
||||
{/* 版本更新对话框 */}
|
||||
<Dialog open={isUpdateDialogOpen} onOpenChange={setIsUpdateDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
|
||||
@@ -95,15 +95,18 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
|
||||
think: typeof data.Router.think === 'string' ? data.Router.think : '',
|
||||
longContext: typeof data.Router.longContext === 'string' ? data.Router.longContext : '',
|
||||
longContextThreshold: typeof data.Router.longContextThreshold === 'number' ? data.Router.longContextThreshold : 60000,
|
||||
webSearch: typeof data.Router.webSearch === 'string' ? data.Router.webSearch : ''
|
||||
webSearch: typeof data.Router.webSearch === 'string' ? data.Router.webSearch : '',
|
||||
image: typeof data.Router.image === 'string' ? data.Router.image : ''
|
||||
} : {
|
||||
default: '',
|
||||
background: '',
|
||||
think: '',
|
||||
longContext: '',
|
||||
longContextThreshold: 60000,
|
||||
webSearch: ''
|
||||
}
|
||||
webSearch: '',
|
||||
image: ''
|
||||
},
|
||||
CUSTOM_ROUTER_PATH: typeof data.CUSTOM_ROUTER_PATH === 'string' ? data.CUSTOM_ROUTER_PATH : ''
|
||||
};
|
||||
|
||||
setConfig(validConfig);
|
||||
@@ -131,8 +134,10 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
|
||||
think: '',
|
||||
longContext: '',
|
||||
longContextThreshold: 60000,
|
||||
webSearch: ''
|
||||
}
|
||||
webSearch: '',
|
||||
image: ''
|
||||
},
|
||||
CUSTOM_ROUTER_PATH: ''
|
||||
});
|
||||
setError(err as Error);
|
||||
}
|
||||
|
||||
726
ui/src/components/LogViewer.tsx
Normal file
726
ui/src/components/LogViewer.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, RefreshCw, Download, Trash2, ArrowLeft, File, Layers } from 'lucide-react';
|
||||
|
||||
interface LogViewerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
showToast?: (message: string, type: 'success' | 'error' | 'warning') => void;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
level: 'info' | 'warn' | 'error' | 'debug';
|
||||
message: string; // 现在这个字段直接包含原始JSON字符串
|
||||
source?: string;
|
||||
reqId?: string;
|
||||
}
|
||||
|
||||
interface LogFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
interface GroupedLogs {
|
||||
[reqId: string]: LogEntry[];
|
||||
}
|
||||
|
||||
interface LogGroupSummary {
|
||||
reqId: string;
|
||||
logCount: number;
|
||||
firstLog: string;
|
||||
lastLog: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface GroupedLogsResponse {
|
||||
grouped: boolean;
|
||||
groups: { [reqId: string]: LogEntry[] };
|
||||
summary: {
|
||||
totalRequests: number;
|
||||
totalLogs: number;
|
||||
requests: LogGroupSummary[];
|
||||
};
|
||||
}
|
||||
|
||||
export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [logFiles, setLogFiles] = useState<LogFile[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<LogFile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [groupByReqId, setGroupByReqId] = useState(false);
|
||||
const [groupedLogs, setGroupedLogs] = useState<GroupedLogsResponse | null>(null);
|
||||
const [selectedReqId, setSelectedReqId] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const refreshInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadLogFiles();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 创建内联 Web Worker
|
||||
const createInlineWorker = (): Worker => {
|
||||
const workerCode = `
|
||||
// 日志聚合Web Worker
|
||||
self.onmessage = function(event) {
|
||||
const { type, data } = event.data;
|
||||
|
||||
if (type === 'groupLogsByReqId') {
|
||||
try {
|
||||
const { logs } = data;
|
||||
|
||||
// 按reqId聚合日志
|
||||
const groupedLogs = {};
|
||||
|
||||
logs.forEach((log, index) => {
|
||||
log = JSON.parse(log);
|
||||
let reqId = log.reqId || 'no-req-id';
|
||||
|
||||
if (!groupedLogs[reqId]) {
|
||||
groupedLogs[reqId] = [];
|
||||
}
|
||||
groupedLogs[reqId].push(log);
|
||||
});
|
||||
|
||||
// 按时间戳排序每个组的日志
|
||||
Object.keys(groupedLogs).forEach(reqId => {
|
||||
groupedLogs[reqId].sort((a, b) => a.time - b.time);
|
||||
});
|
||||
|
||||
// 提取model信息
|
||||
const extractModelInfo = (reqId) => {
|
||||
const logGroup = groupedLogs[reqId];
|
||||
for (const log of logGroup) {
|
||||
try {
|
||||
// 尝试从message字段解析JSON
|
||||
if (log.type === 'request body' && log.data && log.data.model) {
|
||||
return log.data.model;
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,继续尝试下一条日志
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 生成摘要信息
|
||||
const summary = {
|
||||
totalRequests: Object.keys(groupedLogs).length,
|
||||
totalLogs: logs.length,
|
||||
requests: Object.keys(groupedLogs).map(reqId => ({
|
||||
reqId,
|
||||
logCount: groupedLogs[reqId].length,
|
||||
firstLog: groupedLogs[reqId][0]?.time,
|
||||
lastLog: groupedLogs[reqId][groupedLogs[reqId].length - 1]?.time,
|
||||
model: extractModelInfo(reqId)
|
||||
}))
|
||||
};
|
||||
|
||||
const response = {
|
||||
grouped: true,
|
||||
groups: groupedLogs,
|
||||
summary
|
||||
};
|
||||
|
||||
// 发送结果回主线程
|
||||
self.postMessage({
|
||||
type: 'groupLogsResult',
|
||||
data: response
|
||||
});
|
||||
} catch (error) {
|
||||
// 发送错误回主线程
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
const blob = new Blob([workerCode], { type: 'application/javascript' });
|
||||
const workerUrl = URL.createObjectURL(blob);
|
||||
return new Worker(workerUrl);
|
||||
};
|
||||
|
||||
// 初始化Web Worker
|
||||
useEffect(() => {
|
||||
if (typeof Worker !== 'undefined') {
|
||||
try {
|
||||
// 创建内联Web Worker
|
||||
workerRef.current = createInlineWorker();
|
||||
|
||||
// 监听Worker消息
|
||||
workerRef.current.onmessage = (event) => {
|
||||
const { type, data, error } = event.data;
|
||||
|
||||
if (type === 'groupLogsResult') {
|
||||
setGroupedLogs(data);
|
||||
} else if (type === 'error') {
|
||||
console.error('Worker error:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.worker_error') + ': ' + error, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听Worker错误
|
||||
workerRef.current.onerror = (error) => {
|
||||
console.error('Worker error:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.worker_init_failed'), 'error');
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create worker:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.worker_init_failed'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理Worker
|
||||
return () => {
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
workerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefresh && open && selectedFile) {
|
||||
refreshInterval.current = setInterval(() => {
|
||||
loadLogs();
|
||||
}, 5000); // Refresh every 5 seconds
|
||||
} else if (refreshInterval.current) {
|
||||
clearInterval(refreshInterval.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refreshInterval.current) {
|
||||
clearInterval(refreshInterval.current);
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, open, selectedFile]);
|
||||
|
||||
// Load logs when selected file changes
|
||||
useEffect(() => {
|
||||
if (selectedFile && open) {
|
||||
setLogs([]); // Clear existing logs
|
||||
loadLogs();
|
||||
}
|
||||
}, [selectedFile, open]);
|
||||
|
||||
// Handle open/close animations
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setIsVisible(true);
|
||||
// Trigger the animation after a small delay to ensure the element is rendered
|
||||
requestAnimationFrame(() => {
|
||||
setIsAnimating(true);
|
||||
});
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
// Wait for the animation to complete before hiding
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadLogFiles = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.getLogFiles();
|
||||
|
||||
if (response && Array.isArray(response)) {
|
||||
setLogFiles(response);
|
||||
setSelectedFile(null);
|
||||
setLogs([]);
|
||||
} else {
|
||||
setLogFiles([]);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.no_log_files_available'), 'warning');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load log files:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.load_files_failed') + ': ' + (error as Error).message, 'error');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setGroupedLogs(null);
|
||||
setSelectedReqId(null);
|
||||
|
||||
// 始终加载原始日志数据
|
||||
const response = await api.getLogs(selectedFile.path);
|
||||
|
||||
if (response && Array.isArray(response)) {
|
||||
// 现在接口返回的是原始日志字符串数组,直接存储
|
||||
setLogs(response);
|
||||
|
||||
// 如果启用了分组,使用Web Worker进行聚合(需要转换为LogEntry格式供Worker使用)
|
||||
if (groupByReqId && workerRef.current) {
|
||||
// const workerLogs: LogEntry[] = response.map((logLine, index) => ({
|
||||
// timestamp: new Date().toISOString(),
|
||||
// level: 'info',
|
||||
// message: logLine,
|
||||
// source: undefined,
|
||||
// reqId: undefined
|
||||
// }));
|
||||
|
||||
workerRef.current.postMessage({
|
||||
type: 'groupLogsByReqId',
|
||||
data: { logs: response }
|
||||
});
|
||||
} else {
|
||||
setGroupedLogs(null);
|
||||
}
|
||||
} else {
|
||||
setLogs([]);
|
||||
setGroupedLogs(null);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.no_logs_available'), 'warning');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.load_failed') + ': ' + (error as Error).message, 'error');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
try {
|
||||
await api.clearLogs(selectedFile.path);
|
||||
setLogs([]);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.logs_cleared'), 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear logs:', error);
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.clear_failed') + ': ' + (error as Error).message, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectFile = (file: LogFile) => {
|
||||
setSelectedFile(file);
|
||||
setAutoRefresh(false); // Reset auto refresh when changing files
|
||||
};
|
||||
|
||||
|
||||
const toggleGroupByReqId = () => {
|
||||
const newValue = !groupByReqId;
|
||||
setGroupByReqId(newValue);
|
||||
|
||||
if (newValue && selectedFile && logs.length > 0) {
|
||||
// 启用聚合时,如果已有日志,则使用Worker进行聚合
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({
|
||||
type: 'groupLogsByReqId',
|
||||
data: { logs }
|
||||
});
|
||||
}
|
||||
} else if (!newValue) {
|
||||
// 禁用聚合时,清除聚合结果
|
||||
setGroupedLogs(null);
|
||||
setSelectedReqId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const selectReqId = (reqId: string) => {
|
||||
setSelectedReqId(reqId);
|
||||
};
|
||||
|
||||
|
||||
const getDisplayLogs = () => {
|
||||
if (groupByReqId && groupedLogs) {
|
||||
if (selectedReqId && groupedLogs.groups[selectedReqId]) {
|
||||
return groupedLogs.groups[selectedReqId];
|
||||
}
|
||||
// 当在分组模式但没有选中具体请求时,显示原始日志字符串数组
|
||||
return logs.map(logLine => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: logLine,
|
||||
source: undefined,
|
||||
reqId: undefined
|
||||
}));
|
||||
}
|
||||
// 当不在分组模式时,显示原始日志字符串数组
|
||||
return logs.map(logLine => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: logLine,
|
||||
source: undefined,
|
||||
reqId: undefined
|
||||
}));
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
if (!selectedFile || logs.length === 0) return;
|
||||
|
||||
// 直接下载原始日志字符串,每行一个日志
|
||||
const logText = logs.join('\n');
|
||||
|
||||
const blob = new Blob([logText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedFile.name}-${new Date().toISOString().split('T')[0]}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
if (showToast) {
|
||||
showToast(t('log_viewer.logs_downloaded'), 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
// 面包屑导航项类型
|
||||
interface BreadcrumbItem {
|
||||
id: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// 获取面包屑导航项
|
||||
const getBreadcrumbs = (): BreadcrumbItem[] => {
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
id: 'root',
|
||||
label: t('log_viewer.title'),
|
||||
onClick: () => {
|
||||
setSelectedFile(null);
|
||||
setAutoRefresh(false);
|
||||
setLogs([]);
|
||||
setGroupedLogs(null);
|
||||
setSelectedReqId(null);
|
||||
setGroupByReqId(false);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (selectedFile) {
|
||||
breadcrumbs.push({
|
||||
id: 'file',
|
||||
label: selectedFile.name,
|
||||
onClick: () => {
|
||||
if (groupByReqId) {
|
||||
// 如果在分组模式下,点击文件层级应该返回到分组列表
|
||||
setSelectedReqId(null);
|
||||
} else {
|
||||
// 如果不在分组模式下,点击文件层级关闭分组功能
|
||||
setSelectedReqId(null);
|
||||
setGroupedLogs(null);
|
||||
setGroupByReqId(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedReqId) {
|
||||
breadcrumbs.push({
|
||||
id: 'req',
|
||||
label: `${t('log_viewer.request')} ${selectedReqId}`,
|
||||
onClick: () => {
|
||||
// 点击当前层级时不做任何操作
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
// 获取返回按钮的处理函数
|
||||
const getBackAction = (): (() => void) | null => {
|
||||
if (selectedReqId) {
|
||||
return () => {
|
||||
setSelectedReqId(null);
|
||||
};
|
||||
} else if (selectedFile) {
|
||||
return () => {
|
||||
setSelectedFile(null);
|
||||
setAutoRefresh(false);
|
||||
setLogs([]);
|
||||
setGroupedLogs(null);
|
||||
setSelectedReqId(null);
|
||||
setGroupByReqId(false);
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatLogsForEditor = () => {
|
||||
// 如果在分组模式且选中了具体请求,显示该请求的日志
|
||||
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
|
||||
const requestLogs = groupedLogs.groups[selectedReqId];
|
||||
console.log(requestLogs)
|
||||
// 提取原始JSON字符串并每行一个
|
||||
return requestLogs.map(log => JSON.stringify(log)).join('\n');
|
||||
}
|
||||
|
||||
// 其他情况,直接显示原始日志字符串数组,每行一个
|
||||
return logs.join('\n');
|
||||
};
|
||||
|
||||
if (!isVisible && !open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isVisible || open) && (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 transition-all duration-300 ease-out ${
|
||||
isAnimating && open ? 'bg-black/50 opacity-100' : 'bg-black/0 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-white shadow-2xl transition-all duration-300 ease-out transform ${
|
||||
isAnimating && open ? 'translate-y-0' : 'translate-y-full'
|
||||
}`}
|
||||
style={{
|
||||
height: '100vh',
|
||||
maxHeight: '100vh'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getBackAction() && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={getBackAction()!}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
{t('log_viewer.back')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 面包屑导航 */}
|
||||
<nav className="flex items-center space-x-1 text-sm">
|
||||
{getBreadcrumbs().map((breadcrumb, index) => (
|
||||
<React.Fragment key={breadcrumb.id}>
|
||||
{index > 0 && (
|
||||
<span className="text-gray-400 mx-1">/</span>
|
||||
)}
|
||||
{index === getBreadcrumbs().length - 1 ? (
|
||||
<span className="text-gray-900 font-medium">
|
||||
{breadcrumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={breadcrumb.onClick}
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedFile && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleGroupByReqId}
|
||||
className={groupByReqId ? 'bg-blue-100 text-blue-700' : ''}
|
||||
>
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
{groupByReqId ? t('log_viewer.grouped_on') : t('log_viewer.group_by_req_id')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={autoRefresh ? 'bg-blue-100 text-blue-700' : ''}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${autoRefresh ? 'animate-spin' : ''}`} />
|
||||
{autoRefresh ? t('log_viewer.auto_refresh_on') : t('log_viewer.auto_refresh_off')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadLogs}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t('log_viewer.download')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearLogs}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('log_viewer.clear')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
{t('log_viewer.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 bg-gray-50">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : selectedFile ? (
|
||||
<>
|
||||
{groupByReqId && groupedLogs && !selectedReqId ? (
|
||||
// 显示日志组列表
|
||||
<div className="flex flex-col h-full p-6">
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<h3 className="text-lg font-medium mb-2">{t('log_viewer.request_groups')}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t('log_viewer.total_requests')}: {groupedLogs.summary.totalRequests} |
|
||||
{t('log_viewer.total_logs')}: {groupedLogs.summary.totalLogs}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-3">
|
||||
{groupedLogs.summary.requests.map((request) => (
|
||||
<div
|
||||
key={request.reqId}
|
||||
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => selectReqId(request.reqId)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-5 w-5 text-blue-600" />
|
||||
<span className="font-medium text-sm">{request.reqId}</span>
|
||||
{request.model && (
|
||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
|
||||
{request.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||
{request.logCount} {t('log_viewer.logs')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div>{t('log_viewer.first_log')}: {formatDate(request.firstLog)}</div>
|
||||
<div>{t('log_viewer.last_log')}: {formatDate(request.lastLog)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 显示日志内容
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="json"
|
||||
value={formatLogsForEditor()}
|
||||
theme="vs"
|
||||
options={{
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
wordWrap: 'on',
|
||||
readOnly: true,
|
||||
lineNumbers: 'on',
|
||||
folding: true,
|
||||
renderWhitespace: 'all',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium mb-4">{t('log_viewer.select_file')}</h3>
|
||||
{logFiles.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-8">
|
||||
<File className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||
<p>{t('log_viewer.no_log_files_available')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{logFiles.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => selectFile(file)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-5 w-5 text-blue-600" />
|
||||
<span className="font-medium text-sm">{file.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div>{formatFileSize(file.size)}</div>
|
||||
<div>{formatDate(file.lastModified)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,8 @@ export function Router() {
|
||||
think: "",
|
||||
longContext: "",
|
||||
longContextThreshold: 60000,
|
||||
webSearch: ""
|
||||
webSearch: "",
|
||||
image: ""
|
||||
};
|
||||
|
||||
const handleRouterChange = (field: string, value: string | number) => {
|
||||
@@ -40,6 +41,10 @@ export function Router() {
|
||||
setConfig({ ...config, Router: newRouter });
|
||||
};
|
||||
|
||||
const handleForceUseImageAgentChange = (value: boolean) => {
|
||||
setConfig({ ...config, forceUseImageAgent: value });
|
||||
};
|
||||
|
||||
// Handle case where config.Providers might be null or undefined
|
||||
const providers = Array.isArray(config.Providers) ? config.Providers : [];
|
||||
|
||||
@@ -133,6 +138,33 @@ export function Router() {
|
||||
emptyPlaceholder={t("router.noModelFound")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<Label>{t("router.image")} (beta)</Label>
|
||||
<Combobox
|
||||
options={modelOptions}
|
||||
value={routerConfig.image || ""}
|
||||
onChange={(value) => handleRouterChange("image", value)}
|
||||
placeholder={t("router.selectModel")}
|
||||
searchPlaceholder={t("router.searchModel")}
|
||||
emptyPlaceholder={t("router.noModelFound")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<Label htmlFor="forceUseImageAgent">{t("router.forceUseImageAgent")}</Label>
|
||||
<select
|
||||
id="forceUseImageAgent"
|
||||
value={config.forceUseImageAgent ? "true" : "false"}
|
||||
onChange={(e) => handleForceUseImageAgentChange(e.target.value === "true")}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="false">{t("common.no")}</option>
|
||||
<option value="true">{t("common.yes")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -212,6 +212,21 @@ export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
|
||||
className="transition-all-ease focus:scale-[1.01]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="custom-router-path"
|
||||
className="transition-all-ease hover:scale-[1.01] cursor-pointer"
|
||||
>
|
||||
{t("toplevel.custom_router_path")}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-router-path"
|
||||
value={config.CUSTOM_ROUTER_PATH || ""}
|
||||
onChange={(e) => setConfig({ ...config, CUSTOM_ROUTER_PATH: e.target.value })}
|
||||
placeholder={t("toplevel.custom_router_path_placeholder")}
|
||||
className="transition-all-ease focus:scale-[1.01]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="p-4 pt-0">
|
||||
<Button
|
||||
|
||||
@@ -4,15 +4,72 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
const isNumeric = type === "number";
|
||||
const [tempValue, setTempValue] = React.useState(props.value?.toString() || '');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.value !== undefined) {
|
||||
setTempValue(props.value.toString());
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (isNumeric) {
|
||||
// Only allow empty string or numbers for numeric input
|
||||
if (newValue === '' || /^\d+$/.test(newValue)) {
|
||||
setTempValue(newValue);
|
||||
// Only call onChange if the value is not empty
|
||||
if (props.onChange && newValue !== '') {
|
||||
props.onChange(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setTempValue(newValue);
|
||||
if (props.onChange) {
|
||||
props.onChange(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (isNumeric && tempValue === '') {
|
||||
const defaultValue = props.placeholder || "1";
|
||||
setTempValue(defaultValue);
|
||||
|
||||
// Create a synthetic event for the corrected value
|
||||
if (props.onChange) {
|
||||
const syntheticEvent = {
|
||||
...e,
|
||||
target: { ...e.target, value: defaultValue }
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
props.onChange(syntheticEvent);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.onBlur) {
|
||||
props.onBlur(e);
|
||||
}
|
||||
};
|
||||
|
||||
// For numeric inputs, use text type and manage value internally
|
||||
const inputType = isNumeric ? "text" : type;
|
||||
const inputValue = isNumeric ? tempValue : props.value;
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
{...props}
|
||||
type={inputType}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { Config, Provider, Transformer } from '@/types';
|
||||
|
||||
// 日志聚合响应类型
|
||||
interface GroupedLogsResponse {
|
||||
grouped: boolean;
|
||||
groups: { [reqId: string]: Array<{ timestamp: string; level: string; message: string; source?: string; reqId?: string }> };
|
||||
summary: {
|
||||
totalRequests: number;
|
||||
totalLogs: number;
|
||||
requests: Array<{
|
||||
reqId: string;
|
||||
logCount: number;
|
||||
firstLog: string;
|
||||
lastLog: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// API Client Class for handling requests with baseUrl and apikey authentication
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
@@ -204,6 +220,21 @@ class ApiClient {
|
||||
async performUpdate(): Promise<{ success: boolean; message: string }> {
|
||||
return this.post<{ success: boolean; message: string }>('/api/update/perform', {});
|
||||
}
|
||||
|
||||
// Get log files list
|
||||
async getLogFiles(): Promise<Array<{ name: string; path: string; size: number; lastModified: string }>> {
|
||||
return this.get<Array<{ name: string; path: string; size: number; lastModified: string }>>('/logs/files');
|
||||
}
|
||||
|
||||
// Get logs from specific file
|
||||
async getLogs(filePath: string): Promise<string[]> {
|
||||
return this.get<string[]>(`/logs?file=${encodeURIComponent(filePath)}`);
|
||||
}
|
||||
|
||||
// Clear logs from specific file
|
||||
async clearLogs(filePath: string): Promise<void> {
|
||||
return this.delete<void>(`/logs?file=${encodeURIComponent(filePath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default instance of the API client
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code Router",
|
||||
"save": "Save",
|
||||
@@ -42,7 +46,9 @@
|
||||
"port": "Port",
|
||||
"apikey": "API Key",
|
||||
"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": {
|
||||
"title": "Custom Transformers",
|
||||
@@ -105,6 +111,8 @@
|
||||
"longContext": "Long Context",
|
||||
"longContextThreshold": "Context Threshold",
|
||||
"webSearch": "Web Search",
|
||||
"image": "Image",
|
||||
"forceUseImageAgent": "Force Use Image Agent",
|
||||
"selectModel": "Select a model...",
|
||||
"searchModel": "Search model...",
|
||||
"noModelFound": "No model found."
|
||||
@@ -185,5 +193,36 @@
|
||||
"template_download_success": "Template downloaded successfully",
|
||||
"template_download_success_desc": "Configuration template has been downloaded to your device",
|
||||
"template_download_failed": "Failed to download template"
|
||||
},
|
||||
"log_viewer": {
|
||||
"title": "Log Viewer",
|
||||
"close": "Close",
|
||||
"download": "Download",
|
||||
"clear": "Clear",
|
||||
"auto_refresh_on": "Auto Refresh On",
|
||||
"auto_refresh_off": "Auto Refresh Off",
|
||||
"load_failed": "Failed to load logs",
|
||||
"no_logs_available": "No logs available",
|
||||
"logs_cleared": "Logs cleared successfully",
|
||||
"clear_failed": "Failed to clear logs",
|
||||
"logs_downloaded": "Logs downloaded successfully",
|
||||
"back_to_files": "Back to Files",
|
||||
"select_file": "Select a log file to view",
|
||||
"no_log_files_available": "No log files available",
|
||||
"load_files_failed": "Failed to load log files",
|
||||
"group_by_req_id": "Group by Request ID",
|
||||
"grouped_on": "Grouped",
|
||||
"request_groups": "Request Groups",
|
||||
"total_requests": "Total Requests",
|
||||
"total_logs": "Total Logs",
|
||||
"request": "Request",
|
||||
"logs": "logs",
|
||||
"first_log": "First Log",
|
||||
"last_log": "Last Log",
|
||||
"back_to_all_logs": "Back to All Logs",
|
||||
"worker_error": "Worker error",
|
||||
"worker_init_failed": "Failed to initialize worker",
|
||||
"grouping_not_supported": "Log grouping not supported by server",
|
||||
"back": "Back"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"yes": "是",
|
||||
"no": "否"
|
||||
},
|
||||
"app": {
|
||||
"title": "Claude Code Router",
|
||||
"save": "保存",
|
||||
@@ -42,7 +46,9 @@
|
||||
"port": "端口",
|
||||
"apikey": "API 密钥",
|
||||
"timeout": "API 超时时间 (毫秒)",
|
||||
"proxy_url": "代理地址"
|
||||
"proxy_url": "代理地址",
|
||||
"custom_router_path": "自定义路由脚本路径",
|
||||
"custom_router_path_placeholder": "输入自定义路由脚本文件的绝对路径"
|
||||
},
|
||||
"transformers": {
|
||||
"title": "自定义转换器",
|
||||
@@ -105,6 +111,8 @@
|
||||
"longContext": "长上下文",
|
||||
"longContextThreshold": "上下文阈值",
|
||||
"webSearch": "网络搜索",
|
||||
"image": "图像",
|
||||
"forceUseImageAgent": "强制使用图像代理",
|
||||
"selectModel": "选择一个模型...",
|
||||
"searchModel": "搜索模型...",
|
||||
"noModelFound": "未找到模型."
|
||||
@@ -185,5 +193,36 @@
|
||||
"template_download_success": "模板下载成功",
|
||||
"template_download_success_desc": "配置模板已下载到您的设备",
|
||||
"template_download_failed": "模板下载失败"
|
||||
},
|
||||
"log_viewer": {
|
||||
"title": "日志查看器",
|
||||
"close": "关闭",
|
||||
"download": "下载",
|
||||
"clear": "清除",
|
||||
"auto_refresh_on": "自动刷新开启",
|
||||
"auto_refresh_off": "自动刷新关闭",
|
||||
"load_failed": "加载日志失败",
|
||||
"no_logs_available": "暂无日志",
|
||||
"logs_cleared": "日志清除成功",
|
||||
"clear_failed": "清除日志失败",
|
||||
"logs_downloaded": "日志下载成功",
|
||||
"back_to_files": "返回文件列表",
|
||||
"select_file": "选择要查看的日志文件",
|
||||
"no_log_files_available": "暂无日志文件",
|
||||
"load_files_failed": "加载日志文件失败",
|
||||
"group_by_req_id": "按请求ID分组",
|
||||
"grouped_on": "已分组",
|
||||
"request_groups": "请求组",
|
||||
"total_requests": "总请求数",
|
||||
"total_logs": "总日志数",
|
||||
"request": "请求",
|
||||
"logs": "条日志",
|
||||
"first_log": "首条日志",
|
||||
"last_log": "末条日志",
|
||||
"back_to_all_logs": "返回所有日志",
|
||||
"worker_error": "Worker错误",
|
||||
"worker_init_failed": "Worker初始化失败",
|
||||
"grouping_not_supported": "服务器不支持日志分组",
|
||||
"back": "返回"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface RouterConfig {
|
||||
longContext: string;
|
||||
longContextThreshold: number;
|
||||
webSearch: string;
|
||||
image: string;
|
||||
custom?: any;
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ export interface Config {
|
||||
Router: RouterConfig;
|
||||
transformers: Transformer[];
|
||||
StatusLine?: StatusLineConfig;
|
||||
forceUseImageAgent?: boolean;
|
||||
// Top-level settings
|
||||
LOG: boolean;
|
||||
LOG_LEVEL: string;
|
||||
@@ -62,6 +64,7 @@ export interface Config {
|
||||
APIKEY: string;
|
||||
API_TIMEOUT_MS: string;
|
||||
PROXY_URL: string;
|
||||
CUSTOM_ROUTER_PATH?: string;
|
||||
}
|
||||
|
||||
export type AccessLevel = 'restricted' | 'full';
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user