feat: add hosted AI design agent

This commit is contained in:
134 2026-07-11 16:24:41 +08:00
parent 7d6879c416
commit d7ebe1d6f6
27 changed files with 1326 additions and 102 deletions

View File

@ -5,3 +5,29 @@
# Dev server port (default: 3000)
# PORT=3000
# AI design agent. The endpoint must implement the OpenAI-compatible chat API.
# PASCAL_AGENT_API_KEY=your_api_key
# PASCAL_AGENT_BASE_URL=https://api.openai.com/v1
# PASCAL_AGENT_MODEL=gpt-4.1-mini
# Optional agent safety limits.
# PASCAL_AGENT_MAX_STEPS=18
# PASCAL_AGENT_STEP_TIMEOUT_MS=90000
# PASCAL_AGENT_TOTAL_TIMEOUT_MS=300000
# Required in production. Configure the reverse proxy to overwrite this header
# before forwarding requests to Pascal; never expose the value to browser code.
# PASCAL_AGENT_PROXY_SECRET=replace_with_a_long_random_value
# Optional when Bun or the built MCP executable are not discoverable automatically.
# PASCAL_MCP_COMMAND=/absolute/path/to/bun
# PASCAL_MCP_ENTRY=/absolute/path/to/packages/mcp/dist/bin/pascal-mcp.js
# Shared scene storage used by the editor and each isolated Pascal MCP process.
# PASCAL_DATA_DIR=/absolute/path/to/pascal-data
# Hosted integration. The reverse proxy must authenticate the request, strip
# any client-supplied user header, and inject the trusted host user id.
# NEXT_PUBLIC_PASCAL_BASE_PATH=/pascal-service
# PASCAL_REQUIRE_HOST_USER=true

View File

@ -2,7 +2,7 @@
## Prerequisites
- [Bun](https://bun.sh/) 1.3+ (or Node.js 18+)
- [Bun](https://bun.sh/) 1.3+ (recommended), or Node.js 22+
## Quick Start
@ -26,7 +26,49 @@ cp .env.example .env
| `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` | No | Enables address search in the editor |
| `PORT` | No | Dev server port (default: 3000) |
The editor works fully without any environment variables.
## AI Design Agent
Saved scenes expose an `AI Designer` sidebar. Configure an OpenAI-compatible
model endpoint before using it:
```env
PASCAL_AGENT_API_KEY=your_api_key
PASCAL_AGENT_BASE_URL=https://api.openai.com/v1
PASCAL_AGENT_MODEL=gpt-4.1-mini
PASCAL_DATA_DIR=/absolute/path/to/pascal-data
PASCAL_AGENT_PROXY_SECRET=replace_with_a_long_random_value
```
The editor and Agent MCP processes must use the same `PASCAL_DATA_DIR` or
`PASCAL_DB_PATH`. Each chat request launches an isolated `pascal-mcp` stdio
process, binds it to the current scene, and closes it when streaming finishes.
This prevents concurrent users from sharing the MCP bridge's in-memory scene.
The default safety limits are 18 tool steps, 90 seconds per tool, and 5 minutes
per turn. Override them with `PASCAL_AGENT_MAX_STEPS`,
`PASCAL_AGENT_STEP_TIMEOUT_MS`, and `PASCAL_AGENT_TOTAL_TIMEOUT_MS`.
When the application runs under Node rather than Bun, make Bun available on
`PATH` or set `PASCAL_MCP_COMMAND` to its absolute path. Set
`PASCAL_MCP_ENTRY` when the built MCP executable is outside the monorepo.
Production requests must pass through a trusted reverse proxy that authenticates
the user, strips any incoming `X-Pascal-Agent-Proxy-Secret` header, and injects
the configured value before forwarding to Pascal. Bind the Pascal service to a
private interface so users cannot bypass the proxy.
For a same-domain hosted deployment, set
`NEXT_PUBLIC_PASCAL_BASE_PATH=/pascal-service` before building and
`PASCAL_REQUIRE_HOST_USER=true` at build and runtime. The reverse proxy must
also strip and inject `X-Pascal-User-Id`. New scenes persist that value as
`ownerId`; list, read, mutation, event-stream, and Agent routes reject scenes
owned by another user.
The standalone app scripts force Next.js to run under Bun because scene
persistence uses built-in SQLite. Starting the app with Node 20 or earlier will
fail when a scene is first loaded or saved.
The core editor works without environment variables; the AI agent remains disabled until configured.
## Monorepo Structure

View File

@ -0,0 +1,161 @@
import { createOpenAI } from '@ai-sdk/openai'
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { validatePascalAgentAccess } from '@/lib/pascal-agent-access'
import { getPascalAgentConfig } from '@/lib/pascal-agent-config'
import { createPascalAgentMcpSession } from '@/lib/pascal-agent-mcp'
import { buildPascalAgentPrompt } from '@/lib/pascal-agent-prompt'
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
export const maxDuration = 600
export const runtime = 'nodejs'
const requestSchema = z.object({
messages: z.array(z.unknown()).min(1).max(100),
sceneId: z
.string()
.min(1)
.max(64)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
})
const AGENT_ERROR_MESSAGE = 'AI design agent is temporarily unavailable. Please try again.'
const SCENE_ERROR_MESSAGE = 'The current scene could not be loaded.'
function accessErrorResponse(result: ReturnType<typeof validatePascalAgentAccess>) {
if (result === 'missing_proxy_secret') {
return NextResponse.json({ error: 'agent_proxy_not_configured' }, { status: 503 })
}
if (result === 'unauthorized') {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
return null
}
export function GET(request: Request) {
const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers))
if (accessError) return accessError
const identity = getPascalHostIdentity(request.headers)
if (!identity.allowed) {
return NextResponse.json({ error: 'host_user_required' }, { status: 401 })
}
const config = getPascalAgentConfig()
return NextResponse.json({
configured: config.configured,
maxSteps: config.maxSteps,
model: config.model,
})
}
export async function POST(request: Request) {
const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers))
if (accessError) return accessError
const identity = getPascalHostIdentity(request.headers)
if (!identity.allowed) {
return NextResponse.json({ error: 'host_user_required' }, { status: 401 })
}
const config = getPascalAgentConfig()
if (!config.configured) {
return NextResponse.json(
{
error: 'agent_not_configured',
message: 'Configure PASCAL_AGENT_API_KEY before using the AI design agent.',
},
{ status: 503 },
)
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'invalid_json' }, { status: 400 })
}
const parsed = requestSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const operations = await getSceneOperations()
const storedScene = await operations.loadStoredScene(parsed.data.sceneId)
if (!storedScene || (identity.userId && storedScene.ownerId !== identity.userId)) {
return NextResponse.json(
{ error: 'scene_unavailable', message: SCENE_ERROR_MESSAGE },
{ status: 404 },
)
}
const abortSignal = AbortSignal.any([request.signal, AbortSignal.timeout(config.totalTimeoutMs)])
let session: Awaited<ReturnType<typeof createPascalAgentMcpSession>>
try {
session = await createPascalAgentMcpSession({
abortSignal,
sceneId: parsed.data.sceneId,
stepTimeoutMs: config.stepTimeoutMs,
})
} catch (error) {
console.error('Pascal agent could not load the requested scene.', error)
return NextResponse.json(
{ error: 'scene_unavailable', message: SCENE_ERROR_MESSAGE },
{ status: 404 },
)
}
const provider = createOpenAI({
apiKey: config.apiKey,
baseURL: config.baseUrl,
name: 'pascal-agent',
})
let finalized = false
const finalize = async (save: boolean) => {
if (finalized) return
finalized = true
try {
if (save && !abortSignal.aborted) {
await session.saveDraft(abortSignal)
}
} finally {
await session.close()
}
}
try {
const result = streamText({
abortSignal,
maxOutputTokens: 1600,
messages: convertToModelMessages(parsed.data.messages as UIMessage[]),
model: provider(config.model),
onAbort: async () => finalize(false),
onError: async () => finalize(false),
onFinish: async () => finalize(true),
stopWhen: stepCountIs(config.maxSteps),
system: buildPascalAgentPrompt({
sceneId: parsed.data.sceneId,
sceneName: session.sceneName,
}),
temperature: 0.2,
tools: session.tools,
})
return result.toUIMessageStreamResponse({
onError: (error) => {
console.error('Pascal agent stream failed.', error)
return AGENT_ERROR_MESSAGE
},
})
} catch (error) {
await finalize(false)
console.error('Pascal agent failed before streaming.', error)
return NextResponse.json(
{ error: 'agent_failed', message: AGENT_ERROR_MESSAGE },
{ status: 500 },
)
}
}

