Files
hair-keeper/src/app/(main)/dev/panel/dev-ai-chat.tsx

407 lines
16 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 { 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>
)
}