805 lines
29 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,
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>
)
}