feat: add self-hosted pascal editor and 3d modeling entry

This commit is contained in:
134 2026-06-30 19:57:52 +08:00
parent b87b44c7b4
commit 7aca7a764d
16 changed files with 798 additions and 5 deletions

View File

@ -107,6 +107,13 @@ KITTYCAD_BASE_URL=https://api.zoo.dev
ZOO_API_TOKEN= ZOO_API_TOKEN=
ZOO_BASE_URL=https://api.zoo.dev ZOO_BASE_URL=https://api.zoo.dev
# Pascal Editor self-hosted workspace. In local development, leaving this blank
# makes /pascal-editor auto-detect http://localhost:3002. For production or a
# fixed deployment, point this to your self-hosted Pascal service or static
# /vendor/pascal-editor/index.html bundle.
NEXT_PUBLIC_PASCAL_EDITOR_URL=
PASCAL_EDITOR_URL=
# RUXA / video callback. # RUXA / video callback.
RUXA_CALLBACK_URL= RUXA_CALLBACK_URL=
RUXA_VIDEO_CALLBACK_URL= RUXA_VIDEO_CALLBACK_URL=

3
.gitignore vendored
View File

@ -87,5 +87,8 @@ checkpoints/
# 第三方参考源码(不提交到git) # 第三方参考源码(不提交到git)
reference-sources/ reference-sources/
# Local self-hosted Pascal Editor checkout.
.pascal-editor/
# Drizzle migrations must be versioned (override the global *.sql ignore above) # Drizzle migrations must be versioned (override the global *.sql ignore above)
!lib/db/migrations/*.sql !lib/db/migrations/*.sql

View File

@ -1122,6 +1122,7 @@ const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
tutorial: Play, tutorial: Play,
imageEditor: Edit, imageEditor: Edit,
aiCad: Box, aiCad: Box,
model3d: Box,
agent: Wand2, agent: Wand2,
videoCanvas: Video, videoCanvas: Video,
flowBoard: GitBranch, flowBoard: GitBranch,
@ -12813,7 +12814,7 @@ export function AdminSettingsClient() {
updateLovartHomeSidebarItem(item.key, { updateLovartHomeSidebarItem(item.key, {
label: defaultItem.label, label: defaultItem.label,
iconUrl: "", iconUrl: "",
enabled: true, enabled: defaultItem.enabled,
}) })
} }
size="sm" size="sm"

View File

@ -0,0 +1,73 @@
import { randomUUID } from "node:crypto";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { and, desc, eq } from "drizzle-orm";
export const dynamic = "force-dynamic";
type Model3dRedirectPageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
function getSearchParam(
params: Record<string, string | string[] | undefined>,
key: string
) {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
}
function buildModel3dUrl(
canvasId: string,
params: Record<string, string | string[] | undefined>
) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (key === "new" || value === undefined) {
continue;
}
if (Array.isArray(value)) {
value.forEach((item) => query.append(key, item));
continue;
}
query.set(key, value);
}
const queryString = query.toString();
return `/canvas/${canvasId}/model3d${queryString ? `?${queryString}` : ""}`;
}
async function getLatestCanvasId(userId: string) {
const [latestProject] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, userId),
eq(canvasProject.workspaceType, "canvas")
)
)
.orderBy(desc(canvasProject.updatedAt))
.limit(1);
return latestProject?.id || null;
}
export default async function Model3dRedirectPage({
searchParams,
}: Model3dRedirectPageProps) {
const params = (await searchParams) || {};
const session = await auth();
if (!session?.user?.id) {
redirect("/login?callbackUrl=%2Fmodel3d");
}
const shouldCreateNew = getSearchParam(params, "new") === "1";
if (shouldCreateNew) {
redirect(buildModel3dUrl(randomUUID(), params));
}
const latestCanvasId = await getLatestCanvasId(session.user.id).catch(() => null);
redirect(buildModel3dUrl(latestCanvasId || randomUUID(), params));
}

View File

@ -0,0 +1,95 @@
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 { PascalEditorFrame } from "@/components/pascal-editor/pascal-editor-frame";
import { getSiteSettings } from "@/lib/db/queries";
const LOCAL_PASCAL_EDITOR_SRC = "/vendor/pascal-editor/index.html";
const DEV_SELF_HOSTED_PASCAL_EDITOR_SRC = "http://localhost:3002";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "建筑编辑",
};
export const runtime = "nodejs";
export default async function PascalEditorPage() {
const [session, siteSettings] = await Promise.all([auth(), getSiteSettings()]);
if (!session?.user?.id) {
redirect("/login?callbackUrl=%2Fpascal-editor");
}
const configuredSrc = normalizePascalEditorUrl(
process.env.NEXT_PUBLIC_PASCAL_EDITOR_URL ||
process.env.PASCAL_EDITOR_URL ||
""
);
const hasLocalEditor = existsSync(
path.join(process.cwd(), "public/vendor/pascal-editor/index.html")
);
const shouldUseDevSelfHosted =
process.env.NODE_ENV === "development" && !configuredSrc && !hasLocalEditor;
const hasDevSelfHostedEditor =
shouldUseDevSelfHosted &&
(await isHttpUrlReachable(DEV_SELF_HOSTED_PASCAL_EDITOR_SRC));
const src =
configuredSrc ||
(hasLocalEditor ? LOCAL_PASCAL_EDITOR_SRC : "") ||
(hasDevSelfHostedEditor ? DEV_SELF_HOSTED_PASCAL_EDITOR_SRC : "") ||
LOCAL_PASCAL_EDITOR_SRC;
const isReady = Boolean(configuredSrc || hasLocalEditor || hasDevSelfHostedEditor);
return (
<PascalEditorFrame
fallbackSrc={LOCAL_PASCAL_EDITOR_SRC}
isReady={isReady}
sourceLabel={
hasDevSelfHostedEditor
? "本机 Pascal Editor"
: configuredSrc
? "Pascal Editor"
: "本地 Pascal Editor"
}
selfHostedSrc={DEV_SELF_HOSTED_PASCAL_EDITOR_SRC}
siteLogo={siteSettings.siteLogo}
siteLogoDark={siteSettings.siteLogoDark}
siteName={siteSettings.siteName}
src={src}
user={session.user}
/>
);
}
function normalizePascalEditorUrl(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (trimmed.startsWith("/")) {
return trimmed;
}
try {
const url = new URL(trimmed);
return url.protocol === "http:" || url.protocol === "https:"
? url.toString()
: "";
} catch {
return "";
}
}
async function isHttpUrlReachable(value: string) {
try {
const response = await fetch(value, {
cache: "no-store",
signal: AbortSignal.timeout(3000),
});
return response.ok;
} catch {
return false;
}
}

View File

@ -78,12 +78,13 @@ export function ChatShell({
const isLovartHome = pathname === "/"; const isLovartHome = pathname === "/";
const isAiCadWorkspace = pathname === "/ai-cad"; const isAiCadWorkspace = pathname === "/ai-cad";
const isFlowBoardWorkspace = pathname === "/flow-board"; const isFlowBoardWorkspace = pathname === "/flow-board";
const isPascalEditorWorkspace = pathname === "/pascal-editor";
if (isLovartHome) { if (isLovartHome) {
return <div className="h-svh w-full overflow-y-auto">{children}</div>; return <div className="h-svh w-full overflow-y-auto">{children}</div>;
} }
if (isAiCadWorkspace || isFlowBoardWorkspace) { if (isAiCadWorkspace || isFlowBoardWorkspace || isPascalEditorWorkspace) {
return <div className="h-svh w-full overflow-hidden">{children}</div>; return <div className="h-svh w-full overflow-hidden">{children}</div>;
} }

View File

@ -309,6 +309,7 @@ const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
tutorial: PlayCircle, tutorial: PlayCircle,
imageEditor: Edit, imageEditor: Edit,
aiCad: Box, aiCad: Box,
model3d: Box,
agent: Wand2, agent: Wand2,
videoCanvas: Clapperboard, videoCanvas: Clapperboard,
flowBoard: Workflow, flowBoard: Workflow,

View File

@ -286,6 +286,7 @@ export function LovartSidebar({
) : null} ) : null}
{tooltipButton("imageEditor", onImageEditor)} {tooltipButton("imageEditor", onImageEditor)}
{tooltipButton("aiCad", () => onRoute("/ai-cad"))} {tooltipButton("aiCad", () => onRoute("/ai-cad"))}
{tooltipButton("model3d", () => onRoute("/model3d"))}
{agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null} {agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null}
{videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null} {videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null}
{tooltipButton("flowBoard", () => onRoute("/flow-board"))} {tooltipButton("flowBoard", () => onRoute("/flow-board"))}

View File

@ -84,6 +84,7 @@ export const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
tutorial: PlayCircle, tutorial: PlayCircle,
imageEditor: Edit, imageEditor: Edit,
aiCad: Box, aiCad: Box,
model3d: Box,
agent: Wand2, agent: Wand2,
videoCanvas: Clapperboard, videoCanvas: Clapperboard,
flowBoard: Workflow, flowBoard: Workflow,

View File

@ -0,0 +1,247 @@
"use client";
import {
AlertTriangle,
ChevronDown,
ExternalLink,
LogOut,
RefreshCw,
UserRound,
} from "lucide-react";
import { useRouter } from "next/navigation";
import type { User } from "next-auth";
import { signOut } from "next-auth/react";
import { useMemo, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { guestRegex } from "@/lib/constants";
import { cn } from "@/lib/utils";
type PascalEditorFrameProps = {
fallbackSrc: string;
isReady: boolean;
selfHostedSrc: string;
siteLogo?: null | string;
siteLogoDark?: null | string;
siteName?: null | string;
src: string;
sourceLabel: string;
user?: null | User;
};
export function PascalEditorFrame({
fallbackSrc,
isReady,
selfHostedSrc,
siteLogo,
siteLogoDark,
siteName,
src,
sourceLabel,
user,
}: PascalEditorFrameProps) {
const router = useRouter();
const [frameKey, setFrameKey] = useState(0);
const [loaded, setLoaded] = useState(false);
const resolvedSrc = useMemo(() => src || fallbackSrc, [fallbackSrc, src]);
const resolvedSiteName = useMemo(
() => String(siteName || "AI Chatbot").trim() || "AI Chatbot",
[siteName]
);
const displayLogo = useMemo(() => {
const darkLogo = String(siteLogoDark || "").trim();
const lightLogo = String(siteLogo || "").trim();
return darkLogo || lightLogo;
}, [siteLogo, siteLogoDark]);
const shouldInvertLogo =
!String(siteLogoDark || "").trim() && Boolean(String(siteLogo || "").trim());
const userEmail = String(user?.email || "").trim();
const userLabel = String(user?.name || userEmail || "已登录用户").trim();
const isGuest = guestRegex.test(userEmail);
const handleAuthAction = async () => {
if (isGuest) {
router.push("/login?callbackUrl=%2Fpascal-editor");
return;
}
try {
await fetch("/api/auth/logout", { method: "POST" });
await signOut({ callbackUrl: "/login", redirect: false });
window.location.replace("/login");
} catch (error) {
console.error("退出登录失败:", error);
window.location.replace("/login");
}
};
const header = (
<div className="flex h-11 shrink-0 items-center justify-between border-white/10 border-b bg-zinc-950 px-3 text-white">
<a
aria-label={`返回首页 - ${resolvedSiteName}`}
className="flex min-w-0 items-center gap-2 transition-opacity hover:opacity-80"
href="/"
>
{displayLogo ? (
<img
alt={resolvedSiteName}
className={cn(
"h-7 w-7 shrink-0 object-contain",
shouldInvertLogo && "brightness-0 invert"
)}
src={displayLogo}
/>
) : (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-white text-center font-semibold text-sm text-zinc-950">
{Array.from(resolvedSiteName)[0] || "A"}
</span>
)}
<span className="min-w-0 truncate font-medium text-sm">
{resolvedSiteName}
</span>
<span className="hidden h-4 w-px shrink-0 bg-white/15 sm:block" />
<span className="hidden shrink-0 text-xs text-zinc-400 sm:block">
建筑编辑
</span>
</a>
<div className="flex shrink-0 items-center gap-2">
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-8 max-w-[180px] items-center gap-2 rounded-md border border-white/10 bg-white/5 px-2 text-white transition-colors hover:bg-white/10"
title={isGuest ? "访客用户" : userLabel}
type="button"
>
{userEmail ? (
<img
alt={userLabel}
className="h-5 w-5 shrink-0 rounded-full"
src={`https://avatar.vercel.sh/${encodeURIComponent(userEmail)}`}
/>
) : (
<UserRound className="h-4 w-4 shrink-0" />
)}
<span className="hidden min-w-0 truncate text-xs sm:block">
{isGuest ? "访客用户" : userLabel}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-zinc-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel className="min-w-0">
<div className="truncate font-medium text-sm">
{isGuest ? "访客用户" : userLabel}
</div>
{userEmail ? (
<div className="truncate font-normal text-muted-foreground text-xs">
{userEmail}
</div>
) : null}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onSelect={(event) => {
event.preventDefault();
void handleAuthAction();
}}
>
<LogOut className="h-4 w-4" />
{isGuest ? "登录正式账号" : "退出登录"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<button
className="flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/5 text-white transition-colors hover:bg-white/10"
onClick={() => {
setLoaded(false);
setFrameKey((key) => key + 1);
}}
title="重新加载"
type="button"
>
<RefreshCw className="h-4 w-4" />
</button>
<a
className="flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/5 text-white transition-colors hover:bg-white/10"
href="/pascal-editor"
rel="noopener noreferrer"
target="_blank"
title="新窗口打开工作区"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
);
if (!isReady) {
return (
<div className="flex h-svh w-full flex-col overflow-hidden bg-zinc-950 text-white">
{header}
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="w-full max-w-2xl rounded-lg border border-white/10 bg-zinc-900 p-6 shadow-2xl">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-300" />
<div className="min-w-0">
<h1 className="font-semibold text-lg">Pascal Editor 尚未配置</h1>
<p className="mt-2 text-sm text-zinc-300 leading-6">
当前页面已经切换为自托管模式。请启动本机 Pascal standalone
服务,或把构建产物放到
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
public/vendor/pascal-editor/index.html
</code>
,或设置
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
NEXT_PUBLIC_PASCAL_EDITOR_URL
</code>
指向自托管 Pascal 服务地址。
</p>
<div className="mt-4 rounded-md border border-white/10 bg-black/30 p-3 font-mono text-xs text-zinc-300">
NEXT_PUBLIC_PASCAL_EDITOR_URL={selfHostedSrc}
</div>
<p className="mt-3 text-xs text-zinc-500">
默认本机服务:{selfHostedSrc};同源静态 bundle:{fallbackSrc}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-svh w-full flex-col overflow-hidden bg-zinc-950">
{header}
<div className="relative min-h-0 flex-1">
{!loaded && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-zinc-950 text-zinc-300">
<div className="flex items-center gap-3 rounded-lg border border-white/10 bg-zinc-900/90 px-4 py-3 shadow-xl">
<RefreshCw className="h-4 w-4 animate-spin" />
<span className="text-sm">正在加载 {sourceLabel}</span>
</div>
</div>
)}
<iframe
allow="clipboard-read; clipboard-write; fullscreen; gamepad; pointer-lock; web-share; xr-spatial-tracking"
className="h-full w-full border-0"
key={frameKey}
onLoad={() => setLoaded(true)}
referrerPolicy="strict-origin-when-cross-origin"
sandbox="allow-downloads allow-forms allow-modals allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts"
src={resolvedSrc}
title="Pascal Editor"
/>
</div>
</div>
);
}

View File

@ -199,6 +199,9 @@ export function TopNavbar({
if (trimmed === "AI-CAD") { if (trimmed === "AI-CAD") {
return "CAD"; return "CAD";
} }
if (trimmed === "建筑编辑") {
return "建筑";
}
if (trimmed === "系统设置") { if (trimmed === "系统设置") {
return "设置"; return "设置";
} }
@ -439,6 +442,19 @@ export function TopNavbar({
<span className={navLabelClass}>{getShortLabel("AI-CAD")}</span> <span className={navLabelClass}>{getShortLabel("AI-CAD")}</span>
</a> </a>
<a
className={navItemBaseClass}
href="/pascal-editor"
onClick={(e) => {
e.preventDefault();
window.location.href = "/pascal-editor";
}}
title="建筑编辑"
>
<Building2 className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("建筑编辑")}</span>
</a>
{/* 功能教程 */} {/* 功能教程 */}
{tutorialEnabled && tutorialVideos.length > 0 && ( {tutorialEnabled && tutorialVideos.length > 0 && (
<DropdownMenu <DropdownMenu

View File

@ -0,0 +1,102 @@
# Pascal Editor Integration
## Current Shape
The app now exposes a first-phase Pascal Editor workspace at `/pascal-editor`.
It is intentionally iframe-based so Pascal can be self-hosted as a standalone
Next app without forcing its unpublished `@pascal-app/nodes` workspace package
into the main application dependency graph.
The wrapper route is protected by the host application's existing login system.
Unauthenticated users are redirected to `/login?callbackUrl=/pascal-editor`.
The embedded Pascal app is self-hosted and does not use Pascal's official hosted
sign-in flow.
The page resolves the editor source in this order:
1. `NEXT_PUBLIC_PASCAL_EDITOR_URL`
2. `PASCAL_EDITOR_URL`
3. `/vendor/pascal-editor/index.html` when that file exists
4. `http://localhost:3002` when that self-hosted Pascal service is reachable in
local development
If none is available, the page shows an explicit configuration state instead of
loading a broken iframe.
## Why Iframe First
`@pascal-app/core`, `@pascal-app/viewer`, and `@pascal-app/editor` are published
on npm, but the official standalone app still depends on `@pascal-app/nodes`
from its monorepo workspace. That package registers the built-in node
definitions used by walls, doors, windows, levels, roofs, stairs, and other
editable building elements. Until it is published or vendored, a pure npm-based
native React integration would be incomplete.
## Deployment Options
### Local Self-Hosted Dev Service
Run Pascal Editor as a local standalone service:
```bash
pnpm pascal:dev
```
The helper clones `pascalorg/editor` into the ignored `.pascal-editor/`
directory on first run, installs its dependencies, and starts `apps/editor` on
`http://localhost:3002`.
Then open:
```text
http://localhost:3000/pascal-editor
```
The host app auto-detects `http://localhost:3002` in development, so no env var
is required for the default local setup.
### Same-Origin Static Bundle
Build Pascal Editor as a static bundle and place it here:
```text
public/vendor/pascal-editor/index.html
```
Then leave `NEXT_PUBLIC_PASCAL_EDITOR_URL` empty or set it explicitly:
```env
NEXT_PUBLIC_PASCAL_EDITOR_URL=/vendor/pascal-editor/index.html
```
### Separate Pascal Service
Run Pascal Editor as its own service, then set:
```env
NEXT_PUBLIC_PASCAL_EDITOR_URL=https://pascal-editor.example.com
```
`next.config.ts` reads this value and allows that origin in `frame-src` so the
iframe is not blocked by our Content Security Policy.
`PASCAL_EDITOR_URL` is also supported for server-side configuration. The final
iframe URL is visible to the browser either way, so do not place secrets in it.
The integration no longer falls back to `editor.pascal.app`, because the
official hosted app can require Pascal sign-in for create, fork, and save
actions. Use a local or self-hosted Pascal service for an auth-independent POC.
Keep any separately deployed Pascal service behind the same private network,
reverse proxy, or SSO boundary if direct access to that service must also be
restricted.
## Next Step
After the iframe POC is stable, the native integration path is:
1. Pin a Pascal upstream commit.
2. Vendor or workspace-link `packages/nodes` as `@pascal-app/nodes`.
3. Add Pascal packages to `transpilePackages`.
4. Upgrade `three` to the Pascal-compatible `^0.184` line and retest existing
3D/CAD surfaces.
5. Wire `Editor` `onLoad` / `onSave` to our project storage APIs.

View File

@ -41,6 +41,12 @@ export const DEFAULT_LOVART_HOME_SIDEBAR_ITEMS = [
label: "AI-CAD", label: "AI-CAD",
description: "跳转 AI-CAD 页面", description: "跳转 AI-CAD 页面",
}, },
{
key: "model3d",
label: "3D建模",
defaultEnabled: false,
description: "跳转 3D 建模工作台",
},
{ {
key: "agent", key: "agent",
label: "风格化", label: "风格化",
@ -93,10 +99,12 @@ export type LovartHomeSidebarConfig = {
const DEFAULT_ITEMS = DEFAULT_LOVART_HOME_SIDEBAR_ITEMS.reduce( const DEFAULT_ITEMS = DEFAULT_LOVART_HOME_SIDEBAR_ITEMS.reduce(
(accumulator, item) => { (accumulator, item) => {
const defaultEnabled =
"defaultEnabled" in item ? item.defaultEnabled : true;
accumulator[item.key] = { accumulator[item.key] = {
label: item.label, label: item.label,
iconUrl: "", iconUrl: "",
enabled: true, enabled: defaultEnabled,
}; };
return accumulator; return accumulator;
}, },
@ -140,12 +148,16 @@ export function normalizeLovartHomeSidebarConfig(
const iconUrl = String(itemRaw.iconUrl || "") const iconUrl = String(itemRaw.iconUrl || "")
.trim() .trim()
.slice(0, 2000); .slice(0, 2000);
const defaultEnabled =
"defaultEnabled" in item ? item.defaultEnabled : true;
accumulator[item.key] = { accumulator[item.key] = {
label: label || item.label, label: label || item.label,
iconUrl, iconUrl,
enabled: enabled:
typeof itemRaw.enabled === "boolean" ? itemRaw.enabled : true, typeof itemRaw.enabled === "boolean"
? itemRaw.enabled
: defaultEnabled,
}; };
return accumulator; return accumulator;
}, },

View File

@ -1,7 +1,36 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
function resolvePascalEditorFrameSource() {
const rawUrl = (
process.env.NEXT_PUBLIC_PASCAL_EDITOR_URL ||
process.env.PASCAL_EDITOR_URL ||
""
).trim();
if (!rawUrl || rawUrl.startsWith("/")) {
return null;
}
try {
const url = new URL(rawUrl);
return url.protocol === "http:" || url.protocol === "https:"
? url.origin
: null;
} catch {
return null;
}
}
function resolvePascalEditorFrameSources(isDev: boolean) {
const sources = ["'self'", resolvePascalEditorFrameSource()];
if (isDev) {
sources.push("http://localhost:3002", "http://127.0.0.1:3002");
}
return sources.filter(Boolean).join(" ");
}
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
poweredByHeader: false, poweredByHeader: false,
allowedDevOrigins: ["127.0.0.1"],
// 禁用 cacheComponents 以支持 dynamic = "force-dynamic" // 禁用 cacheComponents 以支持 dynamic = "force-dynamic"
// cacheComponents: true, // cacheComponents: true,
// 外部包配置(Next.js 16+) // 外部包配置(Next.js 16+)
@ -41,6 +70,7 @@ const nextConfig: NextConfig = {
// HTTP 安全头 // HTTP 安全头
async headers() { async headers() {
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
const frameSources = resolvePascalEditorFrameSources(isDev);
const csp = [ const csp = [
"default-src 'self'", "default-src 'self'",
// Next.js App Router/RSC emits inline bootstrap scripts in production. // Next.js App Router/RSC emits inline bootstrap scripts in production.
@ -53,7 +83,7 @@ const nextConfig: NextConfig = {
// Spark bundles its WASM decoder as a data: URL and initializes it via fetch(). // Spark bundles its WASM decoder as a data: URL and initializes it via fetch().
"connect-src 'self' data: https: wss:", "connect-src 'self' data: https: wss:",
"media-src 'self' blob: https:", "media-src 'self' blob: https:",
"frame-src 'self'", `frame-src ${frameSources}`,
"worker-src 'self' blob:", "worker-src 'self' blob:",
"object-src 'none'", "object-src 'none'",
"base-uri 'self'", "base-uri 'self'",

View File

@ -10,6 +10,7 @@
}, },
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "next dev --turbo",
"pascal:dev": "node scripts/pascal-editor-dev.mjs",
"postinstall": "node scripts/security/auto-enable-perf-guard.mjs", "postinstall": "node scripts/security/auto-enable-perf-guard.mjs",
"prebuild": "node scripts/security/auto-enable-perf-guard.mjs", "prebuild": "node scripts/security/auto-enable-perf-guard.mjs",
"build": "next build", "build": "next build",

View File

@ -0,0 +1,202 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
const cwd = process.cwd();
const pascalDir = path.resolve(
cwd,
process.env.PASCAL_EDITOR_DIR || ".pascal-editor"
);
const repoUrl =
process.env.PASCAL_EDITOR_REPO || "https://github.com/pascalorg/editor.git";
const repoRef = process.env.PASCAL_EDITOR_REF || "main";
const port = process.env.PASCAL_EDITOR_PORT || "3002";
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd,
stdio: "inherit",
...options,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function commandExists(command) {
const result = spawnSync(command, ["--version"], { stdio: "ignore" });
return result.status === 0;
}
function patchNestedNextRoot() {
const configPath = path.join(pascalDir, "apps/editor/next.config.ts");
if (!existsSync(configPath)) {
return;
}
let config = readFileSync(configPath, "utf8");
if (!config.includes("import path from 'node:path'")) {
config = config.replace(
"import type { NextConfig } from 'next'\n",
"import path from 'node:path'\nimport type { NextConfig } from 'next'\n"
);
}
if (!config.includes("root: path.resolve(process.cwd(), '../..')")) {
config = config.replace(
" turbopack: {\n",
" turbopack: {\n root: path.resolve(process.cwd(), '../..'),\n"
);
}
writeFileSync(configPath, config, "utf8");
}
function ensureSparseCheckout() {
run(
"git",
[
"-C",
pascalDir,
"sparse-checkout",
"set",
"apps/editor",
"packages",
"tooling",
"styles",
],
{ cwd }
);
}
function patchCssImports() {
const source = path.join(pascalDir, "styles/elevation.css");
const targetDir = path.join(pascalDir, "apps/editor/styles");
const target = path.join(targetDir, "elevation.css");
const globalsPath = path.join(pascalDir, "apps/editor/app/globals.css");
if (existsSync(source)) {
mkdirSync(targetDir, { recursive: true });
copyFileSync(source, target);
}
if (!existsSync(globalsPath)) {
return;
}
const globals = readFileSync(globalsPath, "utf8");
const patched = globals.replace(
'../../../styles/elevation.css',
'../styles/elevation.css'
);
if (patched !== globals) {
writeFileSync(globalsPath, patched, "utf8");
}
}
function runPackageScript(packageManager, packageDir, scriptName) {
if (packageManager === "bun") {
run("bun", ["--cwd", packageDir, "run", scriptName], { cwd: pascalDir });
return;
}
run("npm", ["--prefix", packageDir, "run", scriptName], { cwd: pascalDir });
}
function ensureBuiltWorkspacePackages(packageManager) {
const requiredBuilds = [
["packages/core", "dist/index.js"],
["packages/viewer", "dist/index.js"],
["packages/nodes", "dist/index.js"],
["packages/mcp", "dist/index.js"],
];
for (const [packageDir, distFile] of requiredBuilds) {
if (existsSync(path.join(pascalDir, packageDir, distFile))) {
continue;
}
runPackageScript(packageManager, packageDir, "build");
}
}
if (!existsSync(pascalDir)) {
mkdirSync(path.dirname(pascalDir), { recursive: true });
run("git", [
"clone",
"--depth",
"1",
"--filter=blob:none",
"--sparse",
"--branch",
repoRef,
repoUrl,
pascalDir,
]);
} else if (!existsSync(path.join(pascalDir, ".git"))) {
console.error(
`${pascalDir} already exists but is not a git checkout. Move it away or set PASCAL_EDITOR_DIR.`
);
process.exit(1);
}
ensureSparseCheckout();
patchCssImports();
const envLocalPath = path.join(pascalDir, ".env.local");
if (!existsSync(envLocalPath)) {
writeFileSync(envLocalPath, `PORT=${port}\n`, "utf8");
}
patchNestedNextRoot();
const hasBun = commandExists("bun");
const hasNpm = commandExists("npm");
const hasNodeModules = existsSync(path.join(pascalDir, "node_modules"));
if (!hasBun && !hasNpm) {
console.error("Pascal Editor needs Bun or npm to install dependencies.");
process.exit(1);
}
if (!hasNodeModules) {
if (hasBun) {
run("bun", ["install"], { cwd: pascalDir });
} else {
run("npm", ["install"], { cwd: pascalDir });
}
}
const packageManager = hasBun ? "bun" : "npm";
ensureBuiltWorkspacePackages(packageManager);
const command = hasBun ? "bun" : "npm";
const args = hasBun
? ["--cwd", "apps/editor", "run", "dev"]
: ["--prefix", "apps/editor", "run", "dev"];
console.log(`Starting self-hosted Pascal Editor on http://localhost:${port}`);
const child = spawn(command, args, {
cwd: pascalDir,
stdio: "inherit",
env: {
...process.env,
PORT: port,
},
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});