feat: add self-hosted pascal editor and 3d modeling entry
This commit is contained in:
parent
b87b44c7b4
commit
7aca7a764d
@ -107,6 +107,13 @@ KITTYCAD_BASE_URL=https://api.zoo.dev
|
||||
ZOO_API_TOKEN=
|
||||
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_CALLBACK_URL=
|
||||
RUXA_VIDEO_CALLBACK_URL=
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -87,5 +87,8 @@ checkpoints/
|
||||
# 第三方参考源码(不提交到git)
|
||||
reference-sources/
|
||||
|
||||
# Local self-hosted Pascal Editor checkout.
|
||||
.pascal-editor/
|
||||
|
||||
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
||||
!lib/db/migrations/*.sql
|
||||
|
||||
@ -1122,6 +1122,7 @@ const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
|
||||
tutorial: Play,
|
||||
imageEditor: Edit,
|
||||
aiCad: Box,
|
||||
model3d: Box,
|
||||
agent: Wand2,
|
||||
videoCanvas: Video,
|
||||
flowBoard: GitBranch,
|
||||
@ -12813,7 +12814,7 @@ export function AdminSettingsClient() {
|
||||
updateLovartHomeSidebarItem(item.key, {
|
||||
label: defaultItem.label,
|
||||
iconUrl: "",
|
||||
enabled: true,
|
||||
enabled: defaultItem.enabled,
|
||||
})
|
||||
}
|
||||
size="sm"
|
||||
|
||||
73
app/(chat)/model3d/page.tsx
Normal file
73
app/(chat)/model3d/page.tsx
Normal 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));
|
||||
}
|
||||
95
app/(chat)/pascal-editor/page.tsx
Normal file
95
app/(chat)/pascal-editor/page.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -78,12 +78,13 @@ export function ChatShell({
|
||||
const isLovartHome = pathname === "/";
|
||||
const isAiCadWorkspace = pathname === "/ai-cad";
|
||||
const isFlowBoardWorkspace = pathname === "/flow-board";
|
||||
const isPascalEditorWorkspace = pathname === "/pascal-editor";
|
||||
|
||||
if (isLovartHome) {
|
||||
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>;
|
||||
}
|
||||
|
||||
|
||||
@ -309,6 +309,7 @@ const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
|
||||
tutorial: PlayCircle,
|
||||
imageEditor: Edit,
|
||||
aiCad: Box,
|
||||
model3d: Box,
|
||||
agent: Wand2,
|
||||
videoCanvas: Clapperboard,
|
||||
flowBoard: Workflow,
|
||||
|
||||
@ -286,6 +286,7 @@ export function LovartSidebar({
|
||||
) : null}
|
||||
{tooltipButton("imageEditor", onImageEditor)}
|
||||
{tooltipButton("aiCad", () => onRoute("/ai-cad"))}
|
||||
{tooltipButton("model3d", () => onRoute("/model3d"))}
|
||||
{agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null}
|
||||
{videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null}
|
||||
{tooltipButton("flowBoard", () => onRoute("/flow-board"))}
|
||||
|
||||
@ -84,6 +84,7 @@ export const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
|
||||
tutorial: PlayCircle,
|
||||
imageEditor: Edit,
|
||||
aiCad: Box,
|
||||
model3d: Box,
|
||||
agent: Wand2,
|
||||
videoCanvas: Clapperboard,
|
||||
flowBoard: Workflow,
|
||||
|
||||
247
components/pascal-editor/pascal-editor-frame.tsx
Normal file
247
components/pascal-editor/pascal-editor-frame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -199,6 +199,9 @@ export function TopNavbar({
|
||||
if (trimmed === "AI-CAD") {
|
||||
return "CAD";
|
||||
}
|
||||
if (trimmed === "建筑编辑") {
|
||||
return "建筑";
|
||||
}
|
||||
if (trimmed === "系统设置") {
|
||||
return "设置";
|
||||
}
|
||||
@ -439,6 +442,19 @@ export function TopNavbar({
|
||||
<span className={navLabelClass}>{getShortLabel("AI-CAD")}</span>
|
||||
</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 && (
|
||||
<DropdownMenu
|
||||
|
||||
102
docs/pascal-editor-integration.md
Normal file
102
docs/pascal-editor-integration.md
Normal 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.
|
||||
@ -41,6 +41,12 @@ export const DEFAULT_LOVART_HOME_SIDEBAR_ITEMS = [
|
||||
label: "AI-CAD",
|
||||
description: "跳转 AI-CAD 页面",
|
||||
},
|
||||
{
|
||||
key: "model3d",
|
||||
label: "3D建模",
|
||||
defaultEnabled: false,
|
||||
description: "跳转 3D 建模工作台",
|
||||
},
|
||||
{
|
||||
key: "agent",
|
||||
label: "风格化",
|
||||
@ -93,10 +99,12 @@ export type LovartHomeSidebarConfig = {
|
||||
|
||||
const DEFAULT_ITEMS = DEFAULT_LOVART_HOME_SIDEBAR_ITEMS.reduce(
|
||||
(accumulator, item) => {
|
||||
const defaultEnabled =
|
||||
"defaultEnabled" in item ? item.defaultEnabled : true;
|
||||
accumulator[item.key] = {
|
||||
label: item.label,
|
||||
iconUrl: "",
|
||||
enabled: true,
|
||||
enabled: defaultEnabled,
|
||||
};
|
||||
return accumulator;
|
||||
},
|
||||
@ -140,12 +148,16 @@ export function normalizeLovartHomeSidebarConfig(
|
||||
const iconUrl = String(itemRaw.iconUrl || "")
|
||||
.trim()
|
||||
.slice(0, 2000);
|
||||
const defaultEnabled =
|
||||
"defaultEnabled" in item ? item.defaultEnabled : true;
|
||||
|
||||
accumulator[item.key] = {
|
||||
label: label || item.label,
|
||||
iconUrl,
|
||||
enabled:
|
||||
typeof itemRaw.enabled === "boolean" ? itemRaw.enabled : true,
|
||||
typeof itemRaw.enabled === "boolean"
|
||||
? itemRaw.enabled
|
||||
: defaultEnabled,
|
||||
};
|
||||
return accumulator;
|
||||
},
|
||||
|
||||
@ -1,7 +1,36 @@
|
||||
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 = {
|
||||
poweredByHeader: false,
|
||||
allowedDevOrigins: ["127.0.0.1"],
|
||||
// 禁用 cacheComponents 以支持 dynamic = "force-dynamic"
|
||||
// cacheComponents: true,
|
||||
// 外部包配置(Next.js 16+)
|
||||
@ -41,6 +70,7 @@ const nextConfig: NextConfig = {
|
||||
// HTTP 安全头
|
||||
async headers() {
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const frameSources = resolvePascalEditorFrameSources(isDev);
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
// 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().
|
||||
"connect-src 'self' data: https: wss:",
|
||||
"media-src 'self' blob: https:",
|
||||
"frame-src 'self'",
|
||||
`frame-src ${frameSources}`,
|
||||
"worker-src 'self' blob:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"pascal:dev": "node scripts/pascal-editor-dev.mjs",
|
||||
"postinstall": "node scripts/security/auto-enable-perf-guard.mjs",
|
||||
"prebuild": "node scripts/security/auto-enable-perf-guard.mjs",
|
||||
"build": "next build",
|
||||
|
||||
202
scripts/pascal-editor-dev.mjs
Normal file
202
scripts/pascal-editor-dev.mjs
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user