407 lines
16 KiB
TypeScript
407 lines
16 KiB
TypeScript
'use client'
|
||
|
||
import { toast } from 'sonner'
|
||
import copy from 'copy-to-clipboard'
|
||
import {
|
||
Conversation,
|
||
ConversationContent,
|
||
ConversationEmptyState,
|
||
ConversationScrollButton,
|
||
} from '@/components/ai-elements/conversation'
|
||
import { Message, MessageContent, MessageResponse, MessageAttachments, MessageAttachment } from '@/components/ai-elements/message'
|
||
import { Actions, Action } from '@/components/ai-elements/actions'
|
||
import {
|
||
PromptInput,
|
||
PromptInputTextarea,
|
||
PromptInputToolbar,
|
||
PromptInputTools,
|
||
PromptInputSubmit,
|
||
PromptInputAttachments,
|
||
PromptInputAttachment,
|
||
PromptInputActionMenu,
|
||
PromptInputActionMenuTrigger,
|
||
PromptInputActionMenuContent,
|
||
PromptInputActionAddAttachments,
|
||
PromptInputButton,
|
||
} from '@/components/ai-elements/prompt-input'
|
||
import {
|
||
ModelSelector,
|
||
ModelSelectorContent,
|
||
ModelSelectorEmpty,
|
||
ModelSelectorGroup,
|
||
ModelSelectorInput,
|
||
ModelSelectorItem,
|
||
ModelSelectorList,
|
||
ModelSelectorLogo,
|
||
ModelSelectorName,
|
||
ModelSelectorTrigger,
|
||
} from '@/components/ai-elements/model-selector'
|
||
import { MessageSquareIcon, BotIcon, CopyIcon, RefreshCcwIcon, CheckIcon, WrenchIcon } from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import { useChat } from '@ai-sdk/react'
|
||
import { DefaultChatTransport } from 'ai'
|
||
import { useState, useEffect } from 'react'
|
||
import { AGENT_TYPES, AVAILABLE_MODELS, getAgentTypeById, getModelById } from './agents-config'
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
|
||
import { DialogDescription } from '@/components/ui/dialog'
|
||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
|
||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||
|
||
export function DevAIChat() {
|
||
// 智能体、模型和工具的状态管理
|
||
const [selectedAgent, setSelectedAgent] = useState(AGENT_TYPES[0].id)
|
||
const [selectedModel, setSelectedModel] = useState(AGENT_TYPES[0].defaultModel)
|
||
const [selectedTools, setSelectedTools] = useState<string[]>(AGENT_TYPES[0].defaultTools)
|
||
const [agentSelectorOpen, setAgentSelectorOpen] = useState(false)
|
||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false)
|
||
const [toolSelectorOpen, setToolSelectorOpen] = useState(false)
|
||
|
||
const { messages, status, sendMessage, regenerate } = useChat({
|
||
transport: new DefaultChatTransport({
|
||
api: '/api/dev/ai-chat',
|
||
}),
|
||
})
|
||
|
||
// 获取当前选中的智能体配置
|
||
const currentAgent = getAgentTypeById(selectedAgent)
|
||
const currentModel = getModelById(selectedModel)
|
||
|
||
// 当智能体切换时,自动选中默认的模型和工具
|
||
useEffect(() => {
|
||
const agent = getAgentTypeById(selectedAgent)
|
||
if (agent) {
|
||
setSelectedModel(agent.defaultModel)
|
||
setSelectedTools(agent.defaultTools)
|
||
}
|
||
}, [selectedAgent])
|
||
|
||
// 切换工具选择
|
||
const toggleTool = (toolId: string) => {
|
||
setSelectedTools(prev =>
|
||
prev.includes(toolId)
|
||
? prev.filter(id => id !== toolId)
|
||
: [...prev, toolId]
|
||
)
|
||
}
|
||
|
||
// 复制文本到剪贴板
|
||
const handleCopy = (text: string) => {
|
||
const success = copy(text)
|
||
if (success) {
|
||
toast.success('已复制到剪贴板')
|
||
} else {
|
||
toast.error('复制失败')
|
||
}
|
||
}
|
||
|
||
// 重新生成回复
|
||
const handleRegenerate = () => {
|
||
regenerate({
|
||
body: {
|
||
agent: selectedAgent,
|
||
model: selectedModel,
|
||
tools: selectedTools,
|
||
},
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-[98%] flex-col">
|
||
{/* 消息列表区域 - 占据剩余空间 */}
|
||
<ScrollArea className="h-full" >
|
||
<Conversation className="flex-1 min-h-0">
|
||
<ConversationContent>
|
||
{messages.length === 0 ? (
|
||
<ConversationEmptyState
|
||
title="开始对话"
|
||
description="与AI助手对话完成各种开发任务"
|
||
icon={<MessageSquareIcon className="size-8" />}
|
||
/>
|
||
) : (
|
||
messages.map((message, messageIndex) => {
|
||
const isLastMessage = messageIndex === messages.length - 1
|
||
const isAssistant = message.role === 'assistant'
|
||
const messageText = message.parts
|
||
.filter(part => part.type === 'text')
|
||
.map(part => part.type === 'text' ? part.text : '')
|
||
.join('')
|
||
|
||
return (
|
||
<div key={message.id} className="group">
|
||
<Message from={message.role}>
|
||
<MessageContent className='select-text'>
|
||
{/* 先渲染附件 */}
|
||
{message.parts.some(part => part.type === 'file') && (
|
||
<MessageAttachments>
|
||
{message.parts
|
||
.filter(part => part.type === 'file')
|
||
.map((part, i) => (
|
||
<MessageAttachment
|
||
key={`${message.id}-file-${i}`}
|
||
data={part}
|
||
/>
|
||
))}
|
||
</MessageAttachments>
|
||
)}
|
||
|
||
{/* 然后渲染其他内容 */}
|
||
{message.parts.map((part, i) => {
|
||
switch (part.type) {
|
||
case 'text':
|
||
return (
|
||
<MessageResponse key={`${message.id}-${i}`}>
|
||
{part.text}
|
||
</MessageResponse>
|
||
);
|
||
case 'reasoning':
|
||
return (
|
||
<Reasoning
|
||
key={`${message.id}-${i}`}
|
||
className="w-full"
|
||
isStreaming={status === 'streaming' && i === message.parts.length - 1 && message.id === messages.at(-1)?.id}
|
||
>
|
||
<ReasoningTrigger />
|
||
<ReasoningContent>{part.text}</ReasoningContent>
|
||
</Reasoning>
|
||
);
|
||
case 'file':
|
||
// 文件已经在上面单独渲染了
|
||
return null;
|
||
default:
|
||
return null;
|
||
}
|
||
})}
|
||
</MessageContent>
|
||
</Message>
|
||
|
||
{/* 操作按钮 - 悬停时显示 */}
|
||
<Actions className={cn(
|
||
"mt-2 opacity-0 group-hover:opacity-100 transition-opacity",
|
||
message.role === 'user' ? 'mr-1 justify-end' : 'ml-1'
|
||
)}>
|
||
{/* 复制按钮 - 所有消息都有 */}
|
||
<Action
|
||
tooltip="复制"
|
||
label="复制"
|
||
onClick={() => handleCopy(messageText)}
|
||
>
|
||
<CopyIcon className="size-4" />
|
||
</Action>
|
||
|
||
{/* 重新生成按钮 - 仅最后一条AI消息显示 */}
|
||
{isAssistant && isLastMessage && (
|
||
<Action
|
||
tooltip="重新生成"
|
||
label="重新生成"
|
||
onClick={handleRegenerate}
|
||
disabled={status === 'streaming'}
|
||
>
|
||
<RefreshCcwIcon className="size-4" />
|
||
</Action>
|
||
)}
|
||
</Actions>
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</ConversationContent>
|
||
<ConversationScrollButton />
|
||
</Conversation>
|
||
</ScrollArea>
|
||
|
||
{/* 输入框区域 - 固定在底部 */}
|
||
<div className="border-t bg-background">
|
||
<PromptInput
|
||
accept="image/*"
|
||
multiple
|
||
maxFiles={5}
|
||
maxFileSize={50 * 1024 * 1024} // 50MB
|
||
onError={(error) => {
|
||
if ('message' in error) {
|
||
console.error('文件上传错误:', error.message)
|
||
|
||
// 根据错误类型显示不同的提示
|
||
switch (error.code) {
|
||
case 'max_file_size':
|
||
toast.error('文件过大', {
|
||
description: '单个文件大小不能超过 10MB,请压缩后重试'
|
||
})
|
||
break
|
||
case 'max_files':
|
||
toast.error('文件数量超限', {
|
||
description: '最多只能上传 5 个文件'
|
||
})
|
||
break
|
||
case 'accept':
|
||
toast.error('文件类型不支持', {
|
||
description: '仅支持图片文件'
|
||
})
|
||
break
|
||
default:
|
||
toast.error('文件上传失败', {
|
||
description: error.message
|
||
})
|
||
}
|
||
}
|
||
}}
|
||
onSubmit={async (message) => {
|
||
sendMessage(
|
||
{
|
||
text: message.text || '',
|
||
files: message.files,
|
||
},
|
||
{
|
||
body: {
|
||
agent: selectedAgent,
|
||
model: selectedModel,
|
||
tools: selectedTools,
|
||
},
|
||
}
|
||
)
|
||
}}
|
||
className="max-h-[50vh]"
|
||
>
|
||
{/* 附件预览区域 */}
|
||
<PromptInputAttachments>
|
||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||
</PromptInputAttachments>
|
||
|
||
{/* 文本输入框 */}
|
||
<PromptInputTextarea
|
||
placeholder="输入消息或上传图片..."
|
||
className="min-h-[60px] max-h-[40vh] resize-none"
|
||
/>
|
||
|
||
{/* 工具栏 */}
|
||
<PromptInputToolbar>
|
||
<PromptInputTools className='flex-wrap'>
|
||
{/* 智能体选择器 */}
|
||
<Popover open={agentSelectorOpen} onOpenChange={setAgentSelectorOpen}>
|
||
<PopoverTrigger asChild>
|
||
<PromptInputButton>
|
||
<BotIcon className="size-4 text-muted-foreground" />
|
||
<span>{currentAgent?.name}</span>
|
||
</PromptInputButton>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-[300px] p-0" align="start">
|
||
<Command>
|
||
<CommandList>
|
||
<CommandEmpty>未找到智能体</CommandEmpty>
|
||
<CommandGroup>
|
||
{AGENT_TYPES.map((agent) => (
|
||
<CommandItem
|
||
key={agent.id}
|
||
value={agent.id}
|
||
onSelect={() => {
|
||
setSelectedAgent(agent.id)
|
||
setAgentSelectorOpen(false)
|
||
}}
|
||
>
|
||
<div className="flex flex-1 flex-col">
|
||
<span className="font-medium text-sm">{agent.name}</span>
|
||
<span className="text-muted-foreground text-xs">{agent.description}</span>
|
||
</div>
|
||
{selectedAgent === agent.id && (
|
||
<CheckIcon className="ml-2 size-4" />
|
||
)}
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
{/* 模型选择器 */}
|
||
<ModelSelector open={modelSelectorOpen} onOpenChange={setModelSelectorOpen}>
|
||
<ModelSelectorTrigger asChild>
|
||
<PromptInputButton>
|
||
<ModelSelectorLogo provider={currentModel?.logo || 'unknown'} />
|
||
<ModelSelectorName>{currentModel?.name}</ModelSelectorName>
|
||
</PromptInputButton>
|
||
</ModelSelectorTrigger>
|
||
<ModelSelectorContent title="选择模型">
|
||
<VisuallyHidden><DialogDescription>选择模型</DialogDescription></VisuallyHidden>
|
||
<ModelSelectorInput placeholder="搜索模型..." />
|
||
<ModelSelectorList>
|
||
<ModelSelectorEmpty>未找到模型</ModelSelectorEmpty>
|
||
<ModelSelectorGroup>
|
||
{AVAILABLE_MODELS.map((model) => (
|
||
<ModelSelectorItem
|
||
key={model.id}
|
||
value={model.id}
|
||
onSelect={() => {
|
||
setSelectedModel(model.id)
|
||
setModelSelectorOpen(false)
|
||
}}
|
||
>
|
||
<ModelSelectorLogo provider={model.logo} />
|
||
<ModelSelectorName>{model.name}</ModelSelectorName>
|
||
{selectedModel === model.id && (
|
||
<CheckIcon className="ml-auto size-4" />
|
||
)}
|
||
</ModelSelectorItem>
|
||
))}
|
||
</ModelSelectorGroup>
|
||
</ModelSelectorList>
|
||
</ModelSelectorContent>
|
||
</ModelSelector>
|
||
|
||
{/* 工具选择器 */}
|
||
{currentAgent && currentAgent.availableTools.length > 0 && (
|
||
<Popover open={toolSelectorOpen} onOpenChange={setToolSelectorOpen}>
|
||
<PopoverTrigger asChild>
|
||
<PromptInputButton>
|
||
<WrenchIcon className="size-3 text-muted-foreground" />
|
||
<span>{selectedTools.length} 个工具</span>
|
||
</PromptInputButton>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-[350px] p-0" align="start">
|
||
<Command>
|
||
<CommandList>
|
||
<CommandEmpty>未找到工具</CommandEmpty>
|
||
<CommandGroup heading="可用工具">
|
||
{currentAgent.availableTools.map((tool) => (
|
||
<CommandItem
|
||
key={tool.id}
|
||
value={tool.id}
|
||
onSelect={() => toggleTool(tool.id)}
|
||
>
|
||
<div className="flex flex-1 flex-col">
|
||
<span className="font-medium text-sm">{tool.name}</span>
|
||
<span className="text-muted-foreground text-xs">{tool.description}</span>
|
||
</div>
|
||
{selectedTools.includes(tool.id) && (
|
||
<CheckIcon className="ml-2 size-4 text-primary" />
|
||
)}
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
)}
|
||
</PromptInputTools>
|
||
|
||
{/* 右侧按钮组 */}
|
||
<div className="flex items-center gap-2">
|
||
{/* 附件上传菜单 */}
|
||
<PromptInputActionMenu>
|
||
<PromptInputActionMenuTrigger />
|
||
<PromptInputActionMenuContent>
|
||
<PromptInputActionAddAttachments label="添加图片" />
|
||
</PromptInputActionMenuContent>
|
||
</PromptInputActionMenu>
|
||
|
||
{/* 发送按钮 */}
|
||
<PromptInputSubmit status={status} />
|
||
</div>
|
||
</PromptInputToolbar>
|
||
</PromptInput>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |