feat(助手库): 添加助手库页面和组件

- 新增助手库页面,支持分类浏览和搜索
- 新增 AssistantCard 助手卡片组件
- 新增 AssistantDetailModal 助手详情弹窗
- 新增 AssistantEditModal 助手编辑弹窗
- 新增 AssistantSelector 助手选择器组件
- 集成 IconRenderer 组件显示 lucide 图标
This commit is contained in:
gaoziman 2025-12-20 20:46:05 +08:00
parent 34aa3e50cf
commit c987fcf909
6 changed files with 1374 additions and 0 deletions

369
src/app/assistants/page.tsx Normal file
View File

@ -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<Category[]>([]);
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | 'all' | 'favorites'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [selectedAssistant, setSelectedAssistant] = useState<Assistant | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingAssistant, setEditingAssistant] = useState<Partial<AssistantFormData> | 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 (
<AppLayout>
<div className="flex-1 flex flex-col h-full overflow-hidden w-full">
{/* 头部 */}
<header className="shrink-0 px-6 py-4 border-b border-[var(--color-border-light)]">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">
</h1>
<p className="text-sm text-[var(--color-text-tertiary)] mt-1">
AI
</p>
</div>
<button
onClick={handleCreateAssistant}
className="flex items-center gap-2 px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<Plus size={18} />
</button>
</div>
{/* 搜索栏 */}
<div className="relative max-w-md">
<Search
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => 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)]"
/>
</div>
</div>
</header>
{/* 分类标签 */}
<div className="shrink-0 px-6 py-3 border-b border-[var(--color-border-light)] overflow-x-auto">
<div className="max-w-7xl mx-auto flex gap-2">
<button
onClick={() => setSelectedCategoryId('all')}
className={cn(
'px-4 py-2 text-sm rounded-full whitespace-nowrap transition-colors',
selectedCategoryId === 'all'
? 'bg-[var(--color-primary)] text-white'
: 'bg-white border border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
)}
>
</button>
<button
onClick={() => setSelectedCategoryId('favorites')}
className={cn(
'flex items-center gap-1.5 px-4 py-2 text-sm rounded-full whitespace-nowrap transition-colors',
selectedCategoryId === 'favorites'
? 'bg-red-500 text-white'
: 'bg-white border border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-red-400 hover:text-red-500'
)}
>
<Heart size={14} />
</button>
{categories.map((category) => (
<button
key={category.id}
onClick={() => setSelectedCategoryId(category.id)}
className={cn(
'flex items-center gap-1.5 px-4 py-2 text-sm rounded-full whitespace-nowrap transition-colors',
selectedCategoryId === category.id
? 'bg-[var(--color-primary)] text-white'
: 'bg-white border border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
)}
>
{category.icon && <IconRenderer icon={category.icon} size={14} />}
{category.name}
</button>
))}
</div>
</div>
{/* 助手列表 */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-7xl mx-auto">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 size={32} className="animate-spin text-[var(--color-text-tertiary)]" />
</div>
) : assistants.length === 0 ? (
<div className="text-center py-20">
<div className="text-6xl mb-4">🤖</div>
<p className="text-[var(--color-text-tertiary)]">
{selectedCategoryId === 'favorites'
? '暂无收藏的助手'
: searchQuery
? '未找到匹配的助手'
: '暂无助手'}
</p>
{selectedCategoryId !== 'favorites' && (
<button
onClick={handleCreateAssistant}
className="mt-4 text-[var(--color-primary)] hover:underline"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{assistants.map((assistant) => (
<AssistantCard
key={assistant.id}
{...assistant}
onClick={() => handleOpenDetail(assistant)}
onFavoriteToggle={() => handleFavoriteToggle(assistant)}
onUse={() => handleUseAssistant(assistant)}
/>
))}
</div>
)}
</div>
</div>
</div>
{/* 详情弹窗 */}
{selectedAssistant && (
<AssistantDetailModal
assistant={selectedAssistant}
isOpen={showDetailModal}
onClose={() => {
setShowDetailModal(false);
setSelectedAssistant(null);
}}
onUse={() => {
setShowDetailModal(false);
handleUseAssistant(selectedAssistant);
}}
onFavoriteToggle={() => handleFavoriteToggle(selectedAssistant)}
/>
)}
{/* 编辑弹窗 */}
<AssistantEditModal
isOpen={showEditModal}
onClose={() => {
setShowEditModal(false);
setEditingAssistant(undefined);
}}
onSave={handleSaveAssistant}
categories={categories}
initialData={editingAssistant}
isEditing={!!editingAssistant?.id}
/>
</AppLayout>
);
}

