'use client' // Node registry bootstrap is loaded once at the root via // `` in `app/layout.tsx` — no per-page side-effect // import here. import { applySceneGraphToEditor, Editor, type SceneGraph, type SidebarTab, } from '@pascal-app/editor' import { Bot, Hammer, Layers } from 'lucide-react' import Image from 'next/image' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PASCAL_AGENT_ACTIVITY_EVENT, type PascalAgentActivityDetail, } from '@/lib/pascal-agent-browser-events' import { withPascalBasePath } from '@/lib/pascal-base-path' import { AiDesignAgentPanel } from './ai-design-agent-panel' import { BuildTab } from './build-tab' import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar' export interface SceneMeta { id: string name: string projectId: string | null thumbnailUrl: string | null version: number createdAt: string updatedAt: string ownerId: string | null sizeBytes: number nodeCount: number } const BASE_SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ { id: 'site', label: '场景', component: () => null, // Built-in SitePanel handles this mobileDefaultSnap: 0.5, mobileIcon: , icon: ( ), }, { id: 'build', label: '搭建', component: BuildTab, mobileDefaultSnap: 0.5, mobileIcon: , icon: ( ), }, ] interface SceneLoaderProps { initialScene: SceneGraph meta: SceneMeta } type SceneGraphWithCollections = SceneGraph & { collections?: Record } interface LiveSceneEvent { eventId: number sceneId: string version: number kind: string createdAt: string graph: SceneGraphWithCollections } interface RemoteSceneSnapshot extends SceneMeta { graph: SceneGraphWithCollections } const AGENT_SCENE_POLL_MS = 700 function sceneGraphSignature(graph: SceneGraphWithCollections): string { return JSON.stringify({ nodes: graph.nodes, rootNodeIds: graph.rootNodeIds, collections: graph.collections, }) } export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const router = useRouter() const versionRef = useRef(meta.version) const lastRemoteGraphJsonRef = useRef(null) const suppressRemoteSaveUntilRef = useRef(0) const [conflict, setConflict] = useState(false) const [saveError, setSaveError] = useState(null) const sidebarTabs = useMemo<(SidebarTab & { component: React.ComponentType })[]>(() => { function SceneAgentPanel() { return } return [ ...BASE_SIDEBAR_TABS, { id: 'ai', label: 'AI 设计助手', component: SceneAgentPanel, mobileDefaultSnap: 0.72, mobileIcon: , icon: , }, ] }, [meta.id]) const handleLoad = useCallback(async () => initialScene, [initialScene]) const applyRemoteSnapshot = useCallback((snapshot: RemoteSceneSnapshot | LiveSceneEvent) => { if (snapshot.version <= versionRef.current) return false versionRef.current = snapshot.version lastRemoteGraphJsonRef.current = sceneGraphSignature(snapshot.graph) suppressRemoteSaveUntilRef.current = Date.now() + 2500 applySceneGraphToEditor(snapshot.graph) setConflict(false) setSaveError(null) return true }, []) const fetchLatestScene = useCallback(async () => { const response = await fetch(withPascalBasePath(`/api/scenes/${meta.id}`), { cache: 'no-store', }) if (!response.ok) return false const snapshot = (await response.json()) as RemoteSceneSnapshot return applyRemoteSnapshot(snapshot) }, [applyRemoteSnapshot, meta.id]) const handleSave = useCallback( async (graph: SceneGraph, options?: { keepalive?: boolean }) => { const graphJson = sceneGraphSignature(graph) const isRecentRemoteApply = Date.now() < suppressRemoteSaveUntilRef.current if (lastRemoteGraphJsonRef.current === graphJson) { lastRemoteGraphJsonRef.current = null suppressRemoteSaveUntilRef.current = 0 return } if (isRecentRemoteApply) return try { const response = await fetch(withPascalBasePath(`/api/scenes/${meta.id}`), { method: 'PUT', headers: { 'Content-Type': 'application/json', 'If-Match': String(versionRef.current), }, body: JSON.stringify({ name: meta.name, graph }), // `keepalive` lets the request outlive a page unload (the autosave // flush on refresh/close). Browsers cap keepalive bodies at 64KB, so // only the unload flush opts in — normal debounced saves omit it and // can carry arbitrarily large scenes. keepalive: options?.keepalive, }) if (response.status === 409) { setConflict(true) return } if (!response.ok) { setSaveError(`保存失败 (${response.status})`) return } const next = (await response.json()) as SceneMeta versionRef.current = next.version setSaveError(null) } catch (error) { setSaveError(error instanceof Error ? error.message : '保存失败') } }, [meta.id, meta.name], ) useEffect(() => { const source = new EventSource(withPascalBasePath(`/api/scenes/${meta.id}/events`)) source.addEventListener('scene', (event) => { let payload: LiveSceneEvent try { payload = JSON.parse((event as MessageEvent).data) as LiveSceneEvent } catch { return } if (payload.sceneId !== meta.id) return if (payload.version <= versionRef.current) return applyRemoteSnapshot(payload) }) source.addEventListener('error', () => { void fetchLatestScene().catch(() => undefined) if (source.readyState === EventSource.CLOSED) { setSaveError('场景实时连接已断开') } }) return () => source.close() }, [applyRemoteSnapshot, fetchLatestScene, meta.id]) useEffect(() => { let disposed = false let polling = false let timer: ReturnType | null = null const clearTimer = () => { if (timer) clearTimeout(timer) timer = null } const poll = async () => { clearTimer() try { await fetchLatestScene() } catch { // SSE remains active; the next short poll can recover from a transient request failure. } if (!disposed && polling) timer = setTimeout(poll, AGENT_SCENE_POLL_MS) } const handleAgentActivity = (event: Event) => { const detail = (event as CustomEvent).detail if (!detail || detail.sceneId !== meta.id) return polling = detail.running clearTimer() void poll() } window.addEventListener(PASCAL_AGENT_ACTIVITY_EVENT, handleAgentActivity) return () => { disposed = true polling = false clearTimer() window.removeEventListener(PASCAL_AGENT_ACTIVITY_EVENT, handleAgentActivity) } }, [fetchLatestScene, meta.id]) const handleThumb = useCallback( async (_blob: Blob) => { // TODO(phase7): upload thumbnail via POST /api/scenes/[id]/thumbnail. // Stub endpoint is not yet implemented in v0.1 — skip upload for now. await fetch(withPascalBasePath(`/api/scenes/${meta.id}/thumbnail`), { method: 'POST', // Intentionally no body — endpoint is a stub. }).catch(() => { // Swallow errors silently; thumbnail upload is best-effort. }) }, [meta.id], ) return (
{conflict && (

其他会话已先保存,是否刷新?

当前更改尚未保存,请重新加载最新版本。

)} {saveError && !conflict && (

{saveError}

)} } viewerToolbarRight={} />
) }