ai-chatbot/apps/editor/components/ai-design-agent-panel.tsx
134 c3faeba1c8 Squashed 'services/pascal-editor/' content from commit 312064e6
git-subtree-dir: services/pascal-editor
git-subtree-split: 312064e6e8122f958d2961e28ea85ad8b9db40c5
2026-07-12 00:43:59 +08:00

349 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}