View File

@ -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 (
<div
className="group relative bg-[var(--color-bg-primary)] border border-[var(--color-border-light)] rounded p-4 hover:border-[var(--color-primary)] hover:shadow-md transition-all cursor-pointer"
onClick={onClick}
>
{/* 内置标签 */}
{isBuiltin && (
<div className="absolute top-3 right-3 flex items-center gap-1 px-2 py-0.5 bg-gradient-to-r from-amber-500/10 to-orange-500/10 text-amber-600 dark:text-amber-400 text-xs rounded-full">
<Sparkles size={12} />
<span></span>
</div>
)}
{/* 头部:图标和名称 */}
<div className="flex items-start gap-3 mb-3">
<div className="w-12 h-12 flex items-center justify-center bg-[var(--color-bg-secondary)] rounded text-[var(--color-text-secondary)]">
<IconRenderer icon={icon} size={24} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-[var(--color-text-primary)] truncate pr-16">
{name}
</h3>
{categoryName && (
<span className="text-xs text-[var(--color-text-tertiary)]">
{categoryName}
</span>
)}
</div>
</div>
{/* 描述 */}
<p className="text-sm text-[var(--color-text-secondary)] line-clamp-2 mb-3 min-h-[40px]">
{description || '暂无描述'}
</p>
{/* 标签 */}
{tags && tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{tags.slice(0, 3).map((tag, index) => (
<span
key={index}
className="px-2 py-0.5 bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] text-xs rounded-full"
>
{tag}
</span>
))}
{tags.length > 3 && (
<span className="px-2 py-0.5 text-[var(--color-text-tertiary)] text-xs">
+{tags.length - 3}
</span>
)}
</div>
)}
{/* 底部:使用次数和操作按钮 */}
<div className="flex items-center justify-between pt-3 border-t border-[var(--color-border-light)]">
<span className="text-xs text-[var(--color-text-tertiary)]">
使 {useCount || 0}
</span>
<div className="flex items-center gap-2">
{/* 收藏按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
onFavoriteToggle?.();
}}
className={cn(
'p-1.5 rounded transition-colors',
isFavorited
? 'text-red-500 bg-red-50 dark:bg-red-500/10'
: 'text-[var(--color-text-tertiary)] hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10'
)}
title={isFavorited ? '取消收藏' : '收藏'}
>
<Heart size={16} fill={isFavorited ? 'currentColor' : 'none'} />
</button>
{/* 使用按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
onUse?.();
}}
className="px-3 py-1.5 bg-[var(--color-primary)] text-white text-xs rounded hover:opacity-90 transition-opacity"
>
使
</button>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 遮罩 */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* 弹窗内容 */}
<div className="relative w-full max-w-2xl max-h-[90vh] mx-4 bg-[var(--color-bg-primary)] rounded-2xl shadow-2xl overflow-hidden flex flex-col">
{/* 头部 */}
<div className="flex items-start gap-4 p-6 border-b border-[var(--color-border-light)]">
<div className="w-16 h-16 flex items-center justify-center bg-[var(--color-bg-secondary)] rounded-xl shrink-0 text-[var(--color-text-secondary)]">
<IconRenderer icon={assistant.icon} size={32} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
{assistant.name}
</h2>
{assistant.isBuiltin && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-gradient-to-r from-amber-500/10 to-orange-500/10 text-amber-600 dark:text-amber-400 text-xs rounded-full">
<Sparkles size={12} />
</span>
)}
</div>
{assistant.categoryName && (
<span className="flex items-center gap-1 text-sm text-[var(--color-text-tertiary)]">
{assistant.categoryIcon && <IconRenderer icon={assistant.categoryIcon} size={14} />}
{assistant.categoryName}
</span>
)}
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
{assistant.description || '暂无描述'}
</p>
</div>
<button
onClick={onClose}
className="p-2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* 标签 */}
{assistant.tags && assistant.tags.length > 0 && (
<div>
<h3 className="text-sm font-medium text-[var(--color-text-primary)] mb-2">
</h3>
<div className="flex flex-wrap gap-2">
{assistant.tags.map((tag, index) => (
<span
key={index}
className="px-3 py-1 bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] text-sm rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* 系统提示词 */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-[var(--color-text-primary)]">
</h3>
<button
onClick={handleCopyPrompt}
className="flex items-center gap-1 px-2 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
>
{copied ? (
<>
<Check size={14} />
</>
) : (
<>
<Copy size={14} />
</>
)}
</button>
</div>
<div className="p-4 bg-[var(--color-bg-secondary)] rounded-xl max-h-[300px] overflow-y-auto">
<pre className="text-sm text-[var(--color-text-secondary)] whitespace-pre-wrap font-mono">
{assistant.systemPrompt}
</pre>
</div>
</div>
{/* 使用统计 */}
<div className="flex items-center gap-6 text-sm text-[var(--color-text-tertiary)]">
<span>使{assistant.useCount || 0}</span>
{assistant.createdAt && (
<span>
{new Date(assistant.createdAt).toLocaleDateString()}
</span>
)}
</div>
</div>
{/* 底部操作 */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-[var(--color-border-light)]">
<button
onClick={onFavoriteToggle}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg transition-colors',
assistant.isFavorited
? 'text-red-500 bg-red-50 dark:bg-red-500/10'
: 'text-[var(--color-text-secondary)] border border-[var(--color-border)] hover:border-red-300 hover:text-red-500'
)}
>
<Heart size={18} fill={assistant.isFavorited ? 'currentColor' : 'none'} />
{assistant.isFavorited ? '已收藏' : '收藏'}
</button>
<button
onClick={onUse}
className="px-6 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:opacity-90 transition-opacity font-medium"
>
使
</button>
</div>
</div>
</div>
);
}

View File

@ -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<void>;
categories: Category[];
initialData?: Partial<AssistantFormData>;
isEditing?: boolean;
}
export function AssistantEditModal({
isOpen,
onClose,
onSave,
categories,
initialData,
isEditing = false,
}: AssistantEditModalProps) {
const [formData, setFormData] = useState<AssistantFormData>({
name: '',
description: '',
icon: DEFAULT_ICON,
categoryId: null,
systemPrompt: '',
tags: [],
});
const [tagInput, setTagInput] = useState('');
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const categoryPickerRef = useRef<HTMLDivElement>(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<string, string> = {};
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 遮罩 */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* 弹窗内容 */}
<div className="relative w-full max-w-3xl max-h-[85vh] mx-4 bg-[var(--color-bg-primary)] rounded shadow-2xl overflow-hidden flex flex-col animate-scale-in">
{/* 头部 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border-light)]">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
{isEditing ? '编辑助手' : '创建助手'}
</h2>
<button
onClick={onClose}
className="p-1.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
>
<X size={18} />
</button>
</div>
{/* 表单内容 */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* 第一行:图标 + 名称 */}
<div className="grid grid-cols-[140px_1fr] gap-5 mb-5">
{/* 图标选择 */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
</label>
<IconPicker
value={formData.icon}
onChange={(icon) => setFormData({ ...formData, icon })}
triggerSize={48}
/>
</div>
{/* 名称 */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="给助手起个名字"
className={cn(
inputBaseClass,
errors.name ? 'border-red-500' : 'border-[var(--color-border)]'
)}
maxLength={100}
/>
{errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
)}
</div>
</div>
{/* 第二行:分类 + 描述 */}
<div className="grid grid-cols-2 gap-5 mb-5">
{/* 分类 - 自定义下拉 */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
</label>
<div className="relative" ref={categoryPickerRef}>
<button
type="button"
onClick={() => setShowCategoryPicker(!showCategoryPicker)}
className={cn(
inputBaseClass,
'border-[var(--color-border)] flex items-center justify-between gap-2 text-left cursor-pointer'
)}
>
<span className={cn(
'truncate',
!selectedCategory && 'text-[var(--color-text-tertiary)]'
)}>
{selectedCategory ? (
<>
{selectedCategory.icon && <span className="mr-1.5">{selectedCategory.icon}</span>}
{selectedCategory.name}
</>
) : (
'选择分类(可选)'
)}
</span>
<ChevronDown
size={16}
className={cn(
'flex-shrink-0 text-[var(--color-text-tertiary)] transition-transform',
showCategoryPicker && 'rotate-180'
)}
/>
</button>
{/* 下拉菜单 */}
{showCategoryPicker && (
<div className="absolute left-0 top-full mt-1 w-full bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded shadow-lg z-20 py-1 max-h-[200px] overflow-y-auto">
{/* 空选项 */}
<button
type="button"
onClick={() => handleCategorySelect(null)}
className={cn(
'w-full px-3 py-2 text-sm text-left flex items-center justify-between gap-2 transition-colors',
formData.categoryId === null
? 'bg-[var(--color-primary-light)] text-[var(--color-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
)}
>
<span className="text-[var(--color-text-tertiary)]"></span>
{formData.categoryId === null && (
<Check size={14} className="text-[var(--color-primary)]" />
)}
</button>
{/* 分类选项 */}
{categories.map((category) => (
<button
key={category.id}
type="button"
onClick={() => handleCategorySelect(category.id)}
className={cn(
'w-full px-3 py-2 text-sm text-left flex items-center justify-between gap-2 transition-colors',
formData.categoryId === category.id
? 'bg-[var(--color-primary-light)] text-[var(--color-primary)]'
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
)}
>
<span className="flex items-center gap-1.5 truncate">
{category.icon && <span>{category.icon}</span>}
<span>{category.name}</span>
</span>
{formData.categoryId === category.id && (
<Check size={14} className="flex-shrink-0 text-[var(--color-primary)]" />
)}
</button>
))}
</div>
)}
</div>
</div>
{/* 描述 */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
</label>
<input
type="text"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="简单描述这个助手的功能"
className={cn(inputBaseClass, 'border-[var(--color-border)]')}
maxLength={200}
/>
</div>
</div>
{/* 第三行:标签(全宽) */}
<div className="mb-5">
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
5
</label>
<div className="flex items-center gap-2 flex-wrap">
{/* 已添加的标签 */}
{formData.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] text-xs rounded-full"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="text-[var(--color-text-tertiary)] hover:text-red-500 transition-colors"
>
<X size={12} />
</button>
</span>
))}
{/* 输入框和添加按钮 */}
{formData.tags.length < 5 && (
<div className="flex items-center gap-2 flex-1 min-w-[200px]">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入标签后回车添加"
className={cn(inputBaseClass, 'border-[var(--color-border)] flex-1')}
maxLength={20}
/>
<button
type="button"
onClick={handleAddTag}
disabled={!tagInput.trim()}
className="px-3 py-2 text-xs font-medium text-[var(--color-primary)] border border-[var(--color-primary)] rounded hover:bg-[var(--color-primary)] hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
)}
</div>
</div>
{/* 第四行:系统提示词(全宽) */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
<span className="text-red-500">*</span>
</label>
<textarea
value={formData.systemPrompt}
onChange={(e) =>
setFormData({ ...formData, systemPrompt: e.target.value })
}
placeholder="定义助手的角色、能力和行为规则..."
rows={6}
className={cn(
inputBaseClass,
'resize-none font-mono',
errors.systemPrompt ? 'border-red-500' : 'border-[var(--color-border)]'
)}
/>
{errors.systemPrompt && (
<p className="mt-1 text-xs text-red-500">{errors.systemPrompt}</p>
)}
<p className="mt-1.5 text-xs text-[var(--color-text-tertiary)]">
</p>
</div>
</div>
{/* 底部操作 */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--color-border-light)] bg-[var(--color-bg-secondary)]">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--color-text-secondary)] border border-[var(--color-border)] rounded hover:bg-[var(--color-bg-hover)] transition-colors"
>
</button>
<button
onClick={handleSubmit}
disabled={saving}
className="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-[var(--color-primary)] text-white rounded hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving && <Loader2 size={14} className="animate-spin" />}
{isEditing ? '保存' : '创建'}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,270 @@
'use client';
import { useState, useEffect } from 'react';
import { X, Search, Loader2, Heart, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { IconRenderer } from '@/components/ui/IconRenderer';
interface Category {
id: number;
name: string;
icon: string | null;
}
interface Assistant {
id: number;
name: string;
description: string | null;
icon: string | null;
systemPrompt: string;
tags: string[];
categoryId: number | null;
categoryName?: string | null;
categoryIcon?: string | null;
isBuiltin: boolean | null;
isFavorited: boolean;
useCount: number | null;
}
interface AssistantSelectorProps {
isOpen: boolean;
onClose: () => void;
onSelect: (assistant: Assistant | null) => void;
currentAssistantId?: number | null;
}
export function AssistantSelector({
isOpen,
onClose,
onSelect,
currentAssistantId,
}: AssistantSelectorProps) {
const [categories, setCategories] = useState<Category[]>([]);
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | 'all' | 'favorites'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
// 加载分类和助手数据
useEffect(() => {
if (isOpen) {
loadData();
}
}, [isOpen]);
const loadData = async () => {
try {
setLoading(true);
const [categoriesRes, assistantsRes] = await Promise.all([
fetch('/api/assistants/categories'),
fetch('/api/assistants'),
]);
if (categoriesRes.ok) {
const data = await categoriesRes.json();
setCategories(data);
}
if (assistantsRes.ok) {
const data = await assistantsRes.json();
setAssistants(data.data || []);
}
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
// 过滤助手
const filteredAssistants = assistants.filter((assistant) => {
// 分类过滤
if (selectedCategoryId === 'favorites') {
if (!assistant.isFavorited) return false;
} else if (selectedCategoryId !== 'all') {
if (assistant.categoryId !== selectedCategoryId) return false;
}
// 搜索过滤
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
assistant.name.toLowerCase().includes(query) ||
assistant.description?.toLowerCase().includes(query) ||
assistant.tags?.some((tag) => tag.toLowerCase().includes(query))
);
}
return true;
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 遮罩 */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* 弹窗内容 */}
<div className="relative w-full max-w-3xl max-h-[80vh] mx-4 bg-[var(--color-bg-primary)] rounded-2xl shadow-2xl overflow-hidden flex flex-col">
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-[var(--color-border-light)]">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
</h2>
<button
onClick={onClose}
className="p-2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* 搜索栏 */}
<div className="p-4 border-b border-[var(--color-border-light)]">
<div className="relative">
<Search
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => 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-lg text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]"
/>
</div>
</div>
{/* 分类标签 */}
<div className="flex gap-2 px-4 py-3 border-b border-[var(--color-border-light)] overflow-x-auto">
<button
onClick={() => setSelectedCategoryId('all')}
className={cn(
'px-3 py-1.5 text-sm rounded-full whitespace-nowrap transition-colors',
selectedCategoryId === 'all'
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
)}
>
</button>
<button
onClick={() => setSelectedCategoryId('favorites')}
className={cn(
'flex items-center gap-1 px-3 py-1.5 text-sm rounded-full whitespace-nowrap transition-colors',
selectedCategoryId === 'favorites'
? 'bg-red-500 text-white'
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
)}
>
<Heart size={14} />
</button>
{categories.map((category) => (
<button
key={category.id}
onClick={() => setSelectedCategoryId(category.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full whitespace-nowrap transition-colors',
selectedCategoryId === category.id
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
)}
>
{category.icon && <IconRenderer icon={category.icon} size={14} />}
{category.name}
</button>
))}
</div>
{/* 助手列表 */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-[var(--color-text-tertiary)]" />
</div>
) : filteredAssistants.length === 0 ? (
<div className="text-center py-12 text-[var(--color-text-tertiary)]">
</div>
) : (
<div className="space-y-2">
{/* 默认助手选项 */}
<button
onClick={() => onSelect(null)}
className={cn(
'w-full flex items-center gap-3 p-3 rounded-xl text-left transition-colors',
currentAssistantId === null
? 'bg-[var(--color-primary)]/10 border border-[var(--color-primary)]'
: 'bg-[var(--color-bg-secondary)] hover:bg-[var(--color-bg-tertiary)] border border-transparent'
)}
>
<div className="w-10 h-10 flex items-center justify-center bg-[var(--color-bg-tertiary)] rounded-lg text-[var(--color-text-secondary)]">
<IconRenderer icon="Sparkles" size={20} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-[var(--color-text-primary)]">
</div>
<div className="text-sm text-[var(--color-text-tertiary)] truncate">
使
</div>
</div>
</button>
{/* 助手列表 */}
{filteredAssistants.map((assistant) => (
<button
key={assistant.id}
onClick={() => onSelect(assistant)}
className={cn(
'w-full flex items-center gap-3 p-3 rounded-xl text-left transition-colors',
currentAssistantId === assistant.id
? 'bg-[var(--color-primary)]/10 border border-[var(--color-primary)]'
: 'bg-[var(--color-bg-secondary)] hover:bg-[var(--color-bg-tertiary)] border border-transparent'
)}
>
<div className="w-10 h-10 flex items-center justify-center bg-[var(--color-bg-tertiary)] rounded-lg text-[var(--color-text-secondary)]">
<IconRenderer icon={assistant.icon} size={20} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-[var(--color-text-primary)]">
{assistant.name}
</span>
{assistant.isBuiltin && (
<span className="flex items-center gap-0.5 px-1.5 py-0.5 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs rounded">
<Sparkles size={10} />
</span>
)}
{assistant.isFavorited && (
<Heart size={14} className="text-red-500" fill="currentColor" />
)}
</div>
<div className="text-sm text-[var(--color-text-tertiary)] truncate">
{assistant.description || '暂无描述'}
</div>
</div>
<div className="text-xs text-[var(--color-text-tertiary)]">
{assistant.useCount || 0}
</div>
</button>
))}
</div>
)}
</div>
{/* 底部提示 */}
<div className="px-4 py-3 border-t border-[var(--color-border-light)] text-center text-xs text-[var(--color-text-tertiary)]">
使
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
export { AssistantCard } from './AssistantCard';
export type { AssistantCardProps } from './AssistantCard';
export { AssistantDetailModal } from './AssistantDetailModal';
export type { AssistantDetailModalProps } from './AssistantDetailModal';
export { AssistantEditModal } from './AssistantEditModal';
export type { AssistantFormData } from './AssistantEditModal';
export { AssistantSelector } from './AssistantSelector';