Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑
This commit is contained in:
407
src/app/(main)/dev/panel/dev-ai-chat.tsx
Normal file
407
src/app/(main)/dev/panel/dev-ai-chat.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user