805 lines
29 KiB
TypeScript
805 lines
29 KiB
TypeScript
'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<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 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 (
|
||
<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>
|
||
)
|
||
}
|
||
|
||
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<AgentStatus | null>(null)
|
||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||
const [conversations, setConversations] = useState<ConversationSummary[]>([])
|
||
const [conversationBusy, setConversationBusy] = useState(false)
|
||
const [conversationError, setConversationError] = useState('')
|
||
const [historyOpen, setHistoryOpen] = useState(false)
|
||
const [historyReady, setHistoryReady] = useState(false)
|
||
const [pendingRun, setPendingRun] = useState<PendingPascalAgentRun | null>(null)
|
||
const conversationIdRef = useRef<string | null>(null)
|
||
const endRef = useRef<HTMLDivElement>(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<typeof setTimeout> | 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<string | null> => {
|
||
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 (
|
||
<div className="relative 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>
|
||
<div className="flex items-center gap-0.5">
|
||
<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 ${
|
||
historyOpen ? 'bg-muted text-foreground' : ''
|
||
}`}
|
||
onClick={() => setHistoryOpen((open) => !open)}
|
||
title="历史对话"
|
||
type="button"
|
||
>
|
||
<History className="h-4 w-4" />
|
||
</button>
|
||
<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 || conversationBusy}
|
||
onClick={() => void createConversation()}
|
||
title="新建对话"
|
||
type="button"
|
||
>
|
||
<SquarePen className="h-4 w-4" />
|
||
</button>
|
||
<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 || conversationBusy || !conversationId}
|
||
onClick={() => {
|
||
if (conversationId) void deleteConversation(conversationId)
|
||
}}
|
||
title="删除当前对话"
|
||
type="button"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{historyOpen ? (
|
||
<div className="absolute top-12 right-2 left-2 z-30 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-lg">
|
||
<div className="flex h-10 items-center justify-between border-border/70 border-b px-3">
|
||
<span className="font-medium text-xs">历史对话</span>
|
||
<span className="text-[11px] text-muted-foreground">{conversations.length} 条</span>
|
||
</div>
|
||
<div className="max-h-72 overflow-y-auto p-1.5">
|
||
{conversations.length === 0 ? (
|
||
<div className="px-3 py-8 text-center text-muted-foreground text-xs">
|
||
暂无历史对话
|
||
</div>
|
||
) : (
|
||
conversations.map((conversation) => (
|
||
<div
|
||
className={`group flex items-center rounded-md ${
|
||
conversation.id === conversationId ? 'bg-muted' : 'hover:bg-muted/70'
|
||
}`}
|
||
key={conversation.id}
|
||
>
|
||
<button
|
||
className="min-w-0 flex-1 px-2.5 py-2 text-left"
|
||
disabled={conversationBusy || isRunning}
|
||
onClick={() => void activateConversation(conversation.id)}
|
||
type="button"
|
||
>
|
||
<div className="truncate font-medium text-xs">{conversation.title}</div>
|
||
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground">
|
||
<span>{formatConversationTime(conversation.updatedAt)}</span>
|
||
<span>{conversation.messageCount} 条消息</span>
|
||
</div>
|
||
</button>
|
||
<button
|
||
aria-label={`删除对话:${conversation.title}`}
|
||
className="mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-60 transition-colors hover:bg-background hover:text-destructive group-hover:opacity-100"
|
||
disabled={conversationBusy || isRunning}
|
||
onClick={() => void deleteConversation(conversation.id)}
|
||
title="删除对话"
|
||
type="button"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||
{!historyReady ? (
|
||
<div className="flex min-h-full items-center justify-center text-muted-foreground text-xs">
|
||
<LoaderCircle className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||
正在恢复对话
|
||
</div>
|
||
) : 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 = displayPartFrom(rawPart)
|
||
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>
|
||
))}
|
||
</div>
|
||
)}
|
||
{isRunning ? (
|
||
<div
|
||
aria-live="polite"
|
||
className="mt-4 flex items-center gap-2 text-muted-foreground text-xs"
|
||
role="status"
|
||
>
|
||
<LoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||
<span>{runningStatusText}</span>
|
||
</div>
|
||
) : null}
|
||
<div ref={endRef} />
|
||
</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 && !pendingRun ? (
|
||
<div className="mb-2 text-destructive text-xs">{error.message}</div>
|
||
) : null}
|
||
{conversationError ? (
|
||
<div className="mb-2 text-destructive text-xs">{conversationError}</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 || conversationBusy}
|
||
onChange={(event) => setInput(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' && !event.shiftKey) {
|
||
event.preventDefault()
|
||
void 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={() => void stopAgent()}
|
||
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 || conversationBusy}
|
||
onClick={() => void submit()}
|
||
title="发送"
|
||
type="button"
|
||
>
|
||
<ArrowUp className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|