feat(助手库): 添加助手库页面和组件
- 新增助手库页面,支持分类浏览和搜索 - 新增 AssistantCard 助手卡片组件 - 新增 AssistantDetailModal 助手详情弹窗 - 新增 AssistantEditModal 助手编辑弹窗 - 新增 AssistantSelector 助手选择器组件 - 集成 IconRenderer 组件显示 lucide 图标
This commit is contained in:
parent
34aa3e50cf
commit
c987fcf909
369
src/app/assistants/page.tsx
Normal file
369
src/app/assistants/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/assistants/AssistantCard.tsx
Normal file
126
src/components/assistants/AssistantCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
src/components/assistants/AssistantDetailModal.tsx
Normal file
181
src/components/assistants/AssistantDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
418
src/components/assistants/AssistantEditModal.tsx
Normal file
418
src/components/assistants/AssistantEditModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
src/components/assistants/AssistantSelector.tsx
Normal file
270
src/components/assistants/AssistantSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/components/assistants/index.ts
Normal file
10
src/components/assistants/index.ts
Normal 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';
|
||||||
Loading…
Reference in New Issue
Block a user