diff --git a/src/app/assistants/page.tsx b/src/app/assistants/page.tsx new file mode 100644 index 0000000..31f1e08 --- /dev/null +++ b/src/app/assistants/page.tsx @@ -0,0 +1,369 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { Search, Plus, Loader2, Heart, Filter } from 'lucide-react'; +import { AppLayout } from '@/components/layout/AppLayout'; +import { AssistantCard } from '@/components/assistants/AssistantCard'; +import { AssistantDetailModal } from '@/components/assistants/AssistantDetailModal'; +import { AssistantEditModal, AssistantFormData } from '@/components/assistants/AssistantEditModal'; +import { IconRenderer } from '@/components/ui/IconRenderer'; +import { useConversations } from '@/hooks/useConversations'; +import { useSettings } from '@/hooks/useSettings'; +import { useAuth } from '@/providers/AuthProvider'; +import { cn } from '@/lib/utils'; + +interface Category { + id: number; + name: string; + icon: string | null; + description: string | null; +} + +interface Assistant { + id: number; + categoryId: number | null; + userId: string | null; + name: string; + description: string | null; + icon: string | null; + systemPrompt: string; + tags: string[]; + isBuiltin: boolean | null; + isFavorited: boolean; + useCount: number | null; + createdAt: Date | string | null; + categoryName?: string | null; + categoryIcon?: string | null; +} + +export default function AssistantsPage() { + const router = useRouter(); + const { createConversation } = useConversations(); + const { settings } = useSettings(); + const { user } = useAuth(); + + const [categories, setCategories] = useState([]); + const [assistants, setAssistants] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedCategoryId, setSelectedCategoryId] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedAssistant, setSelectedAssistant] = useState(null); + const [showDetailModal, setShowDetailModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [editingAssistant, setEditingAssistant] = useState | undefined>(undefined); + + // 加载分类数据 + const loadCategories = useCallback(async () => { + try { + const response = await fetch('/api/assistants/categories'); + if (response.ok) { + const data = await response.json(); + setCategories(data); + } + } catch (error) { + console.error('Failed to load categories:', error); + } + }, []); + + // 加载助手数据 + const loadAssistants = useCallback(async () => { + try { + setLoading(true); + const params = new URLSearchParams(); + + // 传递用户 ID 以获取收藏状态 + if (user?.id) { + params.set('userId', user.id); + } + + if (selectedCategoryId !== 'all' && selectedCategoryId !== 'favorites') { + params.set('categoryId', selectedCategoryId.toString()); + } + if (selectedCategoryId === 'favorites') { + params.set('favorites', 'true'); + } + if (searchQuery) { + params.set('search', searchQuery); + } + + const response = await fetch(`/api/assistants?${params.toString()}`); + if (response.ok) { + const data = await response.json(); + setAssistants(data.data || []); + } + } catch (error) { + console.error('Failed to load assistants:', error); + } finally { + setLoading(false); + } + }, [selectedCategoryId, searchQuery, user?.id]); + + useEffect(() => { + loadCategories(); + }, [loadCategories]); + + useEffect(() => { + loadAssistants(); + }, [loadAssistants]); + + // 收藏/取消收藏 + const handleFavoriteToggle = async (assistant: Assistant) => { + if (!user?.id) { + console.error('User not logged in'); + return; + } + + try { + if (assistant.isFavorited) { + await fetch(`/api/assistants/${assistant.id}/favorite?userId=${user.id}`, { + method: 'DELETE', + }); + } else { + await fetch(`/api/assistants/${assistant.id}/favorite`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: user.id }), + }); + } + // 更新本地状态 + setAssistants((prev) => + prev.map((a) => + a.id === assistant.id ? { ...a, isFavorited: !a.isFavorited } : a + ) + ); + if (selectedAssistant?.id === assistant.id) { + setSelectedAssistant({ ...selectedAssistant, isFavorited: !assistant.isFavorited }); + } + } catch (error) { + console.error('Failed to toggle favorite:', error); + } + }; + + // 使用助手创建新对话 + const handleUseAssistant = async (assistant: Assistant) => { + try { + // 增加使用次数 + await fetch(`/api/assistants/${assistant.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'incrementUseCount' }), + }); + + // 创建新对话 + const newConversation = await createConversation({ + model: settings?.defaultModel || 'claude-sonnet-4-20250514', + tools: settings?.defaultTools || [], + enableThinking: settings?.enableThinking || false, + assistantId: assistant.id, + systemPrompt: assistant.systemPrompt, + }); + + router.push(`/chat/${newConversation.conversationId}`); + } catch (error) { + console.error('Failed to use assistant:', error); + } + }; + + // 保存助手 + const handleSaveAssistant = async (data: AssistantFormData) => { + if (!user?.id) { + console.error('User not logged in'); + throw new Error('User not logged in'); + } + + try { + if (data.id) { + // 更新 + await fetch(`/api/assistants/${data.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...data, userId: user.id }), + }); + } else { + // 创建 + await fetch('/api/assistants', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...data, userId: user.id }), + }); + } + loadAssistants(); + } catch (error) { + console.error('Failed to save assistant:', error); + throw error; + } + }; + + // 打开创建助手弹窗 + const handleCreateAssistant = () => { + setEditingAssistant(undefined); + setShowEditModal(true); + }; + + // 打开详情弹窗 + const handleOpenDetail = (assistant: Assistant) => { + setSelectedAssistant(assistant); + setShowDetailModal(true); + }; + + return ( + +
+ {/* 头部 */} +
+
+
+
+

+ 助手库 +

+

+ 选择或创建专属 AI 助手,提升工作效率 +

+
+ +
+ + {/* 搜索栏 */} +
+ + setSearchQuery(e.target.value)} + placeholder="搜索助手..." + className="w-full pl-10 pr-4 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]" + /> +
+
+
+ + {/* 分类标签 */} +
+
+ + + {categories.map((category) => ( + + ))} +
+
+ + {/* 助手列表 */} +
+
+ {loading ? ( +
+ +
+ ) : assistants.length === 0 ? ( +
+
🤖
+

+ {selectedCategoryId === 'favorites' + ? '暂无收藏的助手' + : searchQuery + ? '未找到匹配的助手' + : '暂无助手'} +

+ {selectedCategoryId !== 'favorites' && ( + + )} +
+ ) : ( +
+ {assistants.map((assistant) => ( + handleOpenDetail(assistant)} + onFavoriteToggle={() => handleFavoriteToggle(assistant)} + onUse={() => handleUseAssistant(assistant)} + /> + ))} +
+ )} +
+
+
+ + {/* 详情弹窗 */} + {selectedAssistant && ( + { + setShowDetailModal(false); + setSelectedAssistant(null); + }} + onUse={() => { + setShowDetailModal(false); + handleUseAssistant(selectedAssistant); + }} + onFavoriteToggle={() => handleFavoriteToggle(selectedAssistant)} + /> + )} + + {/* 编辑弹窗 */} + { + setShowEditModal(false); + setEditingAssistant(undefined); + }} + onSave={handleSaveAssistant} + categories={categories} + initialData={editingAssistant} + isEditing={!!editingAssistant?.id} + /> +
+ ); +} diff --git a/src/components/assistants/AssistantCard.tsx b/src/components/assistants/AssistantCard.tsx new file mode 100644 index 0000000..503327f --- /dev/null +++ b/src/components/assistants/AssistantCard.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { Heart, Sparkles } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { IconRenderer } from '@/components/ui/IconRenderer'; + +export interface AssistantCardProps { + id: number; + name: string; + description: string | null; + icon: string | null; + tags: string[]; + categoryName?: string | null; + categoryIcon?: string | null; + isBuiltin: boolean | null; + isFavorited: boolean; + useCount: number | null; + onClick?: () => void; + onFavoriteToggle?: () => void; + onUse?: () => void; +} + +export function AssistantCard({ + name, + description, + icon, + tags, + categoryName, + isBuiltin, + isFavorited, + useCount, + onClick, + onFavoriteToggle, + onUse, +}: AssistantCardProps) { + return ( +
+ {/* 内置标签 */} + {isBuiltin && ( +
+ + 内置 +
+ )} + + {/* 头部:图标和名称 */} +
+
+ +
+
+

+ {name} +

+ {categoryName && ( + + {categoryName} + + )} +
+
+ + {/* 描述 */} +

+ {description || '暂无描述'} +

+ + {/* 标签 */} + {tags && tags.length > 0 && ( +
+ {tags.slice(0, 3).map((tag, index) => ( + + {tag} + + ))} + {tags.length > 3 && ( + + +{tags.length - 3} + + )} +
+ )} + + {/* 底部:使用次数和操作按钮 */} +
+ + 使用 {useCount || 0} 次 + +
+ {/* 收藏按钮 */} + + {/* 使用按钮 */} + +
+
+
+ ); +} diff --git a/src/components/assistants/AssistantDetailModal.tsx b/src/components/assistants/AssistantDetailModal.tsx new file mode 100644 index 0000000..a90bb32 --- /dev/null +++ b/src/components/assistants/AssistantDetailModal.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { X, Heart, Sparkles, Copy, Check } from 'lucide-react'; +import { useState } from 'react'; +import { cn } from '@/lib/utils'; +import { IconRenderer } from '@/components/ui/IconRenderer'; + +export interface AssistantDetailModalProps { + assistant: { + id: number; + name: string; + description: string | null; + icon: string | null; + systemPrompt: string; + tags: string[]; + categoryName?: string | null; + categoryIcon?: string | null; + isBuiltin: boolean | null; + isFavorited: boolean; + useCount: number | null; + createdAt: Date | string | null; + }; + isOpen: boolean; + onClose: () => void; + onUse: () => void; + onFavoriteToggle: () => void; +} + +export function AssistantDetailModal({ + assistant, + isOpen, + onClose, + onUse, + onFavoriteToggle, +}: AssistantDetailModalProps) { + const [copied, setCopied] = useState(false); + + if (!isOpen) return null; + + const handleCopyPrompt = async () => { + try { + await navigator.clipboard.writeText(assistant.systemPrompt); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( +
+ {/* 遮罩 */} +
+ + {/* 弹窗内容 */} +
+ {/* 头部 */} +
+
+ +
+
+
+

+ {assistant.name} +

+ {assistant.isBuiltin && ( + + + 内置 + + )} +
+ {assistant.categoryName && ( + + {assistant.categoryIcon && } + {assistant.categoryName} + + )} +

+ {assistant.description || '暂无描述'} +

+
+ +
+ + {/* 内容区 */} +
+ {/* 标签 */} + {assistant.tags && assistant.tags.length > 0 && ( +
+

+ 标签 +

+
+ {assistant.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ )} + + {/* 系统提示词 */} +
+
+

+ 系统提示词 +

+ +
+
+
+                {assistant.systemPrompt}
+              
+
+
+ + {/* 使用统计 */} +
+ 使用次数:{assistant.useCount || 0} + {assistant.createdAt && ( + + 创建时间:{new Date(assistant.createdAt).toLocaleDateString()} + + )} +
+
+ + {/* 底部操作 */} +
+ + +
+
+
+ ); +} diff --git a/src/components/assistants/AssistantEditModal.tsx b/src/components/assistants/AssistantEditModal.tsx new file mode 100644 index 0000000..02b16f9 --- /dev/null +++ b/src/components/assistants/AssistantEditModal.tsx @@ -0,0 +1,418 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { X, Loader2, ChevronDown, Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { IconPicker } from '@/components/ui/IconPicker'; +import { DEFAULT_ICON } from '@/components/ui/icons'; + +interface Category { + id: number; + name: string; + icon: string | null; +} + +export interface AssistantFormData { + id?: number; + name: string; + description: string; + icon: string; + categoryId: number | null; + systemPrompt: string; + tags: string[]; +} + +interface AssistantEditModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (data: AssistantFormData) => Promise; + categories: Category[]; + initialData?: Partial; + isEditing?: boolean; +} + +export function AssistantEditModal({ + isOpen, + onClose, + onSave, + categories, + initialData, + isEditing = false, +}: AssistantEditModalProps) { + const [formData, setFormData] = useState({ + name: '', + description: '', + icon: DEFAULT_ICON, + categoryId: null, + systemPrompt: '', + tags: [], + }); + const [tagInput, setTagInput] = useState(''); + const [saving, setSaving] = useState(false); + const [errors, setErrors] = useState>({}); + const [showCategoryPicker, setShowCategoryPicker] = useState(false); + + const categoryPickerRef = useRef(null); + + // 初始化表单数据 + useEffect(() => { + if (initialData) { + setFormData({ + id: initialData.id, + name: initialData.name || '', + description: initialData.description || '', + icon: initialData.icon || DEFAULT_ICON, + categoryId: initialData.categoryId ?? null, + systemPrompt: initialData.systemPrompt || '', + tags: initialData.tags || [], + }); + } else { + setFormData({ + name: '', + description: '', + icon: DEFAULT_ICON, + categoryId: null, + systemPrompt: '', + tags: [], + }); + } + setTagInput(''); + setErrors({}); + setShowCategoryPicker(false); + }, [initialData, isOpen]); + + // 点击外部关闭 category picker + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (categoryPickerRef.current && !categoryPickerRef.current.contains(event.target as Node)) { + setShowCategoryPicker(false); + } + }; + if (showCategoryPicker) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showCategoryPicker]); + + if (!isOpen) return null; + + const handleAddTag = () => { + const tag = tagInput.trim(); + if (tag && !formData.tags.includes(tag) && formData.tags.length < 5) { + setFormData({ ...formData, tags: [...formData.tags, tag] }); + setTagInput(''); + } + }; + + const handleRemoveTag = (tagToRemove: string) => { + setFormData({ + ...formData, + tags: formData.tags.filter((tag) => tag !== tagToRemove), + }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } + }; + + const validate = (): boolean => { + const newErrors: Record = {}; + if (!formData.name.trim()) { + newErrors.name = '请输入助手名称'; + } + if (!formData.systemPrompt.trim()) { + newErrors.systemPrompt = '请输入系统提示词'; + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (!validate()) return; + + try { + setSaving(true); + await onSave(formData); + onClose(); + } catch (error) { + console.error('Failed to save assistant:', error); + } finally { + setSaving(false); + } + }; + + const handleCategorySelect = (categoryId: number | null) => { + setFormData({ ...formData, categoryId }); + setShowCategoryPicker(false); + }; + + // 获取当前选中的分类 + const selectedCategory = categories.find((c) => c.id === formData.categoryId); + + // 输入框基础样式 + const inputBaseClass = 'w-full px-3 py-2 bg-[var(--color-bg-secondary)] border rounded text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)] transition-colors'; + + return ( +
+ {/* 遮罩 */} +
+ + {/* 弹窗内容 */} +
+ {/* 头部 */} +
+

+ {isEditing ? '编辑助手' : '创建助手'} +

+ +
+ + {/* 表单内容 */} +
+ {/* 第一行:图标 + 名称 */} +
+ {/* 图标选择 */} +
+ + setFormData({ ...formData, icon })} + triggerSize={48} + /> +
+ + {/* 名称 */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="给助手起个名字" + className={cn( + inputBaseClass, + errors.name ? 'border-red-500' : 'border-[var(--color-border)]' + )} + maxLength={100} + /> + {errors.name && ( +

{errors.name}

+ )} +
+
+ + {/* 第二行:分类 + 描述 */} +
+ {/* 分类 - 自定义下拉 */} +
+ +
+ + + {/* 下拉菜单 */} + {showCategoryPicker && ( +
+ {/* 空选项 */} + + + {/* 分类选项 */} + {categories.map((category) => ( + + ))} +
+ )} +
+
+ + {/* 描述 */} +
+ + + setFormData({ ...formData, description: e.target.value }) + } + placeholder="简单描述这个助手的功能" + className={cn(inputBaseClass, 'border-[var(--color-border)]')} + maxLength={200} + /> +
+
+ + {/* 第三行:标签(全宽) */} +
+ +
+ {/* 已添加的标签 */} + {formData.tags.map((tag, index) => ( + + {tag} + + + ))} + {/* 输入框和添加按钮 */} + {formData.tags.length < 5 && ( +
+ setTagInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="输入标签后回车添加" + className={cn(inputBaseClass, 'border-[var(--color-border)] flex-1')} + maxLength={20} + /> + +
+ )} +
+
+ + {/* 第四行:系统提示词(全宽) */} +
+ +