'use client' import { useChat } from '@ai-sdk/react' import { DefaultChatTransport, type UIMessage } from 'ai' import { ArrowUp, Bot, Check, CircleAlert, History, LoaderCircle, Square, SquarePen, Trash2, Wrench, } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { announcePascalAgentActivity } from '@/lib/pascal-agent-browser-events' import { hasCompletedPascalAgentResponse, type PendingPascalAgentRun, parsePendingPascalAgentRun, } from '@/lib/pascal-agent-recovery' import { withPascalBasePath } from '@/lib/pascal-base-path' interface AiDesignAgentPanelProps { sceneId: string } interface AgentStatus { active?: boolean configured: boolean maxSteps: number model: string } interface ConversationSummary { id: string sceneId: string title: string messageCount: number createdAt: string updatedAt: string } interface Conversation extends ConversationSummary { messages: UIMessage[] } type DisplayPart = { data?: { errorText?: string state?: string toolName?: string } errorText?: string state?: string text?: string toolName?: string type: string } const STARTER_PROMPTS = [ '设计一个紧凑的两室一厅住宅', '检查当前模型并修复结构问题', '增加二层,并创建连接楼梯', ] const PENDING_RUN_GRACE_MS = 10_000 const PENDING_RUN_MAX_AGE_MS = 12 * 60_000 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 displayPartFrom(rawPart: unknown): DisplayPart { const part = rawPart as DisplayPart if (part.type === 'data-pascal-tool-activity' && part.data?.toolName) { return { type: `tool-${part.data.toolName}`, toolName: part.data.toolName, state: part.data.state, errorText: part.data.errorText, } } return part } function formatConversationTime(value: string): string { const date = new Date(value) if (Number.isNaN(date.getTime())) return '' return new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', }).format(date) } 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 ? '已完成' : '正在执行'}
) } function processingStatusText(messages: UIMessage[], status: string): string { if (status === 'submitted') return '正在分析当前场景' for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) { const message = messages[messageIndex] if (message?.role !== 'assistant') continue for (let partIndex = message.parts.length - 1; partIndex >= 0; partIndex--) { const part = displayPartFrom(message.parts[partIndex]) const toolName = toolNameFromPart(part) if (toolName) { if (part.state === 'output-available') return '正在校验并保存建模结果' if (part.state === 'output-error') return '正在调整执行方案' return `正在${TOOL_LABELS[toolName] ?? toolName.replaceAll('_', ' ')}` } if (part.type === 'text' && part.text) return '正在继续处理建模任务' } } return '正在生成建模方案' } export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { const activeConversationKey = `pascal-agent-active-conversation:${sceneId}` const pendingRunKey = `pascal-agent-pending-run:${sceneId}` const [input, setInput] = useState('') const [agentStatus, setAgentStatus] = useState(null) const [conversationId, setConversationId] = useState(null) const [conversations, setConversations] = useState([]) const [conversationBusy, setConversationBusy] = useState(false) const [conversationError, setConversationError] = useState('') const [historyOpen, setHistoryOpen] = useState(false) const [historyReady, setHistoryReady] = useState(false) const [pendingRun, setPendingRun] = useState(null) const conversationIdRef = useRef(null) const endRef = useRef(null) const transport = useMemo( () => new DefaultChatTransport({ api: withPascalBasePath('/api/agent/chat'), prepareSendMessagesRequest(request) { return { body: { ...(conversationIdRef.current ? { conversationId: conversationIdRef.current } : {}), messages: request.messages, sceneId, }, } }, }), [sceneId], ) const { clearError, error, messages, sendMessage, setMessages, status, stop } = useChat({ id: `pascal-agent:${sceneId}`, experimental_throttle: 80, transport, }) const chatIsRunning = status === 'submitted' || status === 'streaming' const isRunning = chatIsRunning || pendingRun !== null const runningStatusText = useMemo( () => pendingRun && !chatIsRunning ? '任务仍在后台执行,正在同步最新结果' : processingStatusText(messages, status), [chatIsRunning, messages, pendingRun, status], ) const latestMessageRenderKey = useMemo(() => { const latest = messages.at(-1) if (!latest) return '' return latest.parts .map((part) => { const displayPart = displayPartFrom(part) return `${displayPart.type}:${displayPart.state ?? ''}:${displayPart.text?.length ?? 0}` }) .join('|') }, [messages]) useEffect(() => { announcePascalAgentActivity(sceneId, isRunning) return () => { if (isRunning) announcePascalAgentActivity(sceneId, false) } }, [isRunning, sceneId]) const clearPendingRun = useCallback( (conversationId?: string) => { setPendingRun((current) => { if (conversationId && current?.conversationId !== conversationId) return current window.localStorage.removeItem(pendingRunKey) return null }) }, [pendingRunKey], ) const trackPendingRun = useCallback( (run: PendingPascalAgentRun) => { window.localStorage.setItem(pendingRunKey, JSON.stringify(run)) setPendingRun(run) }, [pendingRunKey], ) useEffect(() => { const stored = parsePendingPascalAgentRun(window.localStorage.getItem(pendingRunKey)) if (!stored || Date.now() - stored.startedAt > PENDING_RUN_MAX_AGE_MS) { window.localStorage.removeItem(pendingRunKey) return } setPendingRun(stored) }, [pendingRunKey]) 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(() => { let active = true const load = async () => { try { const response = await fetch( withPascalBasePath(`/api/agent/conversations?sceneId=${encodeURIComponent(sceneId)}`), ) if (!response.ok) throw new Error('无法加载历史对话') const data = (await response.json()) as { conversations: ConversationSummary[] } if (!active) return setConversations(data.conversations) const savedId = window.localStorage.getItem(activeConversationKey) const selected = data.conversations.find((item) => item.id === savedId) ? savedId : data.conversations[0]?.id if (!selected) { setMessages([]) return } const detailResponse = await fetch( withPascalBasePath(`/api/agent/conversations/${selected}`), ) if (!detailResponse.ok) throw new Error('无法恢复对话') const detail = (await detailResponse.json()) as { conversation: Conversation } if (!active) return conversationIdRef.current = detail.conversation.id setConversationId(detail.conversation.id) setMessages(detail.conversation.messages) window.localStorage.setItem(activeConversationKey, detail.conversation.id) } catch (loadError) { if (active) { setConversationError(loadError instanceof Error ? loadError.message : '无法加载历史对话') } } finally { if (active) setHistoryReady(true) } } void load() return () => { active = false } }, [activeConversationKey, sceneId, setMessages]) useEffect(() => { if (!historyReady || !pendingRun) return let active = true let timer: ReturnType | undefined const schedule = (delay: number) => { if (!active) return if (timer) clearTimeout(timer) timer = setTimeout(() => void sync(), delay) } const sync = async () => { if (!active) return const age = Date.now() - pendingRun.startedAt if (age > PENDING_RUN_MAX_AGE_MS) { clearPendingRun(pendingRun.conversationId) setConversationError('后台建模任务等待超时,请检查场景结果后重试。') return } try { const query = new URLSearchParams({ conversationId: pendingRun.conversationId, sceneId, }) const [statusResponse, detailResponse] = await Promise.all([ fetch(withPascalBasePath(`/api/agent/chat?${query.toString()}`), { cache: 'no-store', }), fetch(withPascalBasePath(`/api/agent/conversations/${pendingRun.conversationId}`), { cache: 'no-store', }), ]) if (!statusResponse.ok || !detailResponse.ok) throw new Error('recovery_unavailable') const statusData = (await statusResponse.json()) as AgentStatus const detail = (await detailResponse.json()) as { conversation: Conversation } if (!active) return if ( hasCompletedPascalAgentResponse(detail.conversation.messages, pendingRun.userMessageId) ) { conversationIdRef.current = detail.conversation.id setConversationId(detail.conversation.id) setMessages(detail.conversation.messages) window.localStorage.setItem(activeConversationKey, detail.conversation.id) clearError() setConversationError('') clearPendingRun(pendingRun.conversationId) return } if (!statusData.active && age >= PENDING_RUN_GRACE_MS) { clearPendingRun(pendingRun.conversationId) setConversationError('上一次建模任务未能完成,请检查当前场景后重试。') return } setConversationError('') } catch { if (age >= PENDING_RUN_MAX_AGE_MS) { clearPendingRun(pendingRun.conversationId) setConversationError('无法恢复后台建模任务,请稍后重试。') return } } schedule(document.hidden ? 8000 : 1500) } const resumeSync = () => { if (document.visibilityState === 'visible') void sync() } document.addEventListener('visibilitychange', resumeSync) window.addEventListener('focus', resumeSync) void sync() return () => { active = false if (timer) clearTimeout(timer) document.removeEventListener('visibilitychange', resumeSync) window.removeEventListener('focus', resumeSync) } }, [ activeConversationKey, clearError, clearPendingRun, historyReady, pendingRun, sceneId, setMessages, ]) useEffect(() => { if (status !== 'ready' || !conversationIdRef.current || !historyReady) return fetch(withPascalBasePath(`/api/agent/conversations?sceneId=${encodeURIComponent(sceneId)}`)) .then(async (response) => { if (!response.ok) return null return (await response.json()) as { conversations: ConversationSummary[] } }) .then((data) => { if (data) setConversations(data.conversations) }) .catch(() => undefined) }, [historyReady, sceneId, status]) useEffect(() => { if (!latestMessageRenderKey) return endRef.current?.scrollIntoView({ behavior: isRunning ? 'smooth' : 'auto' }) }, [isRunning, latestMessageRenderKey]) const activateConversation = async (id: string) => { if (isRunning || id === conversationIdRef.current) { setHistoryOpen(false) return } setConversationBusy(true) setConversationError('') try { const response = await fetch(withPascalBasePath(`/api/agent/conversations/${id}`)) if (!response.ok) throw new Error('无法加载该对话') const data = (await response.json()) as { conversation: Conversation } conversationIdRef.current = data.conversation.id setConversationId(data.conversation.id) setMessages(data.conversation.messages) window.localStorage.setItem(activeConversationKey, data.conversation.id) setHistoryOpen(false) } catch (loadError) { setConversationError(loadError instanceof Error ? loadError.message : '无法加载该对话') } finally { setConversationBusy(false) } } const createConversation = async (title = '新对话'): Promise => { if (isRunning || conversationBusy) return null setConversationBusy(true) setConversationError('') try { const response = await fetch(withPascalBasePath('/api/agent/conversations'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sceneId, title }), }) if (!response.ok) throw new Error('无法新建对话') const data = (await response.json()) as { conversation: Conversation } conversationIdRef.current = data.conversation.id setConversationId(data.conversation.id) setMessages([]) setConversations((current) => [ data.conversation, ...current.filter((item) => item.id !== data.conversation.id), ]) window.localStorage.setItem(activeConversationKey, data.conversation.id) setHistoryOpen(false) return data.conversation.id } catch (createError) { setConversationError(createError instanceof Error ? createError.message : '无法新建对话') return null } finally { setConversationBusy(false) } } const deleteConversation = async (id: string) => { if (isRunning || conversationBusy) return setConversationBusy(true) setConversationError('') try { const response = await fetch(withPascalBasePath(`/api/agent/conversations/${id}`), { method: 'DELETE', }) if (!response.ok) throw new Error('无法删除对话') const remaining = conversations.filter((item) => item.id !== id) setConversations(remaining) if (conversationIdRef.current === id) { conversationIdRef.current = null setConversationId(null) setMessages([]) window.localStorage.removeItem(activeConversationKey) const next = remaining[0] if (next) { const detailResponse = await fetch( withPascalBasePath(`/api/agent/conversations/${next.id}`), ) if (detailResponse.ok) { const data = (await detailResponse.json()) as { conversation: Conversation } conversationIdRef.current = data.conversation.id setConversationId(data.conversation.id) setMessages(data.conversation.messages) window.localStorage.setItem(activeConversationKey, data.conversation.id) } } } } catch (deleteError) { setConversationError(deleteError instanceof Error ? deleteError.message : '无法删除对话') } finally { setConversationBusy(false) } } const submit = async () => { const text = input.trim() if (!text || isRunning || conversationBusy || agentStatus?.configured === false) return let activeId = conversationIdRef.current if (!activeId) activeId = await createConversation(text.slice(0, 32)) if (!activeId) return setInput('') setConversationError('') clearError() const userMessageId = crypto.randomUUID() trackPendingRun({ conversationId: activeId, startedAt: Date.now(), userMessageId, }) try { await sendMessage({ id: userMessageId, role: 'user', parts: [{ type: 'text', text }] }) } catch { // Recovery polling keeps the server-side task attached to the conversation. } } const stopAgent = async () => { const run = pendingRun stop() if (!run) return try { await fetch(withPascalBasePath('/api/agent/chat'), { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId: run.conversationId, sceneId, }), }) } finally { clearPendingRun(run.conversationId) setConversationError('已停止本次建模任务。') } } return (
AI 设计助手
{agentStatus === null ? '正在检查服务' : agentStatus.configured ? agentStatus.model : '尚未配置模型'}
{historyOpen ? (
历史对话 {conversations.length} 条
{conversations.length === 0 ? (
暂无历史对话
) : ( conversations.map((conversation) => (
)) )}
) : null}
{!historyReady ? (
正在恢复对话
) : messages.length === 0 ? (

从描述开始建模

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

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

{part.text}

) } return })}
))}
)} {isRunning ? (
{runningStatusText}
) : null}
{agentStatus?.configured === false ? (
请先配置 PASCAL_AGENT_API_KEY、模型和接口地址。
) : null} {error && !pendingRun ? (
{error.message}
) : null} {conversationError ? (
{conversationError}
) : null}