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
+
+
+
+
+
+
+
+
+
+
+
+ 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 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
+
+
+
+
+
+
+ claudeapi
+
+
+
+
+
+
+
+
+ Qiniu Cloud AI
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+ 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、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
-
## 为什么使用 CCR
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
@@ -175,6 +208,36 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
TeamoRouter
+
+
+
+
+ code0.ai
+
+
+
+
+
+
+ claudeapi
+
+
+
+
+
+
+
+
+ 七牛云 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("