diff --git a/.gitignore b/.gitignore
index 9123977..328c2e4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,7 +15,9 @@
# next.js
/.next/
+/.next-cli-build/
/out/
+cli/.build-home/
product
# production
/build
diff --git a/cli/package.json b/cli/package.json
index d18c26b..aad50da 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -42,6 +42,7 @@
],
"license": "MIT",
"devDependencies": {
+ "esbuild": "^0.25.12",
"nodemon": "^3.1.14"
}
}
diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js
index 649cfff..4b5443f 100644
--- a/cli/scripts/build-cli.js
+++ b/cli/scripts/build-cli.js
@@ -8,6 +8,17 @@ const cliDir = path.resolve(__dirname, "..");
const appDir = path.resolve(cliDir, "..");
const rootDir = path.resolve(appDir, "..");
const cliAppDir = path.join(cliDir, "app");
+const buildHomeDir = path.join(cliDir, ".build-home");
+const buildDistDirName = ".next-cli-build";
+const buildDistDir = path.join(appDir, buildDistDirName);
+
+function shouldUseWorkspaceTracingRoot() {
+ const appNodeModules = path.join(appDir, "node_modules");
+ const rootNodeModules = path.join(rootDir, "node_modules");
+
+ // Only widen tracing when dependencies are actually hoisted above appDir.
+ return !fs.existsSync(appNodeModules) && fs.existsSync(rootNodeModules);
+}
// Exclude patterns for files/folders we don't want to copy
const EXCLUDE_PATTERNS = [
@@ -81,14 +92,22 @@ function copyRecursive(src, dest) {
console.log("📦 Building 9Router CLI package with Next.js...\n");
+fs.mkdirSync(buildHomeDir, { recursive: true });
+fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true });
+fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true });
+
// Step 0: Sync version from app/cli/package.json to app/package.json
console.log("0️⃣ Syncing version to app/package.json...");
const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8"));
const appPkgPath = path.join(appDir, "package.json");
const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8"));
-appPkg.version = cliPkg.version;
-fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
-console.log(`✅ Version synced: ${cliPkg.version}\n`);
+if (appPkg.version !== cliPkg.version) {
+ appPkg.version = cliPkg.version;
+ fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
+ console.log(`✅ Version synced: ${cliPkg.version}\n`);
+} else {
+ console.log(`✅ Version already synced: ${cliPkg.version}\n`);
+}
// Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone).
console.log("1️⃣ Building Next.js app...");
@@ -96,7 +115,15 @@ try {
execSync("npm run build", {
stdio: "inherit",
cwd: appDir,
- env: { ...process.env, NEXT_TRACING_ROOT_MODE: "workspace" }
+ env: {
+ ...process.env,
+ HOME: buildHomeDir,
+ USERPROFILE: buildHomeDir,
+ APPDATA: path.join(buildHomeDir, "AppData", "Roaming"),
+ LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"),
+ NEXT_DIST_DIR: buildDistDirName,
+ NEXT_TRACING_ROOT_MODE: shouldUseWorkspaceTracingRoot() ? "workspace" : "project",
+ }
});
console.log("✅ Next.js build completed\n");
} catch (error) {
@@ -112,21 +139,25 @@ if (fs.existsSync(cliAppDir)) {
console.log("✅ Cleaned\n");
// Step 3: Copy Next.js standalone build to app/cli/app.
-// With workspace tracing root, Next places app files under .next/standalone/app/ and traced
-// node_modules under .next/standalone/node_modules/ (slim, tracing-pruned).
+// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and
+// node_modules/ directly under .next/standalone. Older builds may still use a nested app/.
console.log("3️⃣ Copying Next.js standalone build to app/cli/app...");
const standaloneRoot = path.join(appDir, ".next", "standalone");
-const standaloneApp = path.join(standaloneRoot, "app");
+const standaloneRootResolved = path.join(buildDistDir, "standalone");
+const standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot;
+const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js"))
+ ? standaloneRootToUse
+ : path.join(standaloneRootToUse, "app");
if (!fs.existsSync(standaloneApp)) {
- console.error("❌ Next.js standalone build not found at .next/standalone/app");
- console.error("Make sure output: 'standalone' is set in next.config.js");
+ console.error("❌ Next.js standalone build not found under .next/standalone");
+ console.error("Expected either .next/standalone/server.js or .next/standalone/app/");
process.exit(1);
}
copyRecursive(standaloneApp, cliAppDir);
-// Copy traced node_modules from standalone root into CLI bundle
-const standaloneNodeModules = path.join(standaloneRoot, "node_modules");
-if (fs.existsSync(standaloneNodeModules)) {
+// Older nested-app layout stores traced node_modules at standalone root.
+const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules");
+if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) {
copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules"));
}
console.log("✅ Copied standalone build\n");
@@ -166,9 +197,10 @@ console.log("");
// Step 4: Copy static files
console.log("4️⃣ Copying static files...");
const staticSrc = path.join(appDir, ".next", "static");
-const staticDest = path.join(cliAppDir, ".next", "static");
-if (fs.existsSync(staticSrc)) {
- copyRecursive(staticSrc, staticDest);
+const staticSrcResolved = path.join(buildDistDir, "static");
+const staticDest = path.join(cliAppDir, buildDistDirName, "static");
+if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) {
+ copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest);
console.log("✅ Copied static files\n");
} else {
console.log("⏭️ No static files found\n");
@@ -188,9 +220,10 @@ if (fs.existsSync(publicSrc)) {
// Step 6: Copy vendor-chunks (required for production)
console.log("6️⃣ Copying vendor-chunks...");
const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks");
-const vendorChunksDest = path.join(cliAppDir, ".next", "server", "vendor-chunks");
-if (fs.existsSync(vendorChunksSrc)) {
- copyRecursive(vendorChunksSrc, vendorChunksDest);
+const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks");
+const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks");
+if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) {
+ copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest);
console.log("✅ Copied vendor-chunks\n");
} else {
console.log("⏭️ No vendor-chunks found\n");
diff --git a/next.config.mjs b/next.config.mjs
index 0121ea3..85c7a25 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -10,6 +10,7 @@ const tracingRoot = process.env.NEXT_TRACING_ROOT_MODE === "workspace"
/** @type {import('next').NextConfig} */
const nextConfig = {
+ distDir: process.env.NEXT_DIST_DIR || ".next",
output: "standalone",
serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"],
turbopack: {
diff --git a/package.json b/package.json
index 450ff7d..79b91f8 100644
--- a/package.json
+++ b/package.json
@@ -5,11 +5,11 @@
"private": true,
"scripts": {
"dev": "next dev --webpack --port 20128",
- "build": "NODE_ENV=production next build --webpack",
- "start": "NODE_ENV=production next start",
+ "build": "next build --webpack",
+ "start": "next start",
"dev:bun": "bun --bun next dev --webpack --port 20128",
- "build:bun": "NODE_ENV=production bun --bun next build --webpack",
- "start:bun": "NODE_ENV=production bun ./.next/standalone/server.js"
+ "build:bun": "bun --bun next build --webpack",
+ "start:bun": "bun ./.next/standalone/server.js"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/postcss.config.mjs b/postcss.config.mjs
index fa4a1da..17fa423 100644
--- a/postcss.config.mjs
+++ b/postcss.config.mjs
@@ -1,6 +1,12 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const projectRoot = path.dirname(fileURLToPath(import.meta.url));
+
export default {
plugins: {
- "@tailwindcss/postcss": {},
+ "@tailwindcss/postcss": {
+ base: projectRoot,
+ },
},
};
-
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
index 3e6eb44..01d1aaa 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
-import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal } from "@/shared/components";
+import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
@@ -43,7 +43,9 @@ export default function CoworkToolCard({
const [plugins, setPlugins] = useState([]);
const [localPlugins, setLocalPlugins] = useState([]);
const [customPlugins, setCustomPlugins] = useState([]);
+ const [modelAliases, setModelAliases] = useState({});
const [comboModalOpen, setComboModalOpen] = useState(false);
+ const [modelSelectOpen, setModelSelectOpen] = useState(false);
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
const [addMcpOpen, setAddMcpOpen] = useState(false);
const [addMcpForm, setAddMcpForm] = useState({ type: "url", name: "", url: "", command: "", args: "" });
@@ -62,6 +64,16 @@ export default function CoworkToolCard({
if (isExpanded && !status) checkStatus();
}, [isExpanded]);
+ useEffect(() => {
+ if (!isExpanded) return;
+ fetch("/api/models/alias")
+ .then((r) => r.ok ? r.json() : null)
+ .then((data) => {
+ if (data) setModelAliases(data.aliases || {});
+ })
+ .catch(() => {});
+ }, [isExpanded]);
+
useEffect(() => {
if (status?.cowork?.models?.length) {
setSelectedModels(status.cowork.models);
@@ -170,6 +182,17 @@ export default function CoworkToolCard({
}
};
+ const handleAddModel = (model) => {
+ const value = model?.value || model?.name || model;
+ if (!value || selectedModels.includes(value)) return;
+ setSelectedModels((prev) => [...prev, value]);
+ };
+
+ const handleRemoveModel = (model) => {
+ const value = model?.value || model?.name || model;
+ setSelectedModels((prev) => prev.filter((item) => item !== value));
+ };
+
const handleReset = async () => {
setRestoring(true);
setMessage(null);
@@ -313,13 +336,20 @@ export default function CoworkToolCard({
selectedModels.map((m) => (
{m}
-
))
)}
+ setModelSelectOpen(true)}
+ disabled={!hasActiveProviders}
+ className={`shrink-0 px-2 py-1.5 rounded border text-xs whitespace-nowrap transition-colors ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
+ >
+ Select Model
+
setComboModalOpen(true)} disabled={!hasActiveProviders} className={`shrink-0 px-2 py-1.5 rounded border text-xs whitespace-nowrap transition-colors ${hasActiveProviders ? "bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>+ Combo
@@ -501,6 +531,18 @@ export default function CoworkToolCard({
title="Create Cowork Combo"
/>
+ setModelSelectOpen(false)}
+ onSelect={handleAddModel}
+ onDeselect={handleRemoveModel}
+ activeProviders={activeProviders}
+ modelAliases={modelAliases}
+ title="Select Cowork Model"
+ addedModelValues={selectedModels}
+ closeOnSelect={false}
+ />
+
setMarketplaceOpen(false)}