diff --git a/.env.example b/.env.example index f4b956304..f226d0491 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 7ebf32e7f..30becd5e1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index e598d881f..3a064a746 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -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" diff --git a/app/(chat)/model3d/page.tsx b/app/(chat)/model3d/page.tsx new file mode 100644 index 000000000..679124c33 --- /dev/null +++ b/app/(chat)/model3d/page.tsx @@ -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>; +}; + +function getSearchParam( + params: Record, + key: string +) { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; +} + +function buildModel3dUrl( + canvasId: string, + params: Record +) { + 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)); +} diff --git a/app/(chat)/pascal-editor/page.tsx b/app/(chat)/pascal-editor/page.tsx new file mode 100644 index 000000000..7c027d519 --- /dev/null +++ b/app/(chat)/pascal-editor/page.tsx @@ -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 ( + + ); +} + +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; + } +} diff --git a/components/chat-shell.tsx b/components/chat-shell.tsx index 1faa62ede..8a8c0cf1b 100644 --- a/components/chat-shell.tsx +++ b/components/chat-shell.tsx @@ -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
{children}
; } - if (isAiCadWorkspace || isFlowBoardWorkspace) { + if (isAiCadWorkspace || isFlowBoardWorkspace || isPascalEditorWorkspace) { return
{children}
; } diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index a813dd560..5c9565254 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -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, diff --git a/components/home/lovart/lovart-sidebar.tsx b/components/home/lovart/lovart-sidebar.tsx index 825ce8f60..e00a52458 100644 --- a/components/home/lovart/lovart-sidebar.tsx +++ b/components/home/lovart/lovart-sidebar.tsx @@ -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"))} diff --git a/components/home/lovart/utils.ts b/components/home/lovart/utils.ts index 27bd8cfe1..40c20e53f 100644 --- a/components/home/lovart/utils.ts +++ b/components/home/lovart/utils.ts @@ -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, diff --git a/components/pascal-editor/pascal-editor-frame.tsx b/components/pascal-editor/pascal-editor-frame.tsx new file mode 100644 index 000000000..d2479a71e --- /dev/null +++ b/components/pascal-editor/pascal-editor-frame.tsx @@ -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 = ( +
+ + {displayLogo ? ( + {resolvedSiteName} + ) : ( + + {Array.from(resolvedSiteName)[0] || "A"} + + )} + + {resolvedSiteName} + + + + 建筑编辑 + + +
+ {user ? ( + + + + + + +
+ {isGuest ? "访客用户" : userLabel} +
+ {userEmail ? ( +
+ {userEmail} +
+ ) : null} +
+ + { + event.preventDefault(); + void handleAuthAction(); + }} + > + + {isGuest ? "登录正式账号" : "退出登录"} + +
+
+ ) : null} + + + + +
+
+ ); + + if (!isReady) { + return ( +
+ {header} +
+
+
+ +
+

Pascal Editor 尚未配置

+

+ 当前页面已经切换为自托管模式。请启动本机 Pascal standalone + 服务,或把构建产物放到 + + public/vendor/pascal-editor/index.html + + ,或设置 + + NEXT_PUBLIC_PASCAL_EDITOR_URL + + 指向自托管 Pascal 服务地址。 +

+
+ NEXT_PUBLIC_PASCAL_EDITOR_URL={selfHostedSrc} +
+

+ 默认本机服务:{selfHostedSrc};同源静态 bundle:{fallbackSrc} +

+
+
+
+
+
+ ); + } + + return ( +
+ {header} +
+ {!loaded && ( +
+
+ + 正在加载 {sourceLabel} +
+
+ )} +