diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..86b7318 --- /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 291dafe..6efc5ae 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,8 @@ release tmp release-local logs -.opencat \ No newline at end of file +.opencat +test-results +playwright-report +blob-report +.tmp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c0c868 --- /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 3f5ce1e..f32ddf1 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,52 @@ -

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 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. +

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. - ## Why Use CCR - Use one local endpoint for multiple agent tools instead of configuring every client separately. @@ -176,6 +209,36 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co TeamoRouter + + + code0.ai icon +
+ code0.ai +
+ + + + claudeapi icon +
+ claudeapi +
+ + + + + + Qiniu Cloud AI icon +
+ Qiniu Cloud AI +
+ + + + Fenno.ai icon +
+ Fenno.ai +
+ diff --git a/README_zh.md b/README_zh.md index cd79161..7065c54 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,19 +1,52 @@ -

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 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 +

Claude Code Router Desktop 项目截图

-Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 - ## 为什么使用 CCR - 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 @@ -175,6 +208,36 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 TeamoRouter + + + code0.ai 图标 +
+ code0.ai +
+ + + + claudeapi 图标 +
+ claudeapi +
+ + + + + + 七牛云 AI 图标 +
+ 七牛云 AI +
+ + + + Fenno.ai 图标 +
+ Fenno.ai +
+ diff --git a/build/build.mjs b/build/build.mjs index a07412b..ba43565 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 a7cf8fd..b91a341 100644 --- a/build/dev.mjs +++ b/build/dev.mjs @@ -5,12 +5,14 @@ import { spawn } from "node:child_process"; import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs"; import path from "node:path"; import { - binPath, buildStyles, cleanDist, browserRendererHtmlInput, + cliSourceRoot, + coreSourceRoot, copyAppAssets, copyBrowserRendererHtml, + copyCliRuntimeToElectronDist, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, @@ -21,12 +23,12 @@ import { createRendererBuildOptions, createTrayRendererBuildOptions, createWebClientBridgeBuildOptions, - cssInput, - cssOutput, appAssetsInput, modelCatalogInput, projectRoot, + rendererRoot, rendererHtmlInput, + syncUiRendererToRuntimeDists, trayRendererHtmlInput, watchPlugin } from "./esbuild.config.mjs"; @@ -37,7 +39,12 @@ 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 +53,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,6 +90,7 @@ function relativePath(file) { function readyState() { return Object.entries(ready) + .filter(([name]) => activeReadyNames.has(name)) .map(([name, value]) => `${name}:${value ? "ready" : "pending"}`) .join(" "); } @@ -163,7 +197,63 @@ function handleWatchedInput(label, watchedPath, eventType, filename, options, on } onChange(); - scheduleRestart(reason); + if (enabled.electron && options?.restart !== false) { + scheduleRestart(reason); + } +} + +function scheduleStyleBuild(reason) { + queuedStyleBuildReason = reason; + if (styleBuildTimer) { + clearTimeout(styleBuildTimer); + } + styleBuildTimer = setTimeout(() => { + styleBuildTimer = null; + void rebuildStyles(queuedStyleBuildReason ?? reason); + }, styleBuildDelayMs); +} + +async function rebuildStyles(reason) { + if (styleBuildInFlight) { + queuedStyleBuildReason = reason; + return; + } + + 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 markReady(name, reason = `${name} esbuild completed`) { @@ -171,7 +261,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); } } @@ -221,114 +311,153 @@ function restartElectron() { }); } -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)) { +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); } const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, copyRendererHtml); + handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => { + copyRendererHtml(); + syncUiRendererToRuntimeDists(); + }); }); const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, copyBrowserRendererHtml); + handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => { + copyBrowserRendererHtml(); + syncUiRendererToRuntimeDists(); + }); }); const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, copyTrayRendererHtml); + handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => { + copyTrayRendererHtml(); + syncUiRendererToRuntimeDists(); + }); }); -const appAssetsWatcher = watch(appAssetsInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets); -}); +const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs); -const modelCatalogWatcher = existsSync(modelCatalogInput) +const appAssetsWatcher = enabled.electron + ? watch(appAssetsInput, { persistent: true }, (eventType, filename) => { + handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets); + }) + : { close: () => undefined }; + +const modelCatalogWatcher = (enabled.cli || enabled.electron) && 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 contexts = []; -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() { @@ -340,13 +469,16 @@ async function shutdown() { if (electronProcess) { electronProcess.kill(); } - tailwindProcess.kill(); + if (styleBuildTimer) { + clearTimeout(styleBuildTimer); + } htmlWatcher.close(); browserHtmlWatcher.close(); trayHtmlWatcher.close(); + clearInterval(stylePoller); appAssetsWatcher.close(); modelCatalogWatcher.close(); - await Promise.all([mainContext.dispose(), cliContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose(), webClientBridgeContext.dispose()]); + 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 0000000..75d4ed1 --- /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 bcf3a1a..e4a05f6 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,14 @@ 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"); +export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.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 +88,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 +125,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 +149,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 +176,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,18 +189,38 @@ 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", "browser-web-search-proxy-mcp.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"), + electronUndiciProxyAgentInput, + path.join(electronSourceRoot, "main", "preload.ts") ], external: nodeExternals, format: "cjs", @@ -131,9 +228,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" }; @@ -144,15 +241,44 @@ 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") + ], 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") + ], + 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" }; @@ -183,7 +309,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" @@ -210,19 +336,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`, @@ -239,11 +386,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)); } @@ -260,6 +434,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]; @@ -305,6 +487,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", @@ -371,19 +573,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) { @@ -392,5 +598,5 @@ function resolveRendererImport(importPath) { } } - return basePath; + return packageBasePath; } diff --git a/build/run-tests.mjs b/build/run-tests.mjs index a870705..aaa9cfb 100644 --- a/build/run-tests.mjs +++ b/build/run-tests.mjs @@ -31,7 +31,7 @@ function runSuite(suite) { console.log(`\nRunning ${suite} tests...`); return new Promise((resolve, reject) => { - const child = spawn(electron, ["--test", `dist/tests/${suite}/*.js`], { + const child = spawn(electron, ["--test", `dist/tests/${suite}/*.test.js`], { cwd: projectRoot, env: { ...process.env, diff --git a/build/test.mjs b/build/test.mjs index 8319a30..be7b4ad 100644 --- a/build/test.mjs +++ b/build/test.mjs @@ -6,7 +6,9 @@ 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 rendererRoot = path.join(projectRoot, "packages", "ui", "src"); +const cliSourceRoot = path.join(projectRoot, "packages", "cli", "src"); +const coreSourceRoot = path.join(projectRoot, "packages", "core", "src"); const testSuites = [ { name: "main", testDir: path.join(projectRoot, "tests", "main") }, { name: "renderer", testDir: path.join(projectRoot, "tests", "renderer") } @@ -25,7 +27,10 @@ if (unknownSuites.length > 0) { rmSync(testsOutDir, { force: true, recursive: true }); for (const suite of selectedSuites) { - const entryPoints = findTestFiles(suite.testDir); + const entryPoints = [ + ...findTestFiles(suite.testDir), + ...runtimeEntryPointsForSuite(suite.name) + ]; if (entryPoints.length === 0) { continue; } @@ -53,11 +58,21 @@ for (const suite of selectedSuites) { logLevel: "info", outdir: path.join(testsOutDir, suite.name), platform: "node", - plugins: [rendererAliasPlugin()], + plugins: [rendererAliasPlugin(), packageAliasPlugin()], target: "node22" }); } +function runtimeEntryPointsForSuite(suiteName) { + if (suiteName !== "main") { + return []; + } + return [ + path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), + path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts") + ]; +} + function findTestFiles(dir) { if (!existsSync(dir)) { return []; @@ -87,8 +102,26 @@ function rendererAliasPlugin() { }; } +function packageAliasPlugin() { + return { + name: "test-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)) }; + }); + } + }; +} + function resolveRendererImport(importPath) { - const basePath = path.resolve(rendererRoot, importPath); + return resolvePackageImport(rendererRoot, importPath); +} + +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 0d6371a..ee52160 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 e6f7108..a1acbd1 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 0000000..d4a0916 --- /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 0000000..2b51a1f --- /dev/null +++ b/docker/README.md @@ -0,0 +1,91 @@ +# Docker deployment + +This image runs the core server package with PM2 and serves the built UI package +through Nginx. Nginx is the only published entrypoint: it serves the UI, proxies +management API calls to the internal core server, and proxies gateway API calls +to the internal gateway listener. + +## Build and run + +```sh +docker compose up --build +``` + +Then open: + +- Web UI: +- Gateway endpoint: + +`docker-compose.yml` publishes only Nginx (`3458:8080`). Behind Nginx, the image +runs separate container-private listeners for management RPC, API gateway +routing, and the core gateway runtime. They are implementation details and are +not published or configured by the default Compose file. + +To use a different host port, change the Compose port mapping and keep the +public router endpoint in sync: + +```yaml +services: + ccr: + ports: + - "8088:8080" + environment: + CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088 +``` + +The container stores config and SQLite databases under `/data`, backed by the +`ccr-data` volume in `docker-compose.yml`. + +On a fresh data volume, the Web UI starts immediately. The gateway endpoint is +available through the same Nginx entrypoint, but the gateway only starts after at +least one provider and model are configured. + +## Image scripts + +```sh +npm run docker:build +npm run docker:run +``` + +## Smoke test + +```sh +npm run test:docker +``` + +The smoke test builds the image, starts an isolated temporary container with a +special-character `CCR_WEB_AUTH_TOKEN`, verifies that only the Nginx port is +published, checks UI and RPC authentication, confirms legacy Docker config is +migrated to the public Nginx router endpoint, and removes its temporary +container and volume. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an already +built image. + +The Dockerfile uses `node:22-bookworm` for build and native SQLite dependency +installation, then copies the production dependencies into a smaller +`node:22-bookworm-slim` runtime image. To use different base images: + +```sh +docker build \ + --build-arg NODE_IMAGE=node:22-bookworm \ + --build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \ + -t claude-code-router:local . +``` + +## Environment + +Most deployments only need the published Nginx port mapping, `CCR_WEB_AUTH_TOKEN`, +and optionally `CCR_PUBLIC_BASE_URL` when the host-facing URL is not +`http://127.0.0.1:3458`. + +| Variable | Default | Description | +| --- | --- | --- | +| `CCR_WEB_AUTH_TOKEN` | generated | Shared management UI token used by Nginx redirects and the core server. | +| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Full public router endpoint override. Set this when changing the host-facing Compose port. | +| `CCR_DATA_DIR` | `/data` | Container data root. | +| `CCR_NO_GATEWAY` | `0` | Set to `1` to run only the Web UI management service. | +| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable first-run `config.json` bootstrap. | +| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Sync existing Docker config to the Nginx public router endpoint on startup. | + +The first-run bootstrap writes a minimal legacy `config.json` only when neither +`config.json` nor `config.sqlite` exists in the mounted data directory. Once the +UI saves settings into SQLite, existing persisted configuration takes priority. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..c4b8983 --- /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 < TeamoRouterAnthropic / Chat / Responses + + + code0.aiAnthropic / Chat / Responses + + + + claudeapiAnthropic Messages + + + + Qiniu Cloud AIChat / Responses / Anthropic / Gemini Generate + + + + Fenno.aiChat / Responses / Anthropic + ## Embeddable Button Component diff --git a/docs/src/content/docs/en/configuration/routing.md b/docs/src/content/docs/en/configuration/routing.md index a378c56..f21a521 100644 --- a/docs/src/content/docs/en/configuration/routing.md +++ b/docs/src/content/docs/en/configuration/routing.md @@ -162,7 +162,7 @@ Network errors move to the next attempt. Status-code fallback depends on the mod | Retry | `408`, `409`, `429`, `5xx` | | Fallback targets | Any `4xx` or `5xx` | -For `429` rate-limit responses, CCR waits before the next attempt. It honors `Retry-After` when the upstream provides it; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt. +Before moving to the next attempt, CCR waits for every fallback-triggering failure, including network errors. It honors a positive `Retry-After` header when the upstream provides one; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt. **Fallback targets** also switches on `4xx` because model-not-found, auth, or provider-side rejection errors may only affect the current target. If the fallback model works, the request can still succeed. diff --git a/docs/src/content/docs/en/configuration/toolhub.md b/docs/src/content/docs/en/configuration/toolhub.md new file mode 100644 index 0000000..c098456 --- /dev/null +++ b/docs/src/content/docs/en/configuration/toolhub.md @@ -0,0 +1,167 @@ +--- +title: ToolHub +pageTitle: ToolHub +eyebrow: Detailed Configuration +lead: Collapse many MCP servers into one compact entry point so agents lazy-load task-specific tools and save context. +--- + +## When To Use It + +As your MCP setup grows, exposing every tool directly to an agent makes the eager tool list large and easier to misuse. ToolHub exposes one `ccr-toolhub` MCP server with two meta tools: + +- `tool_hub.resolve`: searches the available MCP tool catalog for the current task. +- `tool_hub.invoke`: calls a real MCP tool that was selected for this task. + +Use ToolHub for tools that are not needed often but are still useful occasionally. It lazy-loads them only when a task actually needs them. The main value is saving context: large low-frequency tool catalogs do not have to stay in the agent's eager tool list, which reduces context use and the chance of selecting the wrong tool. Simple local code, file, or conversation tasks usually do not need ToolHub. + +## How It Works + +1. Enable ToolHub in **Settings → ToolHub**. +2. Select a configured model as the **Resolver model**. It reads the MCP tool catalog and chooses the tools needed for the task. Prefer `deepseek-v4-flash`, or another stable lightweight model in a similar flash-price tier. +3. Add or import backend MCP servers. ToolHub supports `stdio`, `streamable-http`, and `sse`. +4. Open Claude Code or Codex from CCR. CCR writes the `ccr-toolhub` MCP server into that agent config. +5. When the agent receives a request about external services, installed MCP capabilities, or business APIs, it calls `tool_hub.resolve` first, then uses `tool_hub.invoke` to run the selected tools. + +ToolHub combines MCP servers configured on the ToolHub page with compatible global Agent MCP servers from older configs, and excludes `ccr-toolhub` itself to avoid recursive calls. + +## Built-In Browser Automation + +When ToolHub is enabled in CCR Desktop and **Built-in browser automation** is turned on, agents can use the desktop built-in browser for web tasks. You do not need to add a browser backend on the ToolHub page, and you do not need a separate API key; CCR connects to it through the local gateway authentication path. + +To enable it: + +1. Open **Settings → ToolHub** and turn on **Enable ToolHub**. +2. Turn on **Built-in browser automation** on the same page. This switch is shown only after ToolHub is enabled. +3. After saving settings, reopen Claude Code or Codex from CCR so the new agent instance loads the latest configuration. + +> Already-running agent instances usually do not pick up this switch immediately. Restart the agent instance, or use the agent's own controls to restart ToolHub. + +Built-in browser automation is useful for tasks that need real browser state, such as opening sites, reading pages, filling forms, clicking buttons, scrolling, or completing web flows like ordering, booking, lookup, and checkout when no domain-specific capability exists. After it is enabled, the agent can: + +- Open or attach built-in browser tabs, then navigate to URLs or search queries. +- Read page content and find buttons, links, form fields, and other page elements. +- Click, type, select, press keys, and scroll page elements. +- Wait for page loads, navigation, dialogs, or human handoff results before continuing. +- Request human help for login, verification codes, CAPTCHA, human checks, or manual confirmation. + +When a web flow needs login, verification codes, CAPTCHA, a human check, or manual confirmation, CCR shows the built-in browser window and displays the requested action in the top toolbar. After the user clicks **Done** or **Hide**, the agent receives the result and continues. Handoff waits support up to 10 minutes. + +### Chrome Login Import Extension + +Built-in browser automation can also import login state for selected domains from system Chrome into CCR's in-app browser. This lets the agent reuse sites where you are already signed in to Chrome. It requires the unpacked Chrome extension in this repository: `extension/chrome`. + +Install it: + +1. Open `chrome://extensions` in Chrome. +2. Enable **Developer mode**. +3. Click **Load unpacked**. +4. Select the repository's `extension/chrome` directory. + +Import flow: + +1. When a task needs existing Chrome login state, the agent can request an import; the user can also click the key button in CCR's in-app browser toolbar. +2. CCR creates a one-time import job and opens a confirmation page. If your default browser is not Chrome, copy the confirmation URL into Chrome with the extension installed. +3. Review the requested domains on the confirmation page, then click **Confirm and Import**. +4. The Chrome extension reads cookies and localStorage for those domains and submits them to CCR. After it completes, the agent can continue the task in the built-in browser. + +The extension reads only the domains listed in the CCR import job. It does not enumerate every Chrome cookie. For localStorage, the extension temporarily opens non-active tabs for the selected origins, reads `localStorage`, and closes those tabs. If the confirmation page says the extension does not have site access, allow the extension to access the target domains in Chrome extension settings, reload the unpacked extension, and try again. + +> Note: Built-in browser automation depends on CCR Desktop's built-in browser and is only available in the desktop app. CLI, server deployments, and pure web environments do not include this built-in capability; use an external browser automation MCP server instead. + +## Options + +| Option | Description | +| --- | --- | +| Enable ToolHub | Exposes `ccr-toolhub` to agents. If no backend MCP server is available, CCR does not generate a ToolHub MCP config. | +| Built-in browser automation | Shown only after ToolHub is enabled. Lets agents use CCR Desktop's built-in browser for web tasks. | +| Resolver model | Choose from configured provider models. Prefer `deepseek-v4-flash`, or another stable lightweight model in a similar flash-price tier with enough tool-description understanding. | +| Max tools | Maximum tools returned by one resolve call. Range `1` to `20`, default `10`. | +| Timeout ms | Base timeout for ToolHub resolving and invocation. Range `8000` to `300000`, default `60000`. If a backend MCP server needs a longer request timeout, CCR raises the effective invocation timeout to match the backend. | +| MCP servers | Backend tool sources. Each server needs a unique name plus transport, command or URL, environment variables, headers, and timeouts. | +| Import JSON | Imports common MCP JSON shapes. Supports a root object, array, `mcpServers`, or `mcp_servers`. | + +## Add MCP Servers + +### stdio + +Use `stdio` for local command-line MCP servers. Configure: + +- **Command**: launch command, such as `npx`, `node`, or `python`. +- **Arguments**: command arguments. +- **Working directory**: optional working directory. +- **Stdio message mode**: keep `content-length` by default; use `newline-json` for line-delimited JSON servers. +- **Environment variables**: variables needed only by this MCP server. + +### streamable-http / sse + +Remote MCP servers need a URL. Authentication can use: + +- **API key**: stored directly in the config. +- **API key env**: read from an environment variable. +- **Headers**: custom request headers. + +If a remote server starts slowly or has long-running calls, adjust that server's **Startup timeout** or **Request timeout**. + +## JSON Example + +The desktop app's SQLite config is the effective source, so prefer editing through the UI. The fields below are useful for backups, migration, or troubleshooting: + +```json +{ + "toolHub": { + "enabled": true, + "browserAutomation": true, + "llm": { + "apiKey": "sk-...", + "baseUrl": "https://api.openai.com/v1", + "model": "gpt-5-mini" + }, + "maxTools": 10, + "requestTimeoutMs": 60000, + "mcpServers": [ + { + "name": "filesystem", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {}, + "stdioMessageMode": "content-length", + "requestTimeoutMs": 30000, + "startupTimeoutMs": 600000 + } + ] + } +} +``` + +The import dialog also accepts common MCP JSON: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } +} +``` + +## ToolHub vs Fusion MCP + +| Capability | ToolHub | Fusion Custom MCP Tool | +| --- | --- | --- | +| Entry point | Agent-side `ccr-toolhub` MCP server | Capability inside one Fusion model | +| Tool selection | Dynamically resolves a tool bundle for each task | Fixed tools selected in the model config | +| Best for | Many MCP servers, changing tool catalogs, agent-led capability discovery | Adding a known tool set to one model | +| Visibility | Claude Code or Codex configs opened through CCR | Routes or agents that select that Fusion model | + +## Troubleshooting + +- Agent cannot see ToolHub: make sure ToolHub is enabled and at least one backend MCP server is configured or **Built-in browser automation** is turned on, then reopen Claude Code or Codex from CCR. +- Missing resolver model or API key: select a configured resolver model and confirm the provider credential works. +- The agent cannot use built-in browser automation: make sure you are using CCR Desktop, turned on **Built-in browser automation** in **Settings → ToolHub**, and reopened Claude Code or Codex from CCR. CLI, server deployments, and pure web environments do not include this built-in capability. +- Chrome login import confirmation keeps waiting for the extension: make sure the unpacked `extension/chrome` extension is loaded in Chrome and has site access for the target domains. If your default browser is not Chrome, copy the confirmation URL into Chrome manually. +- No tools are resolved: confirm the MCP server can list tools, improve tool names and descriptions, or increase **Max tools**. +- Calls time out: check ToolHub **Timeout ms** and the backend server request/startup timeouts. +- Import fails: validate JSON, avoid duplicate server names, make sure `stdio` entries have a command and remote entries have a URL. diff --git a/docs/src/content/docs/zh/configuration.md b/docs/src/content/docs/zh/configuration.md index 6515d0f..c4db009 100644 --- a/docs/src/content/docs/zh/configuration.md +++ b/docs/src/content/docs/zh/configuration.md @@ -2,29 +2,37 @@ title: Claude Code Router 详细配置 pageTitle: 详细配置 eyebrow: 详细配置 -lead: 深入配置概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置。这里是按功能查字段和扩展能力的地方。 +lead: 按应用中的实际顺序,将主页页面和设置页分开说明:主页覆盖概览、供应商、Agent配置、路由、Fusion、API 密钥、日志&观测、服务和扩展;设置页覆盖 ToolHub、Bot、数据和托盘。 --- ## 页面结构 -详细配置文档已经拆成独立页面。左侧目录中的每一项都会进入一个页面;当前页面内的标题由右侧大纲负责。 +详细配置文档已经拆成独立页面。左侧目录中的每一项都会进入一个页面;当前页面内的标题由右侧大纲负责。主页页面跟随应用左侧主导航顺序;设置页单独分组,并按设置弹窗顺序排列。 + +## 主页页面 | 页面 | 内容 | | --- | --- | | 概览仪表盘 | 系统状态、账户余额、用量组件、布局编辑和分享卡片 | -| API 密钥 | 客户端访问 Key、过期时间和本地限额 | -| 服务配置 | Host、Port、代理模式、系统代理、网络捕获和 CA 证书 | | 供应商配置 | 上游服务、协议、基础 URL、模型列表和凭据 | | 一键导入供应商 | Provider deeplink 协议、Manifest 导入、一键导入按钮和安全边界 | -| 路由配置 | 条件规则、fallback 和请求改写 | -| 日志&观测 | 请求日志、Agent 执行追踪、工具调用和工具结果 | -| Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 | | Agent配置 | Agent 启动方式、模型、作用范围、多开和 Bot 绑定 | +| 路由配置 | 条件规则、fallback 和请求改写 | +| Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 | +| API 密钥 | 客户端访问 Key、过期时间和本地限额 | +| 日志&观测 | 请求日志、Agent 执行追踪、工具调用和工具结果 | +| 服务配置 | Host、Port、代理模式、系统代理、网络捕获和 CA 证书 | | 扩展机制 | Wrapper plugin、Core gateway plugin、自定义扩展创建和调试 | + +## 设置页 + +| 页面 | 内容 | +| --- | --- | +| ToolHub | 将多个 MCP server 收束成一个 Agent 可用的动态工具检索入口 | | Bot 与 IM 接力 Agent | Bot 转发、接力模式和平台页面 | -| 托盘配置 | 托盘图标、余额进度条和托盘窗口组件 | | 配置数据库位置 | 桌面 App 维护的 SQLite 配置数据库位置 | +| 托盘配置 | 托盘图标、余额进度条和托盘窗口组件 | ## 内容关系 -概览仪表盘用于查看系统状态和用量;API 密钥控制客户端访问 CCR;服务配置控制本地网关监听和代理能力;供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;路由决定模型请求的上游去向;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;Fusion 页面覆盖图像、搜索和 MCP 工具;扩展机制页面覆盖本地插件的创建、安装和调试;Bot 页面覆盖 IM 平台接力;托盘配置覆盖菜单栏图标和托盘窗口。 +概览仪表盘用于查看系统状态和用量;供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;路由决定模型请求的上游去向;Fusion 页面覆盖图像、搜索和 MCP 工具;API 密钥控制客户端访问 CCR;日志&观测覆盖请求日志和 Agent 执行链路;服务配置控制本地网关监听和代理能力;扩展机制覆盖本地插件的创建、安装和调试。ToolHub、Bot、配置数据库位置和托盘配置对应设置弹窗中的同名配置页。 diff --git a/docs/src/content/docs/zh/configuration/profile.md b/docs/src/content/docs/zh/configuration/profile.md index f5b1ffc..6a7c91c 100644 --- a/docs/src/content/docs/zh/configuration/profile.md +++ b/docs/src/content/docs/zh/configuration/profile.md @@ -24,7 +24,7 @@ lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过 | --- | --- | | 独立配置文件 | 选择“仅从 CCR 打开时生效”时,Claude Code 和 Codex 会写入 CCR 管理的独立配置目录,路径按配置 `id` 区分 | | 独立启动器 | Claude Code 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 | -| 独立 App 数据目录 | 从 App 打开时,Claude App、Codex App、ZCode App 都会使用按配置 `id` 区分的用户数据目录 | +| 独立 App 数据目录 | 从 App 打开时,Claude App、ChatGPT(Codex 桌面端的新名称)、ZCode App 都会使用按配置 `id` 区分的用户数据目录 | | 运行状态 | CCR 按打开入口和配置 `id` 记录运行中的 App 实例;同一个配置再次打开会激活已有窗口,不同配置可以打开不同实例 | 这意味着你可以为同一个 Agent 建多个配置,例如“Claude Code - 工作项目”“Claude Code - 测试模型”“Codex - Fusion 图像能力”。它们可以选择不同模型、不同作用范围和不同 Bot,打开后就是不同的 Agent 实例。 @@ -74,10 +74,12 @@ Claude App 和 Claude Code CLI 的模型列表适配方式不同: | Codex model | 写入 Codex 默认模型。可以选择普通供应商模型或 Fusion 模型;留空时 CCR 使用可用模型中的默认值。 | | Show all sessions | 让 Codex 显示所有会话。ZCode 不提供该项。 | | 配置文件 | 默认是 `~/.codex/config.toml`。仅从 CCR 打开时生效会写入 CCR 管理的独立配置目录。 | -| 环境变量 | 注入 Codex CLI 或 Codex App。Claude Code 专用的模型发现变量不会传给 Codex。 | -| Bot | 只在 Codex App 入口生效。 | +| 环境变量 | 注入 Codex CLI 或 ChatGPT。Claude Code 专用的模型发现变量不会传给 Codex。 | +| Bot | 只在 ChatGPT App 入口生效。 | -保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr "Codex - Work"`。Codex App 使用播放图标打开。CCR 会生成 `config.toml`、模型目录文件和中间层启动器,让 Codex CLI 与 Codex App 都使用同一套 CCR 模型和供应商信息。 +保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr "Codex - Work"`。ChatGPT 使用播放图标打开。CCR 按照 CodexL 的启动方式,直接运行 ChatGPT App bundle 内的 Electron 可执行文件,为它设置隔离的用户数据目录,并把 `CODEX_CLI_PATH` 指向 CCR 中间层。中间层把 app-server 流量转发给 ChatGPT 内置的 Codex CLI,只适配账号展示:隔离目录已有有效 ChatGPT token 时显示真实账号;没有凭据时使用无 token、ChatGPT 形态的虚拟工作区身份,让桌面端在不保存真实用户登录的情况下仍可使用模型选择。为让原生 app-server 选择官方 API marketplace,CCR 只在进程启动阶段创建精确的 `ccr-local-profile` 引导标记,收到第一条原生响应后立即删除;正常启动后或异常退出时也会清理,不会把它保留成登录状态。其他认证文件全部保留。旧版 `Codex.app` 仍然兼容。 + +模型和公共插件列表不再由中间层合成。原生 Codex app-server 读取生成的 `model_catalog_json`,并原样处理 `model/list` 与公共 `plugin/list` 请求,因此 Codex 可以自行联网刷新官方公开 [`openai/plugins`](https://github.com/openai/plugins) Git marketplace。虚拟 workspace 中,只有必须使用真实 ChatGPT 鉴权的账号私有 marketplace 请求会得到明确空结果,绝不会用本地插件替代。下载后的 Git checkout 只作为 Codex 自己的常规 last-known-good 数据,CCR 不会拿它替代远端目录。 ### ZCode @@ -112,7 +114,7 @@ Claude Code CLI 配置会写入设置文件。选择“仅从 CCR 打开时生 Codex 配置会写入 `config.toml`,并生成模型目录文件。选择“仅从 CCR 打开时生效”时,CCR 会把这些文件放在按配置 `id` 区分的目录中。 -Codex 支持 CLI 和 App。CLI 会通过对应配置的启动器打开;App 会使用独立用户数据目录,并把当前配置中的模型和供应商信息带入 Codex App。 +Codex 支持 CLI 和 App。CLI 会通过对应配置的启动器打开;App 会启动 ChatGPT、使用独立用户数据目录,并把当前配置中的模型和供应商信息带入 App。 ### ZCode diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index 703ad8e..a281471 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -78,6 +78,22 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少 TeamoRouterAnthropic / Chat / Responses + + + code0.aiAnthropic / Chat / Responses + + + + claudeapiAnthropic Messages + + + + 七牛云 AIChat / Responses / Anthropic / Gemini Generate + + + + Fenno.aiChat / Responses / Anthropic + ## 嵌入式按钮组件 diff --git a/docs/src/content/docs/zh/configuration/routing.md b/docs/src/content/docs/zh/configuration/routing.md index c00f069..332eda9 100644 --- a/docs/src/content/docs/zh/configuration/routing.md +++ b/docs/src/content/docs/zh/configuration/routing.md @@ -162,7 +162,7 @@ Fallback 处理请求失败后的降级。第一次选模型由路由完成; | 继续重试 | `408`、`409`、`429`、`5xx` | | 失败降级目标 | 任意 `4xx` 或 `5xx` | -对于 `429` 限流响应,CCR 会在下一次尝试前等待。上游提供 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。 +进入下一次尝试前,CCR 会对每个触发 Fallback 的失败进行等待,包括网络错误。上游提供正数 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。 **失败降级目标** 对 `4xx` 也会切换,是因为模型不存在、鉴权或供应商侧拒绝等错误可能只影响当前目标。切换后如果备用模型可用,请求仍然可以成功。 diff --git a/docs/src/content/docs/zh/configuration/toolhub.md b/docs/src/content/docs/zh/configuration/toolhub.md new file mode 100644 index 0000000..defadd0 --- /dev/null +++ b/docs/src/content/docs/zh/configuration/toolhub.md @@ -0,0 +1,167 @@ +--- +title: ToolHub +pageTitle: ToolHub +eyebrow: 详细配置 +lead: 将多个 MCP server 收束成一个紧凑入口,让 Agent 按任务懒加载需要的工具,减少工具列表占用的上下文。 +--- + +## 适用场景 + +当你接入的 MCP server 越来越多时,直接把所有工具暴露给 Agent 会让工具列表变长,也更容易选错工具。ToolHub 会向 Agent 暴露一个 `ccr-toolhub` MCP server,里面只有两个元工具: + +- `tool_hub.resolve`:根据用户任务和上下文检索可用 MCP 工具。 +- `tool_hub.invoke`:调用已经被本轮任务选中的真实 MCP 工具。 + +它适合把不常用但偶尔会用到的工具统一懒加载,在任务真正需要时才交给 Agent 使用。核心价值是节省上下文:避免把大量低频工具常驻在 Agent 的工具列表里,减少上下文占用和选错工具的概率。简单本地代码、文件或普通聊天任务通常不需要经过 ToolHub。 + +## 工作方式 + +1. 在 **设置 → ToolHub** 中启用 ToolHub。 +2. 选择一个已配置模型作为 **检索模型**。它负责阅读 MCP 工具目录并挑选本轮任务需要的工具;建议使用 `deepseek-v4-flash`,或同等 Flash 价位、响应稳定的轻量模型。 +3. 添加或导入后端 MCP server。ToolHub 支持 `stdio`、`streamable-http` 和 `sse`。 +4. 从 CCR 打开 Claude Code 或 Codex。CCR 会在对应 Agent 配置中写入 `ccr-toolhub`。 +5. Agent 遇到外部服务、已安装 MCP 能力或业务 API 相关请求时,先调用 `tool_hub.resolve`,再用 `tool_hub.invoke` 执行选中的工具。 + +ToolHub 会合并 **ToolHub 页面配置的 MCP servers** 和兼容旧配置中的全局 Agent MCP servers,并自动排除 `ccr-toolhub` 自身,避免递归调用。 + +## 内置浏览器自动化 + +在 CCR Desktop 中启用 ToolHub,并打开 **内置浏览器自动化** 开关后,Agent 可以使用桌面端内置浏览器完成网页操作。不需要在 ToolHub 页面手动添加浏览器后端,也不需要额外 API Key;CCR 会使用本地网关鉴权连接它。 + +启用步骤: + +1. 打开 **设置 → ToolHub**,先开启 **启用 ToolHub**。 +2. 在同一页打开 **内置浏览器自动化** 开关。该开关只会在 ToolHub 已启用时显示。 +3. 保存设置后,从 CCR 重新打开 Claude Code 或 Codex,让新的 Agent 实例加载最新配置。 + +> 已经运行中的 Agent 实例通常不会立即拿到这个开关变化。要让现有会话生效,请重启该 Agent 实例,或使用 Agent 自身能力重启 ToolHub。 + +内置浏览器自动化适合让 Agent 处理需要真实浏览器状态的任务,例如打开网站、读取页面、填写表单、点击按钮、在页面中滚动,或在没有专用业务能力时完成下单、预约、查询、结账等网页流程。开启后 Agent 可以: + +- 打开或附加内置浏览器标签页、导航 URL 或搜索词。 +- 读取页面内容,并找到按钮、链接、输入框等页面元素。 +- 点击、输入、选择、按键和滚动页面元素。 +- 等待页面加载、跳转、弹窗或人类接管结果,再继续后续步骤。 +- 在登录、验证码、CAPTCHA、人机验证或人工确认时请求用户接管。 + +当网页流程需要登录、验证码、CAPTCHA、人机验证或人工确认时,CCR 会显示内置浏览器窗口,并在顶部工具栏提示用户需要完成的步骤。用户点击 **Done** 或 **Hide** 后,Agent 会收到结果并继续执行。接管等待最长支持 10 分钟。 + +### Chrome 登录态导入扩展 + +内置浏览器自动化还支持把系统 Chrome 中指定域名的登录状态导入 CCR 内置浏览器。这样 Agent 处理网页任务时,可以复用你已经在 Chrome 中登录过的网站状态。该能力需要安装仓库里的 Chrome 解包扩展:`extension/chrome`。 + +安装方式: + +1. 在 Chrome 打开 `chrome://extensions`。 +2. 开启 **Developer mode**。 +3. 点击 **Load unpacked**。 +4. 选择仓库中的 `extension/chrome` 目录。 + +导入流程: + +1. 当任务需要复用 Chrome 登录状态时,Agent 会请求导入;用户也可以在 CCR 内置浏览器工具栏点击钥匙按钮主动发起。 +2. CCR 创建一次性导入任务,并打开确认页。如果默认浏览器不是 Chrome,请把确认页 URL 复制到已安装扩展的 Chrome 中打开。 +3. 用户在确认页检查要导入的域名,点击 **Confirm and Import**。 +4. Chrome 扩展读取这些域名的 cookies 和 localStorage,提交给 CCR;完成后 Agent 可以继续使用内置浏览器执行任务。 + +扩展只读取 CCR 导入任务列出的域名,不会枚举 Chrome 中的全部 cookies。读取 localStorage 时,扩展会临时打开对应 origin 的非激活标签页,读取后自动关闭。若确认页提示扩展没有站点访问权限,请在 Chrome 扩展设置中允许该扩展访问目标域名,然后重新加载解包扩展再重试。 + +> 注意:内置浏览器自动化依赖 CCR Desktop 的内置浏览器,只在桌面端可用。CLI、服务器部署或纯 Web 环境没有这项内置能力,请改用外部浏览器自动化 MCP server。 + +## 配置项 + +| 配置项 | 说明 | +| --- | --- | +| 启用 ToolHub | 开启后才会向 Agent 暴露 `ccr-toolhub`。如果没有可用后端 MCP server,CCR 不会生成 ToolHub MCP 配置。 | +| 内置浏览器自动化 | 仅在启用 ToolHub 后显示。开启后让 Agent 可以使用 CCR Desktop 的内置浏览器完成网页操作。 | +| 检索模型 | 从已配置供应商模型中选择。建议使用 `deepseek-v4-flash`,或同等 Flash 价位、响应稳定、工具理解能力足够的轻量模型。 | +| 最大工具数 | 单次解析最多返回的工具数量,范围 `1` 到 `20`,默认 `10`。 | +| 超时毫秒 | ToolHub 解析和调用的基础超时时间,范围 `8000` 到 `300000`,默认 `60000`。如果后端 MCP server 需要更长 request timeout,CCR 会按后端超时自动抬高实际调用超时。 | +| MCP servers | 后端工具来源。每个 server 需要唯一名称,并配置 transport、命令或 URL、环境变量、headers 和超时。 | +| Import JSON | 导入常见 MCP JSON。支持根对象、数组、`mcpServers` 或 `mcp_servers`。 | + +## 添加 MCP Server + +### stdio + +`stdio` 适合本地命令行 MCP server。需要填写: + +- **Command**:启动命令,例如 `npx`、`node`、`python`。 +- **Arguments**:命令参数。 +- **Working directory**:可选工作目录。 +- **Stdio message mode**:默认 `content-length`,如果 server 使用逐行 JSON,选择 `newline-json`。 +- **Environment variables**:只放这个 MCP server 需要的变量。 + +### streamable-http / sse + +远程 MCP server 需要填写 URL。鉴权可以使用: + +- **API key**:直接保存在配置中。 +- **API key env**:从环境变量读取。 +- **Headers**:添加自定义请求头。 + +如果远程服务启动慢或请求耗时长,可以单独调高该 server 的 **Startup timeout** 或 **Request timeout**。 + +## JSON 示例 + +桌面 App 的 SQLite 配置是当前生效来源,建议优先通过 UI 修改。下面字段适用于备份、迁移或排查时理解 ToolHub 配置结构: + +```json +{ + "toolHub": { + "enabled": true, + "browserAutomation": true, + "llm": { + "apiKey": "sk-...", + "baseUrl": "https://api.openai.com/v1", + "model": "gpt-5-mini" + }, + "maxTools": 10, + "requestTimeoutMs": 60000, + "mcpServers": [ + { + "name": "filesystem", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {}, + "stdioMessageMode": "content-length", + "requestTimeoutMs": 30000, + "startupTimeoutMs": 600000 + } + ] + } +} +``` + +导入 MCP JSON 时也可以使用常见格式: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } +} +``` + +## 与 Fusion MCP 的区别 + +| 能力 | ToolHub | Fusion 自定义 MCP 工具 | +| --- | --- | --- | +| 使用入口 | Agent 侧的 `ccr-toolhub` MCP server | 某个 Fusion 模型内部能力 | +| 工具选择 | 每个任务动态检索并返回工具包 | 模型配置中固定选择工具 | +| 适合场景 | MCP server 很多、工具目录经常变化、希望 Agent 自主发现能力 | 给某个模型补一组明确工具 | +| 可见范围 | 通过 CCR 打开的 Claude Code 或 Codex 配置 | 选择该 Fusion 模型的路由或 Agent | + +## 排查 + +- Agent 看不到 ToolHub:确认已启用 ToolHub,并且至少配置了一个后端 MCP server 或开启了 **内置浏览器自动化**,然后从 CCR 重新打开 Claude Code 或 Codex。 +- 提示缺少检索模型或 API Key:在 **检索模型** 中选择已配置模型,并确认供应商凭据可用。 +- Agent 无法使用内置浏览器自动化:确认正在使用 CCR Desktop,并且已在 **设置 → ToolHub** 中开启 **内置浏览器自动化**,然后从 CCR 重新打开 Claude Code 或 Codex。CLI、服务器部署或纯 Web 环境没有这项内置能力。 +- Chrome 登录态导入确认页一直等待扩展:确认已在 Chrome 中加载 `extension/chrome` 解包扩展,并允许扩展访问要导入的目标域名。如果默认浏览器不是 Chrome,请手动把确认页 URL 复制到 Chrome。 +- 解析不到工具:检查 MCP server 是否能正常列出工具,工具名称和描述是否足够清楚,必要时提高 **最大工具数**。 +- 调用超时:分别检查 ToolHub 的 **超时毫秒** 和单个 MCP server 的 request/startup timeout。 +- 导入失败:检查 JSON 是否有效、server 名称是否重复、`stdio` 是否有 command,远程 transport 是否有 URL。 diff --git a/docs/src/i18n/content.ts b/docs/src/i18n/content.ts index 8569685..58a9dc1 100644 --- a/docs/src/i18n/content.ts +++ b/docs/src/i18n/content.ts @@ -60,25 +60,33 @@ export const docsContent = { configuration: { sidebarGroups: [ { - label: "详细配置", + label: "主页页面", icon: "wand", items: [ "概览仪表盘", "供应商配置", "一键导入供应商", - "路由配置", - "日志&观测", - "Fusion 组合模型", "Agent配置", + "路由配置", + "Fusion 组合模型", "API 密钥", + "日志&观测", "服务配置", - "托盘配置", "扩展机制", - "Bot 与 IM 接力 Agent", - "配置数据库位置", ], active: "概览仪表盘", }, + { + label: "设置页", + icon: "book", + items: [ + "ToolHub", + "Bot 与 IM 接力 Agent", + "配置数据库位置", + "托盘配置", + ], + active: "", + }, ], expandableSidebarItems: ["Fusion 组合模型", "Bot 与 IM 接力 Agent"], sidebarChildren: { @@ -105,6 +113,7 @@ export const docsContent = { 内置图像能力: "/configuration/fusion-vision/", 内置联网搜索: "/configuration/fusion-web-search/", "自定义 MCP 工具": "/configuration/fusion-mcp-tool/", + ToolHub: "/configuration/toolhub/", Agent配置: "/configuration/profile/", "API 密钥": "/configuration/api-keys/", 服务配置: "/configuration/server/", @@ -208,25 +217,33 @@ export const docsContent = { configuration: { sidebarGroups: [ { - label: "Detailed Configuration", + label: "Main Pages", icon: "wand", items: [ "Overview Dashboard", "Provider Config", "One click import", - "Routing Config", - "Logs & Observability", - "Fusion Models", "Agent Config", + "Routing Config", + "Fusion Models", "API Keys", + "Logs & Observability", "Server", - "Tray Configuration", "Extension Mechanism", - "Bots And IM Agent Relay", - "Config Database Location", ], active: "Overview Dashboard", }, + { + label: "Settings Pages", + icon: "book", + items: [ + "ToolHub", + "Bots And IM Agent Relay", + "Config Database Location", + "Tray Configuration", + ], + active: "", + }, ], expandableSidebarItems: ["Fusion Models", "Bots And IM Agent Relay"], sidebarChildren: { @@ -243,6 +260,7 @@ export const docsContent = { "Built-In Vision": "/en/configuration/fusion-vision/", "Built-In Web Search": "/en/configuration/fusion-web-search/", "Custom MCP Tool": "/en/configuration/fusion-mcp-tool/", + ToolHub: "/en/configuration/toolhub/", "Agent Config": "/en/configuration/profiles/", "API Keys": "/en/configuration/api-keys/", Server: "/en/configuration/server/", diff --git a/docs/src/layouts/DocsLayout.astro b/docs/src/layouts/DocsLayout.astro index 6be2c33..60d714c 100644 --- a/docs/src/layouts/DocsLayout.astro +++ b/docs/src/layouts/DocsLayout.astro @@ -291,6 +291,11 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation";