From 2d4bdfb7f5a991cec7efa68d34e274a4769313da Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Sat, 20 Dec 2025 20:46:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=81=8A=E5=A4=A9):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=96=B0=E5=AF=B9=E8=AF=9D=E5=BC=B9=E7=AA=97=E5=92=8C=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E5=A4=B4=E9=83=A8=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 NewChatModal 新对话弹窗,支持快速开始和助手选择 - 新增 ChatHeader 聊天头部组件,显示当前助手和模型信息 - 支持搜索助手和显示收藏助手 - 集成 IconRenderer 显示助手图标 --- src/components/features/ChatHeader.tsx | 170 +++++++++ src/components/features/NewChatModal.tsx | 462 +++++++++++++++++++++++ 2 files changed, 632 insertions(+) create mode 100644 src/components/features/ChatHeader.tsx create mode 100644 src/components/features/NewChatModal.tsx diff --git a/src/components/features/ChatHeader.tsx b/src/components/features/ChatHeader.tsx new file mode 100644 index 0000000..f711fdb --- /dev/null +++ b/src/components/features/ChatHeader.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { ChevronDown, Check, Bot } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface Assistant { + id: number; + name: string; + icon: string | null; + description: string | null; +} + +interface Model { + id: string; + name: string; + displayName: string; + tag?: string; +} + +interface ChatHeaderInfoProps { + assistant: Assistant | null; + currentModel: string; + models: Model[]; + onModelChange: (modelId: string) => Promise; +} + +export function ChatHeaderInfo({ + assistant, + currentModel, + models, + onModelChange, +}: ChatHeaderInfoProps) { + const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); + const [isChanging, setIsChanging] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + const menuRef = useRef(null); + + // 点击外部关闭菜单 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsModelMenuOpen(false); + } + }; + if (isModelMenuOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isModelMenuOpen]); + + // 处理模型切换 + const handleModelSelect = async (modelId: string) => { + if (modelId === currentModel || isChanging) return; + + try { + setIsChanging(true); + await onModelChange(modelId); + setIsModelMenuOpen(false); + + // 显示成功提示 + setShowSuccess(true); + setTimeout(() => { + setShowSuccess(false); + }, 2000); + } catch (error) { + console.error('Failed to change model:', error); + } finally { + setIsChanging(false); + } + }; + + // 获取当前模型的显示名称 + const currentModelInfo = models.find((m) => m.id === currentModel); + const modelDisplayName = currentModelInfo?.displayName || currentModel; + + return ( +
+ {/* 助手信息 */} +
+ {assistant ? ( + <> + {assistant.icon || '🤖'} + {assistant.name} + + ) : ( + <> + + 默认助手 + + )} +
+ + {/* 分隔符 */} + · + + {/* 模型选择器 */} +
+ + + {/* 模型下拉菜单 */} + {isModelMenuOpen && ( +
+
+ 选择模型 +
+ {models.map((model) => ( + + ))} +
+ )} +
+ + {/* 成功提示 */} + {showSuccess && ( +
+ + 已切换 +
+ )} +
+ ); +} diff --git a/src/components/features/NewChatModal.tsx b/src/components/features/NewChatModal.tsx new file mode 100644 index 0000000..f150aa6 --- /dev/null +++ b/src/components/features/NewChatModal.tsx @@ -0,0 +1,462 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { X, Search, Bot, Star, Clock, ChevronRight, Loader2, Library } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAuth } from '@/providers/AuthProvider'; +import { IconRenderer } from '@/components/ui/IconRenderer'; +import { useConversations } from '@/hooks/useConversations'; +import { useSettings } from '@/hooks/useSettings'; + +interface Assistant { + id: number; + name: string; + description: string | null; + icon: string | null; + systemPrompt: string; + categoryName?: string | null; + tags?: string[] | null; +} + +interface NewChatModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function NewChatModal({ isOpen, onClose }: NewChatModalProps) { + const router = useRouter(); + const { user } = useAuth(); + const { createConversation } = useConversations(); + const { settings } = useSettings(); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [favoriteAssistants, setFavoriteAssistants] = useState([]); + const [recentAssistants, setRecentAssistants] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [isSearching, setIsSearching] = useState(false); + + const modalRef = useRef(null); + const searchInputRef = useRef(null); + const searchTimeoutRef = useRef(null); + + // 加载收藏和最近使用的助手 + const loadAssistants = useCallback(async () => { + if (!user) return; + + setIsLoading(true); + try { + // 并行加载收藏和最近使用的助手 + const [favoritesRes, recentRes] = await Promise.all([ + fetch(`/api/assistants?favorites=true&userId=${user.id}&limit=6`), + fetch('/api/assistants/recent?limit=3'), + ]); + + if (favoritesRes.ok) { + const favData = await favoritesRes.json(); + setFavoriteAssistants(favData.data || []); + } + + if (recentRes.ok) { + const recentData = await recentRes.json(); + setRecentAssistants(recentData.data || []); + } + } catch (error) { + console.error('Failed to load assistants:', error); + } finally { + setIsLoading(false); + } + }, [user]); + + // 搜索助手 + const searchAssistants = useCallback(async (query: string) => { + if (!query.trim()) { + setSearchResults([]); + return; + } + + setIsSearching(true); + try { + const res = await fetch(`/api/assistants?search=${encodeURIComponent(query)}&limit=10`); + if (res.ok) { + const data = await res.json(); + setSearchResults(data.data || []); + } + } catch (error) { + console.error('Failed to search assistants:', error); + } finally { + setIsSearching(false); + } + }, []); + + // 处理搜索输入(防抖) + const handleSearchChange = (value: string) => { + setSearchQuery(value); + + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + searchTimeoutRef.current = setTimeout(() => { + searchAssistants(value); + }, 300); + }; + + // 创建新对话 + const handleCreateChat = async (assistant?: Assistant) => { + if (isCreating) return; + + setIsCreating(true); + try { + const newConversation = await createConversation({ + model: settings?.defaultModel || 'claude-sonnet-4-20250514', + tools: settings?.defaultTools || [], + enableThinking: settings?.enableThinking || false, + assistantId: assistant?.id, + systemPrompt: assistant?.systemPrompt, + }); + + onClose(); + router.push(`/chat/${newConversation.conversationId}`); + } catch (error) { + console.error('Failed to create conversation:', error); + } finally { + setIsCreating(false); + } + }; + + // 跳转到助手库 + const handleGoToAssistants = () => { + onClose(); + router.push('/assistants'); + }; + + // 打开时加载数据和聚焦搜索框 + useEffect(() => { + if (isOpen) { + loadAssistants(); + setSearchQuery(''); + setSearchResults([]); + // 延迟聚焦,等待动画完成 + setTimeout(() => { + searchInputRef.current?.focus(); + }, 100); + } + }, [isOpen, loadAssistants]); + + // ESC 键关闭 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + // 点击遮罩关闭 + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + // 清理定时器 + useEffect(() => { + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, []); + + if (!isOpen) return null; + + const showSearchResults = searchQuery.trim().length > 0; + + return ( +
+
+ {/* 标题栏 */} +
+

+ 开始新对话 +

+ +
+ + {/* 搜索框 */} +
+
+ + handleSearchChange(e.target.value)} + placeholder="搜索助手..." + className="w-full pl-10 pr-4 py-2.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] placeholder:text-[var(--color-text-quaternary)] focus:outline-none focus:border-[var(--color-primary)] transition-colors" + /> + {isSearching && ( + + )} +
+
+ + {/* 内容区域 */} +
+ {isLoading ? ( +
+ +
+ ) : showSearchResults ? ( + /* 搜索结果 - 使用网格布局 */ +
+

+ 搜索结果 +

+ {searchResults.length === 0 ? ( +

+ 未找到匹配的助手 +

+ ) : ( +
+ {searchResults.map((assistant, index) => ( + handleCreateChat(assistant)} + disabled={isCreating} + index={index} + /> + ))} +
+ )} +
+ ) : ( + /* 默认视图:快速开始 + 收藏 + 最近使用 */ +
+ {/* 快速开始 */} +
+

+ + 快速开始 +

+ +
+ + {/* 收藏助手 - 网格布局 */} + {favoriteAssistants.length > 0 && ( +
+

+ + 收藏助手 +

+
+ {favoriteAssistants.map((assistant, index) => ( + handleCreateChat(assistant)} + disabled={isCreating} + index={index} + /> + ))} +
+
+ )} + + {/* 最近使用 - 芯片式布局 */} + {recentAssistants.length > 0 && ( +
+

+ + 最近使用 +

+
+ {recentAssistants.map((assistant, index) => ( + handleCreateChat(assistant)} + disabled={isCreating} + index={index} + /> + ))} +
+
+ )} +
+ )} +
+ + {/* 底部:浏览全部助手 */} +
+ +
+
+
+ ); +} + +// 网格卡片组件 - 用于收藏助手和搜索结果 +function AssistantGridCard({ + assistant, + onClick, + disabled, + index = 0, +}: { + assistant: Assistant; + onClick: () => void; + disabled?: boolean; + index?: number; +}) { + const [showTooltip, setShowTooltip] = useState(false); + + return ( +
+ + + {/* Tooltip */} + {showTooltip && assistant.description && ( +
+

+ {assistant.description} +

+
+
+ )} +
+ ); +} + +// 芯片组件 - 用于最近使用 +function AssistantChip({ + assistant, + onClick, + disabled, + index = 0, +}: { + assistant: Assistant; + onClick: () => void; + disabled?: boolean; + index?: number; +}) { + const [showTooltip, setShowTooltip] = useState(false); + + return ( +
+ + + {/* Tooltip */} + {showTooltip && assistant.description && ( +
+

+ {assistant.description} +

+
+
+ )} +
+ ); +}