diff --git a/.env.example b/.env.example index daa84046e..1c1b5eaa2 100644 --- a/.env.example +++ b/.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 diff --git a/SETUP.md b/SETUP.md index 3dfef51f4..551069acc 100644 --- a/SETUP.md +++ b/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 diff --git a/apps/editor/app/api/agent/chat/route.ts b/apps/editor/app/api/agent/chat/route.ts new file mode 100644 index 000000000..e4a39509f --- /dev/null +++ b/apps/editor/app/api/agent/chat/route.ts @@ -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) { + 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> + 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 }, + ) + } +} diff --git a/apps/editor/app/api/scenes/[id]/events/route.ts b/apps/editor/app/api/scenes/[id]/events/route.ts index 2167a12b2..4bae0e3b9 100644 --- a/apps/editor/app/api/scenes/[id]/events/route.ts +++ b/apps/editor/app/api/scenes/[id]/events/route.ts @@ -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) diff --git a/apps/editor/app/api/scenes/[id]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts index 1712ad4ad..faabeb5e7 100644 --- a/apps/editor/app/api/scenes/[id]/route.ts +++ b/apps/editor/app/api/scenes/[id]/route.ts @@ -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}"` }, diff --git a/apps/editor/app/api/scenes/route.ts b/apps/editor/app/api/scenes/route.ts index 01ffc3ba8..8aecf2d5b 100644 --- a/apps/editor/app/api/scenes/route.ts +++ b/apps/editor/app/api/scenes/route.ts @@ -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) diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index e4ccf7ed3..794b7fde8 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -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} /> ), diff --git a/apps/editor/app/scene/[id]/page.tsx b/apps/editor/app/scene/[id]/page.tsx index 95df49f36..1bfb59a84 100644 --- a/apps/editor/app/scene/[id]/page.tsx +++ b/apps/editor/app/scene/[id]/page.tsx @@ -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 { - 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 { - 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 }> }) { diff --git a/apps/editor/app/scenes/page.tsx b/apps/editor/app/scenes/page.tsx index 39b8dbf07..532e4de3f 100644 --- a/apps/editor/app/scenes/page.tsx +++ b/apps/editor/app/scenes/page.tsx @@ -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 { - 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 { - 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 { diff --git a/apps/editor/components/ai-design-agent-panel.tsx b/apps/editor/components/ai-design-agent-panel.tsx new file mode 100644 index 000000000..b1693c999 --- /dev/null +++ b/apps/editor/components/ai-design-agent-panel.tsx @@ -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 = { + 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 ( +
+ + {isError ? ( + + ) : isComplete ? ( + + ) : ( + + )} + +
+
+ {TOOL_LABELS[name] ?? name.replaceAll('_', ' ')} +
+
+ {isError ? part.errorText || '执行失败' : isComplete ? '已完成' : '正在执行'} +
+
+
+ ) +} + +export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { + const historyKey = `pascal-agent-history:${sceneId}` + const [input, setInput] = useState('') + const [agentStatus, setAgentStatus] = useState(null) + const [historyReady, setHistoryReady] = useState(false) + const endRef = useRef(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 ( +
+
+
+ + + +
+
AI 设计助手
+
+ + + {agentStatus === null + ? '正在检查服务' + : agentStatus.configured + ? agentStatus.model + : '尚未配置模型'} + +
+
+
+ +
+ +
+ {messages.length === 0 ? ( +
+
+ +
+

从描述开始建模

+

+ 描述空间、面积、房间和风格要求,助手会直接操作当前场景并自动检查结果。 +

+
+ {STARTER_PROMPTS.map((prompt) => ( + + ))} +
+
+ ) : ( +
+ {messages.map((message) => ( +
+
+ {message.parts.map((rawPart, index) => { + const part = rawPart as DisplayPart + if (part.type === 'text' && part.text) { + return ( +

+ {part.text} +

+ ) + } + return + })} +
+
+ ))} + {status === 'submitted' ? ( +
+ + 正在分析当前场景 +
+ ) : null} +
+
+ )} +
+ +
+ {agentStatus?.configured === false ? ( +
+ + 请先配置 PASCAL_AGENT_API_KEY、模型和接口地址。 +
+ ) : null} + {error ?
{error.message}
: null} +
+