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} +
+
+ )} +