feat: add hosted AI design agent
This commit is contained in:
parent
7d6879c416
commit
d7ebe1d6f6
26
.env.example
26
.env.example
@ -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
|
||||
|
||||
46
SETUP.md
46
SETUP.md
@ -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
|
||||
|
||||
|
||||
161
apps/editor/app/api/agent/chat/route.ts
Normal file
161
apps/editor/app/api/agent/chat/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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}"` },
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
),
|
||||
|
||||
@ -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 }> }) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
348
apps/editor/components/ai-design-agent-panel.tsx
Normal file
348
apps/editor/components/ai-design-agent-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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 />}
|
||||
/>
|
||||
|
||||
@ -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 = [
|
||||
|
||||
25
apps/editor/lib/pascal-agent-access.ts
Normal file
25
apps/editor/lib/pascal-agent-access.ts
Normal 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'
|
||||
}
|
||||
104
apps/editor/lib/pascal-agent-config.test.ts
Normal file
104
apps/editor/lib/pascal-agent-config.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
39
apps/editor/lib/pascal-agent-config.ts
Normal file
39
apps/editor/lib/pascal-agent-config.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
186
apps/editor/lib/pascal-agent-mcp.ts
Normal file
186
apps/editor/lib/pascal-agent-mcp.ts
Normal 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
|
||||
}
|
||||
}
|
||||
17
apps/editor/lib/pascal-agent-prompt.ts
Normal file
17
apps/editor/lib/pascal-agent-prompt.ts
Normal 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.
|
||||
`
|
||||
}
|
||||
10
apps/editor/lib/pascal-base-path.ts
Normal file
10
apps/editor/lib/pascal-base-path.ts
Normal 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}`
|
||||
}
|
||||
24
apps/editor/lib/pascal-host-identity.ts
Normal file
24
apps/editor/lib/pascal-host-identity.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
55
bun.lock
55
bun.lock
@ -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=="],
|
||||
|
||||
15
turbo.json
15
turbo.json
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
62
wiki/architecture/agent-runtime.md
Normal file
62
wiki/architecture/agent-runtime.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user