Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑

This commit is contained in:
2025-11-13 15:24:54 +08:00
commit 42be39b343
249 changed files with 38843 additions and 0 deletions

View 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>
)
}