View File

@ -1,3 +1,4 @@
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
import {
guardSceneApiRequest,
sceneApiJson,
@ -22,6 +23,10 @@ export function OPTIONS(request: Request) {
export async function GET(request: Request, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
if (!identity.allowed) {
return sceneApiJson(request, { error: 'host_user_required' }, { status: 401 })
}
const { id } = await params
const operations = await getSceneOperations()
@ -34,6 +39,9 @@ export async function GET(request: Request, { params }: RouteParams) {
if (!scene) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (identity.userId && scene.ownerId !== identity.userId) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const url = new URL(request.url)
const afterFromQuery = Number.parseInt(url.searchParams.get('after') ?? '0', 10)

View File

@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { apiGraphSchema } from '@/lib/graph-schema'
import { getPascalHostIdentity, type PascalHostIdentity } from '@/lib/pascal-host-identity'
import {
guardSceneApiRequest,
sceneApiJson,
@ -25,6 +26,16 @@ const patchSceneSchema = z.object({
expectedVersion: z.number().int().nonnegative().optional(),
})
function hostIdentityError(request: NextRequest, identity: PascalHostIdentity) {
return identity.allowed
? null
: sceneApiJson(request, { error: 'host_user_required' }, { status: 401 })
}
function canAccessScene(identity: PascalHostIdentity, ownerId: string | null): boolean {
return !identity.userId || ownerId === identity.userId
}
export function OPTIONS(request: NextRequest) {
return sceneApiPreflight(request)
}
@ -32,6 +43,9 @@ export function OPTIONS(request: NextRequest) {
export async function GET(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
const identityError = hostIdentityError(request, identity)
if (identityError) return identityError
const { id } = await params
const operations = await getSceneOperations()
@ -40,6 +54,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
if (!scene) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (!canAccessScene(identity, scene.ownerId)) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
return sceneApiJson(request, scene, {
headers: { ETag: `"${scene.version}"` },
})
@ -51,6 +68,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
export async function PUT(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
const identityError = hostIdentityError(request, identity)
if (identityError) return identityError
const { id } = await params
@ -83,6 +103,9 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
if (!existing) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (!canAccessScene(identity, existing.ownerId)) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const meta = await operations.saveScene({
id,
name: parsed.data.name ?? existing.name,
@ -104,12 +127,19 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
const identityError = hostIdentityError(request, identity)
if (identityError) return identityError
const { id } = await params
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const operations = await getSceneOperations()
try {
const existing = await operations.loadStoredScene(id)
if (!existing || !canAccessScene(identity, existing.ownerId)) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const removed = await operations.deleteStoredScene(id, { expectedVersion: ifMatch })
if (!removed) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
@ -123,6 +153,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
const identityError = hostIdentityError(request, identity)
if (identityError) return identityError
const { id } = await params
@ -151,6 +184,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const operations = await getSceneOperations()
try {
const existing = await operations.loadStoredScene(id)
if (!existing || !canAccessScene(identity, existing.ownerId)) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const meta = await operations.renameStoredScene(id, parsed.data.name, { expectedVersion })
return sceneApiJson(request, meta, {
headers: { ETag: `"${meta.version}"` },

View File

@ -1,6 +1,8 @@
import type { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { apiGraphSchema } from '@/lib/graph-schema'
import { withPascalBasePath } from '@/lib/pascal-base-path'
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
@ -26,6 +28,10 @@ export function OPTIONS(request: NextRequest) {
export async function GET(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
if (!identity.allowed) {
return sceneApiJson(request, { error: 'host_user_required' }, { status: 401 })
}
const url = new URL(request.url)
const parsed = listQuerySchema.safeParse({
@ -42,6 +48,7 @@ export async function GET(request: NextRequest) {
const operations = await getSceneOperations()
const scenes = await operations.listScenes({
ownerId: identity.userId ?? undefined,
projectId: parsed.data.projectId,
limit: parsed.data.limit,
})
@ -51,6 +58,10 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
if (!identity.allowed) {
return sceneApiJson(request, { error: 'host_user_required' }, { status: 401 })
}
let body: unknown
try {
@ -77,13 +88,14 @@ export async function POST(request: NextRequest) {
const meta = await operations.saveScene({
id: parsed.data.id,
name: parsed.data.name,
ownerId: identity.userId,
projectId: parsed.data.projectId ?? null,
graph: parsed.data.graph as never,
thumbnailUrl: parsed.data.thumbnailUrl ?? null,
})
return sceneApiJson(request, meta, {
status: 201,
headers: { Location: `/scene/${meta.id}` },
headers: { Location: withPascalBasePath(`/scene/${meta.id}`) },
})
} catch (error) {
return handleStoreError(request, error)

View File

@ -9,6 +9,7 @@ import {
CommunityViewerToolbarLeft,
CommunityViewerToolbarRight,
} from '@/components/viewer-toolbar'
import { withPascalBasePath } from '@/lib/pascal-base-path'
// The open-source editor only ships the built-in catalog (no uploaded items),
// so the Library/Community/Mine source chips and tag filters add nothing —
@ -29,7 +30,7 @@ const SIDEBAR_TABS = [
alt=""
className="h-8 w-8 object-contain"
height={32}
src="/icons/scene.webp"
src={withPascalBasePath('/icons/scene.webp')}
width={32}
/>
),
@ -45,7 +46,7 @@ const SIDEBAR_TABS = [
alt=""
className="h-8 w-8 object-contain"
height={32}
src="/icons/build.webp"
src={withPascalBasePath('/icons/build.webp')}
width={32}
/>
),
@ -61,7 +62,7 @@ const SIDEBAR_TABS = [
alt=""
className="h-8 w-8 object-contain"
height={32}
src="/icons/couch.webp"
src={withPascalBasePath('/icons/couch.webp')}
width={32}
/>
),
@ -77,7 +78,7 @@ const SIDEBAR_TABS = [
alt=""
className="h-8 w-8 object-contain"
height={32}
src="/icons/settings.webp"
src={withPascalBasePath('/icons/settings.webp')}
width={32}
/>
),

View File

@ -2,6 +2,8 @@ import type { SceneGraph } from '@pascal-app/editor'
import { headers } from 'next/headers'
import Link from 'next/link'
import { SceneLoader, type SceneMeta } from '@/components/scene-loader'
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
@ -9,31 +11,13 @@ interface SceneWithGraph extends SceneMeta {
graph: SceneGraph
}
async function resolveBaseUrl(): Promise<string> {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL
}
const h = await headers()
const host = h.get('x-forwarded-host') ?? h.get('host')
const proto = h.get('x-forwarded-proto') ?? 'http'
if (!host) {
return 'http://localhost:3000'
}
return `${proto}://${host}`
}
async function fetchScene(id: string): Promise<SceneWithGraph | null> {
const base = await resolveBaseUrl()
const response = await fetch(`${base}/api/scenes/${encodeURIComponent(id)}`, {
cache: 'no-store',
})
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(`Failed to load scene: ${response.status}`)
}
return (await response.json()) as SceneWithGraph
const identity = getPascalHostIdentity(await headers())
if (!identity.allowed) return null
const operations = await getSceneOperations()
const scene = await operations.loadStoredScene(id)
if (!scene || (identity.userId && scene.ownerId !== identity.userId)) return null
return scene as SceneWithGraph
}
export default async function ScenePage({ params }: { params: Promise<{ id: string }> }) {

View File

@ -2,35 +2,16 @@ import { headers } from 'next/headers'
import Link from 'next/link'
import { CreateSceneButton } from '@/components/save-button'
import type { SceneMeta } from '@/components/scene-loader'
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
async function resolveBaseUrl(): Promise<string> {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL
}
const h = await headers()
const host = h.get('x-forwarded-host') ?? h.get('host')
const proto = h.get('x-forwarded-proto') ?? 'http'
if (!host) {
return 'http://localhost:3000'
}
return `${proto}://${host}`
}
async function fetchScenes(): Promise<SceneMeta[]> {
const base = await resolveBaseUrl()
const response = await fetch(`${base}/api/scenes?limit=50`, {
cache: 'no-store',
})
if (!response.ok) {
return []
}
const payload = (await response.json()) as { scenes?: SceneMeta[] } | SceneMeta[]
if (Array.isArray(payload)) {
return payload
}
return payload.scenes ?? []
const identity = getPascalHostIdentity(await headers())
if (!identity.allowed) return []
const operations = await getSceneOperations()
return operations.listScenes({ limit: 50, ownerId: identity.userId ?? undefined })
}
function formatDate(iso: string): string {

View File

@ -0,0 +1,348 @@
'use client'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import {
ArrowUp,
Bot,
Check,
CircleAlert,
LoaderCircle,
Square,
Trash2,
Wrench,
} from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { withPascalBasePath } from '@/lib/pascal-base-path'
interface AiDesignAgentPanelProps {
sceneId: string
}
interface AgentStatus {
configured: boolean
maxSteps: number
model: string
}
type DisplayPart = {
errorText?: string
state?: string
text?: string
toolName?: string
type: string
}
const STARTER_PROMPTS = [
'设计一个紧凑的两室一厅住宅',
'检查当前模型并修复结构问题',
'增加二层,并创建连接楼梯',
]
const TOOL_LABELS: Record<string, string> = {
add_door: '添加门',
add_window: '添加窗户',
apply_patch: '更新场景结构',
check_collisions: '检查空间碰撞',
create_house_from_brief: '生成建筑方案',
create_level: '创建楼层',
create_roof: '创建屋顶',
create_room: '创建房间',
create_stair_between_levels: '创建楼梯',
create_story_shell: '创建楼层结构',
create_wall: '创建墙体',
delete_node: '删除场景元素',
find_nodes: '查找场景元素',
furnish_room: '布置房间',
get_scene: '读取当前场景',
place_item: '放置物品',
save_scene: '保存设计进度',
validate_scene: '验证建筑结构',
}
function textOnlyHistory(messages: UIMessage[]): UIMessage[] {
return messages
.map((message) => ({
id: message.id,
parts: message.parts.filter((part) => part.type === 'text'),
role: message.role,
}))
.filter((message) => message.parts.length > 0)
.slice(-40) as UIMessage[]
}
function toolNameFromPart(part: DisplayPart): string | null {
if (part.toolName) return part.toolName
if (part.type.startsWith('tool-')) return part.type.slice(5)
return null
}
function ToolActivity({ part }: { part: DisplayPart }) {
const name = toolNameFromPart(part)
if (!name) return null
const isComplete = part.state === 'output-available'
const isError = part.state === 'output-error'
return (
<div className="flex items-center gap-2 border-border/60 border-y px-1 py-2 text-xs">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
{isError ? (
<CircleAlert className="h-3.5 w-3.5 text-destructive" />
) : isComplete ? (
<Check className="h-3.5 w-3.5" />
) : (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{TOOL_LABELS[name] ?? name.replaceAll('_', ' ')}
</div>
<div className={isError ? 'truncate text-destructive' : 'text-muted-foreground'}>
{isError ? part.errorText || '执行失败' : isComplete ? '已完成' : '正在执行'}
</div>
</div>
</div>
)
}
export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
const historyKey = `pascal-agent-history:${sceneId}`
const [input, setInput] = useState('')
const [agentStatus, setAgentStatus] = useState<AgentStatus | null>(null)
const [historyReady, setHistoryReady] = useState(false)
const endRef = useRef<HTMLDivElement>(null)
const transport = useMemo(
() =>
new DefaultChatTransport({
api: withPascalBasePath('/api/agent/chat'),
prepareSendMessagesRequest(request) {
return {
body: {
messages: request.messages,
sceneId,
},
}
},
}),
[sceneId],
)
const { error, messages, sendMessage, setMessages, status, stop } = useChat({
id: `pascal-agent:${sceneId}`,
experimental_throttle: 80,
transport,
})
const isRunning = status === 'submitted' || status === 'streaming'
const latestMessageRenderKey = useMemo(() => {
const latest = messages.at(-1)
if (!latest) return ''
return latest.parts
.map((part) => {
const displayPart = part as DisplayPart
return `${displayPart.type}:${displayPart.state ?? ''}:${displayPart.text?.length ?? 0}`
})
.join('|')
}, [messages])
useEffect(() => {
let active = true
fetch(withPascalBasePath('/api/agent/chat'))
.then(async (response) => {
if (!response.ok) throw new Error('status_unavailable')
return (await response.json()) as AgentStatus
})
.then((next) => {
if (active) setAgentStatus(next)
})
.catch(() => {
if (active) setAgentStatus({ configured: false, maxSteps: 0, model: '' })
})
return () => {
active = false
}
}, [])
useEffect(() => {
try {
const saved = window.localStorage.getItem(historyKey)
if (saved) {
const parsed = JSON.parse(saved) as UIMessage[]
if (Array.isArray(parsed)) setMessages(parsed)
}
} catch {
window.localStorage.removeItem(historyKey)
} finally {
setHistoryReady(true)
}
}, [historyKey, setMessages])
useEffect(() => {
if (!historyReady) return
window.localStorage.setItem(historyKey, JSON.stringify(textOnlyHistory(messages)))
}, [historyKey, historyReady, messages])
useEffect(() => {
if (!latestMessageRenderKey) return
endRef.current?.scrollIntoView({ behavior: isRunning ? 'smooth' : 'auto' })
}, [isRunning, latestMessageRenderKey])
const submit = () => {
const text = input.trim()
if (!text || isRunning || agentStatus?.configured === false) return
sendMessage({ role: 'user', parts: [{ type: 'text', text }] })
setInput('')
}
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex h-12 shrink-0 items-center justify-between border-border/70 border-b px-3">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Bot className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="truncate font-semibold text-sm">AI 设计助手</div>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span
className={`h-1.5 w-1.5 rounded-full ${
agentStatus?.configured ? 'bg-emerald-500' : 'bg-amber-500'
}`}
/>
<span className="truncate">
{agentStatus === null
? '正在检查服务'
: agentStatus.configured
? agentStatus.model
: '尚未配置模型'}
</span>
</div>
</div>
</div>
<button
aria-label="清空对话"
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
disabled={isRunning || messages.length === 0}
onClick={() => {
setMessages([])
window.localStorage.removeItem(historyKey)
}}
title="清空对话"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{messages.length === 0 ? (
<div className="flex min-h-full flex-col justify-center py-6">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-xl bg-muted text-foreground">
<Wrench className="h-5 w-5" />
</div>
<h2 className="font-semibold text-base">从描述开始建模</h2>
<p className="mt-1 max-w-[28rem] text-muted-foreground text-xs leading-5">
描述空间、面积、房间和风格要求,助手会直接操作当前场景并自动检查结果。
</p>
<div className="mt-5 flex flex-col gap-1.5">
{STARTER_PROMPTS.map((prompt) => (
<button
className="w-full rounded-md border border-border/70 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
key={prompt}
onClick={() => setInput(prompt)}
type="button"
>
{prompt}
</button>
))}
</div>
</div>
) : (
<div className="space-y-4">
{messages.map((message) => (
<div
className={message.role === 'user' ? 'flex justify-end' : 'flex justify-start'}
key={message.id}
>
<div
className={
message.role === 'user'
? 'max-w-[88%] rounded-lg bg-primary px-3 py-2 text-primary-foreground text-sm'
: 'w-full min-w-0 space-y-2 text-sm'
}
>
{message.parts.map((rawPart, index) => {
const part = rawPart as DisplayPart
if (part.type === 'text' && part.text) {
return (
<p className="whitespace-pre-wrap leading-6" key={`${message.id}-${index}`}>
{part.text}
</p>
)
}
return <ToolActivity key={`${message.id}-${index}`} part={part} />
})}
</div>
</div>
))}
{status === 'submitted' ? (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
正在分析当前场景
</div>
) : null}
<div ref={endRef} />
</div>
)}
</div>
<div className="shrink-0 border-border/70 border-t p-3">
{agentStatus?.configured === false ? (
<div className="mb-2 flex items-start gap-2 rounded-md bg-amber-500/10 px-2.5 py-2 text-amber-700 text-xs dark:text-amber-300">
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
请先配置 PASCAL_AGENT_API_KEY、模型和接口地址。
</div>
) : null}
{error ? <div className="mb-2 text-destructive text-xs">{error.message}</div> : null}
<div className="flex items-end gap-2 rounded-lg border border-input bg-background p-2 shadow-sm focus-within:ring-1 focus-within:ring-ring">
<textarea
aria-label="输入建模要求"
className="max-h-32 min-h-9 min-w-0 flex-1 resize-none bg-transparent px-1 py-2 text-sm outline-none placeholder:text-muted-foreground"
disabled={agentStatus?.configured === false}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submit()
}
}}
placeholder="描述你想创建或修改的空间"
rows={1}
value={input}
/>
{isRunning ? (
<button
aria-label="停止建模"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground"
onClick={() => stop()}
title="停止建模"
type="button"
>
<Square className="h-3.5 w-3.5 fill-current" />
</button>
) : (
<button
aria-label="发送"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-opacity disabled:opacity-35"
disabled={!input.trim() || agentStatus?.configured === false}
onClick={submit}
title="发送"
type="button"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
)
}

View File

@ -11,6 +11,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/toolbar-tooltip'
import { withPascalBasePath } from '@/lib/pascal-base-path'
import { cn } from '@/lib/utils'
/**
@ -68,37 +69,97 @@ type MepItem = {
// Same icons + ordering as the community Build sidebar, minus presets.
const BUILD_TYPES: BuildType[] = [
{ id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' },
{ id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' },
{ id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' },
{ id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' },
{ id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' },
{ id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' },
{ id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' },
{ id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' },
{ id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' },
{ id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' },
{ id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' },
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' },
{ id: 'wall', label: 'Wall', iconSrc: withPascalBasePath('/icons/wall.webp'), kind: 'wall' },
{ id: 'fence', label: 'Fence', iconSrc: withPascalBasePath('/icons/fence.webp'), kind: 'fence' },
{ id: 'slab', label: 'Slab', iconSrc: withPascalBasePath('/icons/floor.webp'), kind: 'slab' },
{
id: 'ceiling',
label: 'Ceiling',
iconSrc: withPascalBasePath('/icons/ceiling.webp'),
kind: 'ceiling',
},
{ id: 'roof', label: 'Roof', iconSrc: withPascalBasePath('/icons/roof.webp'), kind: 'roof' },
{
id: 'stair',
label: 'Stairs',
iconSrc: withPascalBasePath('/icons/stairs.webp'),
kind: 'stair',
},
{
id: 'elevator',
label: 'Elevator',
iconSrc: withPascalBasePath('/icons/elevator.webp'),
kind: 'elevator',
},
{ id: 'door', label: 'Door', iconSrc: withPascalBasePath('/icons/door.webp'), kind: 'door' },
{
id: 'window',
label: 'Window',
iconSrc: withPascalBasePath('/icons/window.webp'),
kind: 'window',
},
{
id: 'column',
label: 'Column',
iconSrc: withPascalBasePath('/icons/column.webp'),
kind: 'column',
},
{ id: 'shelf', label: 'Shelf', iconSrc: withPascalBasePath('/icons/shelf.webp'), kind: 'shelf' },
{
id: 'spawn',
label: 'Spawn Point',
iconSrc: withPascalBasePath('/icons/spawn-point.webp'),
kind: 'spawn',
},
// Group tile — no tool of its own; opens the MEP sub-grid below (like Roof).
{ id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' },
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
{ id: 'mep', label: 'MEP', iconSrc: withPascalBasePath('/icons/HVAC.webp') },
{
id: 'painting',
label: 'Painting',
iconSrc: withPascalBasePath('/icons/paint.webp'),
mode: 'material-paint',
},
]
// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP
// tools had in the community Build sidebar.
const MEP_ITEMS: MepItem[] = [
{ id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' },
{
id: 'duct-segment',
label: 'Duct',
iconSrc: withPascalBasePath('/icons/duct.webp'),
kind: 'duct-segment',
},
{
id: 'duct-terminal',
label: 'Register',
iconSrc: '/icons/registers.webp',
iconSrc: withPascalBasePath('/icons/registers.webp'),
kind: 'duct-terminal',
},
{ id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' },
{ id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' },
{ id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' },
{ id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' },
{
id: 'hvac-equipment',
label: 'HVAC Unit',
iconSrc: withPascalBasePath('/icons/HVAC.webp'),
kind: 'hvac-equipment',
},
{
id: 'lineset',
label: 'Lineset',
iconSrc: withPascalBasePath('/icons/lineset.webp'),
kind: 'lineset',
},
{
id: 'liquid-line',
label: 'Liquid Line',
iconSrc: withPascalBasePath('/icons/lineset.webp'),
kind: 'liquid-line',
},
{
id: 'pipe-segment',
label: 'DWV Pipe',
iconSrc: withPascalBasePath('/icons/dwv-pipes.webp'),
kind: 'pipe-segment',
},
]
/**
@ -125,7 +186,7 @@ function activatePaintMode(): void {
type RoofFeature = { kind: string; label: string; iconSrc: string }
const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp'
const ROOF_FEATURE_FALLBACK_ICON = withPascalBasePath('/icons/roof.webp')
/**
* Roof accessories surfaced under the Roof tile (a "Features" group). Unlike
@ -411,7 +472,7 @@ export function BuildTab() {
aria-hidden
className="size-4 object-contain"
height={16}
src="/icons/duct-fitting.webp"
src={withPascalBasePath('/icons/duct-fitting.webp')}
width={16}
/>
Add Fitting
@ -441,7 +502,7 @@ export function BuildTab() {
aria-hidden
className="size-4 object-contain"
height={16}
src="/icons/duct-fitting.webp"
src={withPascalBasePath('/icons/duct-fitting.webp')}
width={16}
/>
Add Fitting
@ -465,7 +526,7 @@ export function BuildTab() {
aria-hidden
className="size-4 object-contain"
height={16}
src="/icons/dwv-pipes.webp"
src={withPascalBasePath('/icons/dwv-pipes.webp')}
width={16}
/>
Add Trap

View File

@ -3,6 +3,7 @@
import type { SceneGraph } from '@pascal-app/editor'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { withPascalBasePath } from '@/lib/pascal-base-path'
const EMPTY_GRAPH: SceneGraph = {
nodes: {},
@ -28,7 +29,7 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri
setIsCreating(true)
setError(null)
try {
const response = await fetch('/api/scenes', {
const response = await fetch(withPascalBasePath('/api/scenes'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Untitled scene', graph: EMPTY_GRAPH }),
@ -80,7 +81,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
setIsSaving(true)
setStatus(null)
try {
const response = await fetch(`/api/scenes/${sceneId}`, {
const response = await fetch(withPascalBasePath(`/api/scenes/${sceneId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@ -115,7 +116,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
setIsSaving(true)
setStatus(null)
try {
const response = await fetch('/api/scenes', {
const response = await fetch(withPascalBasePath('/api/scenes'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, graph }),

View File

@ -9,11 +9,13 @@ import {
type SceneGraph,
type SidebarTab,
} from '@pascal-app/editor'
import { Hammer, Layers } from 'lucide-react'
import { Bot, Hammer, Layers } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
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'
@ -30,7 +32,7 @@ export interface SceneMeta {
nodeCount: number
}
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
const BASE_SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
{
id: 'site',
label: 'Scene',
@ -42,7 +44,7 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
alt=""
className="h-8 w-8 object-contain"
height={32}
src="/icons/scene.webp"
src={withPascalBasePath('/icons/scene.webp')}
width={32}
/>
),
@ -58,7 +60,7 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
alt=""
className="h-8 w-8 object-contain"
height={32}
src="/icons/build.webp"
src={withPascalBasePath('/icons/build.webp')}
width={32}
/>
),
@ -98,6 +100,23 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
const suppressRemoteSaveUntilRef = useRef(0)
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const sidebarTabs = useMemo<(SidebarTab & { component: React.ComponentType })[]>(() => {
function SceneAgentPanel() {
return <AiDesignAgentPanel sceneId={meta.id} />
}
return [
...BASE_SIDEBAR_TABS,
{
id: 'ai',
label: 'AI Designer',
component: SceneAgentPanel,
mobileDefaultSnap: 0.72,
mobileIcon: <Bot className="h-5 w-5" />,
icon: <Bot className="h-6 w-6" />,
},
]
}, [meta.id])
const handleLoad = useCallback(async () => initialScene, [initialScene])
@ -113,7 +132,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
if (isRecentRemoteApply) return
try {
const response = await fetch(`/api/scenes/${meta.id}`, {
const response = await fetch(withPascalBasePath(`/api/scenes/${meta.id}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@ -148,7 +167,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
)
useEffect(() => {
const source = new EventSource(`/api/scenes/${meta.id}/events`)
const source = new EventSource(withPascalBasePath(`/api/scenes/${meta.id}/events`))
source.addEventListener('scene', (event) => {
let payload: LiveSceneEvent
@ -181,7 +200,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
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(`/api/scenes/${meta.id}/thumbnail`, {
await fetch(withPascalBasePath(`/api/scenes/${meta.id}/thumbnail`), {
method: 'POST',
// Intentionally no body — endpoint is a stub.
}).catch(() => {
@ -236,7 +255,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
onSave={handleSave}
onThumbnailCapture={handleThumb}
projectId={meta.projectId ?? 'default'}
sidebarTabs={SIDEBAR_TABS}
sidebarTabs={sidebarTabs}
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
viewerToolbarRight={<CommunityViewerToolbarRight />}
/>

View File

@ -41,6 +41,7 @@ import {
import Image from 'next/image'
import { type ReactNode, useCallback } from 'react'
import { flushSync } from 'react-dom'
import { withPascalBasePath } from '@/lib/pascal-base-path'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip'
@ -86,7 +87,7 @@ const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
alt=""
className="h-3.5 w-3.5 object-contain"
height={14}
src="/icons/building.webp"
src={withPascalBasePath('/icons/building.webp')}
width={14}
/>
),
@ -99,7 +100,7 @@ const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
alt=""
className="h-3.5 w-3.5 object-contain"
height={14}
src="/icons/blueprint.webp"
src={withPascalBasePath('/icons/blueprint.webp')}
width={14}
/>
),
@ -121,10 +122,10 @@ const levelModeLabels: Record<string, string> = {
const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const
const wallModeConfig: Record<string, { icon: string; label: string }> = {
up: { icon: '/icons/room.webp', label: 'Full height' },
cutaway: { icon: '/icons/wallcut.webp', label: 'Cutaway' },
down: { icon: '/icons/walllow.webp', label: 'Low' },
translucent: { icon: '/icons/wall.webp', label: 'Translucent' },
up: { icon: withPascalBasePath('/icons/room.webp'), label: 'Full height' },
cutaway: { icon: withPascalBasePath('/icons/wallcut.webp'), label: 'Cutaway' },
down: { icon: withPascalBasePath('/icons/walllow.webp'), label: 'Low' },
translucent: { icon: withPascalBasePath('/icons/wall.webp'), label: 'Translucent' },
}
const SHADING_OPTIONS = [

View File

@ -0,0 +1,25 @@
import { timingSafeEqual } from 'node:crypto'
export const PASCAL_AGENT_PROXY_HEADER = 'x-pascal-agent-proxy-secret'
export type PascalAgentAccessResult = 'allowed' | 'missing_proxy_secret' | 'unauthorized'
function safeEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
if (leftBuffer.length !== rightBuffer.length) return false
return timingSafeEqual(leftBuffer, rightBuffer)
}
export function validatePascalAgentAccess(
headers: Headers,
env: NodeJS.ProcessEnv = process.env,
): PascalAgentAccessResult {
const expected = env.PASCAL_AGENT_PROXY_SECRET?.trim() ?? ''
if (!expected) {
return env.NODE_ENV === 'production' ? 'missing_proxy_secret' : 'allowed'
}
const supplied = headers.get(PASCAL_AGENT_PROXY_HEADER)?.trim() ?? ''
return supplied && safeEqual(supplied, expected) ? 'allowed' : 'unauthorized'
}

View File

@ -0,0 +1,104 @@
import { describe, expect, test } from 'bun:test'
import { PASCAL_AGENT_PROXY_HEADER, validatePascalAgentAccess } from './pascal-agent-access'
import { getPascalAgentConfig } from './pascal-agent-config'
import { isPascalAgentToolAllowed } from './pascal-agent-mcp'
import { buildPascalAgentPrompt } from './pascal-agent-prompt'
import { getPascalHostIdentity } from './pascal-host-identity'
describe('Pascal agent configuration', () => {
test('stays disabled until a model API key is configured', () => {
const config = getPascalAgentConfig({})
expect(config.configured).toBe(false)
expect(config.baseUrl).toBe('https://api.openai.com/v1')
expect(config.maxSteps).toBe(18)
})
test('normalizes endpoints and clamps safety limits', () => {
const config = getPascalAgentConfig({
PASCAL_AGENT_API_KEY: ' test-key ',
PASCAL_AGENT_BASE_URL: 'https://models.example/v1/',
PASCAL_AGENT_MAX_STEPS: '999',
PASCAL_AGENT_MODEL: ' design-model ',
PASCAL_AGENT_STEP_TIMEOUT_MS: '1000',
PASCAL_AGENT_TOTAL_TIMEOUT_MS: '9999999',
})
expect(config).toMatchObject({
apiKey: 'test-key',
baseUrl: 'https://models.example/v1',
configured: true,
maxSteps: 40,
model: 'design-model',
stepTimeoutMs: 10_000,
totalTimeoutMs: 600_000,
})
})
})
describe('Pascal agent proxy access', () => {
test('allows local development without a proxy secret', () => {
expect(validatePascalAgentAccess(new Headers(), { NODE_ENV: 'development' })).toBe('allowed')
})
test('requires a configured and matching proxy secret in production', () => {
expect(validatePascalAgentAccess(new Headers(), { NODE_ENV: 'production' })).toBe(
'missing_proxy_secret',
)
const headers = new Headers({ [PASCAL_AGENT_PROXY_HEADER]: 'proxy-secret' })
expect(
validatePascalAgentAccess(headers, {
NODE_ENV: 'production',
PASCAL_AGENT_PROXY_SECRET: 'proxy-secret',
}),
).toBe('allowed')
expect(
validatePascalAgentAccess(new Headers(), {
NODE_ENV: 'production',
PASCAL_AGENT_PROXY_SECRET: 'proxy-secret',
}),
).toBe('unauthorized')
})
})
describe('Pascal agent scene isolation', () => {
test('blocks tools that can switch or destroy projects', () => {
expect(isPascalAgentToolAllowed('load_scene')).toBe(false)
expect(isPascalAgentToolAllowed('delete_scene')).toBe(false)
expect(isPascalAgentToolAllowed('create_room')).toBe(true)
expect(isPascalAgentToolAllowed('save_scene')).toBe(true)
})
test('binds the system prompt to the active scene', () => {
const prompt = buildPascalAgentPrompt({ sceneId: 'scene-12', sceneName: 'Loft' })
expect(prompt).toContain('scene-12')
expect(prompt).toContain('Loft')
expect(prompt).toContain('validate_scene')
expect(prompt).toContain('check_collisions')
})
})
describe('Pascal host identity', () => {
test('requires a trusted host user in production', () => {
const missing = getPascalHostIdentity(new Headers(), { NODE_ENV: 'production' })
const present = getPascalHostIdentity(
new Headers({ 'x-pascal-user-id': '0de0ce74-ff6f-48c5-a719-9ad70e688ab8' }),
{ NODE_ENV: 'production' },
)
expect(missing.allowed).toBe(false)
expect(present.allowed).toBe(true)
expect(present.userId).toBe('0de0ce74-ff6f-48c5-a719-9ad70e688ab8')
})
test('rejects malformed forwarded identities', () => {
const identity = getPascalHostIdentity(new Headers({ 'x-pascal-user-id': '../another-user' }), {
NODE_ENV: 'production',
})
expect(identity.allowed).toBe(false)
expect(identity.userId).toBeNull()
})
})

View File

@ -0,0 +1,39 @@
export interface PascalAgentConfig {
apiKey: string
baseUrl: string
configured: boolean
maxSteps: number
model: string
stepTimeoutMs: number
totalTimeoutMs: number
}
const DEFAULT_BASE_URL = 'https://api.openai.com/v1'
const DEFAULT_MODEL = 'gpt-4.1-mini'
function readBoundedInteger(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
): number {
const parsed = Number.parseInt(value ?? '', 10)
if (!Number.isFinite(parsed)) return fallback
return Math.min(maximum, Math.max(minimum, parsed))
}
export function getPascalAgentConfig(env: NodeJS.ProcessEnv = process.env): PascalAgentConfig {
const apiKey = env.PASCAL_AGENT_API_KEY?.trim() ?? ''
const baseUrl = (env.PASCAL_AGENT_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/$/, '')
const model = env.PASCAL_AGENT_MODEL?.trim() || DEFAULT_MODEL
return {
apiKey,
baseUrl,
configured: apiKey.length > 0 && model.length > 0,
maxSteps: readBoundedInteger(env.PASCAL_AGENT_MAX_STEPS, 18, 4, 40),
model,
stepTimeoutMs: readBoundedInteger(env.PASCAL_AGENT_STEP_TIMEOUT_MS, 90_000, 10_000, 180_000),
totalTimeoutMs: readBoundedInteger(env.PASCAL_AGENT_TOTAL_TIMEOUT_MS, 300_000, 30_000, 600_000),
}
}

View File

@ -0,0 +1,186 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import { experimental_createMCPClient, type experimental_MCPClient } from '@ai-sdk/mcp'
import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'
import type { ModelMessage, ToolSet } from 'ai'
const BLOCKED_TOOL_NAMES = new Set([
'create_project',
'delete_scene',
'generate_variants',
'get_project_status',
'list_scenes',
'load_scene',
'rename_scene',
])
type RequiredTool = ToolSet[string] & {
execute: NonNullable<ToolSet[string]['execute']>
}
export interface PascalAgentMcpSession {
close: () => Promise<void>
saveDraft: (abortSignal?: AbortSignal) => Promise<void>
sceneName: string
tools: ToolSet
}
function stringEnvironment(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
}
function resolveMcpEntry(env: NodeJS.ProcessEnv): string {
const configured = env.PASCAL_MCP_ENTRY?.trim()
if (configured) return path.resolve(configured)
const candidates = [
path.resolve(process.cwd(), '../../packages/mcp/dist/bin/pascal-mcp.js'),
path.resolve(process.cwd(), 'packages/mcp/dist/bin/pascal-mcp.js'),
path.resolve(process.cwd(), '../packages/mcp/dist/bin/pascal-mcp.js'),
]
const match = candidates.find((candidate) => existsSync(candidate))
if (!match) {
throw new Error(
'Pascal MCP executable was not found. Build @pascal-app/mcp or set PASCAL_MCP_ENTRY.',
)
}
return match
}
function resolveMcpCommand(env: NodeJS.ProcessEnv): string {
const configured = env.PASCAL_MCP_COMMAND?.trim()
if (configured) return configured
return process.versions.bun ? process.execPath : 'bun'
}
function toolExecutionOptions(abortSignal?: AbortSignal) {
return {
abortSignal,
messages: [] as ModelMessage[],
toolCallId: `pascal-agent-${crypto.randomUUID()}`,
}
}
function requireTool(tools: ToolSet, name: string): RequiredTool {
const candidate = tools[name]
if (!candidate?.execute) {
throw new Error(`Pascal MCP tool is unavailable: ${name}`)
}
return candidate as RequiredTool
}
function withStepTimeout(tool: RequiredTool, timeoutMs: number): RequiredTool {
return {
...tool,
execute: async (input, options) => {
const timeoutSignal = AbortSignal.timeout(timeoutMs)
const abortSignal = options.abortSignal
? AbortSignal.any([options.abortSignal, timeoutSignal])
: timeoutSignal
return tool.execute(input, { ...options, abortSignal })
},
}
}
export function isPascalAgentToolAllowed(name: string): boolean {
return !BLOCKED_TOOL_NAMES.has(name)
}
function readSceneName(result: unknown, fallback: string): string {
const structured = (result as { structuredContent?: { name?: unknown } })?.structuredContent
return typeof structured?.name === 'string' && structured.name.trim()
? structured.name.trim()
: fallback
}
export async function createPascalAgentMcpSession(params: {
abortSignal?: AbortSignal
env?: NodeJS.ProcessEnv
sceneId: string
stepTimeoutMs: number
}): Promise<PascalAgentMcpSession> {
const env = params.env ?? process.env
const entry = resolveMcpEntry(env)
const transport = new Experimental_StdioMCPTransport({
args: [entry, '--stdio'],
command: resolveMcpCommand(env),
cwd: path.resolve(path.dirname(entry), '../../../..'),
env: stringEnvironment(env),
stderr: 'inherit',
})
let client: experimental_MCPClient | null = null
try {
client = await experimental_createMCPClient({
name: 'pascal-editor-agent',
transport,
version: '0.1.0',
})
const allTools = (await client.tools()) as ToolSet
const loadScene = requireTool(allTools, 'load_scene')
const loadResult = await loadScene.execute(
{ id: params.sceneId },
toolExecutionOptions(params.abortSignal),
)
const sceneName = readSceneName(loadResult, params.sceneId)
const saveScene = withStepTimeout(requireTool(allTools, 'save_scene'), params.stepTimeoutMs)
const scopedSaveScene: RequiredTool = {
...saveScene,
execute: async (input, options) => {
const candidate = input && typeof input === 'object' ? input : {}
return saveScene.execute(
{
...candidate,
graph: undefined,
id: params.sceneId,
includeCurrentScene: true,
name: sceneName,
projectId: undefined,
},
options,
)
},
}
const tools = Object.fromEntries(
Object.entries(allTools)
.filter(([name]) => isPascalAgentToolAllowed(name))
.map(([name, tool]) => [
name,
name === 'save_scene'
? scopedSaveScene
: withStepTimeout(tool as RequiredTool, params.stepTimeoutMs),
]),
) as ToolSet
let closed = false
const close = async () => {
if (closed) return
closed = true
await client?.close()
}
return {
close,
sceneName,
tools,
saveDraft: async (abortSignal) => {
await scopedSaveScene.execute(
{
id: params.sceneId,
includeCurrentScene: true,
name: sceneName,
saveMode: 'draft',
},
toolExecutionOptions(abortSignal),
)
},
}
} catch (error) {
await client?.close().catch(() => undefined)
throw error
}
}

View File

@ -0,0 +1,17 @@
export function buildPascalAgentPrompt(params: { sceneId: string; sceneName: string }): string {
return `You are the autonomous architectural design agent embedded in Pascal Editor.
You are already bound to scene "${params.sceneName}" (${params.sceneId}). Never create, load, rename, list, or delete another project. Work only on the current scene.
Operating rules:
- Interpret the user's design intent, make reasonable architectural defaults, and start working without asking questions unless a missing constraint makes the task unsafe or impossible.
- Use semantic Pascal tools before low-level patches: create_house_from_brief, create_story_shell, create_room, add_door, add_window, create_stair_between_levels, create_roof, and furnish_room.
- Inspect the current scene before changing an existing design. Preserve unrelated work.
- Pascal uses metres, with X/Z on the ground plane and Y pointing up.
- Build in coherent groups. Call save_scene with saveMode "draft" after each meaningful group so the open editor updates while you work.
- Before finishing, call validate_scene and check_collisions. Repair structural errors and serious collisions, then save_scene with saveMode "checkpoint".
- Never claim a change succeeded unless the corresponding tool succeeded.
- Keep destructive edits narrowly scoped. Prefer update or targeted delete_node operations and preserve the ability to undo.
- Respond in the user's language. Keep progress updates concise and finish with a short summary of what changed and any assumptions.
`
}

View File

@ -0,0 +1,10 @@
const configuredBasePath = process.env.NEXT_PUBLIC_PASCAL_BASE_PATH?.trim() ?? ''
export const PASCAL_BASE_PATH = configuredBasePath
? `/${configuredBasePath.replace(/^\/+|\/+$/g, '')}`
: ''
export function withPascalBasePath(pathname: string): string {
const path = pathname.startsWith('/') ? pathname : `/${pathname}`
return `${PASCAL_BASE_PATH}${path}`
}

View File

@ -0,0 +1,24 @@
export const PASCAL_HOST_USER_HEADER = 'x-pascal-user-id'
export interface PascalHostIdentity {
allowed: boolean
required: boolean
userId: string | null
}
export function getPascalHostIdentity(
headers: Headers,
env: NodeJS.ProcessEnv = process.env,
): PascalHostIdentity {
const required =
env.PASCAL_REQUIRE_HOST_USER === 'true' ||
(env.NODE_ENV === 'production' && env.PASCAL_REQUIRE_HOST_USER !== 'false')
const supplied = headers.get(PASCAL_HOST_USER_HEADER)?.trim() ?? ''
const userId = /^[a-zA-Z0-9_-]{1,128}$/.test(supplied) ? supplied : null
return {
allowed: Boolean(userId) || !required,
required,
userId,
}
}

View File

@ -1,6 +1,10 @@
import type { NextConfig } from 'next'
const configuredBasePath = process.env.NEXT_PUBLIC_PASCAL_BASE_PATH?.trim() ?? ''
const basePath = configuredBasePath ? `/${configuredBasePath.replace(/^\/+|\/+$/g, '')}` : undefined
const nextConfig: NextConfig = {
basePath,
logging: {
browserToTerminal: true,
},

View File

@ -4,13 +4,16 @@
"type": "module",
"private": true,
"scripts": {
"dev": "dotenv -e ../../.env.local -- next dev --port 3002",
"dev": "dotenv -e ../../.env.local -- bun --bun next dev --port 3002",
"build": "dotenv -e ../../.env.local -- next build",
"start": "next start",
"start": "bun --bun next start",
"lint": "biome lint",
"check-types": "next typegen && tsgo --noEmit"
},
"dependencies": {
"@ai-sdk/mcp": "0.0.23",
"@ai-sdk/openai": "2.0.13",
"@ai-sdk/react": "2.0.26",
"@iconify/react": "^6.0.2",
"@number-flow/react": "^0.6.0",
"@pascal-app/core": "*",
@ -23,6 +26,7 @@
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/postcss": "^4.2.1",
"ai": "5.0.117",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"lucide-react": "^1.7.0",

View File

@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "editor",
@ -27,6 +26,9 @@
"name": "editor",
"version": "0.1.0",
"dependencies": {
"@ai-sdk/mcp": "0.0.23",
"@ai-sdk/openai": "2.0.13",
"@ai-sdk/react": "2.0.26",
"@iconify/react": "^6.0.2",
"@number-flow/react": "^0.6.0",
"@pascal-app/core": "*",
@ -39,6 +41,7 @@
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/postcss": "^4.2.1",
"ai": "5.0.117",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"lucide-react": "^1.7.0",
@ -348,6 +351,18 @@
"three": "0.184.0",
},
"packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.24", "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-2.0.24.tgz", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-mflk80YF8hj8vrF9e1IHhovGKC1ubX+sY88pesSk3pUiXfH5VPO8dgzNnxjwsqsCZrnkHcztxS5cSl4TzSiEuA=="],
"@ai-sdk/mcp": ["@ai-sdk/mcp@0.0.23", "https://registry.npmmirror.com/@ai-sdk/mcp/-/mcp-0.0.23.tgz", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.28", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qQgNtg4o3ps2dkF+JHV3U8yEhVQenEDCGv+pjdaiEsDHoOAa5NaaGQbQeC+ZvWx/1X33zDelDF0Py4r7C39aQw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.13", "https://registry.npmmirror.com/@ai-sdk/openai/-/openai-2.0.13.tgz", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.3" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-xt9C0Mg3+ytKnHrXFPR4JeV/V2GWaP7Awgok8eAuFHNcb9jShBLtSO0B3vNm1PtxJOwmT85tzOkZam/fRgHdYw=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-2.0.3.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.28", "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-3.0.28.tgz", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bXlX1WX7E50a2N+AJW+1a/x63m52aPhm+6xYe5THxWrx9vW9NR7E2Ay+1G1ndlCdMdYKo2Fnsd7kBhuyQPaphw=="],
"@ai-sdk/react": ["@ai-sdk/react@2.0.26", "https://registry.npmmirror.com/@ai-sdk/react/-/react-2.0.26.tgz", { "dependencies": { "@ai-sdk/provider-utils": "3.0.7", "ai": "5.0.26", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.25.76 || ^4" }, "optionalPeers": ["zod"] }, "sha512-l1lUnEmB26j/LtBkxVNpPxVTnwyPee0b7xTxiuyyF68D8QyAsoz5DJYK7I2LRhwdgqII7Grw7/WJs6tBiF8SPw=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
@ -960,6 +975,8 @@
"@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="],
"@vercel/oidc": ["@vercel/oidc@3.0.5", "https://registry.npmmirror.com/@vercel/oidc/-/oidc-3.0.5.tgz", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
"@visual-json/core": ["@visual-json/core@0.4.0", "", {}, "sha512-T/HvRIp7r6SehxVnO+akV72NLv2OMBEoQdYTFbpwMi6uU2TaXQOpIOHBG4r9XBz7OqC2e8aedOwal/NtqtzyCg=="],
"@visual-json/react": ["@visual-json/react@0.4.0", "", { "dependencies": { "@visual-json/core": "0.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-qiDrXI7wu9VoTE0TaLoJp7kwRj8SQTpkS/wkts5sBPA8078zdrIKc+0u0Y8wAXGYd3udw9dNfD4kmpO48OEwXQ=="],
@ -976,6 +993,8 @@
"agentation": ["agentation@3.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-iGzBxFVTuZEIKzLY6AExSLAQH6i6SwxV4pAu7v7m3X6bInZ7qlZXAwrEqyc4+EfP4gM7z2RXBF6SF4DeH0f2lA=="],
"ai": ["ai@5.0.117", "https://registry.npmmirror.com/ai/-/ai-5.0.117.tgz", { "dependencies": { "@ai-sdk/gateway": "2.0.24", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uE6HNkdSwxbeHGKP/YbvapwD8fMOpj87wyfT9Z00pbzOh2fpnw5acak/4kzU00SX2vtI9K0uuy+9Tf9ytw5RwA=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@ -1124,6 +1143,8 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"deslop-js": ["deslop-js@0.0.21", "", { "dependencies": { "@oxc-project/types": "^0.132.0", "fast-glob": "^3.3.3", "minimatch": "^10.2.5", "oxc-parser": "^0.132.0", "oxc-resolver": "^11.19.1", "typescript": "^6.0.3" } }, "sha512-1fYusMl4tDaQ/xdHFtLfDe8kFrEeU0Vu0Pfiy2k/Gd4HSqYlQj1Z7iRqldNneRyuCzNMhwNBvhaL07Urbh5VOA=="],
"detect-gpu": ["detect-gpu@5.0.70", "", { "dependencies": { "webgl-constants": "^1.1.1" } }, "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w=="],
@ -1444,6 +1465,8 @@
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema": ["json-schema@0.4.0", "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@ -1806,6 +1829,8 @@
"svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="],
"swr": ["swr@2.4.2", "https://registry.npmmirror.com/swr/-/swr-2.4.2.tgz", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
@ -1824,6 +1849,8 @@
"three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="],
"throttleit": ["throttleit@2.1.0", "https://registry.npmmirror.com/throttleit/-/throttleit-2.1.0.tgz", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@ -1954,6 +1981,18 @@
"zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="],
"@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-2.0.1.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-3.0.20.tgz", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-2.0.0.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.3", "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-3.0.3.tgz", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-kAxIw1nYmFW1g5TvE54ZB3eNtgZna0RnLjPUp1ltz1+t9xkXJIuDT4atrwfau9IbS0BOef38wqrI8CjFfQrxhw=="],
"@ai-sdk/react/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.7", "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-3.0.7.tgz", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-o3BS5/t8KnBL3ubP8k3w77AByOypLm+pkIL/DCw0qKkhDbvhCy+L3hRTGPikpdb8WHcylAeKsjgwOxhj4cqTUA=="],
"@ai-sdk/react/ai": ["ai@5.0.26", "https://registry.npmmirror.com/ai/-/ai-5.0.26.tgz", { "dependencies": { "@ai-sdk/gateway": "1.0.15", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.7", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-bGNtG+nYQ2U+5mzuLbxIg9WxGQJ2u5jv2gYgP8C+CJ1YI4qqIjvjOgGEZWzvNet8jiOGIlqstsht9aQefKzmBw=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@ -1994,6 +2033,12 @@
"agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"ai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-2.0.1.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-3.0.20.tgz", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.0.tgz", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"conf/semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="],
"deslop-js/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@ -2036,6 +2081,14 @@
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@ai-sdk/react/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-2.0.0.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/react/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.15", "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-1.0.15.tgz", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.7" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-xySXoQ29+KbGuGfmDnABx+O6vc7Gj7qugmj1kGpn0rW0rQNn6UKUuvscKMzWyv1Uv05GyC1vqHq8ZhEOLfXscQ=="],
"@ai-sdk/react/ai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-2.0.0.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/react/ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.0.tgz", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],

View File

@ -16,7 +16,20 @@
"POSTGRES_URL",
"NEXT_PUBLIC_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
"SUPABASE_SERVICE_ROLE_KEY"
"SUPABASE_SERVICE_ROLE_KEY",
"PASCAL_AGENT_API_KEY",
"PASCAL_AGENT_BASE_URL",
"PASCAL_AGENT_MODEL",
"PASCAL_AGENT_MAX_STEPS",
"PASCAL_AGENT_STEP_TIMEOUT_MS",
"PASCAL_AGENT_TOTAL_TIMEOUT_MS",
"PASCAL_AGENT_PROXY_SECRET",
"PASCAL_DATA_DIR",
"PASCAL_DB_PATH",
"PASCAL_MCP_COMMAND",
"PASCAL_MCP_ENTRY",
"NEXT_PUBLIC_PASCAL_BASE_PATH",
"PASCAL_REQUIRE_HOST_USER"
]
},
"lint": {

View File

@ -22,6 +22,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
| [node-schemas](node-schemas.md) | Zod schema pattern for node types, `createNode`, `updateNode` |
| [events](events.md) | Typed event bus — emitting and listening to node and grid events |
| [creating-rules](creating-rules.md) | How to add or update a page in this folder |
| [agent-runtime](agent-runtime.md) | Conversational AI runtime, MCP isolation, scene scope, and UI contract |
## Reading order for an architecture review

View File

@ -0,0 +1,62 @@
# AI Agent Runtime
*How the standalone editor lets a conversational agent operate saved scenes through Pascal MCP.*
Applies to: `apps/editor/app/api/agent/**`, `apps/editor/components/ai-design-agent-panel.tsx`, `apps/editor/lib/pascal-agent-*.ts`.
The editor owns the chat surface and model call. Pascal MCP remains the only
agent mutation boundary; the UI must not translate natural language directly
into scene-store writes.
## Process Isolation
`SceneBridge` uses an in-process scene store, so one long-lived MCP process
cannot safely hold active scenes for multiple users. The Agent API therefore
starts one stdio MCP child process per chat turn. Every child process:
1. Opens the shared SQLite scene store.
2. Loads exactly the scene supplied by the authenticated route.
3. Runs the bounded model/tool loop.
4. Saves the final draft and closes.
Do not replace this with a global MCP client unless `SceneBridge` first becomes
request-scoped and concurrency-safe.
## Scene Scope
The server loads the scene before the model receives tools. Cross-project
lifecycle tools such as `create_project`, `list_scenes`, `load_scene`,
`rename_scene`, and `delete_scene` are removed from the model tool set.
`save_scene` is wrapped so the model cannot override the active scene ID or
persist an arbitrary graph payload.
Authentication and ownership checks belong before MCP startup. A host that
embeds the editor must map its authenticated user and project to the requested
scene ID before forwarding a chat turn.
Hosted scene routes read only the reverse-proxy-provided `X-Pascal-User-Id`.
When `PASCAL_REQUIRE_HOST_USER=true`, missing or malformed identities are
rejected. The same identity is written as `ownerId` on scene creation and is
checked by list, load, save, rename, delete, event-stream, server-rendered scene,
and Agent paths.
In production the Agent API also requires `PASCAL_AGENT_PROXY_SECRET`. The
trusted reverse proxy must remove any client-supplied copy of
`X-Pascal-Agent-Proxy-Secret` and inject its own value. The Pascal port must not
be publicly reachable; this service-level gate complements, but does not
replace, host-level user and scene ownership checks.
## Execution Limits
Every turn has independent limits for model/tool steps, individual tool time,
and total duration. The client can abort the response stream; cancellation
closes the MCP child process. Agent prompts require draft saves during work and
validation plus collision checks before the final checkpoint.
## UI Contract
The `AI Designer` panel is a host-provided sidebar tab. It uses the editor's
existing color tokens and responsive sidebar behavior. Tool calls render as
compact activity rows; raw scene payloads are not shown or stored in browser
history. Only user and assistant text is retained locally until a host-owned
conversation store is connected.