git-subtree-dir: services/pascal-editor git-subtree-split: 312064e6e8122f958d2961e28ea85ad8b9db40c5
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
'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>
|
||
)
|
||
}
|