diff --git a/.gitignore b/.gitignore
index b18b56081..8ebc49623 100644
--- a/.gitignore
+++ b/.gitignore
@@ -90,5 +90,11 @@ reference-sources/
# Legacy external Pascal Editor checkout (the managed source is under services/).
.pascal-editor/
+# Local self-hosted Zoo Design Studio checkout.
+.zoo-modeling-app/
+
+# Generated official Zoo Design Studio web bundle.
+public/vendor/zoo-modeling-app/
+
# Drizzle migrations must be versioned (override the global *.sql ignore above)
!lib/db/migrations/*.sql
diff --git a/app/(chat)/ai-cad/page.tsx b/app/(chat)/ai-cad/page.tsx
index 285bd14c7..d360c7de1 100644
--- a/app/(chat)/ai-cad/page.tsx
+++ b/app/(chat)/ai-cad/page.tsx
@@ -1,7 +1,33 @@
-import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
+import { existsSync } from "node:fs";
+import path from "node:path";
+import type { Metadata } from "next";
+import { redirect } from "next/navigation";
+import { auth } from "@/app/(auth)/auth";
+import { ZooDesignStudioFrame } from "@/components/ai-cad/zoo-design-studio-frame";
+
+const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html";
export const dynamic = "force-dynamic";
+export const metadata: Metadata = {
+ title: "HiCAD",
+};
+export const runtime = "nodejs";
-export default function AiCadPage() {
- return ;
+export default async function AiCadPage() {
+ const session = await auth();
+ if (!session?.user?.id) {
+ redirect("/login?callbackUrl=%2Fai-cad");
+ }
+
+ const isReady = existsSync(
+ path.join(process.cwd(), "public/vendor/zoo-modeling-app/index.html")
+ );
+
+ return (
+
+ );
}
diff --git a/app/api/ai-cad/zoo-ws-authorize/route.ts b/app/api/ai-cad/zoo-ws-authorize/route.ts
new file mode 100644
index 000000000..dcada45b3
--- /dev/null
+++ b/app/api/ai-cad/zoo-ws-authorize/route.ts
@@ -0,0 +1,66 @@
+import { timingSafeEqual } from "node:crypto";
+import { NextResponse } from "next/server";
+import { auth } from "@/app/(auth)/auth";
+import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+const ALLOWED_WEBSOCKET_PATHS = new Set([
+ "/api/ai-cad/zoo-ws/ml/copilot",
+ "/api/ai-cad/zoo-ws/modeling/commands",
+]);
+
+function hasValidInternalSecret(request: Request) {
+ const expected = String(process.env.AI_CAD_WS_PROXY_SECRET || "").trim();
+ const received = String(
+ request.headers.get("x-hicad-ws-proxy-secret") || ""
+ ).trim();
+ if (!expected || !received) {
+ return false;
+ }
+
+ const expectedBuffer = Buffer.from(expected);
+ const receivedBuffer = Buffer.from(received);
+ return (
+ expectedBuffer.length === receivedBuffer.length &&
+ timingSafeEqual(expectedBuffer, receivedBuffer)
+ );
+}
+
+export async function GET(request: Request) {
+ if (!hasValidInternalSecret(request)) {
+ return NextResponse.json({ error: "禁止访问" }, { status: 403 });
+ }
+
+ const originalUri = String(
+ request.headers.get("x-hicad-original-uri") || ""
+ ).split("?", 1)[0];
+ if (!ALLOWED_WEBSOCKET_PATHS.has(originalUri)) {
+ return NextResponse.json({ error: "不支持的 Zoo WebSocket" }, { status: 403 });
+ }
+
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "未登录" }, { status: 401 });
+ }
+
+ try {
+ const config = await getKittyCadProviderConfig();
+ return new Response(null, {
+ headers: {
+ "Cache-Control": "private, no-store",
+ "X-Zoo-Authorization": `Bearer ${config.apiKey}`,
+ },
+ status: 204,
+ });
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "Zoo WebSocket 鉴权失败",
+ },
+ { status: 503 }
+ );
+ }
+}
diff --git a/app/api/ai-cad/zoo/[...path]/route.ts b/app/api/ai-cad/zoo/[...path]/route.ts
new file mode 100644
index 000000000..de27031e2
--- /dev/null
+++ b/app/api/ai-cad/zoo/[...path]/route.ts
@@ -0,0 +1,140 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/app/(auth)/auth";
+import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
+
+export const dynamic = "force-dynamic";
+export const maxDuration = 60;
+export const runtime = "nodejs";
+
+const HOP_BY_HOP_HEADERS = new Set([
+ "connection",
+ "content-length",
+ "host",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+]);
+
+const BLOCKED_PATH_PREFIXES = [
+ "/admin",
+ "/internal",
+ "/logout",
+ "/oauth2",
+ "/org/api-tokens",
+ "/user/api-tokens",
+ "/users",
+];
+
+type RouteContext = {
+ params: Promise<{ path: string[] }>;
+};
+
+function isBlockedPath(pathname: string) {
+ return BLOCKED_PATH_PREFIXES.some(
+ (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
+ );
+}
+
+function copyRequestHeaders(request: Request, apiKey: string) {
+ const headers = new Headers();
+
+ for (const [name, value] of request.headers.entries()) {
+ const normalizedName = name.toLowerCase();
+ if (
+ HOP_BY_HOP_HEADERS.has(normalizedName) ||
+ normalizedName === "authorization" ||
+ normalizedName === "cookie" ||
+ normalizedName === "origin" ||
+ normalizedName === "referer" ||
+ normalizedName.startsWith("x-forwarded-") ||
+ normalizedName === "x-hicad-ws-proxy-secret"
+ ) {
+ continue;
+ }
+ headers.set(name, value);
+ }
+
+ headers.set("Authorization", `Bearer ${apiKey}`);
+ headers.set("Accept", request.headers.get("accept") || "application/json");
+ return headers;
+}
+
+function copyResponseHeaders(upstream: Response) {
+ const headers = new Headers();
+
+ for (const [name, value] of upstream.headers.entries()) {
+ const normalizedName = name.toLowerCase();
+ if (
+ HOP_BY_HOP_HEADERS.has(normalizedName) ||
+ normalizedName === "content-encoding" ||
+ normalizedName === "set-cookie"
+ ) {
+ continue;
+ }
+ headers.set(name, value);
+ }
+
+ headers.set("Cache-Control", "private, no-store");
+ return headers;
+}
+
+async function proxyZooRequest(request: Request, context: RouteContext) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "未登录" }, { status: 401 });
+ }
+
+ const { path } = await context.params;
+ const pathname = `/${path.map(encodeURIComponent).join("/")}`;
+ if (isBlockedPath(pathname)) {
+ return NextResponse.json(
+ { error: "该 Zoo API 不允许通过 HiCAD 代理访问" },
+ { status: 403 }
+ );
+ }
+
+ try {
+ const config = await getKittyCadProviderConfig();
+ const incomingUrl = new URL(request.url);
+ const baseUrl = config.baseUrl.replace(/\/+$/, "");
+ const upstreamUrl = `${baseUrl}${pathname}${incomingUrl.search}`;
+ const method = request.method.toUpperCase();
+ const hasBody = method !== "GET" && method !== "HEAD";
+ const init: RequestInit & { duplex?: "half" } = {
+ body: hasBody ? request.body : undefined,
+ cache: "no-store",
+ duplex: hasBody ? "half" : undefined,
+ headers: copyRequestHeaders(request, config.apiKey),
+ method,
+ redirect: "manual",
+ signal: request.signal,
+ };
+ const upstream = await fetch(upstreamUrl, init);
+
+ return new Response(upstream.body, {
+ headers: copyResponseHeaders(upstream),
+ status: upstream.status,
+ statusText: upstream.statusText,
+ });
+ } catch (error) {
+ return NextResponse.json(
+ {
+ error:
+ error instanceof Error ? error.message : "Zoo API 代理请求失败",
+ },
+ { status: 502 }
+ );
+ }
+}
+
+export const DELETE = proxyZooRequest;
+export const GET = proxyZooRequest;
+export const HEAD = proxyZooRequest;
+export const OPTIONS = proxyZooRequest;
+export const PATCH = proxyZooRequest;
+export const POST = proxyZooRequest;
+export const PUT = proxyZooRequest;
diff --git a/components/ai-cad/zoo-design-studio-frame.tsx b/components/ai-cad/zoo-design-studio-frame.tsx
new file mode 100644
index 000000000..c22282d5c
--- /dev/null
+++ b/components/ai-cad/zoo-design-studio-frame.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { AlertTriangle, RefreshCw } from "lucide-react";
+import { useState } from "react";
+
+type ZooDesignStudioFrameProps = {
+ isReady: boolean;
+ sourceLabel: string;
+ src: string;
+};
+
+export function ZooDesignStudioFrame({
+ isReady,
+ sourceLabel,
+ src,
+}: ZooDesignStudioFrameProps) {
+ const [loaded, setLoaded] = useState(false);
+
+ if (!isReady) {
+ return (
+
+
+
+
+
+
+ HiCAD 官方前端尚未构建
+
+
+ 请运行
+
+ pnpm zoo-modeling-app:vendor
+
+ 生成官方 Zoo Design Studio 前端。生成后刷新本页即可使用。
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {!loaded && (
+
+
+
+ 正在加载 {sourceLabel}
+
+
+ )}
+
+ );
+}
diff --git a/deploy/nginx-hicad-zoo-ws.conf.template b/deploy/nginx-hicad-zoo-ws.conf.template
new file mode 100644
index 000000000..c5af37798
--- /dev/null
+++ b/deploy/nginx-hicad-zoo-ws.conf.template
@@ -0,0 +1,35 @@
+# Include inside the authenticated HiCAD HTTPS server block.
+# Replace __AI_CAD_WS_PROXY_SECRET__ during deployment and chmod the rendered file 600.
+
+location = /_hicad_zoo_ws_auth {
+ internal;
+ proxy_pass http://127.0.0.1:3125/api/ai-cad/zoo-ws-authorize;
+ proxy_pass_request_body off;
+ proxy_set_header Content-Length "";
+ proxy_set_header Host $host;
+ proxy_set_header Cookie $http_cookie;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-HiCAD-Original-URI $request_uri;
+ proxy_set_header X-HiCAD-WS-Proxy-Secret "__AI_CAD_WS_PROXY_SECRET__";
+}
+
+location ^~ /api/ai-cad/zoo-ws/ {
+ auth_request /_hicad_zoo_ws_auth;
+ auth_request_set $zoo_authorization $upstream_http_x_zoo_authorization;
+
+ proxy_pass https://api.zoo.dev/ws/;
+ proxy_http_version 1.1;
+ proxy_ssl_server_name on;
+ proxy_ssl_name api.zoo.dev;
+ proxy_set_header Host api.zoo.dev;
+ proxy_set_header Origin https://app.zoo.dev;
+ proxy_set_header Authorization $zoo_authorization;
+ proxy_set_header Cookie "";
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_buffering off;
+ proxy_request_buffering off;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+}
diff --git a/package.json b/package.json
index 1c4996806..cd893a74f 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,8 @@
"pascal:deploy": "bash scripts/deploy-pascal-production.sh deploy",
"pascal:check": "bash scripts/deploy-pascal-production.sh check",
"pascal:smoke": "bash scripts/smoke-pascal-agent.sh",
+ "zoo-modeling-app:vendor": "node scripts/vendor-zoo-modeling-app.mjs",
+ "zoo-modeling-app:ws-proxy": "node scripts/ai-cad-zoo-ws-dev-proxy.mjs",
"postinstall": "node scripts/security/auto-enable-perf-guard.mjs",
"prebuild": "node scripts/security/auto-enable-perf-guard.mjs",
"build": "next build",
diff --git a/scripts/ai-cad-zoo-ws-dev-proxy.mjs b/scripts/ai-cad-zoo-ws-dev-proxy.mjs
new file mode 100644
index 000000000..fa7362354
--- /dev/null
+++ b/scripts/ai-cad-zoo-ws-dev-proxy.mjs
@@ -0,0 +1,121 @@
+#!/usr/bin/env node
+
+import { createServer, request as httpRequest } from "node:http";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+import dotenv from "dotenv";
+import WebSocket, { WebSocketServer } from "ws";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+dotenv.config({ path: path.join(root, ".env.local") });
+
+const host = "127.0.0.1";
+const port = Number(process.env.HICAD_ZOO_WS_DEV_PROXY_PORT || 3010);
+const appPort = Number(process.env.HICAD_APP_DEV_PORT || 3003);
+const apiBaseUrl = String(
+ process.env.KITTYCAD_BASE_URL ||
+ process.env.ZOO_BASE_URL ||
+ "https://api.zoo.dev"
+).replace(/\/+$/, "");
+const apiKey = String(
+ process.env.KITTYCAD_API_TOKEN ||
+ process.env.KITTYCAD_API_KEY ||
+ process.env.ZOO_API_TOKEN ||
+ ""
+)
+ .replace(/\s+/g, "")
+ .trim();
+
+if (!apiKey) {
+ throw new Error("Missing KITTYCAD_API_TOKEN in .env.local");
+}
+
+const allowedPaths = new Map([
+ ["/api/ai-cad/zoo-ws/ml/copilot", "/ws/ml/copilot"],
+ ["/api/ai-cad/zoo-ws/modeling/commands", "/ws/modeling/commands"],
+]);
+const browserSockets = new WebSocketServer({ noServer: true });
+const server = createServer((request, response) => {
+ const upstreamRequest = httpRequest(
+ {
+ headers: request.headers,
+ host,
+ method: request.method,
+ path: request.url,
+ port: appPort,
+ },
+ (upstreamResponse) => {
+ response.writeHead(
+ upstreamResponse.statusCode || 502,
+ upstreamResponse.headers
+ );
+ upstreamResponse.pipe(response);
+ }
+ );
+ upstreamRequest.on("error", (error) => {
+ if (!response.headersSent) {
+ response.writeHead(502, {
+ "Content-Type": "text/plain; charset=utf-8",
+ });
+ }
+ response.end(`HiCAD development proxy failed: ${error.message}`);
+ });
+ request.pipe(upstreamRequest);
+});
+
+server.on("upgrade", (request, socket, head) => {
+ const incomingUrl = new URL(request.url || "/", `http://${host}:${port}`);
+ const upstreamPath = allowedPaths.get(incomingUrl.pathname);
+ if (!upstreamPath) {
+ socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
+ socket.destroy();
+ return;
+ }
+
+ browserSockets.handleUpgrade(request, socket, head, (browserSocket) => {
+ const base = new URL(apiBaseUrl);
+ base.protocol = base.protocol === "http:" ? "ws:" : "wss:";
+ base.pathname = upstreamPath;
+ base.search = incomingUrl.search;
+ const upstreamSocket = new WebSocket(base, {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Origin: "https://app.zoo.dev",
+ },
+ });
+
+ const closeBoth = (code = 1011, reason = "Zoo connection closed") => {
+ if (browserSocket.readyState < WebSocket.CLOSING) {
+ browserSocket.close(code, reason);
+ }
+ if (upstreamSocket.readyState < WebSocket.CLOSING) {
+ upstreamSocket.close(code, reason);
+ }
+ };
+
+ upstreamSocket.on("open", () => {
+ browserSocket.on("message", (data, isBinary) => {
+ if (upstreamSocket.readyState === WebSocket.OPEN) {
+ upstreamSocket.send(data, { binary: isBinary });
+ }
+ });
+ upstreamSocket.on("message", (data, isBinary) => {
+ if (browserSocket.readyState === WebSocket.OPEN) {
+ browserSocket.send(data, { binary: isBinary });
+ }
+ });
+ });
+ browserSocket.on("close", () => closeBoth(1000, "Browser disconnected"));
+ browserSocket.on("error", () => closeBoth());
+ upstreamSocket.on("close", () => closeBoth(1000, "Zoo disconnected"));
+ upstreamSocket.on("error", (error) => {
+ console.error("[HiCAD Zoo WS proxy]", error.message);
+ closeBoth();
+ });
+ });
+});
+
+server.listen(port, host, () => {
+ console.log(`HiCAD development proxy: http://${host}:${port}`);
+});
diff --git a/scripts/vendor-zoo-modeling-app.mjs b/scripts/vendor-zoo-modeling-app.mjs
new file mode 100644
index 000000000..0ccf212a4
--- /dev/null
+++ b/scripts/vendor-zoo-modeling-app.mjs
@@ -0,0 +1,313 @@
+#!/usr/bin/env node
+
+import { spawnSync } from "node:child_process";
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { cp } from "node:fs/promises";
+import path from "node:path";
+
+const repoUrl = "https://github.com/KittyCAD/modeling-app.git";
+const repoRef =
+ process.env.ZOO_MODELING_APP_REF ||
+ "6f87f4e66c07c67f09d8f7db284496a3ae3332e7";
+const repoTarballUrls = [
+ `https://ghfast.top/https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`,
+ `https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`,
+];
+const root = process.cwd();
+const workspaceDir = path.join(root, ".zoo-modeling-app");
+const sourceDir = path.join(workspaceDir, "modeling-app");
+const sourceRefFile = path.join(sourceDir, ".hicad-source-ref");
+const outputDir = path.join(root, "public/vendor/zoo-modeling-app");
+const proxyToken = "hicad-session";
+
+function run(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd || root,
+ env: { ...process.env, ...(options.env || {}) },
+ shell: false,
+ stdio: "inherit",
+ });
+
+ if (result.status !== 0) {
+ throw new Error(`${command} ${args.join(" ")} failed`);
+ }
+}
+
+function tryRun(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd || root,
+ env: { ...process.env, ...(options.env || {}) },
+ shell: false,
+ stdio: "inherit",
+ });
+ return result.status === 0;
+}
+
+function downloadOfficialSource() {
+ mkdirSync(workspaceDir, { recursive: true });
+ const archivePath = path.join(workspaceDir, "modeling-app-main.tar.gz");
+ const extractedDir = path.join(workspaceDir, `modeling-app-${repoRef}`);
+ rmSync(archivePath, { force: true });
+ rmSync(extractedDir, { force: true, recursive: true });
+
+ const downloaded = repoTarballUrls.some((url) =>
+ tryRun("curl", [
+ "--http1.1",
+ "--location",
+ "--fail",
+ "--retry",
+ "3",
+ url,
+ "--output",
+ archivePath,
+ ])
+ );
+ if (!downloaded) {
+ throw new Error("Unable to download KittyCAD/modeling-app");
+ }
+
+ run("tar", ["-xzf", archivePath, "-C", workspaceDir]);
+ rmSync(sourceDir, { force: true, recursive: true });
+ run("mv", [extractedDir, sourceDir]);
+ writeFileSync(sourceRefFile, `${repoRef}\n`);
+}
+
+function ensureOfficialSource() {
+ if (existsSync(path.join(sourceDir, "package.json"))) {
+ if (!existsSync(sourceRefFile)) {
+ writeFileSync(sourceRefFile, `${repoRef}\n`);
+ }
+ const currentRef = readFileSync(sourceRefFile, "utf8").trim();
+ if (currentRef !== repoRef) {
+ downloadOfficialSource();
+ }
+ return;
+ }
+ downloadOfficialSource();
+}
+
+function replaceSource(relativePath, before, after) {
+ const filePath = path.join(sourceDir, relativePath);
+ const source = readFileSync(filePath, "utf8");
+ if (after && source.includes(after)) {
+ return;
+ }
+ if (!source.includes(before)) {
+ throw new Error(`Official source changed; patch failed for ${relativePath}`);
+ }
+ writeFileSync(filePath, source.replace(before, after));
+}
+
+function patchOfficialSource() {
+ replaceSource(
+ "index.html",
+ ' ',
+ ' '
+ );
+ replaceSource(
+ "index.html",
+ ' \n',
+ ""
+ );
+ replaceSource(
+ "index.html",
+ " Zoo Design Studio",
+ " HiCAD"
+ );
+ replaceSource(
+ "src/Router.tsx",
+ " createBrowserRouter,\n createHashRouter,",
+ " createHashRouter,"
+ );
+ replaceSource(
+ "src/Router.tsx",
+ "const createRouter = isDesktop() ? createHashRouter : createBrowserRouter",
+ "const createRouter = createHashRouter"
+ );
+ replaceSource(
+ "src/Router.tsx",
+ "import { isDesktop } from '@src/lib/isDesktop'\n",
+ ""
+ );
+ replaceSource(
+ "src/lib/socket.ts",
+ ` ws.addEventListener('open', () => {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n resolve(ws)\n })`,
+ ` ws.addEventListener('open', () => {\n if (token !== '${proxyToken}') {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n }\n resolve(ws)\n })`
+ );
+ replaceSource(
+ "src/network/websocketConnection.ts",
+ " if (token) {",
+ ` if (token && token !== '${proxyToken}') {`
+ );
+ replaceSource(
+ "src/lib/withBaseURL.ts",
+ `export function withKittycadWebSocketURL(qs: string): string {\n return mergeQueryString(env().VITE_KITTYCAD_WEBSOCKET_URL, qs)\n}\n\nexport function withMlephantWebSocketURL(qs: string): string {\n return mergeQueryString(env().VITE_MLEPHANT_WEBSOCKET_URL, qs)\n}`,
+ `export function withKittycadWebSocketURL(qs: string): string {\n return mergeWebSocketQueryString(env().VITE_KITTYCAD_WEBSOCKET_URL, qs)\n}\n\nexport function withMlephantWebSocketURL(qs: string): string {\n return mergeWebSocketQueryString(env().VITE_MLEPHANT_WEBSOCKET_URL, qs)\n}\n\nfunction mergeWebSocketQueryString(\n url: string | undefined,\n qs: string\n): string {\n if (!url) return ''\n const absoluteUrl =\n typeof window !== 'undefined' && url.startsWith('/')\n ? \`\${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//\${window.location.host}\${url}\`\n : url\n return mergeQueryString(absoluteUrl, qs)\n}`
+ );
+ replaceSource(
+ "src/lang/wasmUtils.ts",
+ `export const wasmUrl = () => {\n const wasmFile = '/kcl_wasm_lib_bg.wasm'\n // Check for test environment override\n if (typeof process !== 'undefined' && process.env?.VITE_WASM_BASE_URL) {\n return process.env.VITE_WASM_BASE_URL + wasmFile\n }\n\n // For when we're in electron (file based) or web server (network based)\n // For some reason relative paths don't work as expected. Otherwise we would\n // just do /wasm_lib_bg.wasm. In particular, the issue arises when the path\n // is used from within worker.ts.\n const fullUrl = document.location.protocol.includes('http')\n ? document.location.origin + '/kcl_wasm_lib_bg.wasm'\n : document.location.protocol +\n webSafeJoin(webSafePathSplit(document.location.pathname).slice(0, -1)) +\n wasmFile\n\n return fullUrl\n}`,
+ `export const wasmUrl = () => {\n const wasmFile = 'kcl_wasm_lib_bg.wasm'\n if (typeof process !== 'undefined' && process.env?.VITE_WASM_BASE_URL) {\n return process.env.VITE_WASM_BASE_URL + '/' + wasmFile\n }\n\n return new URL(wasmFile, document.baseURI).toString()\n}`
+ );
+ replaceSource(
+ "src/lang/wasmUtils.ts",
+ "import { webSafeJoin, webSafePathSplit } from '@src/lib/paths'\n",
+ ""
+ );
+ replaceSource(
+ "src/components/UserSidebarMenu.tsx",
+ " const displayedName = getDisplayName(user)\n",
+ ` const isHiCadEmbedded = env().VITE_ZOO_API_TOKEN === '${proxyToken}'\n const displayedName = isHiCadEmbedded ? 'HiCAD' : getDisplayName(user)\n`
+ );
+ replaceSource(
+ "src/components/UserSidebarMenu.tsx",
+ ` [\n {\n id: 'account',`,
+ ` [\n {\n id: 'hicad-home',\n Element: 'button',\n children: 'Back to HiCAD',\n onClick: () => window.top?.location.assign('/'),\n className: isHiCadEmbedded ? '' : 'hidden',\n },\n {\n id: 'account',`
+ );
+ replaceSource(
+ "src/components/UserSidebarMenu.tsx",
+ ` ),\n },\n {\n id: 'settings',`,
+ ` ),\n className: isHiCadEmbedded ? 'hidden' : '',\n },\n {\n id: 'settings',`
+ );
+ replaceSource(
+ "src/components/UserSidebarMenu.tsx",
+ " className: '',",
+ " className: isHiCadEmbedded ? 'hidden' : '',"
+ );
+ replaceSource(
+ "src/components/UserSidebarMenu.tsx",
+ " {user?.image && !imageLoadFailed ? (",
+ " {!isHiCadEmbedded && user?.image && !imageLoadFailed ? ("
+ );
+ replaceSource(
+ "src/components/UserSidebarMenu.tsx",
+ " {displayedName !== user.email && (",
+ " {!isHiCadEmbedded && displayedName !== user.email && ("
+ );
+}
+
+function ensureDependencies() {
+ if (existsSync(path.join(sourceDir, "node_modules/.package-lock.json"))) {
+ return;
+ }
+ run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], {
+ cwd: sourceDir,
+ });
+}
+
+function ensureWasm() {
+ const wasmJs = path.join(
+ sourceDir,
+ "rust/kcl-wasm-lib/pkg/kcl_wasm_lib.js"
+ );
+ const wasmBinary = path.join(
+ sourceDir,
+ "rust/kcl-wasm-lib/pkg/kcl_wasm_lib_bg.wasm"
+ );
+ if (!(existsSync(wasmJs) && existsSync(wasmBinary))) {
+ const cargoEnv = {
+ CARGO_HTTP_MULTIPLEXING: "false",
+ CARGO_REGISTRIES_CRATES_IO_INDEX:
+ process.env.CARGO_REGISTRIES_CRATES_IO_INDEX ||
+ "sparse+https://rsproxy.cn/index/",
+ CARGO_REGISTRIES_CRATES_IO_PROTOCOL: "sparse",
+ };
+ run(
+ "wasm-pack",
+ [
+ "build",
+ "kcl-wasm-lib",
+ "--release",
+ "--target",
+ "web",
+ "--out-dir",
+ "pkg",
+ "--scope",
+ "kittycad",
+ "--no-opt",
+ ],
+ { cwd: path.join(sourceDir, "rust"), env: cargoEnv }
+ );
+ run(
+ "cargo",
+ [
+ "test",
+ "-p",
+ "kcl-lib",
+ "--features",
+ "artifact-graph",
+ "export_bindings",
+ ],
+ { cwd: path.join(sourceDir, "rust"), env: cargoEnv }
+ );
+ }
+
+ copyFileSync(
+ path.join(sourceDir, "rust/kcl-wasm-lib/README.md"),
+ path.join(sourceDir, "rust/kcl-wasm-lib/pkg/README.md")
+ );
+ copyFileSync(wasmBinary, path.join(sourceDir, "public/kcl_wasm_lib_bg.wasm"));
+}
+
+async function main() {
+ ensureOfficialSource();
+ ensureDependencies();
+ ensureWasm();
+ patchOfficialSource();
+
+ const buildEnv = {
+ VITE_KITTYCAD_WEBSOCKET_URL:
+ process.env.HICAD_ZOO_MODELING_WEBSOCKET_URL ||
+ "/api/ai-cad/zoo-ws/modeling/commands",
+ VITE_MLEPHANT_WEBSOCKET_URL:
+ process.env.HICAD_ZOO_AGENT_WEBSOCKET_URL ||
+ "/api/ai-cad/zoo-ws/ml/copilot",
+ VITE_ZOO_API_BASE_URL: "/api/ai-cad/zoo",
+ VITE_ZOO_API_TOKEN: proxyToken,
+ VITE_ZOO_BASE_DOMAIN: "zoo.dev",
+ };
+ rmSync(path.join(sourceDir, "build"), { force: true, recursive: true });
+ run("npx", ["vite", "build", "--base=./", "--mode=production"], {
+ cwd: sourceDir,
+ env: buildEnv,
+ });
+
+ const buildDir = path.join(sourceDir, "build");
+ if (!existsSync(path.join(buildDir, "index.html"))) {
+ throw new Error("Zoo Design Studio build did not produce index.html");
+ }
+
+ rmSync(outputDir, { force: true, recursive: true });
+ mkdirSync(path.dirname(outputDir), { recursive: true });
+ await cp(buildDir, outputDir, { recursive: true });
+
+ const license = path.join(sourceDir, "LICENSE");
+ if (existsSync(license)) {
+ await cp(license, path.join(outputDir, "LICENSE"));
+ }
+ writeFileSync(
+ path.join(outputDir, "SOURCE.txt"),
+ [
+ "HiCAD web frontend based on Zoo Design Studio",
+ `Source: ${repoUrl}`,
+ `Source commit: ${repoRef}`,
+ "License: MIT",
+ "Authentication: HiCAD session with server-side Zoo proxy",
+ "",
+ ].join("\n")
+ );
+
+ console.log(`HiCAD official frontend copied to ${outputDir}`);
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+});