diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..86b73183 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.DS_Store +.env +.env.* +node_modules +dist +packages/*/dist +release +release-local +logs +tmp +test-results +playwright-report +blob-report +docs/node_modules +docs/dist +docs/.astro diff --git a/.gitignore b/.gitignore index 291dafef..871b4112 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,9 @@ release tmp release-local logs -.opencat \ No newline at end of file +.opencat +test-results +playwright-report +blob-report +.tmp +.test-dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..4c0c868d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +ARG NODE_IMAGE=node:22-bookworm +ARG RUNTIME_NODE_IMAGE=node:22-bookworm-slim + +FROM ${NODE_IMAGE} AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY packages/cli/package.json packages/cli/package.json +COPY packages/core/package.json packages/core/package.json +COPY packages/electron/package.json packages/electron/package.json +COPY packages/ui/package.json packages/ui/package.json +RUN npm ci + +COPY . . +RUN npm run build:docker + +FROM ${NODE_IMAGE} AS production-deps +WORKDIR /app +ENV NODE_ENV=production + +COPY package.json package-lock.json ./ +COPY packages/core/package.json packages/core/package.json +RUN npm ci --omit=dev --workspace=@claude-code-router/core --include-workspace-root=false \ + && npm cache clean --force + +FROM ${RUNTIME_NODE_IMAGE} AS runtime +ENV NODE_ENV=production \ + CCR_DATA_DIR=/data \ + CCR_WEB_HOST=127.0.0.1 \ + CCR_WEB_PORT=3459 \ + CCR_NGINX_PORT=8080 \ + CCR_GATEWAY_HOST=127.0.0.1 \ + CCR_GATEWAY_PORT=3456 \ + CCR_GATEWAY_CORE_PORT=3457 \ + CCR_PUBLIC_HOST=127.0.0.1 \ + CCR_PUBLIC_PORT=3458 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libstdc++6 nginx \ + && rm -rf /var/lib/apt/lists/* \ + && rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf \ + && rm -rf \ + /opt/yarn-* \ + /usr/local/bin/corepack \ + /usr/local/bin/npm \ + /usr/local/bin/npx \ + /usr/local/bin/yarn \ + /usr/local/bin/yarnpkg \ + /usr/local/include/node \ + /usr/local/lib/node_modules/corepack \ + /usr/local/lib/node_modules/npm \ + /usr/local/share/doc \ + /usr/local/share/man + +COPY package.json package-lock.json ./ +COPY packages/core/package.json packages/core/package.json +COPY --from=production-deps /app/node_modules node_modules + +COPY --from=build /app/packages/core/dist packages/core/dist +COPY --from=build /app/packages/ui/dist/renderer /usr/share/nginx/html +COPY docker/entrypoint.sh /usr/local/bin/ccr-docker-entrypoint +COPY docker/pm2.config.cjs docker/pm2.config.cjs + +RUN chmod +x /usr/local/bin/ccr-docker-entrypoint \ + && mkdir -p /data /run/nginx /var/lib/nginx /var/log/nginx + +VOLUME ["/data"] +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:' + (process.env.CCR_NGINX_PORT || '8080') + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +ENTRYPOINT ["ccr-docker-entrypoint"] diff --git a/README.md b/README.md index d41af53d..007d0360 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,94 @@ -

Claude Code Router Desktop

+

Claude Code Router

Chinese README Discord + X License + Desktop downloads Documentation

+
+ + + + + + + + +
+ + Kimi K2.7 Code sponsor banner + +
+ + Kimi Code Subscription +  ·  + API Global +  ·  + API China + +
+

+ Thanks to Kimi for sponsoring this project! Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard. +

+

+ CCR already supports Kimi. Visit the Kimi Open Platform (中文站 | Global) to try the API, or explore the cost-effective Coding Plan. +

+
+ +
+ +Claude Code Router Desktop is a local control plane for coding agents. It gives Claude Code, Codex, Grok CLI, ZCode, and compatible API clients one stable local endpoint, then lets you decide which provider, model, routing policy, tool stack, and account should handle each request. + +Instead of wiring every agent to every model service by hand, CCR centralizes the model layer on your own machine: provider presets, custom endpoints, credential pools, fallback chains, Fusion-enhanced models, MCP tools, request logs, account usage, and desktop launch profiles all live in one app. +

- Claude Code Router Desktop screenshot + Claude Code Router Desktop screenshot

-Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. +## What CCR Helps You Do -CCR runs on your machine, keeps provider configuration in your local config directory, and exposes a local gateway at `http://127.0.0.1:3456`. +| Goal | CCR gives you | +| --- | --- | +| Keep the same agent workflow while switching models | Local profiles for Claude Code, Codex, Grok CLI, and ZCode, with CLI/app launch entries and per-profile model selection | +| Try many providers without rebuilding config every time | Built-in provider presets, custom OpenAI/Anthropic/Gemini-compatible endpoints, protocol probing, model discovery, and connectivity checks | +| Make routing a runtime policy | Built-in agent routing, conditional rules, request rewrites, model-prefix routing, retries, and fallback model chains | +| Control cost and quota pressure | Credential pools, key rotation, local usage limits, account balance snapshots, token/cost dashboards, and tray status | +| Upgrade a model without replacing it | Fusion models that add vision, web search, or selected MCP tools to an existing base model | +| Keep large tool sets usable | ToolHub, a compact MCP entry point that lets agents resolve and invoke the tools needed for the current task | +| Debug what actually happened | Request logs, resolved provider/model fields, latency, token usage, estimated cost, network capture, and agent observability | ## Why Use CCR -- Use one local endpoint for multiple agent tools instead of configuring every client separately. -- Route requests with explicit rules instead of editing client configuration by hand. -- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers. -- Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs. -- Manage everything from a desktop UI instead of editing JSON by hand. -- Extend the gateway with plugins, proxy routes, local HTTP backends, and provider deeplinks. +- **One gateway for your agent stack**: point clients at CCR once, then move routing, models, keys, and providers from scattered client configs into a single desktop UI. +- **Provider freedom without workflow churn**: use OpenAI Chat/Responses, Anthropic Messages, Gemini Generate Content/Interactions, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom compatible providers. +- **Reliability policies you can see and change**: define when a request should be rewritten, retried, or moved to another model, then verify the result in local logs. +- **Operational visibility for AI work**: track requests, tokens, cost estimates, success rate, latency, model distribution, provider usage, and account balances from the dashboard or tray. +- **Agent-native tools and extensions**: add Fusion capabilities, expose dynamic MCP tools through ToolHub, automate the built-in browser, relay agents through IM bots, or install local extensions. -## Features +## Feature Highlights -- **Desktop dashboard**: start or stop the local gateway, inspect usage, configure the tray window, and manage runtime settings. -- **Provider management**: add provider presets or custom endpoints, test connectivity, manage credentials, and monitor supported account balances where available. -- **Routing rules**: configure conditional and model-prefix routing rules with fallback handling. -- **Agent profiles**: configure Claude Code, Codex, and ZCode profiles that point to the CCR gateway. -- **Gateway compatibility**: translate client requests through the local CCR wrapper and the core gateway runtime. -- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture. -- **Plugins**: install or load wrapper plugins, including routes for Claude Design and Cursor Proxy style integrations. -- **Virtual models**: expose aliases or composed model profiles for clients that expect a specific model name. -- **Provider deeplinks**: import provider configuration through `ccr://provider?...` links after user confirmation. +- **Agent profiles**: create profiles for Claude Code, Codex, Grok CLI, and ZCode with model overrides, scopes, CLI/app launch surfaces, environment settings, and multi-instance app workflows. +- **Provider management**: add preset providers or custom endpoints; probe supported protocols; detect model lists; run real connectivity checks; manage single keys or credential pools; import local agent login state where supported. +- **Model catalog**: search all configured models, edit model descriptions, and use those descriptions to guide Claude Code subagent, Task, and Workflow model selection. +- **Routing engine**: combine built-in agent routing, request-header/body conditions, model-prefix routing, request rewrites, retry policy, and ordered fallback targets. +- **Fusion models**: publish reusable virtual models that keep a base model's behavior while adding vision, hosted web search, or selected MCP tools. +- **ToolHub**: merge multiple MCP servers into one dynamic MCP server so agents can resolve tools only when a task needs them; desktop builds can also expose built-in browser automation and Chrome login-state import. +- **API keys and quotas**: create CCR client keys with expiration and local request/token/image limits, separate from upstream provider credentials. +- **Logs and observability**: inspect request/response details, resolved provider and model, credential, status, latency, token usage, estimated cost, tool calls, and agent execution traces. +- **Proxy and networking**: run CCR as a local HTTP/HTTPS proxy, optionally install the CA certificate, route supported API traffic through CCR, and capture network exchanges for debugging. +- **Bot relay**: connect agent profiles to supported IM platforms including Weixin iLink, WeCom, Slack, Discord, Telegram, LINE, Feishu, and DingTalk. +- **Extensions**: install wrapper plugins and core gateway plugins that can register local routes, proxy routes, provider account connectors, apps, and virtual models. + +## Documentation + +Read the full documentation at [ccrdesk.top](https://ccrdesk.top/), including the [CLI reference](https://ccrdesk.top/en/guides/cli/) and [Docker deployment guide](https://ccrdesk.top/en/guides/docker/). ## Download And Install -### npm CLI - -Install the CLI package when you want to run CCR without the desktop tray or `ccr://` protocol integration: - -```bash -npm install -g claude-code-router -ccr start -``` - -Common commands: - -```bash -ccr start # start the background CCR service and web management UI -ccr stop # stop the background CCR service -ccr cli # launch the saved profile as a CLI -ccr app # launch the saved profile as an app -``` - -The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr start --host --port ` to change it. - -### Desktop App - 1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases). 2. Download the package for your platform: - macOS Apple Silicon: `Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` or `.zip` @@ -69,14 +98,30 @@ The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr st 3. Install and launch **Claude Code Router**. 4. On first launch, CCR creates its local configuration database: - macOS/Linux: `~/.claude-code-router/config.sqlite` - - Windows: `%APPDATA%\Claude Code Router\config.sqlite` + - Windows: `%APPDATA%\claude-code-router\config.sqlite` CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists. -CCR starts two local services when the gateway is enabled: +After the service is started from the **Server** page, CCR listens on `http://127.0.0.1:3456` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status. -- CCR wrapper gateway: `http://127.0.0.1:3456` -- Core gateway runtime: `http://127.0.0.1:3457` +## CLI And Docker + +The npm CLI requires Node.js 22 or newer and provides the browser management UI, gateway, and Agent Config launch commands without Electron: + +```sh +npm install -g @musistudio/claude-code-router +ccr ui +``` + +The CLI management UI defaults to `http://127.0.0.1:3458`, while its model gateway defaults to `http://127.0.0.1:3456`. See the [complete CLI reference](https://ccrdesk.top/en/guides/cli/) for background/foreground service commands, options, profile launching, authentication, and data locations. + +To run the browser UI and gateway behind one Nginx port with persistent Docker storage: + +```sh +docker compose up -d --build +``` + +Docker exposes both management and gateway routes at `http://127.0.0.1:3458` by default. Read the [Docker deployment guide](https://ccrdesk.top/en/guides/docker/) before remote exposure; it covers the internal port topology, management and gateway authentication, `CCR_PUBLIC_BASE_URL`, volumes, backup/restore, upgrades, and health checks. ## Quick Start @@ -84,229 +129,306 @@ CCR can be configured entirely from the desktop UI. Use this setup order for a c ### 1. Add a provider -Open **Providers**, click **Add Provider**, then choose a built-in preset or create a custom provider. Fill in the provider name, endpoint, protocol, API key, and model list in the form. Use the connectivity check when available, then save the provider. +Open **Providers**, click **Add Provider**, then choose a built-in preset, import a supported local agent login state, or select **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider. ### 2. Configure routing -Open **Routing** to add explicit rules and configure failure handling. - -Use **Add Routing Rule** for request conditions, model-prefix routing, or fallback behavior. +Open **Routing** to enable built-in agent routes, add conditional rules, configure request rewrites, and set fallback behavior. Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets. ### 3. Start the gateway -Open **Server** and click **Start**. Enable auto start if you want CCR to start the local gateway whenever the desktop app opens. +Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://127.0.0.1:3456` by default. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens. ### 4. Connect your agent tool -Open **Profiles** and choose the client you want to use. Configure the Claude Code, Codex, or ZCode profile from the form, select the target model, and apply the profile. For app-based profiles, use the profile action button to open the target app through CCR. +Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, Grok CLI, or ZCode, select the target model and effect scope, then apply the config. For app entries, use **Open Agent** to launch the target app through CCR. ### 5. Monitor and adjust -Use **Dashboard** for usage and provider health, the tray window for quick token and account status, **Network Logs** for debugging provider behavior, and **Extensions** for plugin configuration. - -## Provider Deeplink - -Provider websites can open CCR and import a model provider with a custom protocol link: - -```text -ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions -``` - -Supported query parameters: - -- `name`: display name for the provider. -- `base_url`: provider API base URL. -- `api_key`: optional provider API key. -- `models`: comma-separated or newline-separated model list. You can also repeat `models=...`. -- `protocol`: one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, or `gemini_generate_content`. - -For larger payloads, pass `payload` as URL-encoded JSON or base64url JSON with the same fields. CCR always opens a confirmation dialog before writing a provider imported from an external link. - -## Plugins - -CCR has two plugin layers: - -- Core gateway plugins: use `providerPlugins` and `virtualModelProfiles`; these are passed through to the core gateway. -- Wrapper plugins: use top-level `plugins` to extend the Electron wrapper, register local HTTP backends, add gateway routes, and route proxy-mode traffic to plugin backends. - -Example wrapper plugin route: - -```json -{ - "plugins": [ - { - "id": "local-admin-api", - "enabled": true, - "proxy": { - "routes": [ - { - "id": "admin-api", - "host": "api.example.com", - "paths": ["/v1/admin"], - "upstream": "http://127.0.0.1:4510", - "stripPathPrefix": false - } - ] - } - } - ] -} -``` - -Plugin modules export a function or object with `setup(ctx)`. The context supports: - -- `ctx.registerGatewayRoute({ method, path, auth, handler })` -- `ctx.registerHttpBackend({ id, host, port, handler })` -- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })` -- `ctx.openSqliteStore({ filename, migrate })` -- `ctx.registerCoreGatewayProviderPlugin(plugin)` -- `ctx.registerCoreGatewayVirtualModelProfile(profile)` - -Local plugin examples are available in [examples/plugins](examples/plugins). - -## Development - -```bash -npm install -npm run dev -npm run typecheck -npm run build:assets -npm run build:app:mac -npm run build:app:win -``` - -`npm run build:assets` compiles the Electron main process and renderer assets into `dist/`. - -`npm run build` packages the app for the current platform and writes installer artifacts to `release/`. - -`npm run build:app:mac` and `npm run build:app:win` package platform-specific app artifacts. Linux AppImage packaging is configured in `electron-builder.json`. - -`npm run build:app:mac` creates a local macOS test package in `release-local/` using ad-hoc signing. It is useful with a free Apple Account or Apple Development certificate, but it is not suitable for public distribution because downloaded copies will not pass Gatekeeper notarization checks. - -macOS release builds are signed and notarized for distribution. Before running `npm run build:app:mac:release`, the build machine must have a `Developer ID Application` certificate available through the keychain or `CSC_LINK`/`CSC_KEY_PASSWORD`, full Xcode selected with `xcode-select`, and one notarization credential set: - -- `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER` -- `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID` -- `APPLE_KEYCHAIN_PROFILE`, optionally with `APPLE_KEYCHAIN` - -The macOS packaging hook validates codesigning, the stapled notarization ticket, and Gatekeeper assessment before writing distributable artifacts. - -Packaged builds check GitHub Releases for updates through `electron-updater`. For local update feed testing, set `CCR_UPDATE_FEED_URL` to a generic electron-updater feed URL before starting the app. `CCR_UPDATE_ALLOW_PRERELEASE=1` enables prerelease updates. - -## Further Reading - -- [Project Motivation and How It Works](blog/en/project-motivation-and-how-it-works.md) -- [Maybe We Can Do More with the Router](blog/en/maybe-we-can-do-more-with-the-route.md) +Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors. Use the dashboard and tray window for token, cost, model distribution, and account status. ## Acknowledgements -Codex support and Bot handoff are powered by [musistudio/codexl](https://github.com/musistudio/codexl). +Codex support is powered by [musistudio/codexl](https://github.com/musistudio/codexl). ## Support & Sponsoring -If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated. +
-[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F31GN2GM) - -[Paypal](https://paypal.me/musistudio1999) +

If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.

- - + +
AlipayWeChat Pay + + Support on Ko-fi + +
+ One-time support via Ko-fi +
+ + Sponsor with PayPal + +
+ International sponsorship +
+ + + + + +
+ Alipay +
+ Alipay QR code +
+ WeChat Pay +
+ WeChat Pay QR code +
+ +
+ ### Our Sponsors -A huge thank you to all our sponsors for their generous support. +
-- [AIHubmix](https://aihubmix.com/) -- [BurnCloud](https://ai.burncloud.com) -- @Simon Leischnig -- [@duanshuaimin](https://github.com/duanshuaimin) -- [@vrgitadmin](https://github.com/vrgitadmin) -- @\*o -- [@ceilwoo](https://github.com/ceilwoo) -- @\*说 -- @\*更 -- @K\*g -- @R\*R -- [@bobleer](https://github.com/bobleer) -- @\*苗 -- @\*划 -- [@Clarence-pan](https://github.com/Clarence-pan) -- [@carter003](https://github.com/carter003) -- @S\*r -- @\*晖 -- @\*敏 -- @Z\*z -- @\*然 -- [@cluic](https://github.com/cluic) -- @\*苗 -- [@PromptExpert](https://github.com/PromptExpert) -- @\*应 -- [@yusnake](https://github.com/yusnake) -- @\*飞 -- @董\* -- @\*汀 -- @\*涯 -- @\*:-) -- @\*\*磊 -- @\*琢 -- @\*成 -- @Z\*o -- @\*琨 -- [@congzhangzh](https://github.com/congzhangzh) -- @\*\_ -- @Z\*m -- @\*鑫 -- @c\*y -- @\*昕 -- [@witsice](https://github.com/witsice) -- @b\*g -- @\*亿 -- @\*辉 -- @JACK -- @\*光 -- @W\*l -- [@kesku](https://github.com/kesku) -- [@biguncle](https://github.com/biguncle) -- @二吉吉 -- @a\*g -- @\*林 -- @\*咸 -- @\*明 -- @S\*y -- @f\*o -- @\*智 -- @F\*t -- @r\*c -- [@qierkang](http://github.com/qierkang) -- @\*军 -- [@snrise-z](http://github.com/snrise-z) -- @\*王 -- [@greatheart1000](http://github.com/greatheart1000) -- @\*王 -- @zcutlip -- [@Peng-YM](http://github.com/Peng-YM) -- @\*更 -- @\*. -- @F\*t -- @\*政 -- @\*铭 -- @\*叶 -- @七\*o -- @\*青 -- @\*\*晨 -- @\*远 -- @\*霄 -- @\*\*吉 -- @\*\*飞 -- @\*\*驰 -- @x\*g +

A huge thank you to all our sponsors for their generous support.

-(If your name is masked, please contact me via my homepage email to update it with your GitHub username.) + + + + + + + + + + + + + + + + + + +
+ + Zhipu icon +
+ Z智谱 +
+
+ + AIHubmix icon +
+ AIHubmix +
+
+ + BurnCloud icon +
+ BurnCloud +
+
+ + 302.AI icon +
+ 302.AI +
+
+ + RunAPI icon +
+ RunAPI +
+
+ + TeamoRouter icon +
+ TeamoRouter +
+
+ + code0.ai icon +
+ code0.ai +
+
+ + claudeapi icon +
+ claudeapi +
+
+ + Qiniu Cloud AI icon +
+ Qiniu Cloud AI +
+
+ + Fenno.ai icon +
+ Fenno.ai +
+
+ + Unity2.Ai icon +
+ Unity2.Ai +
+
+ +

Community Sponsors

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@Simon Leischnig@duanshuaimin@vrgitadmin@*o@ceilwoo@*说
@*更@K*g@R*R@bobleer@*苗@*划
@Clarence-pan@carter003@S*r@*晖@*敏@Z*z
@*然@cluic@*苗@PromptExpert@*应@yusnake
@*飞@董*@*汀@*涯@*:-)@**磊
@*琢@*成@Z*o@*琨@congzhangzh@*_
@Z*m@*鑫@c*y@*昕@witsice@b*g
@*亿@*辉@JACK@*光@W*l@kesku
@biguncle@二吉吉@a*g@*林@*咸@*明
@S*y@f*o@*智@F*t@r*c@qierkang
@*军@snrise-z@*王@greatheart1000@*王@zcutlip
@Peng-YM@*更@*.@F*t@*政@*铭
@*叶@七*o@*青@**晨@*远@*霄
@**吉@**飞@**驰@x*g@**东@*落
@哆*k@*涛@苗大@*呢@d*u@crizcraig
s*s*火*勤**锟*涛**明
*知*语*瓜
+ +If your name is masked, please contact me via my homepage email to update it with your GitHub username. + +
## License diff --git a/README_zh.md b/README_zh.md index de8ee689..8dfa475d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,65 +1,94 @@ -

Claude Code Router Desktop

+

Claude Code Router

English README Discord + X License + 桌面端下载次数 文档

+
+ + + + + + + + +
+ + Kimi K2.7 Code 赞助横幅 + +
+ + Kimi Code 订阅 +  ·  + API 中文站 +  ·  + API Global + +
+

+ 感谢 Kimi 赞助本项目!Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。 +

+

+ CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站Global)体验 API,或了解高性价比 Coding Plan 套餐。 +

+
+ +
+ +Claude Code Router Desktop 是给编程 Agent 用的本地控制平面。它为 Claude Code、Codex、Grok CLI、ZCode 以及兼容 API 客户端提供一个稳定的本地入口,然后由你在 CCR 中决定每个请求应该走哪个供应商、哪个模型、哪套路由策略、哪些工具能力和哪组账号凭据。 + +相比在每个 Agent、每个模型服务里反复改配置,CCR 把模型层收束到本机桌面应用里:供应商预设、自定义端点、凭据池、Fallback、Fusion 组合模型、MCP 工具、请求日志、账号用量和 Agent 启动配置都在一个地方管理。 +

- Claude Code Router Desktop 项目截图 + Claude Code Router Desktop 项目截图

-Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 +## CCR 能帮你做什么 -CCR 在你的本机运行,Provider 配置保存在本地配置目录,并默认暴露本地网关地址:`http://127.0.0.1:3456`。 +| 目标 | CCR 提供的能力 | +| --- | --- | +| 保持 Agent 工作流不变,同时自由切换模型 | 为 Claude Code、Codex、Grok CLI、ZCode 创建本地配置档案,支持 CLI / App 启动入口和按配置选择模型 | +| 快速接入多个模型供应商 | 内置供应商预设、自定义 OpenAI / Anthropic / Gemini 兼容端点、协议探测、模型发现和连通性检测 | +| 把路由变成可配置策略 | 内置 Agent 路由、条件规则、请求改写、模型前缀路由、自动重试和 Fallback 模型链 | +| 控制成本和额度压力 | 凭据池、Key 轮换、本地限额、账号余额快照、Token / 成本仪表盘和托盘状态 | +| 给稳定模型补能力 | 通过 Fusion 给基础模型叠加视觉、联网搜索或指定 MCP 工具 | +| 让大量工具变得可用 | ToolHub 把多个 MCP server 收束成一个紧凑入口,让 Agent 按任务动态解析和调用工具 | +| 排查每一次请求 | 请求日志、最终供应商 / 模型、耗时、Token、成本估算、网络捕获和 Agent 观测链路 | ## 为什么使用 CCR -- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 -- 不同任务使用不同模型,例如后台任务、推理任务、长上下文、图片任务或支持联网搜索的模型。 -- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。 -- 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。 -- 使用桌面 UI 管理配置,减少手写 JSON。 -- 通过插件、代理路由、本地 HTTP 后端和 Provider deeplink 扩展网关能力。 +- **一个本地网关,接管整套 Agent 模型层**:客户端只需要指向 CCR,模型、供应商、Key、路由和工具能力都可以在桌面 UI 中调整。 +- **换供应商,不换工作流**:支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Content / Interactions、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义兼容供应商。 +- **可见、可改、可验证的可靠性策略**:配置请求什么时候改写、重试或切到备用模型,并在本地日志里确认真实命中结果。 +- **面向 AI 工作流的运营视角**:从仪表盘或托盘查看请求量、Token、成本估算、成功率、延迟、模型分布、供应商用量和账号余额。 +- **Agent 原生工具与扩展**:使用 Fusion 扩展模型能力,通过 ToolHub 暴露动态 MCP 工具,让内置浏览器参与任务,通过 IM Bot 接力 Agent,或安装本地扩展。 -## 功能和特性 +## 功能亮点 -- **桌面控制台**:启动或停止本地网关,查看用量,配置托盘窗口和运行时设置。 -- **Provider 管理**:添加预设或自定义端点,检测连通性,管理凭据,并在可用时查看账号余额。 -- **路由规则**:配置默认、后台、thinking、长上下文、图片、Web Search、Subagent、模型前缀和条件路由。 -- **Agent Profiles**:为 Claude Code、Codex 和 ZCode 配置指向 CCR 网关的 Profile。 -- **网关兼容层**:通过本地 CCR wrapper 和 core gateway runtime 转换客户端请求。 -- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。 -- **插件系统**:安装或加载 wrapper 插件,包括 Claude Design、Cursor Proxy 这类集成路由。 -- **虚拟模型**:为客户端暴露模型别名或组合模型配置,适配固定模型名场景。 -- **Provider Deeplink**:通过 `ccr://provider?...` 链接导入 Provider 配置,写入前会弹出确认。 +- **Agent 配置档案**:为 Claude Code、Codex、Grok CLI 和 ZCode 创建配置档案,支持模型覆盖、作用范围、CLI / App 启动方式、环境变量和多开 App 工作流。 +- **供应商管理**:添加预设供应商或自定义端点;探测协议;发现模型列表;运行真实连通性检测;管理单 Key 或凭据池;在支持时导入本机 Agent 登录态。 +- **模型目录**:搜索全部已配置模型,编辑模型描述,并把这些描述用于 Claude Code Subagent、Task 和 Workflow 的模型选择提示。 +- **路由引擎**:组合内置 Agent 路由、请求 Header / Body 条件、模型前缀路由、请求改写、重试策略和有序 Fallback 目标。 +- **Fusion 组合模型**:发布可复用的虚拟模型,在保留基础模型手感的同时增加视觉、托管联网搜索或指定 MCP 工具。 +- **ToolHub**:把多个 MCP server 合并成一个动态 MCP server,让 Agent 只在任务需要时解析工具;桌面端还可暴露内置浏览器自动化和 Chrome 登录态导入。 +- **API Key 与限额**:创建访问 CCR 的客户端 Key,设置过期时间和本地请求 / Token / 图片限额,与上游供应商凭据分开管理。 +- **日志与观测**:查看请求 / 响应详情、最终供应商与模型、凭据、状态、耗时、Token、成本估算、工具调用和 Agent 执行链路。 +- **代理与网络捕获**:把 CCR 作为本地 HTTP / HTTPS 代理运行,可选安装 CA 证书,把支持的 API 流量接入 CCR,并保存网络请求用于排查。 +- **Bot 接力**:把 Agent 配置接入 Weixin iLink、企业微信、Slack、Discord、Telegram、LINE、飞书和钉钉等 IM 平台。 +- **扩展机制**:安装 wrapper plugin 和 core gateway plugin,注册本地路由、代理路由、供应商账号连接器、内置应用和虚拟模型。 + +## 文档 + +完整文档见 [ccrdesk.top](https://ccrdesk.top/),其中包括 [CLI 命令参考](https://ccrdesk.top/guides/cli/) 和 [Docker 部署指南](https://ccrdesk.top/guides/docker/)。 ## 下载和安装 -### npm CLI - -如果你只需要 CLI 版本,不需要桌面 Tray 或 `ccr://` 协议集成,可以安装 npm 包: - -```bash -npm install -g claude-code-router -ccr start -``` - -常用命令: - -```bash -ccr start # 后台启动 CCR 服务和 Web 管理端 -ccr stop # 停止后台 CCR 服务 -ccr cli # 以 CLI 方式启动保存的 profile -ccr app # 以 App 方式启动保存的 profile -``` - -Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host --port ` 修改监听地址。 - -### 桌面应用 - 1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。 2. 按系统下载对应安装包: - macOS Apple 芯片:`Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` 或 `.zip` @@ -69,14 +98,30 @@ Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host 3. 安装并启动 **Claude Code Router**。 4. 首次启动后,CCR 会创建本地配置数据库: - macOS/Linux:`~/.claude-code-router/config.sqlite` - - Windows:`%APPDATA%\Claude Code Router\config.sqlite` + - Windows:`%APPDATA%\claude-code-router\config.sqlite` CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。 -启用网关后,CCR 会启动两个本地服务: +从 **服务** 页面启动后,CCR 默认监听 `http://127.0.0.1:3456`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。 -- CCR wrapper gateway:`http://127.0.0.1:3456` -- Core gateway runtime:`http://127.0.0.1:3457` +## CLI 与 Docker + +npm CLI 要求 Node.js 22 或更高版本,不依赖 Electron,也能提供浏览器管理界面、模型网关和 Agent 配置启动命令: + +```sh +npm install -g @musistudio/claude-code-router +ccr ui +``` + +CLI 管理界面默认是 `http://127.0.0.1:3458`,模型网关默认是 `http://127.0.0.1:3456`。后台 / 前台服务、全部选项、Profile 启动、鉴权和数据位置见[完整 CLI 参考](https://ccrdesk.top/guides/cli/)。 + +如果要使用单一 Nginx 端口和持久化 Docker 数据卷运行管理 UI 与网关: + +```sh +docker compose up -d --build +``` + +Docker 默认把管理和网关路径都发布在 `http://127.0.0.1:3458`。远程暴露前请先阅读 [Docker 部署指南](https://ccrdesk.top/guides/docker/),其中包含内部端口拓扑、管理与网关鉴权、`CCR_PUBLIC_BASE_URL`、数据卷、备份恢复、升级和健康检查。 ## 快速开始 @@ -84,229 +129,306 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 ### 1. 添加 Provider -打开 **Providers**,点击 **Add Provider**,选择内置预设或创建自定义 Provider。按表单填写 Provider 名称、端点、协议、API Key 和模型列表。可用时先运行连通性检测,然后保存 Provider。 +打开 **供应商**,点击 **添加供应商**,选择内置预设、导入支持的本机 Agent 登录态,或选择 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。 ### 2. 设置路由 -打开 **Routing**,先选择默认路由要使用的 provider/model。然后根据需要设置后台任务、Thinking、长上下文、图片任务和 Web Search 等场景的专用模型。 - -如果需要更细粒度控制,使用 **Add Routing Rule** 添加模型前缀、Subagent、请求条件或 fallback 规则。 +打开 **路由**,启用内置 Agent 路由,添加条件规则,配置请求改写和失败降级。如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。 ### 3. 启动网关 -打开 **Server**,点击 **Start** 启动本地网关。如果希望每次打开桌面应用时自动启动网关,可以启用 auto start。 +打开 **服务**,点击 **启动**。页面显示运行中后,CCR 默认会在本机监听 `http://127.0.0.1:3456`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。 ### 4. 连接 Agent 工具 -打开 **Profiles**,选择要使用的客户端。通过表单配置 Claude Code、Codex 或 ZCode Profile,选择目标模型并应用配置。对于 App 类型的 Profile,可以使用页面里的操作按钮通过 CCR 打开目标应用。 +打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex、Grok CLI 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 通过 CCR 打开目标应用。 ### 5. 日常查看和调整 -使用 **Dashboard** 查看用量和 Provider 状态,使用托盘窗口快速查看 Token 和账号状态,使用 **Network Logs** 调试 Provider 行为,使用 **Extensions** 配置插件。 - -## Provider Deeplink - -Provider 网站可以通过自定义协议打开 CCR 并导入模型服务配置: - -```text -ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions -``` - -支持的 query 参数: - -- `name`:Provider 展示名称。 -- `base_url`:Provider API Base URL。 -- `api_key`:可选 Provider API Key。 -- `models`:逗号或换行分隔的模型列表,也可以重复传入 `models=...`。 -- `protocol`:`openai_chat_completions`、`openai_responses`、`anthropic_messages` 或 `gemini_generate_content`。 - -更大的 payload 可以通过 URL 编码 JSON 或 base64url JSON 传入 `payload` 字段。CCR 在写入外部链接导入的 Provider 前,总会弹出确认窗口。 - -## 插件 - -CCR 有两层插件: - -- Core gateway plugins:使用 `providerPlugins` 和 `virtualModelProfiles`,会透传给 core gateway。 -- Wrapper plugins:使用顶层 `plugins` 扩展 Electron wrapper,注册本地 HTTP 后端、添加 gateway route,或把代理模式流量路由到插件后端。 - -Wrapper plugin route 示例: - -```json -{ - "plugins": [ - { - "id": "local-admin-api", - "enabled": true, - "proxy": { - "routes": [ - { - "id": "admin-api", - "host": "api.example.com", - "paths": ["/v1/admin"], - "upstream": "http://127.0.0.1:4510", - "stripPathPrefix": false - } - ] - } - } - ] -} -``` - -插件模块需要导出函数或包含 `setup(ctx)` 的对象。上下文支持: - -- `ctx.registerGatewayRoute({ method, path, auth, handler })` -- `ctx.registerHttpBackend({ id, host, port, handler })` -- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })` -- `ctx.openSqliteStore({ filename, migrate })` -- `ctx.registerCoreGatewayProviderPlugin(plugin)` -- `ctx.registerCoreGatewayVirtualModelProfile(profile)` - -本地插件示例见 [examples/plugins](examples/plugins)。 - -## 开发 - -```bash -npm install -npm run dev -npm run typecheck -npm run build:assets -npm run build:app:mac -npm run build:app:win -``` - -`npm run build:assets` 会把 Electron main process 和 renderer assets 编译到 `dist/`。 - -`npm run build` 会为当前平台打包应用,并把安装包写入 `release/`。 - -`npm run build:app:mac` 和 `npm run build:app:win` 会分别打包对应平台的应用产物。Linux AppImage 打包配置在 `electron-builder.json` 中。 - -`npm run build:app:mac` 会在 `release-local/` 生成本地测试用 macOS 包,使用 ad-hoc 签名。它适合免费 Apple Account 或只有 Apple Development 证书的本机测试,但不适合公开分发,因为用户下载后仍无法通过 Gatekeeper 公证检查。 - -macOS 发布包会使用 Developer ID 签名并提交 Apple 公证。运行 `npm run build:app:mac:release` 前,打包机器必须具备:可用的 `Developer ID Application` 证书(在 keychain 中,或通过 `CSC_LINK`/`CSC_KEY_PASSWORD` 提供)、已通过 `xcode-select` 选择完整 Xcode,以及下面任意一组公证凭据: - -- `APPLE_API_KEY`、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER` -- `APPLE_ID`、`APPLE_APP_SPECIFIC_PASSWORD`、`APPLE_TEAM_ID` -- `APPLE_KEYCHAIN_PROFILE`,可选 `APPLE_KEYCHAIN` - -macOS 打包 hook 会在产物生成前验证代码签名、公证票据 stapling 和 Gatekeeper 评估,避免发布未公证的安装包。 - -打包后的应用会通过 `electron-updater` 检查 GitHub Releases。测试本地更新源时,可以在启动应用前设置 `CCR_UPDATE_FEED_URL` 为 generic electron-updater feed URL。`CCR_UPDATE_ALLOW_PRERELEASE=1` 可以启用 prerelease 更新。 - -## 深入阅读 - -- [项目动机和工作原理](blog/zh/项目初衷及原理.md) -- [也许我们可以用路由器做更多事情](blog/zh/或许我们能在Router中做更多事情.md) +到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用概览仪表盘和托盘窗口查看 Token、成本、模型分布和账号状态。 ## 致谢 -对 Codex 的支持以及 Bot handoff 来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。 +对 Codex 的支持来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。 ## 支持与赞助 -如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。 +
-[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F31GN2GM) - -[Paypal](https://paypal.me/musistudio1999) +

如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。

- - + +
AlipayWeChat Pay + + 通过 Ko-fi 赞助 + +
+ 通过 Ko-fi 单次赞助 +
+ + 通过 PayPal 赞助 + +
+ 国际赞助通道 +
+ + + + + +
+ 支付宝 +
+ 支付宝收款码 +
+ 微信支付 +
+ 微信支付收款码 +
+ +
+ ### 我们的赞助商 -非常感谢所有赞助商的慷慨支持。 +
-- [AIHubmix](https://aihubmix.com/) -- [BurnCloud](https://ai.burncloud.com) -- @Simon Leischnig -- [@duanshuaimin](https://github.com/duanshuaimin) -- [@vrgitadmin](https://github.com/vrgitadmin) -- @\*o -- [@ceilwoo](https://github.com/ceilwoo) -- @\*说 -- @\*更 -- @K\*g -- @R\*R -- [@bobleer](https://github.com/bobleer) -- @\*苗 -- @\*划 -- [@Clarence-pan](https://github.com/Clarence-pan) -- [@carter003](https://github.com/carter003) -- @S\*r -- @\*晖 -- @\*敏 -- @Z\*z -- @\*然 -- [@cluic](https://github.com/cluic) -- @\*苗 -- [@PromptExpert](https://github.com/PromptExpert) -- @\*应 -- [@yusnake](https://github.com/yusnake) -- @\*飞 -- @董\* -- @\*汀 -- @\*涯 -- @\*:-) -- @\*\*磊 -- @\*琢 -- @\*成 -- @Z\*o -- @\*琨 -- [@congzhangzh](https://github.com/congzhangzh) -- @\*\_ -- @Z\*m -- @\*鑫 -- @c\*y -- @\*昕 -- [@witsice](https://github.com/witsice) -- @b\*g -- @\*亿 -- @\*辉 -- @JACK -- @\*光 -- @W\*l -- [@kesku](https://github.com/kesku) -- [@biguncle](https://github.com/biguncle) -- @二吉吉 -- @a\*g -- @\*林 -- @\*咸 -- @\*明 -- @S\*y -- @f\*o -- @\*智 -- @F\*t -- @r\*c -- [@qierkang](http://github.com/qierkang) -- @\*军 -- [@snrise-z](http://github.com/snrise-z) -- @\*王 -- [@greatheart1000](http://github.com/greatheart1000) -- @\*王 -- @zcutlip -- [@Peng-YM](http://github.com/Peng-YM) -- @\*更 -- @\*. -- @F\*t -- @\*政 -- @\*铭 -- @\*叶 -- @七\*o -- @\*青 -- @\*\*晨 -- @\*远 -- @\*霄 -- @\*\*吉 -- @\*\*飞 -- @\*\*驰 -- @x\*g +

非常感谢所有赞助商的慷慨支持。

-(如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。) + + + + + + + + + + + + + + + + + + +
+ + 智谱图标 +
+ Z智谱 +
+
+ + AIHubmix 图标 +
+ AIHubmix +
+
+ + BurnCloud 图标 +
+ BurnCloud +
+
+ + 302.AI 图标 +
+ 302.AI +
+
+ + RunAPI 图标 +
+ RunAPI +
+
+ + TeamoRouter 图标 +
+ TeamoRouter +
+
+ + code0.ai 图标 +
+ code0.ai +
+
+ + claudeapi 图标 +
+ claudeapi +
+
+ + 七牛云 AI 图标 +
+ 七牛云 AI +
+
+ + Fenno.ai 图标 +
+ Fenno.ai +
+
+ + Unity2.Ai 图标 +
+ Unity2.Ai +
+
+ +

社区赞助者

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@Simon Leischnig@duanshuaimin@vrgitadmin@*o@ceilwoo@*说
@*更@K*g@R*R@bobleer@*苗@*划
@Clarence-pan@carter003@S*r@*晖@*敏@Z*z
@*然@cluic@*苗@PromptExpert@*应@yusnake
@*飞@董*@*汀@*涯@*:-)@**磊
@*琢@*成@Z*o@*琨@congzhangzh@*_
@Z*m@*鑫@c*y@*昕@witsice@b*g
@*亿@*辉@JACK@*光@W*l@kesku
@biguncle@二吉吉@a*g@*林@*咸@*明
@S*y@f*o@*智@F*t@r*c@qierkang
@*军@snrise-z@*王@greatheart1000@*王@zcutlip
@Peng-YM@*更@*.@F*t@*政@*铭
@*叶@七*o@*青@**晨@*远@*霄
@**吉@**飞@**驰@x*g@**东@*落
@哆*k@*涛@苗大@*呢@d*u@crizcraig
s*s*火*勤**锟*涛**明
*知*语*瓜
+ +如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。 + +
## 许可证 diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..620db7ab --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,32 @@ +# Request log benchmarks + +Run the request-log benchmark with the same Electron/Node native runtime selection used by the test runner: + +```sh +npm run benchmark:request-logs -- --label local +``` + +To persist a machine-readable result: + +```sh +npm run benchmark:request-logs -- --label local --output benchmarks/results/request-log-local.json +``` + +The benchmark has two layers: + +- A request-log microbenchmark covering small records without a route trace, small records with a 10-hop route trace, 2 MB request bodies with a 10-hop trace, and 8 MB Base64 image requests. +- A real loopback HTTP service benchmark comparing no logging, synchronous main-thread SQLite logging, and the asynchronous worker logger. + +The HTTP benchmark uses a separate load-generator process so client work does not share the server event loop. It reports QPS, durable QPS, p50/p95/p99/max latency, HTTP errors, rejected log events, event-loop p99/max delay, CPU, heap, and RSS changes. + +The default web load is 10,000 requests with concurrency 64 and a 1 KB request body. It can be adjusted without editing code: + +```sh +npm run benchmark:request-logs -- \ + --web-requests 20000 \ + --web-concurrency 128 \ + --web-body-bytes 4096 \ + --label web-load +``` + +Use `--skip-web` when only the storage microbenchmark is needed, or `--skip-storage` when iterating only on HTTP performance. Both layers report request-path time separately from durable flush time so moving work off the request thread cannot hide storage regressions. diff --git a/benchmarks/results/request-log-base64-image.json b/benchmarks/results/request-log-base64-image.json new file mode 100644 index 00000000..176f3fe0 --- /dev/null +++ b/benchmarks/results/request-log-base64-image.json @@ -0,0 +1,87 @@ +{ + "architecture": "worker-runtime", + "cpu": "arm64", + "label": "base64-image-object-scoped", + "node": "v24.16.0", + "platform": "darwin", + "results": [ + { + "accepted": 2000, + "batchP99Ms": 1.632, + "degraded": 0, + "dropped": 0, + "durableMs": 64.936, + "durableRecordsPerSecond": 30799.754, + "eventLoopMaxMs": 2.365, + "eventLoopP99Ms": 1.831, + "flushMs": 47.56, + "heapDeltaMb": -0.89, + "name": "small-no-trace", + "records": 2000, + "requestPathMs": 17.375, + "requestPathRecordsPerSecond": 115108.464, + "rssDeltaMb": 21.203 + }, + { + "accepted": 1000, + "batchP99Ms": 1.15, + "degraded": 0, + "dropped": 0, + "durableMs": 58.399, + "durableRecordsPerSecond": 17123.581, + "eventLoopMaxMs": 2.195, + "eventLoopP99Ms": 2.195, + "flushMs": 45.398, + "heapDeltaMb": 4.493, + "name": "small-10-hops", + "records": 1000, + "requestPathMs": 13.001, + "requestPathRecordsPerSecond": 76915.681, + "rssDeltaMb": 8.891 + }, + { + "accepted": 50, + "batchP99Ms": 43.127, + "degraded": 50, + "dropped": 0, + "durableMs": 170.224, + "durableRecordsPerSecond": 293.73, + "eventLoopMaxMs": 1.916, + "eventLoopP99Ms": 1.628, + "flushMs": 91.361, + "heapDeltaMb": 36.715, + "name": "large-2mb-10-hops", + "records": 50, + "requestPathMs": 78.863, + "requestPathRecordsPerSecond": 634.013, + "rssDeltaMb": 135.344 + }, + { + "accepted": 10, + "batchP99Ms": 10.85, + "degraded": 10, + "dropped": 0, + "durableMs": 16.432, + "durableRecordsPerSecond": 608.572, + "eventLoopMaxMs": 1.2, + "eventLoopP99Ms": 1.2, + "flushMs": 5.581, + "heapDeltaMb": 0.062, + "name": "base64-image-8mb", + "records": 10, + "requestPathMs": 10.851, + "requestPathRecordsPerSecond": 921.595, + "rssDeltaMb": 0 + } + ], + "storageSkipped": false, + "timestamp": "2026-07-15T01:25:30.154Z", + "web": { + "bodyBytes": 1024, + "concurrency": 64, + "requests": 10000, + "results": [], + "skipped": true + }, + "runtime": "electron" +} diff --git a/benchmarks/results/request-log-baseline.json b/benchmarks/results/request-log-baseline.json new file mode 100644 index 00000000..8b2b8fd0 --- /dev/null +++ b/benchmarks/results/request-log-baseline.json @@ -0,0 +1,62 @@ +{ + "architecture": "main-thread-store", + "cpu": "arm64", + "label": "baseline", + "node": "v24.16.0", + "platform": "darwin", + "results": [ + { + "accepted": 2000, + "batchP99Ms": 6.366, + "degraded": 0, + "dropped": 0, + "durableMs": 201.032, + "durableRecordsPerSecond": 9948.653, + "eventLoopMaxMs": 6.365, + "eventLoopP99Ms": 4.239, + "flushMs": 0.002, + "heapDeltaMb": 1.586, + "name": "small-no-trace", + "records": 2000, + "requestPathMs": 201.03, + "requestPathRecordsPerSecond": 9948.756, + "rssDeltaMb": 18.484 + }, + { + "accepted": 1000, + "batchP99Ms": 7.765, + "degraded": 0, + "dropped": 0, + "durableMs": 168.879, + "durableRecordsPerSecond": 5921.412, + "eventLoopMaxMs": 7.782, + "eventLoopP99Ms": 7.782, + "flushMs": 0.003, + "heapDeltaMb": -1.761, + "name": "small-10-hops", + "records": 1000, + "requestPathMs": 168.875, + "requestPathRecordsPerSecond": 5921.534, + "rssDeltaMb": 10.172 + }, + { + "accepted": 50, + "batchP99Ms": 203.52, + "degraded": 0, + "dropped": 0, + "durableMs": 397.142, + "durableRecordsPerSecond": 125.9, + "eventLoopMaxMs": 204.079, + "eventLoopP99Ms": 204.079, + "flushMs": 0.003, + "heapDeltaMb": 2.151, + "name": "large-2mb-10-hops", + "records": 50, + "requestPathMs": 397.139, + "requestPathRecordsPerSecond": 125.901, + "rssDeltaMb": 130.203 + } + ], + "timestamp": "2026-07-14T23:42:58.245Z", + "runtime": "electron" +} diff --git a/benchmarks/results/request-log-comparison.md b/benchmarks/results/request-log-comparison.md new file mode 100644 index 00000000..742be032 --- /dev/null +++ b/benchmarks/results/request-log-comparison.md @@ -0,0 +1,43 @@ +# Request log performance comparison + +Environment: Apple arm64, Electron Node v24.16.0, SQLite WAL, identical benchmark scenarios and batch sizes. + +| Scenario | Metric | Baseline | Optimized | Change | +| --- | ---: | ---: | ---: | ---: | +| Small, no trace | Request-path throughput | 9,949 records/s | 173,695 records/s | 17.5x | +| Small, no trace | Durable throughput | 9,949 records/s | 34,267 records/s | 3.4x | +| Small, no trace | Event-loop p99 | 4.24 ms | 1.61 ms | -62.1% | +| Small, 10 hops | Request-path throughput | 5,922 records/s | 66,716 records/s | 11.3x | +| Small, 10 hops | Durable throughput | 5,921 records/s | 17,640 records/s | 3.0x | +| Small, 10 hops | Event-loop p99 | 7.78 ms | 1.30 ms | -83.3% | +| 2 MB, 10 hops | Request-path throughput | 126 records/s | 553 records/s | 4.4x | +| 2 MB, 10 hops | Durable throughput | 126 records/s | 336 records/s | 2.7x | +| 2 MB, 10 hops | Event-loop max | 204.08 ms | 2.04 ms | -99.0% | +| 2 MB, 10 hops | RSS delta | 130.20 MB | 66.58 MB | -48.9% | + +The optimized 2 MB result above was measured with the then-current 512 KB default, so it is intentionally marked degraded. Production now retains up to the 50 MB hard safety ceiling for each request/response body by default while preserving original size and truncation metadata. + +Raw measurements: + +- `request-log-baseline.json` +- `request-log-optimized.json` + +## HTTP service benchmark + +The HTTP benchmark sends 10,000 POST requests with concurrency 64 and a 1 KB body from a separate load-generator process. + +| Mode | QPS | Durable QPS | p50 | p95 | p99 | Event-loop p99 | Errors | Rejected logs | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| No logging control | 32,878 | 32,877 | 1.93 ms | 2.34 ms | 3.02 ms | 1.96 ms | 0 | 0 | +| Synchronous main-thread SQLite | 9,609 | 9,603 | 5.67 ms | 11.44 ms | 13.51 ms | 10.53 ms | 0 | 0 | +| Asynchronous worker logger | 31,879 | 25,757 | 2.02 ms | 3.01 ms | 6.20 ms | 2.06 ms | 0 | 0 | + +Compared with synchronous main-thread logging, the asynchronous worker delivers 3.3x request QPS, 2.7x durable QPS, 54.1% lower HTTP p99 latency, and 80.5% lower event-loop p99 delay. Its request-path QPS is 3.0% below the no-logging control, and all 10,000 log events were accepted. + +The first unrestricted-network run also exposed a linear scan through the downloaded model-pricing catalog, which limited durable logging to roughly 109 records/s. Building a source-and-model price index once per catalog load removed that bottleneck before the final measurements above. + +## Base64 image benchmark + +The image scenario reuses an already-received 8 MB JSON request body containing an inline Base64 image, matching the point where request logging starts. Ten records were accepted and compacted. Request-path throughput was 922 records/s, durable throughput was 609 records/s, and no RSS growth was observed in the measured run. The stored JSON retains its structure, MIME information, original wire size, and an image-size descriptor without retaining the encoded pixels. MIME and encoding context are scoped to the same JSON object, so adjacent audio payloads are not compacted. + +Raw measurement: `request-log-base64-image.json`. diff --git a/benchmarks/results/request-log-optimized.json b/benchmarks/results/request-log-optimized.json new file mode 100644 index 00000000..f6ce08a9 --- /dev/null +++ b/benchmarks/results/request-log-optimized.json @@ -0,0 +1,149 @@ +{ + "architecture": "worker-runtime", + "cpu": "arm64", + "label": "optimized-final", + "node": "v24.16.0", + "platform": "darwin", + "results": [ + { + "accepted": 2000, + "batchP99Ms": 0.704, + "degraded": 0, + "dropped": 0, + "durableMs": 58.366, + "durableRecordsPerSecond": 34266.696, + "eventLoopMaxMs": 2.052, + "eventLoopP99Ms": 1.605, + "flushMs": 46.851, + "heapDeltaMb": 1.279, + "name": "small-no-trace", + "records": 2000, + "requestPathMs": 11.514, + "requestPathRecordsPerSecond": 173695.29, + "rssDeltaMb": 16 + }, + { + "accepted": 1000, + "batchP99Ms": 2.092, + "degraded": 0, + "dropped": 0, + "durableMs": 56.691, + "durableRecordsPerSecond": 17639.614, + "eventLoopMaxMs": 2.347, + "eventLoopP99Ms": 1.302, + "flushMs": 41.702, + "heapDeltaMb": 3.953, + "name": "small-10-hops", + "records": 1000, + "requestPathMs": 14.989, + "requestPathRecordsPerSecond": 66716.335, + "rssDeltaMb": 6.313 + }, + { + "accepted": 50, + "batchP99Ms": 46.632, + "degraded": 50, + "dropped": 0, + "durableMs": 148.846, + "durableRecordsPerSecond": 335.918, + "eventLoopMaxMs": 2.036, + "eventLoopP99Ms": 1.665, + "flushMs": 58.449, + "heapDeltaMb": -2.104, + "name": "large-2mb-10-hops", + "records": 50, + "requestPathMs": 90.396, + "requestPathRecordsPerSecond": 553.12, + "rssDeltaMb": 66.578 + } + ], + "storageSkipped": false, + "timestamp": "2026-07-15T00:58:51.159Z", + "web": { + "bodyBytes": 1024, + "concurrency": 64, + "requests": 10000, + "results": [ + { + "bodyBytes": 1024, + "concurrency": 64, + "cpuSystemMs": 48.203, + "cpuTotalPercent": 50.988, + "cpuUserMs": 106.881, + "durableMs": 304.161, + "durableQps": 32877.361, + "errors": 0, + "eventLoopMaxMs": 2.017, + "eventLoopP99Ms": 1.959, + "flushMs": 0.002, + "heapDeltaMb": 0.561, + "latencyAvgMs": 1.915, + "latencyMaxMs": 9.838, + "latencyP50Ms": 1.927, + "latencyP95Ms": 2.341, + "latencyP99Ms": 3.021, + "logAccepted": 0, + "logRejected": 0, + "mode": "control", + "qps": 32877.509, + "requestPathMs": 304.159, + "requests": 10000, + "rssDeltaMb": 3.531 + }, + { + "bodyBytes": 1024, + "concurrency": 64, + "cpuSystemMs": 521.521, + "cpuTotalPercent": 90.135, + "cpuUserMs": 416.51, + "durableMs": 1041.303, + "durableQps": 9603.352, + "errors": 0, + "eventLoopMaxMs": 13.369, + "eventLoopP99Ms": 10.527, + "flushMs": 0.609, + "heapDeltaMb": -2.815, + "latencyAvgMs": 6.612, + "latencyMaxMs": 24.691, + "latencyP50Ms": 5.67, + "latencyP95Ms": 11.436, + "latencyP99Ms": 13.508, + "logAccepted": 10000, + "logRejected": 0, + "mode": "sync-main-thread", + "qps": 9608.974, + "requestPathMs": 1040.694, + "requests": 10000, + "rssDeltaMb": -12.125 + }, + { + "bodyBytes": 1024, + "concurrency": 64, + "cpuSystemMs": 126.52, + "cpuTotalPercent": 146.645, + "cpuUserMs": 333.485, + "durableMs": 388.251, + "durableQps": 25756.503, + "errors": 0, + "eventLoopMaxMs": 2.269, + "eventLoopP99Ms": 2.055, + "flushMs": 74.564, + "heapDeltaMb": -5.156, + "latencyAvgMs": 1.967, + "latencyMaxMs": 11.159, + "latencyP50Ms": 2.018, + "latencyP95Ms": 3.007, + "latencyP99Ms": 6.195, + "logAccepted": 10000, + "logRejected": 0, + "mode": "async-worker", + "qps": 31878.941, + "requestPathMs": 313.687, + "requests": 10000, + "rssDeltaMb": 4.516 + } + ], + "skipped": false + }, + "runtime": "electron" +} diff --git a/build/benchmark-request-logs.mjs b/build/benchmark-request-logs.mjs new file mode 100644 index 00000000..088d683e --- /dev/null +++ b/build/benchmark-request-logs.mjs @@ -0,0 +1,129 @@ +import esbuild from "esbuild"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const buildDir = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(buildDir, ".."); +const outputDir = path.join(projectRoot, ".benchmark-dist", "request-logs"); +const outputFile = path.join(outputDir, "request-log-runtime.bench.js"); +const args = parseArgs(process.argv.slice(2)); + +rmSync(outputDir, { force: true, recursive: true }); +mkdirSync(outputDir, { recursive: true }); + +try { + await esbuild.build({ + absWorkingDir: projectRoot, + bundle: true, + entryPoints: { + "request-log-runtime.bench": path.join(projectRoot, "packages/core/benchmark/request-log-runtime.bench.mjs"), + "request-log-web-load": path.join(projectRoot, "packages/core/benchmark/request-log-web-load.mjs"), + "request-log-worker": path.join(projectRoot, "packages/core/src/observability/request-log-worker.ts") + }, + external: ["better-sqlite3", "electron", "undici"], + format: "cjs", + legalComments: "none", + logLevel: "warning", + outdir: outputDir, + platform: "node", + plugins: [packageAliasPlugin()], + target: "node22" + }); + + const runtime = resolveRuntime(); + const executable = runtime === "electron" + ? (await import("electron")).default + : process.execPath; + const child = spawnSync(executable, [outputFile], { + cwd: projectRoot, + encoding: "utf8", + env: { + ...process.env, + CCR_REQUEST_LOG_BENCHMARK_LABEL: args.label, + CCR_REQUEST_LOG_BENCHMARK_SKIP_STORAGE: args.skipStorage ? "1" : "0", + CCR_REQUEST_LOG_BENCHMARK_SKIP_WEB: args.skipWeb ? "1" : "0", + CCR_REQUEST_LOG_BENCHMARK_WEB_BODY_BYTES: args.webBodyBytes, + CCR_REQUEST_LOG_BENCHMARK_WEB_CONCURRENCY: args.webConcurrency, + CCR_REQUEST_LOG_BENCHMARK_WEB_REQUESTS: args.webRequests, + ...(runtime === "electron" ? { ELECTRON_RUN_AS_NODE: "1" } : {}) + }, + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "inherit"] + }); + if (child.status !== 0) { + process.stderr.write(child.stdout || `benchmark exited with ${child.status}\n`); + process.exitCode = child.status ?? 1; + } else { + const result = JSON.parse(child.stdout); + result.runtime = runtime; + const json = `${JSON.stringify(result, null, 2)}\n`; + process.stdout.write(json); + if (args.output) { + const destination = path.resolve(projectRoot, args.output); + mkdirSync(path.dirname(destination), { recursive: true }); + writeFileSync(destination, json, "utf8"); + } + } +} finally { + rmSync(outputDir, { force: true, recursive: true }); +} + +function parseArgs(values) { + const parsed = { + label: "benchmark", + output: "", + skipStorage: false, + skipWeb: false, + webBodyBytes: "1024", + webConcurrency: "64", + webRequests: "10000" + }; + for (let index = 0; index < values.length; index += 1) { + if (values[index] === "--label") parsed.label = values[index + 1] || parsed.label; + if (values[index] === "--output") parsed.output = values[index + 1] || ""; + if (values[index] === "--skip-web") parsed.skipWeb = true; + if (values[index] === "--skip-storage") parsed.skipStorage = true; + if (values[index] === "--web-body-bytes") parsed.webBodyBytes = values[index + 1] || parsed.webBodyBytes; + if (values[index] === "--web-concurrency") parsed.webConcurrency = values[index + 1] || parsed.webConcurrency; + if (values[index] === "--web-requests") parsed.webRequests = values[index + 1] || parsed.webRequests; + } + return parsed; +} + +function resolveRuntime() { + const probe = spawnSync(process.execPath, [ + "-e", + "const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.close();" + ], { stdio: "ignore" }); + return probe.status === 0 ? "node" : "electron"; +} + +function packageAliasPlugin() { + const roots = { + cli: path.join(projectRoot, "packages/cli/src"), + core: path.join(projectRoot, "packages/core/src"), + electron: path.join(projectRoot, "packages/electron/src"), + ui: path.join(projectRoot, "packages/ui/src") + }; + return { + name: "request-log-benchmark-alias", + setup(build) { + build.onResolve({ filter: /^@ccr\/(cli|core|electron|ui)\// }, (resolveArgs) => { + const match = resolveArgs.path.match(/^@ccr\/(cli|core|electron|ui)\/(.+)$/); + if (!match) return undefined; + return { path: resolvePackageImport(roots[match[1]], match[2]) }; + }); + } + }; +} + +function resolvePackageImport(root, importPath) { + const base = path.resolve(root, importPath); + for (const candidate of [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.mjs`, path.join(base, "index.ts")]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + return base; +} diff --git a/build/build.mjs b/build/build.mjs index a07412b0..ba435652 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -1,4 +1,4 @@ -import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs"; +import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs"; const mode = process.argv.includes("--dev") ? "development" : "production"; @@ -19,4 +19,6 @@ await Promise.all([ buildStyles({ minify: mode === "production" }) ]); -console.log(`Built Electron app assets in ${mode} mode.`); +syncUiRendererToRuntimeDists(); + +console.log(`Built monorepo package assets in ${mode} mode.`); diff --git a/build/dev.mjs b/build/dev.mjs index a7cf8fdb..38901c2f 100644 --- a/build/dev.mjs +++ b/build/dev.mjs @@ -2,15 +2,17 @@ import electron from "electron"; import esbuild from "esbuild"; import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; -import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { - binPath, buildStyles, cleanDist, browserRendererHtmlInput, + cliSourceRoot, + coreSourceRoot, copyAppAssets, copyBrowserRendererHtml, + copyCliRuntimeToElectronDist, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, @@ -21,23 +23,30 @@ import { createRendererBuildOptions, createTrayRendererBuildOptions, createWebClientBridgeBuildOptions, - cssInput, - cssOutput, appAssetsInput, modelCatalogInput, projectRoot, + rendererRoot, rendererHtmlInput, + syncUiRendererToRuntimeDists, trayRendererHtmlInput, watchPlugin } from "./esbuild.config.mjs"; let electronProcess = null; let restartTimer = null; +let restartInFlight = false; +let restartQueued = false; let pendingRestartReasons = []; const watchSignatures = new Map(); let shuttingDown = false; const restartDelayMs = 160; +const styleBuildDelayMs = 160; +const stylePollIntervalMs = 1000; const ignoredSignatureEntries = new Set([".DS_Store"]); +let styleBuildTimer = null; +let styleBuildInFlight = false; +let queuedStyleBuildReason = null; const ready = { browser: false, cli: false, @@ -46,6 +55,32 @@ const ready = { tray: false, webBridge: false }; +const devTarget = parseDevTarget(process.argv.slice(2)); +const enabled = { + cli: devTarget === "cli" || devTarget === "electron", + electron: devTarget === "electron", + ui: true +}; +const coreSharedSourceRoot = path.join(coreSourceRoot, "shared"); +const styleWatchRoots = [rendererRoot, coreSharedSourceRoot].filter((watchRoot) => existsSync(watchRoot)); +const activeReadyNames = new Set([ + ...(enabled.ui ? ["browser", "renderer", "tray", "webBridge"] : []), + ...(enabled.cli ? ["cli"] : []), + ...(enabled.electron ? ["main"] : []) +]); + +function parseDevTarget(args) { + const target = args[0] ?? "electron"; + if (target === "--help" || target === "-h") { + console.log("Usage: node build/dev.mjs [ui|cli|electron]"); + process.exit(0); + } + if (target === "ui" || target === "cli" || target === "electron") { + return target; + } + console.error(`Unknown dev target "${target}". Expected ui, cli, or electron.`); + process.exit(2); +} function logDev(message) { console.log(`[dev] ${new Date().toISOString()} ${message}`); @@ -57,17 +92,11 @@ function relativePath(file) { function readyState() { return Object.entries(ready) + .filter(([name]) => activeReadyNames.has(name)) .map(([name, value]) => `${name}:${value ? "ready" : "pending"}`) .join(" "); } -function describeWatchEvent(label, watchedPath, eventType, filename, isDirectory = false) { - const changedPath = filename - ? path.join(isDirectory ? watchedPath : path.dirname(watchedPath), String(filename)) - : watchedPath; - return `${label} ${eventType} ${relativePath(changedPath)}`; -} - function contentSignature(targetPath) { try { return readContentSignature(targetPath); @@ -143,27 +172,124 @@ function listDirectoryFiles(targetPath, basePath = targetPath) { return files; } -function rememberWatchSignature(label, targetPath) { - const signature = contentSignature(targetPath); +function rememberWatchSignature(label, targetPath, options = {}) { + const signature = options.metadataOnly + ? metadataSignature(targetPath) + : contentSignature(targetPath); watchSignatures.set(label, signature.key); logDev(`watch baseline: ${label} ${relativePath(targetPath)}; ${signature.summary}`); } -function handleWatchedInput(label, watchedPath, eventType, filename, options, onChange) { - const reason = describeWatchEvent(label, watchedPath, eventType, filename, options?.isDirectory); - const signature = contentSignature(watchedPath); - const previousSignature = watchSignatures.get(label); - const changed = previousSignature !== signature.key; - watchSignatures.set(label, signature.key); - logDev(`watch event: ${reason}; ${signature.summary}; content=${changed ? "changed" : "unchanged"}`); +function scheduleStyleBuild(reason) { + queuedStyleBuildReason = reason; + if (styleBuildTimer) { + clearTimeout(styleBuildTimer); + } + styleBuildTimer = setTimeout(() => { + styleBuildTimer = null; + void rebuildStyles(queuedStyleBuildReason ?? reason); + }, styleBuildDelayMs); +} - if (!changed) { - logDev(`restart skipped: ${reason} (content unchanged)`); +async function rebuildStyles(reason) { + if (styleBuildInFlight) { + queuedStyleBuildReason = reason; return; } - onChange(); - scheduleRestart(reason); + styleBuildInFlight = true; + queuedStyleBuildReason = null; + try { + logDev(`rebuilding styles: ${reason}`); + await buildStyles({ minify: false }); + syncUiRendererToRuntimeDists(); + if (enabled.electron) { + scheduleRestart(`styles rebuilt: ${reason}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logDev(`style rebuild failed: ${message}`); + } finally { + styleBuildInFlight = false; + if (queuedStyleBuildReason) { + const queuedReason = queuedStyleBuildReason; + queuedStyleBuildReason = null; + scheduleStyleBuild(queuedReason); + } + } +} + +function pollStyleWatchRoots() { + for (const styleWatchRoot of styleWatchRoots) { + const label = `styles ${relativePath(styleWatchRoot)}`; + const signature = contentSignature(styleWatchRoot); + const previousSignature = watchSignatures.get(label); + if (previousSignature === signature.key) { + continue; + } + + watchSignatures.set(label, signature.key); + logDev(`watch event: ${label}; ${signature.summary}; content=changed`); + scheduleStyleBuild(label); + } +} + +function pollWatchedInput(label, targetPath, onChange, options = {}) { + const signature = options.metadataOnly + ? metadataSignature(targetPath) + : contentSignature(targetPath); + const previousSignature = watchSignatures.get(label); + if (previousSignature === signature.key) { + return; + } + + watchSignatures.set(label, signature.key); + logDev(`watch event: ${label} ${relativePath(targetPath)}; ${signature.summary}; content=changed`); + try { + onChange(); + if (enabled.electron && options.restart !== false) { + scheduleRestart(label); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logDev(`watch action failed: ${label}; ${message}`); + } +} + +function metadataSignature(targetPath) { + if (!existsSync(targetPath)) { + return { + key: "missing", + summary: "missing" + }; + } + + const stats = statSync(targetPath); + return { + key: `metadata:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`, + summary: `size=${stats.size} mtime=${stats.mtime.toISOString()} ctime=${stats.ctime.toISOString()}` + }; +} + +function pollSourceWatchTargets() { + pollWatchedInput("home html", rendererHtmlInput, () => { + copyRendererHtml(); + syncUiRendererToRuntimeDists(); + }); + pollWatchedInput("browser html", browserRendererHtmlInput, () => { + copyBrowserRendererHtml(); + syncUiRendererToRuntimeDists(); + }); + pollWatchedInput("tray html", trayRendererHtmlInput, () => { + copyTrayRendererHtml(); + syncUiRendererToRuntimeDists(); + }); + if (enabled.electron) { + pollWatchedInput("app assets", appAssetsInput, copyAppAssets); + } + if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) { + pollWatchedInput("model catalog", modelCatalogInput, copyModelCatalog, { metadataOnly: true }); + } } function markReady(name, reason = `${name} esbuild completed`) { @@ -171,7 +297,7 @@ function markReady(name, reason = `${name} esbuild completed`) { ready[name] = true; } logDev(`build ready: ${reason}; ${readyState()}`); - if (ready.browser && ready.cli && ready.main && ready.renderer && ready.tray && ready.webBridge) { + if (enabled.electron && Array.from(activeReadyNames).every((readyName) => ready[readyName])) { scheduleRestart(reason); } } @@ -191,144 +317,200 @@ function scheduleRestart(reason = "unknown trigger") { restartTimer = setTimeout(restartElectron, restartDelayMs); } -function restartElectron() { +async function restartElectron() { + if (restartInFlight) { + restartQueued = true; + return; + } + restartInFlight = true; const reasons = Array.from(new Set(pendingRestartReasons)); pendingRestartReasons = []; restartTimer = null; - if (electronProcess) { - logDev(`stopping Electron pid=${electronProcess.pid ?? "unknown"}`); - electronProcess.kill(); - electronProcess = null; - } + try { + if (electronProcess) { + await stopElectron(electronProcess); + } - logDev(`starting Electron; reasons=${reasons.join(" | ") || "initial start"}`); - const child = spawn(electron, ["."], { - cwd: projectRoot, - env: { - ...process.env, - NODE_ENV: "development" - }, - stdio: "inherit" - }); - electronProcess = child; - logDev(`Electron started pid=${child.pid ?? "unknown"}`); - child.on("exit", (code, signal) => { - logDev(`Electron exited pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`); + if (shuttingDown) { + return; + } + logDev(`starting Electron; reasons=${reasons.join(" | ") || "initial start"}`); + const child = spawn(electron, ["."], { + cwd: projectRoot, + env: { + ...process.env, + NODE_ENV: "development" + }, + stdio: "inherit" + }); + electronProcess = child; + logDev(`Electron started pid=${child.pid ?? "unknown"}`); + child.on("exit", (code, signal) => { + logDev(`Electron exited pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`); + if (electronProcess === child) { + electronProcess = null; + } + }); + } finally { + restartInFlight = false; + if (restartQueued || pendingRestartReasons.length > 0) { + restartQueued = false; + scheduleRestart("changes queued during Electron restart"); + } + } +} + +async function stopElectron(child) { + logDev(`stopping Electron pid=${child.pid ?? "unknown"}`); + if (child.exitCode !== null || child.signalCode !== null) { if (electronProcess === child) { electronProcess = null; } + return; + } + + await new Promise((resolve) => { + let forceTimer = null; + let giveUpTimer = null; + const finish = () => { + if (forceTimer) clearTimeout(forceTimer); + if (giveUpTimer) clearTimeout(giveUpTimer); + child.off("exit", finish); + resolve(); + }; + child.once("exit", finish); + child.kill(); + forceTimer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + logDev(`force stopping Electron pid=${child.pid ?? "unknown"}`); + child.kill("SIGKILL"); + } + }, 2_000); + giveUpTimer = setTimeout(finish, 5_000); }); + if (electronProcess === child) { + electronProcess = null; + } } -logDev("starting dev build"); +logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} cli=${enabled.cli ? "on" : "off"} electron=${enabled.electron ? "on" : "off"}`); cleanDist(); -copyAppAssets(); -copyMarketplacePlugins(); -copyModelCatalog(); +if (enabled.electron) { + copyAppAssets(); +} +if (enabled.cli || enabled.electron) { + copyMarketplacePlugins(); + copyModelCatalog(); +} copyBrowserRendererHtml(); copyRendererHtml(); copyTrayRendererHtml(); await buildStyles({ minify: false }); - -const tailwindProcess = spawn(binPath("tailwindcss"), ["-i", cssInput, "-o", cssOutput, "--watch"], { - cwd: projectRoot, - stdio: "inherit", - shell: process.platform === "win32" -}); -logDev(`Tailwind watcher started pid=${tailwindProcess.pid ?? "unknown"} input=${relativePath(cssInput)} output=${relativePath(cssOutput)}`); -tailwindProcess.on("exit", (code, signal) => { - logDev(`Tailwind watcher exited code=${code ?? "null"} signal=${signal ?? "null"}`); -}); +syncUiRendererToRuntimeDists(); rememberWatchSignature("home html", rendererHtmlInput); rememberWatchSignature("browser html", browserRendererHtmlInput); rememberWatchSignature("tray html", trayRendererHtmlInput); -rememberWatchSignature("app assets", appAssetsInput); -if (existsSync(modelCatalogInput)) { - rememberWatchSignature("model catalog", modelCatalogInput); +for (const styleWatchRoot of styleWatchRoots) { + rememberWatchSignature(`styles ${relativePath(styleWatchRoot)}`, styleWatchRoot); +} +if (enabled.electron) { + rememberWatchSignature("app assets", appAssetsInput); +} +if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) { + rememberWatchSignature("model catalog", modelCatalogInput, { metadataOnly: true }); } -const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, copyRendererHtml); -}); +const sourcePoller = setInterval(() => { + pollStyleWatchRoots(); + pollSourceWatchTargets(); +}, stylePollIntervalMs); -const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, copyBrowserRendererHtml); -}); +const contexts = []; -const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, copyTrayRendererHtml); -}); - -const appAssetsWatcher = watch(appAssetsInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets); -}); - -const modelCatalogWatcher = existsSync(modelCatalogInput) - ? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog); - }) - : { close: () => undefined }; - -const mainContext = await esbuild.context( - createMainBuildOptions({ - mode: "development", - plugins: [watchPlugin("main", (name) => markReady(name))] - }) -); - -const cliContext = await esbuild.context( - createCliBuildOptions({ - mode: "development", - plugins: [watchPlugin("cli", (name) => markReady(name))] - }) -); - -const rendererContext = await esbuild.context( - createRendererBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("renderer", (name) => { - copyRendererHtml(); - markReady(name); +if (enabled.electron) { + contexts.push( + await esbuild.context( + createMainBuildOptions({ + mode: "development", + plugins: [watchPlugin("main", (name) => markReady(name))] }) - ] - }) -); + ) + ); +} -const trayRendererContext = await esbuild.context( - createTrayRendererBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("tray", (name) => { - copyTrayRendererHtml(); - markReady(name); +if (enabled.cli) { + contexts.push( + await esbuild.context( + createCliBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("cli", (name) => { + if (enabled.electron) { + copyCliRuntimeToElectronDist(); + } + markReady(name); + }) + ] }) - ] - }) -); + ) + ); +} -const browserRendererContext = await esbuild.context( - createBrowserRendererBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("browser", (name) => { - copyBrowserRendererHtml(); - markReady(name); +if (enabled.ui) { + contexts.push( + await esbuild.context( + createRendererBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("renderer", (name) => { + copyRendererHtml(); + syncUiRendererToRuntimeDists(); + markReady(name); + }) + ] }) - ] - }) -); + ), + await esbuild.context( + createTrayRendererBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("tray", (name) => { + copyTrayRendererHtml(); + syncUiRendererToRuntimeDists(); + markReady(name); + }) + ] + }) + ), + await esbuild.context( + createBrowserRendererBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("browser", (name) => { + copyBrowserRendererHtml(); + syncUiRendererToRuntimeDists(); + markReady(name); + }) + ] + }) + ), + await esbuild.context( + createWebClientBridgeBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("webBridge", (name) => { + syncUiRendererToRuntimeDists(); + markReady(name); + }) + ] + }) + ) + ); +} -const webClientBridgeContext = await esbuild.context( - createWebClientBridgeBuildOptions({ - mode: "development", - plugins: [watchPlugin("webBridge", (name) => markReady(name))] - }) -); - -await Promise.all([mainContext.watch(), cliContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch(), webClientBridgeContext.watch()]); +await Promise.all(contexts.map((context) => context.watch())); logDev("watchers are active"); async function shutdown() { @@ -338,15 +520,13 @@ async function shutdown() { clearTimeout(restartTimer); } if (electronProcess) { - electronProcess.kill(); + await stopElectron(electronProcess); } - tailwindProcess.kill(); - htmlWatcher.close(); - browserHtmlWatcher.close(); - trayHtmlWatcher.close(); - appAssetsWatcher.close(); - modelCatalogWatcher.close(); - await Promise.all([mainContext.dispose(), cliContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose(), webClientBridgeContext.dispose()]); + if (styleBuildTimer) { + clearTimeout(styleBuildTimer); + } + clearInterval(sourcePoller); + await Promise.all(contexts.map((context) => context.dispose())); process.exit(0); } diff --git a/build/docker-build.mjs b/build/docker-build.mjs new file mode 100644 index 00000000..75d4ed17 --- /dev/null +++ b/build/docker-build.mjs @@ -0,0 +1,37 @@ +import { + buildBrowserRenderer, + buildCoreServer, + buildRenderer, + buildStyles, + buildTrayRenderer, + buildWebClientBridge, + cleanDist, + copyBrowserRendererHtml, + copyMarketplacePlugins, + copyModelCatalog, + copyRendererHtml, + copyTrayRendererHtml, + syncUiRendererToRuntimeDists +} from "./esbuild.config.mjs"; + +const mode = process.argv.includes("--dev") ? "development" : "production"; + +cleanDist(); +copyMarketplacePlugins(); +copyModelCatalog(); +copyBrowserRendererHtml(); +copyRendererHtml(); +copyTrayRendererHtml(); + +await Promise.all([ + buildCoreServer({ mode }), + buildBrowserRenderer({ mode }), + buildRenderer({ mode }), + buildTrayRenderer({ mode }), + buildWebClientBridge({ mode }), + buildStyles({ minify: mode === "production" }) +]); + +syncUiRendererToRuntimeDists(); + +console.log(`Built Docker core server and UI assets in ${mode} mode.`); diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 9bdaba7b..2e3aacf3 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -1,23 +1,63 @@ import esbuild from "esbuild"; import { spawn } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { builtinModules } from "node:module"; +import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { builtinModules, createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const requireFromHere = createRequire(import.meta.url); export const projectRoot = path.resolve(__dirname, ".."); -export const distDir = path.join(projectRoot, "dist"); -export const mainOutDir = path.join(distDir, "main"); -export const rendererOutDir = path.join(distDir, "renderer"); -export const appAssetsDir = path.join(distDir, "assets"); +export const packagesRoot = path.join(projectRoot, "packages"); +export const cliRoot = path.join(packagesRoot, "cli"); +export const coreRoot = path.join(packagesRoot, "core"); +export const electronRoot = path.join(packagesRoot, "electron"); +export const uiRoot = path.join(packagesRoot, "ui"); +export const cliSourceRoot = path.join(cliRoot, "src"); +export const coreSourceRoot = path.join(coreRoot, "src"); +export const electronSourceRoot = path.join(electronRoot, "src"); +export const uiSourceRoot = path.join(uiRoot, "src"); +export const legacyDistDir = path.join(projectRoot, "dist"); +export const cliDistDir = path.join(cliRoot, "dist"); +export const coreDistDir = path.join(coreRoot, "dist"); +export const electronDistDir = path.join(electronRoot, "dist"); +export const uiDistDir = path.join(uiRoot, "dist"); +export const distDir = electronDistDir; +export const cliMainOutDir = path.join(cliDistDir, "main"); +export const coreMainOutDir = path.join(coreDistDir, "main"); +export const electronMainOutDir = path.join(electronDistDir, "main"); +export const mainOutDir = electronMainOutDir; +export const gatewayPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/ai-gateway/package.json")); +export const gatewayRuntimeInput = path.join(gatewayPackageRoot, "bin", "next-ai-gateway.js"); +export const electronGatewayRuntimeOutput = path.join(electronMainOutDir, "next-ai-gateway.js"); +export const botGatewaySdkPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json")); +export const botGatewaySdkEntryInput = path.join(botGatewaySdkPackageRoot, "dist", "index.js"); +export const botGatewaySdkRunnerInput = path.join(botGatewaySdkPackageRoot, "bin", "bot-gateway-stdio.mjs"); +export const electronBotGatewaySdkRootDir = path.join(electronMainOutDir, "bot-gateway-sdk"); +export const electronBotGatewaySdkDistDir = path.join(electronBotGatewaySdkRootDir, "dist"); +export const electronBotGatewaySdkBinDir = path.join(electronBotGatewaySdkRootDir, "bin"); +export const electronBotGatewaySdkPackageOutput = path.join(electronBotGatewaySdkRootDir, "package.json"); +export const electronBotGatewaySdkEntryOutput = path.join(electronBotGatewaySdkDistDir, "index.js"); +export const electronBotGatewaySdkRunnerOutput = path.join(electronBotGatewaySdkBinDir, "bot-gateway-stdio.mjs"); +export const rendererOutDir = path.join(uiDistDir, "renderer"); +export const cliRendererOutDir = path.join(cliDistDir, "renderer"); +export const coreRendererOutDir = path.join(coreDistDir, "renderer"); +export const electronRendererOutDir = path.join(electronDistDir, "renderer"); +export const runtimeRendererOutDirs = [cliRendererOutDir, coreRendererOutDir, electronRendererOutDir]; +export const appAssetsDir = path.join(electronDistDir, "assets"); export const rendererAssetsDir = path.join(rendererOutDir, "assets"); -export const marketplacePluginsDir = path.join(distDir, "marketplace", "plugins"); -export const appAssetsInput = path.join(projectRoot, "assets"); -export const modelCatalogInput = path.join(projectRoot, "models.json"); -export const modelCatalogOutput = path.join(distDir, "models.json"); -export const rendererRoot = path.join(projectRoot, "src", "renderer"); +export const cliMarketplacePluginsDir = path.join(cliDistDir, "marketplace", "plugins"); +export const coreMarketplacePluginsDir = path.join(coreDistDir, "marketplace", "plugins"); +export const electronMarketplacePluginsDir = path.join(electronDistDir, "marketplace", "plugins"); +export const marketplacePluginsDir = electronMarketplacePluginsDir; +export const appAssetsInput = path.join(electronRoot, "assets"); +export const modelCatalogInput = path.join(coreRoot, "models.json"); +export const cliModelCatalogOutput = path.join(cliDistDir, "models.json"); +export const coreModelCatalogOutput = path.join(coreDistDir, "models.json"); +export const electronModelCatalogOutput = path.join(electronDistDir, "models.json"); +export const modelCatalogOutput = electronModelCatalogOutput; +export const rendererRoot = uiSourceRoot; export const rendererHtmlInput = path.join(rendererRoot, "pages", "home", "index.html"); export const rendererHtmlOutput = path.join(rendererOutDir, "pages", "home", "index.html"); export const browserRendererHtmlInput = path.join(rendererRoot, "pages", "browser", "index.html"); @@ -27,11 +67,15 @@ export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", export const cssInput = path.join(rendererRoot, "styles", "globals.css"); export const cssOutput = path.join(rendererAssetsDir, "main.css"); export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js"); -const lightweightMcpBundleNames = ["fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"]; +export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts"); +export const upstreamHeaderSanitizerInput = path.join(coreSourceRoot, "gateway", "core-runtime", "upstream-header-sanitizer.ts"); +const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"]; const lightweightMcpBundleMaxBytes = 128 * 1024; const forbiddenLightweightMcpInputs = [ - { prefix: "src/main/", reason: "main-process modules can pull in config, Electron, or native storage side effects" }, - { prefix: "src/renderer/", reason: "renderer modules do not belong in stdio MCP subprocesses" }, + { prefix: "packages/core/src/config/", reason: "config modules can pull in native storage side effects" }, + { prefix: "packages/core/src/storage/", reason: "native SQLite storage is not allowed in lightweight MCP subprocesses" }, + { prefix: "packages/electron/src/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" }, + { prefix: "packages/ui/src/", reason: "UI modules do not belong in stdio MCP subprocesses" }, { prefix: "node_modules/better-sqlite3/", reason: "native SQLite is not allowed in lightweight MCP subprocesses" }, { prefix: "node_modules/electron/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" } ]; @@ -45,15 +89,28 @@ const nodeExternals = [ ]; export function cleanDist() { - rmSync(distDir, { force: true, recursive: true }); + rmSync(legacyDistDir, { force: true, recursive: true }); + rmSync(cliDistDir, { force: true, recursive: true }); + rmSync(coreDistDir, { force: true, recursive: true }); + rmSync(electronDistDir, { force: true, recursive: true }); + rmSync(uiDistDir, { force: true, recursive: true }); ensureDist(); } export function ensureDist() { - mkdirSync(mainOutDir, { recursive: true }); + mkdirSync(cliMainOutDir, { recursive: true }); + mkdirSync(coreMainOutDir, { recursive: true }); + mkdirSync(electronMainOutDir, { recursive: true }); + mkdirSync(electronBotGatewaySdkDistDir, { recursive: true }); + mkdirSync(electronBotGatewaySdkBinDir, { recursive: true }); mkdirSync(appAssetsDir, { recursive: true }); - mkdirSync(marketplacePluginsDir, { recursive: true }); + mkdirSync(cliMarketplacePluginsDir, { recursive: true }); + mkdirSync(coreMarketplacePluginsDir, { recursive: true }); + mkdirSync(electronMarketplacePluginsDir, { recursive: true }); mkdirSync(rendererAssetsDir, { recursive: true }); + for (const outputDir of runtimeRendererOutDirs) { + mkdirSync(path.join(outputDir, "assets"), { recursive: true }); + } mkdirSync(path.dirname(rendererHtmlOutput), { recursive: true }); mkdirSync(path.dirname(browserRendererHtmlOutput), { recursive: true }); mkdirSync(path.dirname(trayRendererHtmlOutput), { recursive: true }); @@ -69,12 +126,16 @@ export function copyAppAssets() { export function copyModelCatalog() { ensureDist(); if (existsSync(modelCatalogInput)) { - cpSync(modelCatalogInput, modelCatalogOutput); + cpSync(modelCatalogInput, cliModelCatalogOutput); + cpSync(modelCatalogInput, coreModelCatalogOutput); + cpSync(modelCatalogInput, electronModelCatalogOutput); } } export function copyRendererHtml() { - copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js"); + copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js", { + beforeModuleScriptTags: [' '] + }); } export function copyTrayRendererHtml() { @@ -89,14 +150,25 @@ export function copyMarketplacePlugins() { ensureDist(); for (const filename of ["claude-design-plugin.cjs", "cursor-proxy-plugin.cjs"]) { const source = path.join(projectRoot, "examples", "plugins", filename); - const target = path.join(marketplacePluginsDir, filename); if (existsSync(source)) { - cpSync(source, target); + cpSync(source, path.join(cliMarketplacePluginsDir, filename)); + cpSync(source, path.join(coreMarketplacePluginsDir, filename)); + cpSync(source, path.join(electronMarketplacePluginsDir, filename)); } } } -function copyRendererPageHtml(input, output, scriptName) { +export function syncUiRendererToRuntimeDists() { + ensureDist(); + for (const outputDir of runtimeRendererOutDirs) { + rmSync(outputDir, { force: true, recursive: true }); + if (existsSync(rendererOutDir)) { + cpSync(rendererOutDir, outputDir, { recursive: true }); + } + } +} + +function copyRendererPageHtml(input, output, scriptName, options = {}) { ensureDist(); const source = readFileSync(input, "utf8"); const styleTag = ' '; @@ -105,6 +177,12 @@ function copyRendererPageHtml(input, output, scriptName) { ? source.replace(' ', scriptTag) : source.replace("", `${scriptTag}\n `); + for (const extraScriptTag of options.beforeModuleScriptTags ?? []) { + if (!hasScriptTag(html, extraScriptTag)) { + html = html.replace(scriptTag, `${extraScriptTag}\n${scriptTag}`); + } + } + if (!html.includes('href="../../assets/main.css"')) { html = html.replace("", `${styleTag}\n `); } @@ -112,17 +190,40 @@ function copyRendererPageHtml(input, output, scriptName) { writeFileSync(output, html, "utf8"); } +function hasScriptTag(html, scriptTag) { + const sourceMatch = scriptTag.match(/\bsrc="([^"]+)"/); + return sourceMatch ? html.includes(sourceMatch[1]) : html.includes(scriptTag); +} + +function normalizeDuplicateShebangs(source) { + const lines = source.split("\n"); + if (!lines[0]?.startsWith("#!")) { + return source; + } + let index = 1; + while (lines[index]?.startsWith("#!")) { + index += 1; + } + return [lines[0], ...lines.slice(index)].join("\n"); +} + export function createMainBuildOptions({ mode = "production", plugins = [] } = {}) { return { absWorkingDir: projectRoot, bundle: true, entryNames: "[name]", entryPoints: [ - path.join(projectRoot, "src", "main", "main.ts"), - path.join(projectRoot, "src", "main", "browser-preload.ts"), - path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"), - path.join(projectRoot, "src", "server", "mcp", "fusion-tool-fallback-mcp.ts"), - path.join(projectRoot, "src", "main", "preload.ts") + path.join(electronSourceRoot, "main", "main.ts"), + path.join(electronSourceRoot, "main", "browser-preload.ts"), + gatewayRuntimeInput, + path.join(coreSourceRoot, "mcp", "browser-web-search-proxy-mcp.ts"), + path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), + path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), + path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"), + path.join(coreSourceRoot, "observability", "request-log-worker.ts"), + upstreamHeaderSanitizerInput, + electronUndiciProxyAgentInput, + path.join(electronSourceRoot, "main", "preload.ts") ], external: nodeExternals, format: "cjs", @@ -130,9 +231,9 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = { logLevel: "info", metafile: true, minify: mode === "production", - outdir: mainOutDir, + outdir: electronMainOutDir, platform: "node", - plugins, + plugins: [packageAliasPlugin(), ...plugins], sourcemap: mode !== "production", target: "node22" }; @@ -143,15 +244,48 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {} absWorkingDir: projectRoot, bundle: true, entryNames: "[name]", - entryPoints: [path.join(projectRoot, "src", "main", "cli.ts")], + entryPoints: [ + path.join(cliSourceRoot, "cli.ts"), + path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), + path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), + path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"), + path.join(coreSourceRoot, "observability", "request-log-worker.ts"), + upstreamHeaderSanitizerInput + ], external: nodeExternals.filter((moduleName) => moduleName !== "electron"), format: "cjs", legalComments: "none", logLevel: "info", minify: mode === "production", - outdir: mainOutDir, + outdir: cliMainOutDir, platform: "node", - plugins: [forbidCliElectronPlugin(), ...plugins], + plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins], + sourcemap: mode !== "production", + target: "node22" + }; +} + +export function createCoreServerBuildOptions({ mode = "production", plugins = [] } = {}) { + return { + absWorkingDir: projectRoot, + bundle: true, + entryNames: "[name]", + entryPoints: [ + path.join(coreSourceRoot, "entrypoints", "server.ts"), + path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), + path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), + path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"), + path.join(coreSourceRoot, "observability", "request-log-worker.ts"), + upstreamHeaderSanitizerInput + ], + external: nodeExternals.filter((moduleName) => moduleName !== "electron"), + format: "cjs", + legalComments: "none", + logLevel: "info", + minify: mode === "production", + outdir: coreMainOutDir, + platform: "node", + plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins], sourcemap: mode !== "production", target: "node22" }; @@ -182,7 +316,7 @@ export function createRendererBuildOptions({ mode = "production", plugins = [] } minify: mode === "production", outfile: path.join(rendererAssetsDir, "main.js"), platform: "browser", - plugins: [rendererAliasPlugin(), ...plugins], + plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins], publicPath: "../../assets", sourcemap: mode !== "production", target: "chrome120" @@ -209,19 +343,40 @@ export function createWebClientBridgeBuildOptions({ mode = "production", plugins return { absWorkingDir: projectRoot, bundle: true, - entryPoints: [path.join(projectRoot, "src", "main", "web-client-bridge.ts")], + entryPoints: [path.join(uiSourceRoot, "web-client-bridge.ts")], format: "iife", legalComments: "none", logLevel: "info", minify: mode === "production", outfile: webClientBridgeOutput, platform: "browser", - plugins, + plugins: [packageAliasPlugin(), ...plugins], sourcemap: mode !== "production", target: "chrome120" }; } +export function createBotGatewaySdkBuildOptions({ mode = "production", plugins = [] } = {}) { + return { + absWorkingDir: projectRoot, + bundle: true, + entryPoints: [botGatewaySdkEntryInput], + external: [ + ...builtinModules, + ...builtinModules.map((moduleName) => `node:${moduleName}`) + ], + format: "esm", + legalComments: "none", + logLevel: "info", + minify: mode === "production", + outfile: electronBotGatewaySdkEntryOutput, + platform: "node", + plugins, + sourcemap: mode !== "production", + target: "node22" + }; +} + export function watchPlugin(name, onEnd) { return { name: `${name}-watch`, @@ -238,11 +393,38 @@ export function watchPlugin(name, onEnd) { export async function buildMain(options = {}) { const [mainBuildResult] = await Promise.all([ esbuild.build(createMainBuildOptions(options)), - esbuild.build(createCliBuildOptions(options)) + buildBotGatewaySdkRuntime(options), + buildCoreServer(options), + buildCli(options) ]); + copyCliRuntimeToElectronDist(); validateLightweightMcpBundles(mainBuildResult.metafile); } +export async function buildBotGatewaySdkRuntime(options = {}) { + ensureDist(); + await esbuild.build(createBotGatewaySdkBuildOptions(options)); + writeFileSync( + electronBotGatewaySdkPackageOutput, + `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, + "utf8" + ); + writeFileSync( + electronBotGatewaySdkRunnerOutput, + normalizeDuplicateShebangs(readFileSync(botGatewaySdkRunnerInput, "utf8")), + "utf8" + ); + chmodSync(electronBotGatewaySdkRunnerOutput, 0o755); +} + +export async function buildCli(options = {}) { + await esbuild.build(createCliBuildOptions(options)); +} + +export async function buildCoreServer(options = {}) { + await esbuild.build(createCoreServerBuildOptions(options)); +} + export async function buildRenderer(options = {}) { await esbuild.build(createRendererBuildOptions(options)); } @@ -259,6 +441,14 @@ export async function buildWebClientBridge(options = {}) { await esbuild.build(createWebClientBridgeBuildOptions(options)); } +export function copyCliRuntimeToElectronDist() { + ensureDist(); + const cliRuntime = path.join(cliMainOutDir, "cli.js"); + if (existsSync(cliRuntime)) { + cpSync(cliRuntime, path.join(electronMainOutDir, "cli.js")); + } +} + export async function buildStyles({ minify = false } = {}) { ensureDist(); const args = ["-i", cssInput, "-o", cssOutput]; @@ -304,6 +494,26 @@ function rendererAliasPlugin() { }; } +function packageAliasPlugin() { + return { + name: "ccr-package-alias", + setup(build) { + build.onResolve({ filter: /^@ccr\/cli\// }, (args) => { + return { path: resolvePackageImport(cliSourceRoot, args.path.slice("@ccr/cli/".length)) }; + }); + build.onResolve({ filter: /^@ccr\/core\// }, (args) => { + return { path: resolvePackageImport(coreSourceRoot, args.path.slice("@ccr/core/".length)) }; + }); + build.onResolve({ filter: /^@ccr\/electron\// }, (args) => { + return { path: resolvePackageImport(electronSourceRoot, args.path.slice("@ccr/electron/".length)) }; + }); + build.onResolve({ filter: /^@ccr\/ui\// }, (args) => { + return { path: resolvePackageImport(uiSourceRoot, args.path.slice("@ccr/ui/".length)) }; + }); + } + }; +} + function forbidCliElectronPlugin() { return { name: "forbid-cli-electron", @@ -370,19 +580,23 @@ function normalizeBuildPath(value) { } function resolveRendererImport(importPath) { - const basePath = path.resolve(rendererRoot, importPath); + return resolvePackageImport(rendererRoot, importPath); +} + +function resolvePackageImport(rootDir, importPath) { + const packageBasePath = path.resolve(rootDir, importPath); const candidates = [ - basePath, - `${basePath}.tsx`, - `${basePath}.ts`, - `${basePath}.jsx`, - `${basePath}.js`, - `${basePath}.json`, - `${basePath}.css`, - path.join(basePath, "index.tsx"), - path.join(basePath, "index.ts"), - path.join(basePath, "index.jsx"), - path.join(basePath, "index.js") + packageBasePath, + `${packageBasePath}.tsx`, + `${packageBasePath}.ts`, + `${packageBasePath}.jsx`, + `${packageBasePath}.js`, + `${packageBasePath}.json`, + `${packageBasePath}.css`, + path.join(packageBasePath, "index.tsx"), + path.join(packageBasePath, "index.ts"), + path.join(packageBasePath, "index.jsx"), + path.join(packageBasePath, "index.js") ]; for (const candidate of candidates) { @@ -391,5 +605,5 @@ function resolveRendererImport(importPath) { } } - return basePath; + return packageBasePath; } diff --git a/build/run-tests.mjs b/build/run-tests.mjs index a870705d..59919199 100644 --- a/build/run-tests.mjs +++ b/build/run-tests.mjs @@ -1,67 +1,98 @@ import electron from "electron"; -import { spawn } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); -const testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-test-home-")); -const testSuites = ["main", "renderer"]; -const requestedSuites = process.argv.slice(2); -const suites = requestedSuites.length === 0 ? testSuites : requestedSuites; +const testsOutDir = path.join(projectRoot, ".test-dist"); +const testProjects = { + architecture: { runtime: "node" }, + cli: { runtime: "node" }, + core: { runtime: "node-with-electron-fallback" }, + electron: { runtime: "electron" }, + ui: { runtime: "node" } +}; +const requestedProjects = process.argv.slice(2); +const projects = requestedProjects.length === 0 ? Object.keys(testProjects) : requestedProjects; -for (const suite of suites) { - if (!testSuites.includes(suite)) { - cleanup(); - throw new Error(`Unknown test suite: ${suite}`); +for (const project of projects) { + if (!testProjects[project]) { + throw new Error(`Unknown test project: ${project}`); } } -try { - for (const suite of suites) { - await runSuite(suite); +for (const project of projects) { + await runProject(project); +} + +async function runProject(project) { + const testFiles = findCompiledTests(path.join(testsOutDir, project, "test")); + if (testFiles.length === 0) { + console.log(`No ${project} tests found.`); + return; } -} finally { - cleanup(); -} -function runSuite(suite) { - console.log(`\nRunning ${suite} tests...`); + const testHome = mkdtempSync(path.join(os.tmpdir(), `ccr-${project}-test-home-`)); + const runtime = resolveRuntime(testProjects[project].runtime); + const executable = runtime === "electron" ? electron : process.execPath; + console.log(`\nRunning ${project} tests with ${runtime}...`); - return new Promise((resolve, reject) => { - const child = spawn(electron, ["--test", `dist/tests/${suite}/*.js`], { - cwd: projectRoot, - env: { - ...process.env, - CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"), - CCR_INTERNAL_HOME_DIR: testHome, - CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"), - HOME: testHome, - ELECTRON_RUN_AS_NODE: "1" - }, - shell: process.platform === "win32", - stdio: "inherit" + try { + await new Promise((resolve, reject) => { + const child = spawn(executable, ["--test", ...testFiles], { + cwd: projectRoot, + env: { + ...process.env, + CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"), + CCR_INTERNAL_HOME_DIR: testHome, + CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"), + HOME: testHome, + ...(runtime === "electron" ? { ELECTRON_RUN_AS_NODE: "1" } : {}) + }, + stdio: "inherit" + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + reject(new Error(`${project} tests exited from signal ${signal}`)); + return; + } + if (code === 0) { + resolve(); + return; + } + reject(new Error(`${project} tests exited with code ${code ?? 1}`)); + }); }); - - child.on("error", reject); - child.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - - if (code === 0) { - resolve(); - return; - } - - reject(new Error(`${suite} tests exited with code ${code ?? 1}`)); - }); - }); + } finally { + rmSync(testHome, { force: true, recursive: true }); + } } -function cleanup() { - rmSync(testHome, { force: true, recursive: true }); +function resolveRuntime(runtime) { + if (runtime !== "node-with-electron-fallback") { + return runtime; + } + const probe = spawnSync(process.execPath, [ + "-e", + "const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.close();" + ], { stdio: "ignore" }); + return probe.status === 0 ? "node" : "electron"; +} + +function findCompiledTests(dir) { + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) { + return findCompiledTests(file); + } + return entry.isFile() && entry.name.endsWith(".test.js") ? [file] : []; + }).sort(); } diff --git a/build/test.mjs b/build/test.mjs index 8319a300..445e3b4b 100644 --- a/build/test.mjs +++ b/build/test.mjs @@ -5,41 +5,82 @@ import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); -const testsOutDir = path.join(projectRoot, "dist", "tests"); -const rendererRoot = path.join(projectRoot, "src", "renderer"); -const testSuites = [ - { name: "main", testDir: path.join(projectRoot, "tests", "main") }, - { name: "renderer", testDir: path.join(projectRoot, "tests", "renderer") } -]; -const requestedSuites = new Set(process.argv.slice(2)); -const suiteNames = new Set(testSuites.map((suite) => suite.name)); -const unknownSuites = [...requestedSuites].filter((suite) => !suiteNames.has(suite)); -const selectedSuites = requestedSuites.size === 0 - ? testSuites - : testSuites.filter((suite) => requestedSuites.has(suite.name)); +const testsOutDir = path.join(projectRoot, ".test-dist"); +const packageRoots = { + cli: path.join(projectRoot, "packages", "cli", "src"), + core: path.join(projectRoot, "packages", "core", "src"), + electron: path.join(projectRoot, "packages", "electron", "src"), + ui: path.join(projectRoot, "packages", "ui", "src") +}; +const testProjects = { + architecture: { + testDir: path.join(projectRoot, "tests", "architecture") + }, + cli: { + runtimeEntryPoints: { + "runtime/cli": path.join(packageRoots.cli, "cli.ts") + }, + testDir: path.join(projectRoot, "packages", "cli", "test") + }, + core: { + runtimeEntryPoints: { + "runtime/fusion-vision-mcp": path.join(packageRoots.core, "mcp", "fusion-vision-mcp.ts"), + "runtime/request-log-worker": path.join(packageRoots.core, "observability", "request-log-worker.ts"), + "runtime/upstream-header-sanitizer": path.join(packageRoots.core, "gateway", "core-runtime", "upstream-header-sanitizer.ts"), + "runtime/toolhub-mcp": path.join(packageRoots.core, "mcp", "toolhub-mcp.ts") + }, + testDir: path.join(projectRoot, "packages", "core", "test") + }, + electron: { + testDir: path.join(projectRoot, "packages", "electron", "test") + }, + ui: { + testDir: path.join(projectRoot, "packages", "ui", "test") + } +}; -if (unknownSuites.length > 0) { - throw new Error(`Unknown test suite: ${unknownSuites.join(", ")}`); +const args = process.argv.slice(2); +const scopeIndex = args.indexOf("--scope"); +const scope = scopeIndex >= 0 ? args[scopeIndex + 1] : undefined; +if (scopeIndex >= 0 && !scope) { + throw new Error("--scope requires a test directory name such as unit or integration"); +} +const requestedProjects = new Set(args.filter((arg, index) => + arg !== "--scope" && (scopeIndex < 0 || index !== scopeIndex + 1) +)); +const projectNames = new Set(Object.keys(testProjects)); +const unknownProjects = [...requestedProjects].filter((project) => !projectNames.has(project)); +const selectedProjects = requestedProjects.size === 0 + ? Object.entries(testProjects) + : Object.entries(testProjects).filter(([name]) => requestedProjects.has(name)); + +if (unknownProjects.length > 0) { + throw new Error(`Unknown test project: ${unknownProjects.join(", ")}`); } -rmSync(testsOutDir, { force: true, recursive: true }); +for (const [name, project] of selectedProjects) { + const projectOutDir = path.join(testsOutDir, name); + const scopedTestDir = scope ? path.join(project.testDir, scope) : project.testDir; + const entryPoints = testEntryPoints(scopedTestDir); + if (!scope || scope === "integration") { + Object.assign(entryPoints, project.runtimeEntryPoints ?? {}); + } -for (const suite of selectedSuites) { - const entryPoints = findTestFiles(suite.testDir); - if (entryPoints.length === 0) { + rmSync(projectOutDir, { force: true, recursive: true }); + if (Object.keys(entryPoints).length === 0) { continue; } await esbuild.build({ absWorkingDir: projectRoot, bundle: true, - entryNames: "[name]", entryPoints, external: [ "better-sqlite3", "electron" ], format: "cjs", + jsx: "automatic", legalComments: "none", loader: { ".gif": "file", @@ -51,13 +92,21 @@ for (const suite of selectedSuites) { ".webp": "file" }, logLevel: "info", - outdir: path.join(testsOutDir, suite.name), + outdir: projectOutDir, platform: "node", - plugins: [rendererAliasPlugin()], + plugins: [rendererAliasPlugin(), packageAliasPlugin()], target: "node22" }); } +function testEntryPoints(testDir) { + return Object.fromEntries(findTestFiles(testDir).map((file) => { + const relative = path.relative(testDir, file).replace(/\\/g, "/"); + const outputName = `test/${relative.replace(/\.(mjs|ts|tsx)$/, "")}`; + return [outputName, file]; + })); +} + function findTestFiles(dir) { if (!existsSync(dir)) { return []; @@ -81,14 +130,29 @@ function rendererAliasPlugin() { name: "renderer-test-alias", setup(build) { build.onResolve({ filter: /^@\// }, (args) => { - return { path: resolveRendererImport(args.path.slice(2)) }; + return { path: resolvePackageImport(packageRoots.ui, args.path.slice(2)) }; }); } }; } -function resolveRendererImport(importPath) { - const basePath = path.resolve(rendererRoot, importPath); +function packageAliasPlugin() { + return { + name: "test-package-alias", + setup(build) { + build.onResolve({ filter: /^@ccr\/(cli|core|electron|ui)\// }, (args) => { + const match = args.path.match(/^@ccr\/(cli|core|electron|ui)\/(.+)$/); + if (!match) { + return undefined; + } + return { path: resolvePackageImport(packageRoots[match[1]], match[2]) }; + }); + } + }; +} + +function resolvePackageImport(rootDir, importPath) { + const basePath = path.resolve(rootDir, importPath); const candidates = [ basePath, `${basePath}.tsx`, diff --git a/build/verify-packaged-app.cjs b/build/verify-packaged-app.cjs index 0d6371a0..ee52160d 100644 --- a/build/verify-packaged-app.cjs +++ b/build/verify-packaged-app.cjs @@ -9,6 +9,19 @@ const betterSqliteNativeRelativePath = path.join( "Release", "better_sqlite3.node" ); +const betterSqlitePackageRelativePath = path.join("app.asar.unpacked", "node_modules", "better-sqlite3"); +const betterSqlitePrunablePaths = [ + "deps", + "src", + "binding.gyp", + "README.md", + "docs", + "benchmark", + "benchmarks", + "test", + path.join("build", "Release", "obj"), + path.join("build", "Release", "obj.target") +]; module.exports = async function verifyPackagedApp(context) { const platform = context?.electronPlatformName; @@ -21,6 +34,7 @@ module.exports = async function verifyPackagedApp(context) { const resourcesDir = findResourcesDir(appOutDir, platform); assertFile(path.join(resourcesDir, "app.asar"), "Packaged app archive"); + cleanupBetterSqlitePackage(resourcesDir); const nativeModule = path.join(resourcesDir, betterSqliteNativeRelativePath); assertFile(nativeModule, "better-sqlite3 native module"); @@ -41,6 +55,13 @@ module.exports = async function verifyPackagedApp(context) { } }; +function cleanupBetterSqlitePackage(resourcesDir) { + const packageDir = path.join(resourcesDir, betterSqlitePackageRelativePath); + for (const relativePath of betterSqlitePrunablePaths) { + fs.rmSync(path.join(packageDir, relativePath), { force: true, recursive: true }); + } +} + function findResourcesDir(appOutDir, platform) { if (platform !== "darwin") { return path.join(appOutDir, "resources"); diff --git a/components.json b/components.json index e6f7108c..a1acbd12 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/renderer/styles/globals.css", + "css": "packages/ui/src/styles/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d4a09163 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + ccr: + build: + context: . + dockerfile: Dockerfile + image: claude-code-router:local + ports: + # Publish only Nginx. Internal web/gateway listeners stay inside the container. + - "3458:8080" + volumes: + - ccr-data:/data + restart: unless-stopped + +volumes: + ccr-data: diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..7a929ffe --- /dev/null +++ b/docker/README.md @@ -0,0 +1,339 @@ +# Docker Deployment + +[中文说明](#中文说明) · [Project documentation](https://ccrdesk.top/en/) · [GitHub](https://github.com/musistudio/claude-code-router) + +The Docker image runs the CCR core server under PM2 and serves the built management UI through Nginx. Nginx is the only public container entrypoint: the browser UI, management RPC, gateway API, and health route all share one published port. + +The image is intended for a persistent gateway and browser-based administration. It does not include Electron, the npm `ccr` command, system tray features, desktop Agent/App launching, automatic desktop updates, or desktop-only browser integrations. + +## Architecture And Ports + +```text +host:3458 -> container Nginx:8080 + |-> static management UI + |-> management RPC: 127.0.0.1:3459 + |-> gateway: 127.0.0.1:3456 + `-> gateway core: 127.0.0.1:3457 +``` + +Only Nginx port `8080` should be published. The three internal ports are container implementation details and should not be exposed individually. + +Nginx routes: + +| Public route | Purpose | +| --- | --- | +| `/` and `/pages/home/index.html` | Browser management UI. `/` redirects to a URL containing the management token. | +| `/api/ccr/rpc` | Authenticated management RPC. | +| `/health` | Gateway health, not container/UI health. It can return `502` until a provider and model are configured and the gateway starts. | +| `/v1/*`, `/v1beta/*`, `/messages`, `/chat/completions`, `/responses`, `/interactions`, `/mcp/*` | Supported model and MCP gateway requests. | + +## Quick Start With Compose + +From the repository root: + +```sh +docker compose up -d --build +docker compose logs -f ccr +``` + +Open . On a new volume, the management UI is immediately available. Add a provider and model, create a CCR client key under **API Keys**, and start the gateway from **Server**. + +The repository Compose file publishes `3458:8080`, stores data in the `ccr-data` named volume, and restarts the service unless explicitly stopped. A mapping without a host IP binds on every host interface. For local-only access, change it to: + +```yaml +ports: + - "127.0.0.1:3458:8080" +``` + +Stop or remove the container without deleting its named volume: + +```sh +docker compose stop +docker compose down +``` + +Do not add `--volumes` to `docker compose down` unless you intentionally want to delete all persisted CCR data. + +## `docker run` + +Build and run without Compose: + +```sh +docker build -t claude-code-router:local . +docker run -d \ + --name claude-code-router \ + --restart unless-stopped \ + -p 127.0.0.1:3458:8080 \ + -e CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 \ + -v ccr-data:/data \ + claude-code-router:local +``` + +Equivalent repository scripts are available: + +```sh +npm run docker:build +npm run docker:run +``` + +`npm run docker:run` uses port `3458` and the `ccr-data` volume, but runs an ephemeral container without a fixed name or restart policy. + +## Authentication And Network Security + +There are two independent authentication layers: + +1. `CCR_WEB_AUTH_TOKEN` protects management RPC. Nginx puts it into the management-page URL, and the browser sends it to RPC as `x-ccr-web-auth`. +2. CCR client API keys created in the **API Keys** page protect model gateway requests. These are separate from upstream provider credentials. + +If `CCR_WEB_AUTH_TOKEN` is unset, the entrypoint generates a new random token on each container start. Opening `/` still works because Nginx redirects to a tokenized URL, but a stable token is recommended for persistent or remote deployments. + +Avoid putting the token directly in shell history. Create a protected environment file instead: + +```dotenv +CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value +CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 +``` + +Then use it with `docker run --env-file` or map the same variables under the Compose service's `environment` section. Keep this file out of version control. + +Security guidance: + +- Bind the published port to `127.0.0.1` unless LAN or remote access is intentional. +- Never expose the management UI over untrusted networks without TLS, a firewall/private network, and a fixed strong management token. +- Treat tokenized management URLs as secrets; URLs may be recorded in browser history, proxy logs, screenshots, and support tickets. +- Create scoped CCR client API keys before exposing gateway routes. Do not reuse upstream provider credentials as client keys. +- Protect `/data` and its backups because they contain configuration, provider credentials, CCR client keys, request data, and generated certificates. + +## Changing The Public Address + +The host-facing URL is separate from the container's internal ports. Whenever the host port, hostname, or scheme changes, set `CCR_PUBLIC_BASE_URL` to the exact URL clients should use: + +```yaml +services: + ccr: + ports: + - "127.0.0.1:8088:8080" + environment: + CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088 + CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN} +``` + +`CCR_PUBLIC_BASE_URL` is written to CCR's public router endpoint. It does not publish a Docker port by itself. + +For a reverse proxy or ingress that terminates HTTPS: + +```yaml +services: + ccr: + ports: + - "127.0.0.1:3458:8080" + environment: + CCR_PUBLIC_BASE_URL: https://ccr.example.com + CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN} +``` + +Proxy all paths to Nginx and preserve streaming. The external proxy should allow long-lived responses and should not buffer SSE/model streams. Keep the host port private when the reverse proxy is the public entrypoint. + +## Persistent Data + +The entrypoint sets `HOME=/data`, so CCR stores files under: + +```text +/data/.claude-code-router/ +├── config.sqlite +├── gateway.config.json +├── app-data/ +│ ├── api-keys.sqlite +│ ├── request-logs.sqlite +│ ├── usage.sqlite +│ └── certs/ +├── profiles/ +└── bin/ +``` + +Use a named volume unless a bind mount is operationally required. Bind mounts must be writable by the container and should not be shared by two running CCR containers. + +The first-run bootstrap writes a minimal legacy `config.json` only when neither `config.json` nor `config.sqlite` exists. When the UI saves current settings, SQLite becomes authoritative. By default, every container start also synchronizes the stored gateway listener and `routerEndpoint` to the Docker public endpoint. + +## Backup And Restore + +The safest application-level backup is **Settings → Export data**. For a full volume backup, stop writes before copying the data directory: + +```sh +docker compose stop ccr +docker compose cp ccr:/data/. ./ccr-data-backup/ +docker compose start ccr +``` + +Keep the backup private. It contains secrets and may include request/response data. + +For a full restore, use a new empty volume or empty `/data` directory, copy the backup contents into it while the CCR container is stopped, then start the container. Do not overlay an old backup onto a populated live volume: stale SQLite WAL/SHM files and newer runtime files can produce an inconsistent result. Make a second backup before replacing existing data. + +## Upgrade And Rollback + +Back up `/data`, update the source revision, rebuild with fresh base layers, and recreate the service: + +```sh +git pull +docker compose build --pull +docker compose up -d +docker compose ps +docker compose logs --tail=200 ccr +``` + +Configuration migrations run against the persistent data. To roll back, use the previous image/source revision together with a backup created before the upgrade; do not assume a newer database can always be read by an older build. + +## Environment Variables + +Most deployments should set only `CCR_WEB_AUTH_TOKEN`, `CCR_PUBLIC_BASE_URL`, and the Docker port mapping. Internal listener values normally should remain unchanged. + +| Variable | Default | Description | +| --- | --- | --- | +| `CCR_WEB_AUTH_TOKEN` | Random per container start | Management UI/RPC token. Set a stable strong value for persistent or remote use. | +| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Exact public gateway/UI base URL written into CCR configuration. Overrides `CCR_PUBLIC_HOST` and `CCR_PUBLIC_PORT`. | +| `CCR_PUBLIC_HOST` | `127.0.0.1` | Used only to derive `CCR_PUBLIC_BASE_URL` when the full URL is unset; it does not change Docker port publishing. | +| `CCR_PUBLIC_PORT` | `3458` | Used only to derive `CCR_PUBLIC_BASE_URL` when the full URL is unset. | +| `CCR_DATA_DIR` | `/data` | Container data root and process `HOME`. Mount persistent storage here. | +| `CCR_NGINX_PORT` | `8080` | Container-private Nginx listen port. Match the container side of the published mapping if changed. | +| `CCR_WEB_HOST` | `127.0.0.1` | Container-private management server host. | +| `CCR_WEB_PORT` | `3459` | Container-private management server port. | +| `CCR_GATEWAY_HOST` | `127.0.0.1` | Container-private gateway listener host. | +| `CCR_GATEWAY_PORT` | `3456` | Container-private gateway listener port used by Nginx. | +| `CCR_GATEWAY_CORE_PORT` | `3457` | Container-private core gateway runtime port. | +| `CCR_NO_GATEWAY` | `0` | Set to `1`, `true`, or `yes` to run the management UI without starting the gateway at boot. | +| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable minimal first-run `config.json` bootstrap. | +| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Set to `0` to stop startup from syncing existing JSON/SQLite listener and public endpoint fields to Docker values. | + +Changing internal ports requires corresponding Nginx/PM2 variables and offers no benefit in normal deployments. Publish only `CCR_NGINX_PORT`. + +## Build Options And Smoke Test + +The Dockerfile builds native dependencies with `node:22-bookworm`, then copies production dependencies and built assets into `node:22-bookworm-slim`. Override the base images when required: + +```sh +docker build \ + --build-arg NODE_IMAGE=node:22-bookworm \ + --build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \ + -t claude-code-router:local . +``` + +Run the isolated Docker smoke test: + +```sh +npm run test:docker +``` + +The test builds the image, starts a temporary container and volume, verifies that only Nginx is published, checks UI/RPC authentication, tests public-endpoint migration, starts a configured gateway, checks `/health`, and removes its resources. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an existing image or `CCR_DOCKER_TEST_IMAGE` to test a different local tag. + +## Operations And Troubleshooting + +Useful commands: + +```sh +docker compose ps +docker compose logs -f ccr +docker compose restart ccr +docker compose config +``` + +### `/` returns `302` + +This is expected. Nginx redirects the root URL to the management page and URL-encodes the management token. + +### `/health` returns `502` + +`/health` checks the model gateway, not Nginx or the management UI. On a fresh volume it returns `502` until a provider/model exists and the gateway has started. Use `docker compose ps` for container health and open the UI to configure/start the gateway. + +### The UI returns `401` after a token change + +Open the bare root URL again so Nginx creates a URL with the current token. Close stale tabs and avoid bookmarks that contain an old `ccr_web_token`. + +### Clients still use the old port or hostname + +Update `CCR_PUBLIC_BASE_URL` and recreate the container. Leave `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT=1` so existing SQLite configuration is synchronized at startup. + +### Configuration disappears after recreation + +Confirm that `/data` is mounted and that the same named volume or bind-mount path is being reused. `docker compose down` keeps named volumes; `docker compose down --volumes` deletes them. + +### A bind mount fails with permission errors + +Verify that the host directory exists, is writable by the container, and is not mounted read-only. Named volumes avoid most host ownership and labeling issues. + +### The container is healthy but model requests fail + +Container health only verifies Nginx/UI reachability. Check **Server** status, provider connectivity, CCR client-key authentication, routing, and request logs. Then inspect `docker compose logs --tail=200 ccr` for startup or runtime errors. + +--- + +## 中文说明 + +Docker 镜像通过 PM2 运行 CCR Core,并由 Nginx 同时提供管理 UI、管理 RPC、模型网关和健康检查。对外只应发布 Nginx 的容器端口 `8080`;`3459`、`3456`、`3457` 都是容器内部实现端口,不应单独暴露。 + +这个镜像面向常驻网关和浏览器管理,不包含 Electron、npm 的 `ccr` 命令、系统托盘、桌面 Agent/App 启动、桌面自动更新和桌面专属浏览器集成。 + +### 快速启动 + +```sh +docker compose up -d --build +docker compose logs -f ccr +``` + +打开 。首次启动时管理 UI 可以立即访问;添加供应商和模型、在 **API 密钥** 页面创建 CCR 客户端 Key,然后从 **服务** 页面启动网关。 + +仓库默认映射是 `3458:8080`,会监听宿主机所有网卡。如果只允许本机访问,请改为: + +```yaml +ports: + - "127.0.0.1:3458:8080" +``` + +### 鉴权与远程访问 + +- `CCR_WEB_AUTH_TOKEN` 用于管理 UI / RPC;不设置时,每次容器启动都会生成新的随机 Token。 +- **API 密钥** 页面创建的 CCR 客户端 Key 用于模型网关请求。 +- 上游供应商凭据是第三类凭据,不应拿来代替 CCR 客户端 Key。 + +根路径会重定向到包含 `ccr_web_token` 的管理 URL。请把该 URL 当作密码。远程部署至少应使用固定强 Token、TLS、主机防火墙或私网,并让反向代理把全部路径转发到 Nginx。流式响应和 SSE 不应被代理缓冲。 + +外部端口、域名或协议变化时,必须同步设置公开地址: + +```yaml +environment: + CCR_PUBLIC_BASE_URL: https://ccr.example.com + CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN} +``` + +`CCR_PUBLIC_BASE_URL` 只负责写入客户端应使用的公开地址,不会自动发布 Docker 端口。 + +### 数据、备份与升级 + +数据实际位于 `/data/.claude-code-router/`,其中包括 `config.sqlite`、`app-data/`、Agent 配置和生成文件。优先使用命名卷,不要让两个运行中的 CCR 容器共享同一个数据目录。 + +完整文件备份前先停止写入: + +```sh +docker compose stop ccr +docker compose cp ccr:/data/. ./ccr-data-backup/ +docker compose start ccr +``` + +备份包含密钥和请求数据,必须按敏感数据保存。恢复时应复制到新的空卷或空 `/data`,不要把旧备份覆盖到仍有新数据的目录。升级前先备份,然后执行: + +```sh +git pull +docker compose build --pull +docker compose up -d +docker compose ps +docker compose logs --tail=200 ccr +``` + +### 常见排查 + +- `/` 返回 `302`:正常,Nginx 正在跳转到带管理 Token 的页面。 +- `/health` 返回 `502`:它检查的是模型网关;首次启动尚未配置模型时属于预期行为。 +- 修改 Token 后 UI 返回 `401`:重新打开不带参数的根地址,关闭仍使用旧 Token 的标签页。 +- 重建后配置消失:检查是否仍挂载同一个 `/data` 卷;`docker compose down --volumes` 会删除数据卷。 +- 容器健康但模型请求失败:继续检查服务状态、供应商连通性、CCR 客户端 Key、路由和请求日志;容器健康只表示 Nginx / UI 可访问。 + +完整的环境变量、端口拓扑、远程部署、构建参数和烟雾测试说明见本页英文主体,对应变量名和命令在中英文环境中完全相同。 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 00000000..c4b89830 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,207 @@ +#!/bin/sh +set -eu + +CCR_DATA_DIR="${CCR_DATA_DIR:-/data}" +CCR_WEB_HOST="${CCR_WEB_HOST:-127.0.0.1}" +CCR_WEB_PORT="${CCR_WEB_PORT:-3459}" +CCR_NGINX_PORT="${CCR_NGINX_PORT:-8080}" +CCR_GATEWAY_HOST="${CCR_GATEWAY_HOST:-127.0.0.1}" +CCR_GATEWAY_PORT="${CCR_GATEWAY_PORT:-3456}" +CCR_GATEWAY_CORE_PORT="${CCR_GATEWAY_CORE_PORT:-3457}" +CCR_PUBLIC_HOST="${CCR_PUBLIC_HOST:-127.0.0.1}" +CCR_PUBLIC_PORT="${CCR_PUBLIC_PORT:-3458}" +CCR_PUBLIC_BASE_URL="${CCR_PUBLIC_BASE_URL:-http://${CCR_PUBLIC_HOST}:${CCR_PUBLIC_PORT}}" +CCR_NO_GATEWAY="${CCR_NO_GATEWAY:-0}" + +if [ -z "${CCR_WEB_AUTH_TOKEN:-}" ]; then + CCR_WEB_AUTH_TOKEN="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")" +fi +CCR_WEB_AUTH_TOKEN_QUERY="$(node -e "process.stdout.write(encodeURIComponent(process.argv[1] || ''))" "${CCR_WEB_AUTH_TOKEN}")" + +export HOME="${CCR_DATA_DIR}" +export CCR_DATA_DIR +export CCR_GATEWAY_CORE_PORT +export CCR_GATEWAY_HOST +export CCR_GATEWAY_PORT +export CCR_NGINX_PORT +export CCR_NO_GATEWAY +export CCR_PUBLIC_BASE_URL +export CCR_PUBLIC_HOST +export CCR_PUBLIC_PORT +export CCR_WEB_AUTH_TOKEN +export CCR_WEB_AUTH_TOKEN_QUERY +export CCR_WEB_HOST +export CCR_WEB_PORT + +CONFIG_DIR="${HOME}/.claude-code-router" +CONFIG_FILE="${CONFIG_DIR}/config.json" +APP_CONFIG_DB_FILE="${CONFIG_DIR}/config.sqlite" + +mkdir -p "${CONFIG_DIR}" "${CONFIG_DIR}/app-data" /run/nginx /var/lib/nginx /var/log/nginx + +if [ "${CCR_DOCKER_INIT_CONFIG:-1}" != "0" ] && [ ! -f "${CONFIG_FILE}" ] && [ ! -f "${APP_CONFIG_DB_FILE}" ]; then + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const configDir = path.join(process.env.HOME, ".claude-code-router"); +const configFile = path.join(configDir, "config.json"); +const gatewayHost = process.env.CCR_GATEWAY_HOST || "0.0.0.0"; +const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456"); +const gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457"); +const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, ""); + +fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); +fs.writeFileSync(configFile, `${JSON.stringify({ + HOST: gatewayHost, + PORT: gatewayPort, + gateway: { + coreHost: "127.0.0.1", + corePort: gatewayCorePort, + enabled: true, + host: gatewayHost, + port: gatewayPort + }, + routerEndpoint: publicBaseUrl +}, null, 2)}\n`, { mode: 0o600 }); +NODE +fi + +if [ "${CCR_DOCKER_SYNC_PUBLIC_ENDPOINT:-1}" != "0" ]; then + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const configDir = path.join(process.env.HOME, ".claude-code-router"); +const configFile = path.join(configDir, "config.json"); +const appConfigDbFile = path.join(configDir, "config.sqlite"); +const gatewayHost = process.env.CCR_GATEWAY_HOST || "127.0.0.1"; +const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456"); +const gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457"); +const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, ""); + +function syncConfig(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + value.HOST = gatewayHost; + value.PORT = gatewayPort; + value.gateway = { + ...(value.gateway && typeof value.gateway === "object" && !Array.isArray(value.gateway) ? value.gateway : {}), + coreHost: "127.0.0.1", + corePort: gatewayCorePort, + enabled: true, + host: gatewayHost, + port: gatewayPort + }; + value.routerEndpoint = publicBaseUrl; + return value; +} + +function syncJsonFile() { + if (!fs.existsSync(configFile)) { + return; + } + const parsed = JSON.parse(fs.readFileSync(configFile, "utf8")); + fs.writeFileSync(configFile, `${JSON.stringify(syncConfig(parsed), null, 2)}\n`, { mode: 0o600 }); +} + +function syncSqliteConfig() { + if (!fs.existsSync(appConfigDbFile)) { + return; + } + let Database; + try { + Database = require("better-sqlite3"); + } catch { + return; + } + const db = new Database(appConfigDbFile); + try { + const row = db.prepare("select value_json from app_config where key = ?").get("default"); + if (!row?.value_json) { + return; + } + const parsed = JSON.parse(row.value_json); + db.prepare("update app_config set value_json = ?, updated_at = ? where key = ?") + .run(JSON.stringify(syncConfig(parsed)), new Date().toISOString(), "default"); + } finally { + db.close(); + } +} + +syncJsonFile(); +syncSqliteConfig(); +NODE +fi + +cat > /etc/nginx/conf.d/default.conf < heading.depth === 2); +const tocHeadings = allHeadings.filter((heading) => heading.depth >= 2 && heading.depth <= 5); const isExplicitHref = (value: string) => value.startsWith("#") || value.startsWith("/") || @@ -155,9 +155,10 @@ const sidebarTree = sidebarNavItems.map((navItem, index) => { })), }; }); -const tocItems = headings.map((heading) => ({ +const tocItems = tocHeadings.map((heading) => ({ label: heading.text, href: `#${heading.slug}`, + depth: heading.depth, })); const pageMarkdown = doc.rawContent(); --- diff --git a/docs/src/content/docs/en/configuration.md b/docs/src/content/docs/en/configuration.md index 36f1c24b..2294e3a5 100644 --- a/docs/src/content/docs/en/configuration.md +++ b/docs/src/content/docs/en/configuration.md @@ -2,29 +2,37 @@ title: Claude Code Router Detailed Configuration pageTitle: Detailed Configuration eyebrow: Detailed Configuration -lead: Configure the overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and the config database location in detail. +lead: "Separate main app pages from settings pages while following the app's actual order: main pages cover overview, providers, Agent Config, routing, Fusion, API keys, logs and observability, server, and extensions; settings pages cover ToolHub, Bots, data, and tray." --- ## Page Structure -Detailed configuration docs are split into standalone pages. Every left-sidebar item opens a page; the right outline is reserved for headings inside the current page. +Detailed configuration docs are split into standalone pages. Every left-sidebar item opens a page; the right outline is reserved for headings inside the current page. Main pages follow the app's left navigation order. Settings pages are grouped separately and follow the settings dialog order. + +## Main Pages | Page | Covers | | --- | --- | | Overview Dashboard | System status, account balance, usage widgets, layout editing, and share cards | -| API Keys | Client access keys, expiration, and local limits | -| Server | Host, port, proxy mode, system proxy, network capture, and CA certificate | | Provider Config | Upstream services, protocol, Base URL, model list, and credentials | | One click import | Provider deeplink protocol, manifest import, one-click import buttons, and security boundaries | -| Routing Config | Default routing, conditional rules, fallback, and request rewrites | -| Logs & Observability | Request logs, Agent execution traces, tool calls, and tool results | -| Fusion Models | Combine a base model with vision, search, or MCP tools into a new selectable model | | Agent Config | Agent launch method, model, scope, multi-instance launching, and Bot binding | +| Routing Config | Default routing, conditional rules, fallback, and request rewrites | +| Fusion Models | Combine a base model with vision, search, or MCP tools into a new selectable model | +| API Keys | Client access keys, expiration, and local limits | +| Logs & Observability | Request logs, Agent execution traces, tool calls, and tool results | +| Server | Host, port, proxy mode, system proxy, network capture, and CA certificate | | Extension Mechanism | Wrapper plugins, core gateway plugins, custom extension creation, and debugging | + +## Settings Pages + +| Page | Covers | +| --- | --- | +| ToolHub | Collapse many MCP servers into one dynamic tool resolution entry point for agents | | Bots And IM Agent Relay | Bot forwarding, handoff mode, and platform pages | -| Tray Configuration | Tray icon, balance progress, and tray window widgets | | Config Database Location | SQLite config database location maintained by the desktop app | +| Tray Configuration | Tray icon, balance progress, and tray window widgets | ## Content Relationships -Overview Dashboard shows system status and usage. API Keys control client access to CCR. Server controls the local gateway listener and proxy features. Provider Config and One click import cover how upstream model services enter CCR. Routing determines where model requests go. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Fusion covers vision, web search, and MCP tools. Extension Mechanism covers local plugin creation, installation, and debugging. Bots cover IM platform relay. Tray Configuration covers the menu bar icon and tray window. +Overview Dashboard shows system status and usage. Provider Config and One click import cover how upstream model services enter CCR. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Routing determines where model requests go. Fusion covers vision, web search, and MCP tools. API Keys control client access to CCR. Logs & Observability cover request logs and agent execution traces. Server controls the local gateway listener and proxy features. Extension Mechanism covers local plugin creation, installation, and debugging. ToolHub, Bots, Config Database Location, and Tray Configuration match the corresponding settings pages in the settings dialog. diff --git a/docs/src/content/docs/en/configuration/api-keys.md b/docs/src/content/docs/en/configuration/api-keys.md index 766e5889..a6a3ef01 100644 --- a/docs/src/content/docs/en/configuration/api-keys.md +++ b/docs/src/content/docs/en/configuration/api-keys.md @@ -5,12 +5,6 @@ eyebrow: Detailed Configuration lead: Manage API keys that clients use to access the CCR gateway, with expiration and local limits. --- -## Basic Concept - -The API Keys page manages client access keys for CCR. These keys are different from provider `API key` values and provider credential pools: API Keys control who can call CCR; provider keys control how CCR calls upstream providers. - -When API keys are configured, clients should send `Authorization: Bearer ` or `x-api-key: `. - ## List Fields | Field | Capability | diff --git a/docs/src/content/docs/en/configuration/bot-setup.md b/docs/src/content/docs/en/configuration/bot-setup.md index ef33824e..c21e9a63 100644 --- a/docs/src/content/docs/en/configuration/bot-setup.md +++ b/docs/src/content/docs/en/configuration/bot-setup.md @@ -11,8 +11,9 @@ lead: Add a Bot, bind it to Agent Config, and choose message forwarding or hando 2. Select a platform and fill in the required token, secret, signing secret, robot code, or OAuth fields. 3. Save the Bot. 4. Open the target **Agent Config** and enable **Bot**. -5. Choose **Forward agent messages** or **Handoff**, then reopen the agent from CCR. +5. Configure forwarding, handoff, language, timeout, attachments, streaming, and **Allow Agent shell tools** as needed. +6. Reopen Claude, Codex, ZCode, or OpenCode App from CCR. The Bot is online only while the App is alive. ## Verification -Open the agent from CCR and send a test message. Confirm that Logs include Bot records and the IM side receives the message. +Open the Agent App from CCR, then send `/project current`, `/session list`, and one plain message from IM. The Profile card shows connection state, the last event and delivery, pending outbox count, and redacted errors; `/session doctor` provides the same runtime diagnostics. Closing the App should move the Bot to offline state. diff --git a/docs/src/content/docs/en/configuration/bots.md b/docs/src/content/docs/en/configuration/bots.md index 27a1f136..1d4ae4b3 100644 --- a/docs/src/content/docs/en/configuration/bots.md +++ b/docs/src/content/docs/en/configuration/bots.md @@ -5,15 +5,61 @@ eyebrow: Detailed Configuration lead: Forward agent messages to instant-messaging platforms or hand off active work after desktop idle. --- -## What Bots Do - -Bots forward agent messages to instant-messaging platforms and can hand off active work after the desktop has been idle. +CCR App Relay shares the lifecycle of an Agent App opened by CCR. Opening Claude, Codex, ZCode, or OpenCode App starts its companion worker; closing the App stops both the worker and Bot connection. ## Common Modes - **Forward agent messages**: mirror agent messages into IM. - **Handoff**: relay the interaction into IM after desktop idle. +- **Reply only**: with forwarding and handoff disabled, reply only to turns initiated from IM. + +Natural-language turns are serialized per IM conversation. `/project`, `/session status`, and `/session cancel` remain immediately responsive; queueing, timeout, cancellation, and worker-restart recovery have explicit states. + +## Projects And Sessions + +A Project is an Agent-native project or working directory, and a Session is an Agent-native conversation inside that Project. + +### Project Commands + +| Command | Purpose | +| --- | --- | +| `/project` | Show Project help and the App-online boundary. | +| `/project list [page]` | List known Agent projects with pagination. | +| `/project find ` | Search project names and paths. | +| `/project current` | Show the current Project. | +| `/project use ` | Change Project and clear the previous Session selection. | +| `/project name