Compare commits

...

7 Commits

Author SHA1 Message Date
gaoziman
5307255844 style(界面): 优化聊天页面和侧边栏样式
- 聊天页面集成 ChatHeader 组件显示助手信息
- 添加淡入、滑入等动画效果样式
- 优化侧边栏布局和新对话按钮交互
- 统一分类标签的视觉样式
2025-12-20 20:46:51 +08:00
gaoziman
a5fcc9edae feat(对话): 扩展对话管理支持助手关联
- 对话创建接口支持关联助手ID和系统提示词
- 对话查询接口返回关联的助手信息
- 聊天接口支持使用助手系统提示词
- useConversations Hook 扩展助手相关参数
2025-12-20 20:46:35 +08:00
gaoziman
2d4bdfb7f5 feat(聊天): 添加新对话弹窗和聊天头部组件
- 新增 NewChatModal 新对话弹窗,支持快速开始和助手选择
- 新增 ChatHeader 聊天头部组件,显示当前助手和模型信息
- 支持搜索助手和显示收藏助手
- 集成 IconRenderer 显示助手图标
2025-12-20 20:46:19 +08:00
gaoziman
c987fcf909 feat(助手库): 添加助手库页面和组件
- 新增助手库页面,支持分类浏览和搜索
- 新增 AssistantCard 助手卡片组件
- 新增 AssistantDetailModal 助手详情弹窗
- 新增 AssistantEditModal 助手编辑弹窗
- 新增 AssistantSelector 助手选择器组件
- 集成 IconRenderer 组件显示 lucide 图标
2025-12-20 20:46:05 +08:00
gaoziman
34aa3e50cf feat(API): 添加助手管理接口
- 新增助手 CRUD 接口 (GET/POST/PUT/DELETE)
- 新增助手分类查询接口
- 新增助手收藏/取消收藏接口
- 新增最近使用助手查询接口
- 支持按分类、搜索关键词筛选助手
2025-12-20 20:45:56 +08:00
gaoziman
ee112a5ea3 feat(数据库): 添加助手系统数据表结构
- 新增 assistant_categories 助手分类表
- 新增 assistants 助手表,支持系统提示词和标签
- 新增 assistant_favorites 用户收藏表
- 添加数据库迁移脚本 0006_safe_spitfire.sql
- 添加助手种子数据脚本
2025-12-20 20:45:44 +08:00
gaoziman
bcb2141915 feat(图标系统): 添加图标选择器和渲染器组件
- 新增 icons.ts 图标配置文件,定义图标分类和中文标签
- 新增 IconRenderer 组件,支持渲染 lucide 图标和 emoji
- 新增 IconPicker 组件,提供分类浏览和搜索功能
- 支持向后兼容已有的 emoji 图标数据
2025-12-20 20:45:34 +08:00
28 changed files with 5343 additions and 117 deletions

View File

@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { userFavoriteAssistants, assistants } from '@/drizzle/schema';
import { eq, and } from 'drizzle-orm';
interface RouteParams {
params: Promise<{ id: string }>;
}
// POST /api/assistants/[id]/favorite - 收藏助手
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
const assistantId = parseInt(id);
const body = await request.json();
const { userId } = body;
if (isNaN(assistantId)) {
return NextResponse.json({ error: 'Invalid assistant ID' }, { status: 400 });
}
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 400 });
}
// 检查助手是否存在
const [assistant] = await db
.select()
.from(assistants)
.where(eq(assistants.id, assistantId))
.limit(1);
if (!assistant) {
return NextResponse.json({ error: 'Assistant not found' }, { status: 404 });
}
// 检查是否已经收藏
const [existingFavorite] = await db
.select()
.from(userFavoriteAssistants)
.where(
and(
eq(userFavoriteAssistants.userId, userId),
eq(userFavoriteAssistants.assistantId, assistantId)
)
)
.limit(1);
if (existingFavorite) {
return NextResponse.json({ message: 'Already favorited' }, { status: 200 });
}
// 添加收藏
const [newFavorite] = await db
.insert(userFavoriteAssistants)
.values({
userId,
assistantId,
})
.returning();
return NextResponse.json(newFavorite, { status: 201 });
} catch (error) {
console.error('Failed to favorite assistant:', error);
return NextResponse.json(
{ error: 'Failed to favorite assistant' },
{ status: 500 }
);
}
}
// DELETE /api/assistants/[id]/favorite - 取消收藏
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
const assistantId = parseInt(id);
const searchParams = request.nextUrl.searchParams;
const userId = searchParams.get('userId');
if (isNaN(assistantId)) {
return NextResponse.json({ error: 'Invalid assistant ID' }, { status: 400 });
}
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 400 });
}
// 删除收藏记录
await db
.delete(userFavoriteAssistants)
.where(
and(
eq(userFavoriteAssistants.userId, userId),
eq(userFavoriteAssistants.assistantId, assistantId)
)
);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to unfavorite assistant:', error);
return NextResponse.json(
{ error: 'Failed to unfavorite assistant' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,230 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { assistants, assistantCategories, userFavoriteAssistants } from '@/drizzle/schema';
import { eq, and, sql } from 'drizzle-orm';
interface RouteParams {
params: Promise<{ id: string }>;
}
// GET /api/assistants/[id] - 获取助手详情
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
const assistantId = parseInt(id);
const searchParams = request.nextUrl.searchParams;
const userId = searchParams.get('userId');
if (isNaN(assistantId)) {
return NextResponse.json({ error: 'Invalid assistant ID' }, { status: 400 });
}
const [assistant] = await db
.select({
id: assistants.id,
categoryId: assistants.categoryId,
userId: assistants.userId,
name: assistants.name,
description: assistants.description,
icon: assistants.icon,
systemPrompt: assistants.systemPrompt,
tags: assistants.tags,
isBuiltin: assistants.isBuiltin,
isEnabled: assistants.isEnabled,
sortOrder: assistants.sortOrder,
useCount: assistants.useCount,
createdAt: assistants.createdAt,
updatedAt: assistants.updatedAt,
categoryName: assistantCategories.name,
categoryIcon: assistantCategories.icon,
})
.from(assistants)
.leftJoin(assistantCategories, eq(assistants.categoryId, assistantCategories.id))
.where(eq(assistants.id, assistantId))
.limit(1);
if (!assistant) {
return NextResponse.json({ error: 'Assistant not found' }, { status: 404 });
}
// 查询收藏状态
let isFavorited = false;
if (userId) {
const [favorite] = await db
.select()
.from(userFavoriteAssistants)
.where(
and(
eq(userFavoriteAssistants.userId, userId),
eq(userFavoriteAssistants.assistantId, assistantId)
)
)
.limit(1);
isFavorited = !!favorite;
}
return NextResponse.json({
...assistant,
isFavorited,
});
} catch (error) {
console.error('Failed to fetch assistant:', error);
return NextResponse.json(
{ error: 'Failed to fetch assistant' },
{ status: 500 }
);
}
}
// PUT /api/assistants/[id] - 更新助手
export async function PUT(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
const assistantId = parseInt(id);
const body = await request.json();
const { userId, categoryId, name, description, icon, systemPrompt, tags } = body;
if (isNaN(assistantId)) {
return NextResponse.json({ error: 'Invalid assistant ID' }, { status: 400 });
}
// 查询助手信息
const [existingAssistant] = await db
.select()
.from(assistants)
.where(eq(assistants.id, assistantId))
.limit(1);
if (!existingAssistant) {
return NextResponse.json({ error: 'Assistant not found' }, { status: 404 });
}
// 检查权限:只能编辑自己创建的助手,内置助手不可编辑
if (existingAssistant.isBuiltin) {
return NextResponse.json(
{ error: 'Cannot edit built-in assistant' },
{ status: 403 }
);
}
if (existingAssistant.userId && existingAssistant.userId !== userId) {
return NextResponse.json(
{ error: 'Not authorized to edit this assistant' },
{ status: 403 }
);
}
// 更新助手
const [updatedAssistant] = await db
.update(assistants)
.set({
categoryId: categoryId !== undefined ? categoryId : existingAssistant.categoryId,
name: name || existingAssistant.name,
description: description !== undefined ? description : existingAssistant.description,
icon: icon || existingAssistant.icon,
systemPrompt: systemPrompt || existingAssistant.systemPrompt,
tags: tags !== undefined ? tags : existingAssistant.tags,
updatedAt: new Date(),
})
.where(eq(assistants.id, assistantId))
.returning();
return NextResponse.json(updatedAssistant);
} catch (error) {
console.error('Failed to update assistant:', error);
return NextResponse.json(
{ error: 'Failed to update assistant' },
{ status: 500 }
);
}
}
// DELETE /api/assistants/[id] - 删除助手
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
const assistantId = parseInt(id);
const searchParams = request.nextUrl.searchParams;
const userId = searchParams.get('userId');
if (isNaN(assistantId)) {
return NextResponse.json({ error: 'Invalid assistant ID' }, { status: 400 });
}
// 查询助手信息
const [existingAssistant] = await db
.select()
.from(assistants)
.where(eq(assistants.id, assistantId))
.limit(1);
if (!existingAssistant) {
return NextResponse.json({ error: 'Assistant not found' }, { status: 404 });
}
// 检查权限:只能删除自己创建的助手,内置助手不可删除
if (existingAssistant.isBuiltin) {
return NextResponse.json(
{ error: 'Cannot delete built-in assistant' },
{ status: 403 }
);
}
if (existingAssistant.userId && existingAssistant.userId !== userId) {
return NextResponse.json(
{ error: 'Not authorized to delete this assistant' },
{ status: 403 }
);
}
// 删除相关收藏记录
await db
.delete(userFavoriteAssistants)
.where(eq(userFavoriteAssistants.assistantId, assistantId));
// 删除助手
await db.delete(assistants).where(eq(assistants.id, assistantId));
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete assistant:', error);
return NextResponse.json(
{ error: 'Failed to delete assistant' },
{ status: 500 }
);
}
}
// PATCH /api/assistants/[id] - 增加使用次数
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
const assistantId = parseInt(id);
const body = await request.json();
const { action } = body;
if (isNaN(assistantId)) {
return NextResponse.json({ error: 'Invalid assistant ID' }, { status: 400 });
}
if (action === 'incrementUseCount') {
await db
.update(assistants)
.set({
useCount: sql`${assistants.useCount} + 1`,
updatedAt: new Date(),
})
.where(eq(assistants.id, assistantId));
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
} catch (error) {
console.error('Failed to update assistant:', error);
return NextResponse.json(
{ error: 'Failed to update assistant' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { assistantCategories } from '@/drizzle/schema';
import { eq, asc } from 'drizzle-orm';
// GET /api/assistants/categories - 获取所有助手分类
export async function GET() {
try {
const categories = await db
.select()
.from(assistantCategories)
.where(eq(assistantCategories.isEnabled, true))
.orderBy(asc(assistantCategories.sortOrder));
return NextResponse.json(categories);
} catch (error) {
console.error('Failed to fetch assistant categories:', error);
return NextResponse.json(
{ error: 'Failed to fetch categories' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { conversations, assistants, assistantCategories } from '@/drizzle/schema';
import { eq, desc, isNotNull, and, asc } from 'drizzle-orm';
import { getCurrentUser } from '@/lib/auth';
// GET /api/assistants/recent - 获取最近使用的助手
export async function GET(request: NextRequest) {
try {
// 获取当前用户
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '5');
// 查询用户最近使用过助手的对话(按时间倒序,去重)
const recentConversations = await db
.selectDistinctOn([conversations.assistantId], {
assistantId: conversations.assistantId,
lastUsedAt: conversations.lastMessageAt,
})
.from(conversations)
.where(
and(
eq(conversations.userId, user.userId),
isNotNull(conversations.assistantId)
)
)
.orderBy(conversations.assistantId, desc(conversations.lastMessageAt))
.limit(limit * 2); // 多取一些,因为可能有些助手已被删除
if (recentConversations.length === 0) {
return NextResponse.json({ data: [] });
}
// 获取助手详细信息
const assistantIds = recentConversations
.map((c) => c.assistantId)
.filter((id): id is number => id !== null);
if (assistantIds.length === 0) {
return NextResponse.json({ data: [] });
}
// 查询助手详情(带分类信息)
const assistantsList = await db
.select({
id: assistants.id,
categoryId: assistants.categoryId,
name: assistants.name,
description: assistants.description,
icon: assistants.icon,
systemPrompt: assistants.systemPrompt,
tags: assistants.tags,
isBuiltin: assistants.isBuiltin,
useCount: assistants.useCount,
categoryName: assistantCategories.name,
categoryIcon: assistantCategories.icon,
})
.from(assistants)
.leftJoin(assistantCategories, eq(assistants.categoryId, assistantCategories.id))
.where(
and(
eq(assistants.isEnabled, true),
// 使用 SQL IN 查询
eq(assistants.id, assistantIds[0]) // 临时解决方案
)
);
// 由于 drizzle 的 IN 语法较复杂,改用循环查询
const allAssistants = [];
for (const id of assistantIds.slice(0, limit)) {
const [assistant] = await db
.select({
id: assistants.id,
categoryId: assistants.categoryId,
name: assistants.name,
description: assistants.description,
icon: assistants.icon,
systemPrompt: assistants.systemPrompt,
tags: assistants.tags,
isBuiltin: assistants.isBuiltin,
useCount: assistants.useCount,
categoryName: assistantCategories.name,
categoryIcon: assistantCategories.icon,
})
.from(assistants)
.leftJoin(assistantCategories, eq(assistants.categoryId, assistantCategories.id))
.where(
and(
eq(assistants.isEnabled, true),
eq(assistants.id, id)
)
)
.limit(1);
if (assistant) {
// 找到对应的 lastUsedAt
const conv = recentConversations.find((c) => c.assistantId === id);
allAssistants.push({
...assistant,
lastUsedAt: conv?.lastUsedAt,
});
}
}
// 按最后使用时间排序
allAssistants.sort((a, b) => {
const timeA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0;
const timeB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0;
return timeB - timeA;
});
return NextResponse.json({
data: allAssistants.slice(0, limit),
});
} catch (error) {
console.error('Failed to fetch recent assistants:', error);
return NextResponse.json(
{ error: 'Failed to fetch recent assistants' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,172 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { assistants, assistantCategories, userFavoriteAssistants } from '@/drizzle/schema';
import { eq, and, or, ilike, asc, desc, sql, isNull } from 'drizzle-orm';
// GET /api/assistants - 获取助手列表
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const categoryId = searchParams.get('categoryId');
const search = searchParams.get('search');
const userId = searchParams.get('userId'); // 用于获取用户自定义助手
const onlyFavorites = searchParams.get('favorites') === 'true';
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '50');
const offset = (page - 1) * limit;
// 构建查询条件
const conditions = [eq(assistants.isEnabled, true)];
// 按分类筛选
if (categoryId && categoryId !== 'all') {
conditions.push(eq(assistants.categoryId, parseInt(categoryId)));
}
// 搜索条件(按名称或描述)
if (search) {
conditions.push(
or(
ilike(assistants.name, `%${search}%`),
ilike(assistants.description, `%${search}%`)
)!
);
}
// 用户自定义助手或系统内置助手
// 如果有 userId显示系统助手和该用户创建的助手
if (userId) {
conditions.push(
or(
isNull(assistants.userId), // 系统内置
eq(assistants.userId, userId) // 用户自己创建的
)!
);
} else {
// 没有 userId 时只显示系统内置助手
conditions.push(isNull(assistants.userId));
}
// 如果只获取收藏的助手
if (onlyFavorites && userId) {
const favoriteIds = await db
.select({ assistantId: userFavoriteAssistants.assistantId })
.from(userFavoriteAssistants)
.where(eq(userFavoriteAssistants.userId, userId));
if (favoriteIds.length > 0) {
const ids = favoriteIds.map((f) => f.assistantId);
conditions.push(sql`${assistants.id} IN (${sql.join(ids, sql`, `)})`);
} else {
// 没有收藏则返回空数组
return NextResponse.json({
data: [],
total: 0,
page,
limit,
});
}
}
// 查询助手列表(带分类信息)
const assistantsList = await db
.select({
id: assistants.id,
categoryId: assistants.categoryId,
userId: assistants.userId,
name: assistants.name,
description: assistants.description,
icon: assistants.icon,
systemPrompt: assistants.systemPrompt,
tags: assistants.tags,
isBuiltin: assistants.isBuiltin,
sortOrder: assistants.sortOrder,
useCount: assistants.useCount,
createdAt: assistants.createdAt,
categoryName: assistantCategories.name,
categoryIcon: assistantCategories.icon,
})
.from(assistants)
.leftJoin(assistantCategories, eq(assistants.categoryId, assistantCategories.id))
.where(and(...conditions))
.orderBy(desc(assistants.isBuiltin), asc(assistants.sortOrder), desc(assistants.useCount))
.limit(limit)
.offset(offset);
// 如果有 userId查询收藏状态
let favoritesSet = new Set<number>();
if (userId) {
const userFavorites = await db
.select({ assistantId: userFavoriteAssistants.assistantId })
.from(userFavoriteAssistants)
.where(eq(userFavoriteAssistants.userId, userId));
favoritesSet = new Set(userFavorites.map((f) => f.assistantId));
}
// 添加收藏状态到结果
const result = assistantsList.map((assistant) => ({
...assistant,
isFavorited: favoritesSet.has(assistant.id),
}));
// 获取总数
const totalResult = await db
.select({ count: sql<number>`count(*)` })
.from(assistants)
.where(and(...conditions));
const total = Number(totalResult[0]?.count || 0);
return NextResponse.json({
data: result,
total,
page,
limit,
});
} catch (error) {
console.error('Failed to fetch assistants:', error);
return NextResponse.json(
{ error: 'Failed to fetch assistants' },
{ status: 500 }
);
}
}
// POST /api/assistants - 创建新助手
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { userId, categoryId, name, description, icon, systemPrompt, tags } = body;
if (!name || !systemPrompt) {
return NextResponse.json(
{ error: 'Name and system prompt are required' },
{ status: 400 }
);
}
const [newAssistant] = await db
.insert(assistants)
.values({
userId: userId || null, // null 表示系统内置
categoryId: categoryId || null,
name,
description: description || null,
icon: icon || '🤖',
systemPrompt,
tags: tags || [],
isBuiltin: false, // 用户创建的不是内置
isEnabled: true,
sortOrder: 0,
useCount: 0,
})
.returning();
return NextResponse.json(newAssistant, { status: 201 });
} catch (error) {
console.error('Failed to create assistant:', error);
return NextResponse.json(
{ error: 'Failed to create assistant' },
{ status: 500 }
);
}
}

View File

@ -269,15 +269,26 @@ export async function POST(request: Request) {
try {
const cchUrl = settings.cchUrl || 'http://localhost:13500';
// 获取系统提示词
const baseSystemPrompt = conversation.systemPrompt || settings.systemPrompt || DEFAULT_SYSTEM_PROMPT;
// 获取系统提示词(叠加模式)
// 1. 始终使用 DEFAULT_SYSTEM_PROMPT 作为基础
// 2. 如果对话有关联助手的提示词conversation.systemPrompt则叠加到默认提示词后面
// 3. 助手提示词替代设置页面的自定义提示词,不替代默认提示词
const currentDate = new Date().toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
});
const systemPrompt = baseSystemPrompt.replace('{{CURRENT_DATE}}', currentDate);
const basePrompt = DEFAULT_SYSTEM_PROMPT.replace('{{CURRENT_DATE}}', currentDate);
// 叠加助手提示词
let systemPrompt = basePrompt;
if (conversation.systemPrompt) {
systemPrompt = `${basePrompt}\n\n---\n\n## 🎭 当前助手角色设定\n\n${conversation.systemPrompt}`;
console.log('[API/chat] 🎭 使用助手提示词:', conversation.systemPrompt.substring(0, 100) + '...');
} else {
console.log('[API/chat] 📝 无助手提示词,使用默认提示词');
}
// 获取温度参数
const temperature = parseFloat(conversation.temperature || settings.temperature || '0.7');

View File

@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { conversations, messages } from '@/drizzle/schema';
import { conversations, messages, assistants } from '@/drizzle/schema';
import { eq, asc, and } from 'drizzle-orm';
import { getCurrentUser } from '@/lib/auth';
@ -41,9 +41,23 @@ export async function GET(request: Request, { params }: RouteParams) {
orderBy: [asc(messages.createdAt)],
});
// 如果有关联助手,获取助手信息
let assistant = null;
if (conversation.assistantId) {
assistant = await db.query.assistants.findFirst({
where: eq(assistants.id, conversation.assistantId),
});
}
return NextResponse.json({
...conversation,
messages: messageList,
assistant: assistant ? {
id: assistant.id,
name: assistant.name,
icon: assistant.icon,
description: assistant.description,
} : null,
});
} catch (error) {
console.error('Failed to get conversation:', error);
@ -68,7 +82,7 @@ export async function PUT(request: Request, { params }: RouteParams) {
const { id } = await params;
const body = await request.json();
const { title, isPinned, isArchived } = body;
const { title, isPinned, isArchived, model } = body;
// 验证对话属于当前用户
const existingConversation = await db.query.conversations.findFirst({
@ -101,6 +115,10 @@ export async function PUT(request: Request, { params }: RouteParams) {
updateData.isArchived = isArchived;
}
if (model !== undefined) {
updateData.model = model;
}
const [updated] = await db
.update(conversations)
.set(updateData)

View File

@ -48,7 +48,7 @@ export async function POST(request: Request) {
}
const body = await request.json();
const { title, model, tools, enableThinking } = body;
const { title, model, tools, enableThinking, assistantId, systemPrompt } = body;
const conversationId = nanoid();
@ -61,6 +61,8 @@ export async function POST(request: Request) {
tools: tools || [],
enableThinking: enableThinking || false,
userId: user.userId, // 关联当前用户
assistantId: assistantId || null, // 关联助手
systemPrompt: systemPrompt || null, // 对话专属系统提示词(来自助手)
})
.returning();

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

@ -6,6 +6,7 @@ import { Share2, MoreHorizontal, Loader2, Square, Clock, ChevronDown, Pencil, Tr
import { Sidebar, SidebarToggle } from '@/components/layout/Sidebar';
import { ChatInput } from '@/components/features/ChatInput';
import { MessageBubble } from '@/components/features/MessageBubble';
import { ChatHeaderInfo } from '@/components/features/ChatHeader';
import { cn } from '@/lib/utils';
import { useConversation, useConversations } from '@/hooks/useConversations';
import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat';
@ -263,6 +264,19 @@ export default function ChatPage({ params }: PageProps) {
setEnableThinking(!enableThinking);
};
// 切换模型(持久化到数据库)
const handleModelChange = async (modelId: string) => {
if (!conversation || modelId === selectedModelId) return;
try {
await updateConversation(chatId, { model: modelId });
setSelectedModelId(modelId);
} catch (error) {
console.error('Failed to change model:', error);
throw error;
}
};
// 转换模型格式
const modelOptions = models.map((m) => ({
id: m.modelId,
@ -305,89 +319,91 @@ export default function ChatPage({ params }: PageProps) {
)}
>
{/* 固定顶部 Header */}
<header className="h-[var(--header-height)] px-4 flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-bg-primary)] sticky top-0 z-10">
<div className="flex items-center gap-3">
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
<header className="px-4 py-2 flex flex-col gap-1 border-b border-[var(--color-border)] bg-[var(--color-bg-primary)] sticky top-0 z-10">
{/* 第一行:标题和操作按钮 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
{/* 标题区域 - 可点击显示下拉菜单 */}
{isEditingTitle ? (
// 编辑模式
<div className="flex items-center gap-2">
<input
ref={titleInputRef}
type="text"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={handleTitleKeyDown}
onBlur={handleCancelRename}
className="px-2 py-1 text-base font-medium bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-primary)] max-w-[300px]"
disabled={isSavingTitle}
/>
<button
onMouseDown={(e) => {
e.preventDefault();
handleSubmitRename();
}}
disabled={isSavingTitle || !editingTitle.trim()}
className="p-1 text-green-500 hover:bg-[var(--color-bg-hover)] rounded disabled:opacity-50"
title="Confirm"
>
{isSavingTitle ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Check size={16} />
{/* 标题区域 - 可点击显示下拉菜单 */}
{isEditingTitle ? (
// 编辑模式
<div className="flex items-center gap-2">
<input
ref={titleInputRef}
type="text"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={handleTitleKeyDown}
onBlur={handleCancelRename}
className="px-2 py-1 text-base font-medium bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-primary)] max-w-[300px]"
disabled={isSavingTitle}
/>
<button
onMouseDown={(e) => {
e.preventDefault();
handleSubmitRename();
}}
disabled={isSavingTitle || !editingTitle.trim()}
className="p-1 text-green-500 hover:bg-[var(--color-bg-hover)] rounded disabled:opacity-50"
title="Confirm"
>
{isSavingTitle ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Check size={16} />
)}
</button>
<button
onMouseDown={(e) => {
e.preventDefault();
handleCancelRename();
}}
disabled={isSavingTitle}
className="p-1 text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] rounded disabled:opacity-50"
title="Cancel"
>
<X size={16} />
</button>
</div>
) : (
// 正常模式 - 显示标题和下拉菜单
<div className="relative" ref={titleMenuRef}>
<button
onClick={() => setTitleMenuOpen(!titleMenuOpen)}
className="flex items-center gap-1 text-base font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] px-2 py-1 rounded-lg transition-colors max-w-[300px]"
>
<span className="truncate">{conversation?.title || 'New Chat'}</span>
<ChevronDown size={16} className={cn(
'flex-shrink-0 transition-transform',
titleMenuOpen && 'rotate-180'
)} />
</button>
{/* 下拉菜单 */}
{titleMenuOpen && (
<div className="absolute left-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-20 min-w-[140px]">
<button
onClick={handleStartRename}
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
>
<Pencil size={14} />
Rename
</button>
<button
onClick={handleDeleteConversation}
className="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
>
<Trash2 size={14} />
Delete
</button>
</div>
)}
</button>
<button
onMouseDown={(e) => {
e.preventDefault();
handleCancelRename();
}}
disabled={isSavingTitle}
className="p-1 text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] rounded disabled:opacity-50"
title="Cancel"
>
<X size={16} />
</button>
</div>
) : (
// 正常模式 - 显示标题和下拉菜单
<div className="relative" ref={titleMenuRef}>
<button
onClick={() => setTitleMenuOpen(!titleMenuOpen)}
className="flex items-center gap-1 text-base font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] px-2 py-1 rounded-lg transition-colors max-w-[300px]"
>
<span className="truncate">{conversation?.title || 'New Chat'}</span>
<ChevronDown size={16} className={cn(
'flex-shrink-0 transition-transform',
titleMenuOpen && 'rotate-180'
)} />
</button>
{/* 下拉菜单 */}
{titleMenuOpen && (
<div className="absolute left-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-20 min-w-[140px]">
<button
onClick={handleStartRename}
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
>
<Pencil size={14} />
Rename
</button>
<button
onClick={handleDeleteConversation}
className="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
>
<Trash2 size={14} />
Delete
</button>
</div>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* 思考模式开关 */}
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* 思考模式开关 */}
<button
onClick={handleThinkingToggle}
className={cn(
@ -415,6 +431,17 @@ export default function ChatPage({ params }: PageProps) {
>
<MoreHorizontal size={18} />
</button>
</div>
</div>
{/* 第二行:助手和模型信息 */}
<div className="pl-12">
<ChatHeaderInfo
assistant={conversation?.assistant || null}
currentModel={selectedModelId}
models={modelOptions}
onModelChange={handleModelChange}
/>
</div>
</header>

View File

@ -258,6 +258,26 @@ body {
animation: scaleInFast 0.15s ease-out;
}
.animate-scale-in {
animation: scaleInFast 0.2s ease-out;
}
/* 卡片淡入上浮动画 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.3s ease-out both;
}
/* ========================================
响应式设计
======================================== */

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';

View File

@ -0,0 +1,170 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { ChevronDown, Check, Bot } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Assistant {
id: number;
name: string;
icon: string | null;
description: string | null;
}
interface Model {
id: string;
name: string;
displayName: string;
tag?: string;
}
interface ChatHeaderInfoProps {
assistant: Assistant | null;
currentModel: string;
models: Model[];
onModelChange: (modelId: string) => Promise<void>;
}
export function ChatHeaderInfo({
assistant,
currentModel,
models,
onModelChange,
}: ChatHeaderInfoProps) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const [isChanging, setIsChanging] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsModelMenuOpen(false);
}
};
if (isModelMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isModelMenuOpen]);
// 处理模型切换
const handleModelSelect = async (modelId: string) => {
if (modelId === currentModel || isChanging) return;
try {
setIsChanging(true);
await onModelChange(modelId);
setIsModelMenuOpen(false);
// 显示成功提示
setShowSuccess(true);
setTimeout(() => {
setShowSuccess(false);
}, 2000);
} catch (error) {
console.error('Failed to change model:', error);
} finally {
setIsChanging(false);
}
};
// 获取当前模型的显示名称
const currentModelInfo = models.find((m) => m.id === currentModel);
const modelDisplayName = currentModelInfo?.displayName || currentModel;
return (
<div className="flex items-center gap-2 text-sm">
{/* 助手信息 */}
<div className="flex items-center gap-1.5 text-[var(--color-text-secondary)]">
{assistant ? (
<>
<span className="text-base">{assistant.icon || '🤖'}</span>
<span className="font-medium">{assistant.name}</span>
</>
) : (
<>
<Bot size={16} className="text-[var(--color-text-tertiary)]" />
<span className="font-medium"></span>
</>
)}
</div>
{/* 分隔符 */}
<span className="text-[var(--color-text-quaternary)]">·</span>
{/* 模型选择器 */}
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsModelMenuOpen(!isModelMenuOpen)}
disabled={isChanging}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-md transition-colors',
'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]',
isChanging && 'opacity-50 cursor-not-allowed'
)}
>
<span className="max-w-[280px] truncate">{currentModel}</span>
<ChevronDown
size={14}
className={cn(
'flex-shrink-0 transition-transform',
isModelMenuOpen && 'rotate-180'
)}
/>
</button>
{/* 模型下拉菜单 */}
{isModelMenuOpen && (
<div className="absolute left-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-30 min-w-[280px] max-h-[400px] overflow-y-auto">
<div className="px-3 py-2 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider border-b border-[var(--color-border)]">
</div>
{models.map((model) => (
<button
key={model.id}
onClick={() => handleModelSelect(model.id)}
disabled={isChanging}
className={cn(
'w-full px-3 py-2.5 text-left text-sm flex items-center justify-between gap-2 transition-colors',
model.id === currentModel
? 'bg-[var(--color-primary-light)] text-[var(--color-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]',
isChanging && 'opacity-50'
)}
>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="font-medium truncate">{model.displayName}</span>
<span className="text-xs text-[var(--color-text-tertiary)] truncate">
{model.id}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{model.tag && (
<span className="px-1.5 py-0.5 text-xs bg-[var(--color-primary-light)] text-[var(--color-primary)] rounded">
{model.tag}
</span>
)}
{model.id === currentModel && (
<Check size={16} className="text-[var(--color-primary)]" />
)}
</div>
</button>
))}
</div>
)}
</div>
{/* 成功提示 */}
{showSuccess && (
<div className="flex items-center gap-1 text-green-600 text-xs animate-fade-in">
<Check size={14} />
<span></span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,462 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { X, Search, Bot, Star, Clock, ChevronRight, Loader2, Library } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/providers/AuthProvider';
import { IconRenderer } from '@/components/ui/IconRenderer';
import { useConversations } from '@/hooks/useConversations';
import { useSettings } from '@/hooks/useSettings';
interface Assistant {
id: number;
name: string;
description: string | null;
icon: string | null;
systemPrompt: string;
categoryName?: string | null;
tags?: string[] | null;
}
interface NewChatModalProps {
isOpen: boolean;
onClose: () => void;
}
export function NewChatModal({ isOpen, onClose }: NewChatModalProps) {
const router = useRouter();
const { user } = useAuth();
const { createConversation } = useConversations();
const { settings } = useSettings();
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Assistant[]>([]);
const [favoriteAssistants, setFavoriteAssistants] = useState<Assistant[]>([]);
const [recentAssistants, setRecentAssistants] = useState<Assistant[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 加载收藏和最近使用的助手
const loadAssistants = useCallback(async () => {
if (!user) return;
setIsLoading(true);
try {
// 并行加载收藏和最近使用的助手
const [favoritesRes, recentRes] = await Promise.all([
fetch(`/api/assistants?favorites=true&userId=${user.id}&limit=6`),
fetch('/api/assistants/recent?limit=3'),
]);
if (favoritesRes.ok) {
const favData = await favoritesRes.json();
setFavoriteAssistants(favData.data || []);
}
if (recentRes.ok) {
const recentData = await recentRes.json();
setRecentAssistants(recentData.data || []);
}
} catch (error) {
console.error('Failed to load assistants:', error);
} finally {
setIsLoading(false);
}
}, [user]);
// 搜索助手
const searchAssistants = useCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
return;
}
setIsSearching(true);
try {
const res = await fetch(`/api/assistants?search=${encodeURIComponent(query)}&limit=10`);
if (res.ok) {
const data = await res.json();
setSearchResults(data.data || []);
}
} catch (error) {
console.error('Failed to search assistants:', error);
} finally {
setIsSearching(false);
}
}, []);
// 处理搜索输入(防抖)
const handleSearchChange = (value: string) => {
setSearchQuery(value);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
searchAssistants(value);
}, 300);
};
// 创建新对话
const handleCreateChat = async (assistant?: Assistant) => {
if (isCreating) return;
setIsCreating(true);
try {
const newConversation = await createConversation({
model: settings?.defaultModel || 'claude-sonnet-4-20250514',
tools: settings?.defaultTools || [],
enableThinking: settings?.enableThinking || false,
assistantId: assistant?.id,
systemPrompt: assistant?.systemPrompt,
});
onClose();
router.push(`/chat/${newConversation.conversationId}`);
} catch (error) {
console.error('Failed to create conversation:', error);
} finally {
setIsCreating(false);
}
};
// 跳转到助手库
const handleGoToAssistants = () => {
onClose();
router.push('/assistants');
};
// 打开时加载数据和聚焦搜索框
useEffect(() => {
if (isOpen) {
loadAssistants();
setSearchQuery('');
setSearchResults([]);
// 延迟聚焦,等待动画完成
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
}
}, [isOpen, loadAssistants]);
// ESC 键关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// 点击遮罩关闭
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
// 清理定时器
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, []);
if (!isOpen) return null;
const showSearchResults = searchQuery.trim().length > 0;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 animate-fade-in"
onClick={handleBackdropClick}
>
<div
ref={modalRef}
className="w-full max-w-2xl bg-[var(--color-bg-primary)] rounded-xl shadow-2xl overflow-hidden animate-scale-in"
>
{/* 标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
</h2>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<X size={20} />
</button>
</div>
{/* 搜索框 */}
<div className="px-6 py-4">
<div className="relative">
<Search
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="搜索助手..."
className="w-full pl-10 pr-4 py-2.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] placeholder:text-[var(--color-text-quaternary)] focus:outline-none focus:border-[var(--color-primary)] transition-colors"
/>
{isSearching && (
<Loader2
size={18}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)] animate-spin"
/>
)}
</div>
</div>
{/* 内容区域 */}
<div className="px-6 pb-6 max-h-[60vh] overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-[var(--color-primary)]" />
</div>
) : showSearchResults ? (
/* 搜索结果 - 使用网格布局 */
<div className="space-y-3">
<h3 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider">
</h3>
{searchResults.length === 0 ? (
<p className="text-sm text-[var(--color-text-tertiary)] text-center py-8">
</p>
) : (
<div className="grid grid-cols-3 gap-3">
{searchResults.map((assistant, index) => (
<AssistantGridCard
key={assistant.id}
assistant={assistant}
onClick={() => handleCreateChat(assistant)}
disabled={isCreating}
index={index}
/>
))}
</div>
)}
</div>
) : (
/* 默认视图:快速开始 + 收藏 + 最近使用 */
<div className="space-y-6">
{/* 快速开始 */}
<div>
<h3 className="flex items-center gap-2 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">
<span className="text-yellow-500"></span>
</h3>
<button
onClick={() => handleCreateChat()}
disabled={isCreating}
className={cn(
'w-full flex items-center gap-4 p-4 rounded border border-[var(--color-border)] transition-all duration-200 text-left group',
'hover:bg-[var(--color-bg-hover)] hover:border-[var(--color-primary)] hover:shadow-md',
isCreating && 'opacity-50 cursor-not-allowed'
)}
>
<div className="w-12 h-12 rounded bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-hover)] flex items-center justify-center shadow-sm">
<Bot size={24} className="text-white" />
</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)]">
使 AI
</div>
</div>
{isCreating ? (
<Loader2 size={18} className="animate-spin text-[var(--color-text-tertiary)]" />
) : (
<ChevronRight size={18} className="text-[var(--color-text-tertiary)] group-hover:text-[var(--color-primary)] transition-colors" />
)}
</button>
</div>
{/* 收藏助手 - 网格布局 */}
{favoriteAssistants.length > 0 && (
<div>
<h3 className="flex items-center gap-2 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">
<Star size={14} className="text-yellow-500 fill-yellow-500" />
</h3>
<div className="grid grid-cols-3 gap-3">
{favoriteAssistants.map((assistant, index) => (
<AssistantGridCard
key={assistant.id}
assistant={assistant}
onClick={() => handleCreateChat(assistant)}
disabled={isCreating}
index={index}
/>
))}
</div>
</div>
)}
{/* 最近使用 - 芯片式布局 */}
{recentAssistants.length > 0 && (
<div>
<h3 className="flex items-center gap-2 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">
<Clock size={14} className="text-blue-500" />
使
</h3>
<div className="grid grid-cols-3 gap-2">
{recentAssistants.map((assistant, index) => (
<AssistantChip
key={assistant.id}
assistant={assistant}
onClick={() => handleCreateChat(assistant)}
disabled={isCreating}
index={index}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* 底部:浏览全部助手 */}
<div className="px-6 py-4 border-t border-[var(--color-border)] bg-[var(--color-bg-secondary)]">
<button
onClick={handleGoToAssistants}
className="w-full flex items-center justify-center gap-2 py-2.5 text-sm font-medium text-[var(--color-primary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
>
<Library size={18} />
</button>
</div>
</div>
</div>
);
}
// 网格卡片组件 - 用于收藏助手和搜索结果
function AssistantGridCard({
assistant,
onClick,
disabled,
index = 0,
}: {
assistant: Assistant;
onClick: () => void;
disabled?: boolean;
index?: number;
}) {
const [showTooltip, setShowTooltip] = useState(false);
return (
<div className="relative">
<button
onClick={onClick}
disabled={disabled}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
style={{ animationDelay: `${index * 50}ms` }}
className={cn(
'w-full flex flex-col items-center gap-2 p-4 rounded border border-[var(--color-border)] transition-all duration-200 text-center',
'hover:bg-[var(--color-bg-hover)] hover:border-[var(--color-primary)] hover:shadow-md hover:-translate-y-0.5',
'animate-fade-in-up',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<div className="w-10 h-10 rounded bg-[var(--color-bg-tertiary)] flex items-center justify-center text-[var(--color-text-secondary)]">
<IconRenderer icon={assistant.icon} size={20} />
</div>
<div className="w-full">
<div className="font-medium text-sm text-[var(--color-text-primary)] truncate">
{assistant.name}
</div>
{assistant.tags && assistant.tags.length > 0 && (
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
{assistant.tags.slice(0, 2).map((tag, i) => (
<span key={tag}>
{i > 0 && ' · '}
{tag}
</span>
))}
</div>
)}
</div>
</button>
{/* Tooltip */}
{showTooltip && assistant.description && (
<div className="absolute z-10 left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg max-w-[200px] animate-fade-in pointer-events-none">
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed">
{assistant.description}
</p>
<div className="absolute left-1/2 -translate-x-1/2 top-full w-2 h-2 bg-[var(--color-bg-primary)] border-r border-b border-[var(--color-border)] transform rotate-45 -mt-1" />
</div>
)}
</div>
);
}
// 芯片组件 - 用于最近使用
function AssistantChip({
assistant,
onClick,
disabled,
index = 0,
}: {
assistant: Assistant;
onClick: () => void;
disabled?: boolean;
index?: number;
}) {
const [showTooltip, setShowTooltip] = useState(false);
return (
<div className="relative">
<button
onClick={onClick}
disabled={disabled}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
style={{ animationDelay: `${index * 50}ms` }}
className={cn(
'w-full flex items-center gap-2 px-3 py-2.5 rounded border border-[var(--color-border)] transition-all duration-200 text-left',
'hover:bg-[var(--color-bg-hover)] hover:border-[var(--color-primary)]',
'animate-fade-in-up',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<span className="flex-shrink-0 text-[var(--color-text-secondary)]">
<IconRenderer icon={assistant.icon} size={18} />
</span>
<span className="text-sm font-medium text-[var(--color-text-primary)] truncate">
{assistant.name}
</span>
</button>
{/* Tooltip */}
{showTooltip && assistant.description && (
<div className="absolute z-10 left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg max-w-[200px] animate-fade-in pointer-events-none">
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed">
{assistant.description}
</p>
<div className="absolute left-1/2 -translate-x-1/2 top-full w-2 h-2 bg-[var(--color-bg-primary)] border-r border-b border-[var(--color-border)] transform rotate-45 -mt-1" />
</div>
)}
</div>
);
}

View File

@ -2,11 +2,11 @@
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2, Pencil, Check, X } from 'lucide-react';
import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2, Pencil, Check, X, Bot } from 'lucide-react';
import { UserMenu } from '@/components/ui/UserMenu';
import { NewChatModal } from '@/components/features/NewChatModal';
import { cn } from '@/lib/utils';
import { useConversations } from '@/hooks/useConversations';
import { useSettings } from '@/hooks/useSettings';
import { useAuth } from '@/providers/AuthProvider';
import type { Conversation } from '@/drizzle/schema';
import { useState, useRef, useEffect } from 'react';
@ -19,14 +19,13 @@ interface SidebarProps {
export function Sidebar({ isOpen = true }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const { conversations, loading, createConversation, deleteConversation, updateConversation } = useConversations();
const { settings } = useSettings();
const { conversations, loading, deleteConversation, updateConversation } = useConversations();
const { user } = useAuth();
const [creatingChat, setCreatingChat] = useState(false);
const [menuOpen, setMenuOpen] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [showNewChatModal, setShowNewChatModal] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
@ -53,23 +52,9 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
};
}, [menuOpen]);
// 创建新对话
const handleNewChat = async () => {
if (creatingChat) return;
try {
setCreatingChat(true);
const newConversation = await createConversation({
model: settings?.defaultModel || 'claude-sonnet-4-20250514',
tools: settings?.defaultTools || [],
enableThinking: settings?.enableThinking || false,
});
router.push(`/chat/${newConversation.conversationId}`);
} catch (error) {
console.error('Failed to create conversation:', error);
} finally {
setCreatingChat(false);
}
// 创建新对话 - 显示选择助手弹框
const handleNewChat = () => {
setShowNewChatModal(true);
};
// 删除对话
@ -150,18 +135,29 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
<div className="px-4 py-2">
<button
onClick={handleNewChat}
disabled={creatingChat}
className="flex items-center gap-2 text-[var(--color-primary)] text-sm font-medium py-2 hover:opacity-80 transition-opacity disabled:opacity-50"
className="flex items-center gap-2 text-[var(--color-primary)] text-sm font-medium py-2 hover:opacity-80 transition-opacity"
>
{creatingChat ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Plus size={18} />
)}
<Plus size={18} />
<span>New chat</span>
</button>
</div>
{/* 助手库入口 */}
<div className="px-4 pb-2">
<Link
href="/assistants"
className={cn(
'flex items-center gap-2 text-sm font-medium py-2 transition-colors',
pathname === '/assistants'
? 'text-[var(--color-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-primary)]'
)}
>
<Bot size={18} />
<span></span>
</Link>
</div>
{/* 聊天列表 */}
<nav className="flex-1 overflow-y-auto px-2 flex flex-col gap-1">
{loading ? (
@ -301,6 +297,12 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
onClick={() => setMenuOpen(null)}
/>
)}
{/* 新建对话弹框 */}
<NewChatModal
isOpen={showNewChatModal}
onClose={() => setShowNewChatModal(false)}
/>
</>
);
}

View File

@ -0,0 +1,204 @@
'use client';
import { useState, useRef, useEffect, useMemo } from 'react';
import { Search, ChevronDown } from 'lucide-react';
import { IconRenderer } from './IconRenderer';
import { ICON_CATEGORIES, ICON_LABELS, DEFAULT_ICON } from './icons';
import { cn } from '@/lib/utils';
interface IconPickerProps {
value: string;
onChange: (icon: string) => void;
triggerSize?: number;
triggerClassName?: string;
}
/**
*
*/
export function IconPicker({
value,
onChange,
triggerSize = 48,
triggerClassName,
}: IconPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<string>('recommended');
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
// 聚焦搜索框
setTimeout(() => searchInputRef.current?.focus(), 100);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// 搜索过滤
const filteredIcons = useMemo(() => {
if (!searchQuery.trim()) {
return null; // 不搜索时返回 null显示分类视图
}
const query = searchQuery.toLowerCase();
const results: string[] = [];
// 遍历所有图标,检查名称和标签
Object.values(ICON_CATEGORIES).forEach((category) => {
category.icons.forEach((iconName) => {
// 检查图标名称
if (iconName.toLowerCase().includes(query)) {
if (!results.includes(iconName)) {
results.push(iconName);
}
return;
}
// 检查标签
const labels = ICON_LABELS[iconName] || [];
if (labels.some((label) => label.toLowerCase().includes(query))) {
if (!results.includes(iconName)) {
results.push(iconName);
}
}
});
});
return results;
}, [searchQuery]);
// 选择图标
const handleSelect = (iconName: string) => {
onChange(iconName);
setIsOpen(false);
setSearchQuery('');
};
// 渲染图标按钮
const renderIconButton = (iconName: string, selected: boolean = false) => (
<button
key={iconName}
type="button"
onClick={() => handleSelect(iconName)}
title={iconName}
className={cn(
'w-9 h-9 flex items-center justify-center rounded transition-colors',
selected
? 'bg-[var(--color-primary)] text-white'
: 'hover:bg-[var(--color-bg-hover)] text-[var(--color-text-secondary)]'
)}
>
<IconRenderer icon={iconName} size={20} />
</button>
);
return (
<div className="relative" ref={containerRef}>
{/* 触发按钮 */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center justify-center bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded hover:border-[var(--color-primary)] transition-colors',
triggerClassName
)}
style={{ width: triggerSize, height: triggerSize }}
>
<IconRenderer icon={value || DEFAULT_ICON} size={triggerSize * 0.5} />
</button>
{/* 下拉面板 */}
{isOpen && (
<div className="absolute top-full left-0 mt-2 w-[320px] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded shadow-lg z-50 overflow-hidden">
{/* 搜索框 */}
<div className="p-3 border-b border-[var(--color-border)]">
<div className="relative">
<Search
size={16}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索图标..."
className="w-full pl-8 pr-3 py-2 text-sm bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]"
/>
</div>
</div>
{/* 内容区域 */}
<div className="max-h-[300px] overflow-y-auto">
{filteredIcons ? (
/* 搜索结果 */
<div className="p-3">
{filteredIcons.length === 0 ? (
<p className="text-sm text-[var(--color-text-tertiary)] text-center py-4">
</p>
) : (
<>
<p className="text-xs text-[var(--color-text-tertiary)] mb-2">
{filteredIcons.length}
</p>
<div className="grid grid-cols-8 gap-1">
{filteredIcons.map((iconName) =>
renderIconButton(iconName, iconName === value)
)}
</div>
</>
)}
</div>
) : (
/* 分类视图 */
<div>
{/* 分类标签 */}
<div className="flex flex-wrap gap-1 p-2 border-b border-[var(--color-border)] bg-[var(--color-bg-secondary)]">
{Object.entries(ICON_CATEGORIES).map(([key, category]) => (
<button
key={key}
type="button"
onClick={() => setActiveCategory(key)}
className={cn(
'px-2 py-1 text-xs rounded transition-colors',
activeCategory === key
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
)}
>
{category.label}
</button>
))}
</div>
{/* 图标网格 */}
<div className="p-3">
<div className="grid grid-cols-8 gap-1">
{ICON_CATEGORIES[activeCategory as keyof typeof ICON_CATEGORIES]?.icons.map(
(iconName) => renderIconButton(iconName, iconName === value)
)}
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import { useMemo, ComponentType, SVGProps } from 'react';
import * as LucideIcons from 'lucide-react';
import { isEmoji, DEFAULT_ICON } from './icons';
import { cn } from '@/lib/utils';
interface IconRendererProps {
icon: string | null | undefined;
size?: number;
className?: string;
fallback?: string;
}
// 定义图标组件类型
type IconComponentType = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
// 类型安全的图标映射
const icons = LucideIcons as unknown as Record<string, IconComponentType>;
/**
*
* lucide-react emoji
*/
export function IconRenderer({
icon,
size = 24,
className,
fallback = DEFAULT_ICON,
}: IconRendererProps) {
const renderedIcon = useMemo(() => {
const iconValue = icon || fallback;
// 如果是 emoji直接渲染文本
if (isEmoji(iconValue)) {
return (
<span
className={cn('flex items-center justify-center', className)}
style={{ fontSize: size * 0.9, lineHeight: 1 }}
>
{iconValue}
</span>
);
}
// 尝试从 lucide-react 获取图标组件
const IconComponent = icons[iconValue];
if (IconComponent) {
return <IconComponent size={size} className={className} />;
}
// 如果找不到图标,使用默认图标
const FallbackIcon = icons[fallback];
if (FallbackIcon) {
return <FallbackIcon size={size} className={className} />;
}
// 最后的回退:显示问号图标
return <LucideIcons.HelpCircle size={size} className={className} />;
}, [icon, size, className, fallback]);
return <>{renderedIcon}</>;
}
/**
* Lucide
* 使
*/
export function getLucideIcon(iconName: string): IconComponentType | null {
return icons[iconName] || null;
}

142
src/components/ui/icons.ts Normal file
View File

@ -0,0 +1,142 @@
/**
*
* lucide-react
*/
// 图标分类配置
export const ICON_CATEGORIES = {
recommended: {
label: '推荐',
icons: [
'Code', 'Terminal', 'Bot', 'Brain', 'Lightbulb',
'Rocket', 'Sparkles', 'Palette', 'FileText', 'Globe',
],
},
programming: {
label: '编程开发',
icons: [
'Code', 'Code2', 'Terminal', 'Database', 'Server',
'GitBranch', 'GitCommit', 'GitMerge', 'Bug', 'Cpu',
'HardDrive', 'Monitor', 'Laptop', 'Smartphone', 'Tablet',
'Cloud', 'CloudCog', 'Container', 'Boxes', 'Package',
],
},
design: {
label: '设计创意',
icons: [
'Palette', 'Paintbrush', 'PaintBucket', 'Layers', 'Layout',
'LayoutGrid', 'Image', 'ImagePlus', 'Camera', 'Video',
'Film', 'Figma', 'Pen', 'PenTool', 'Brush',
'Eraser', 'Scissors', 'Crop', 'Frame', 'Shapes',
],
},
writing: {
label: '写作文档',
icons: [
'FileText', 'File', 'FileCode', 'FileJson', 'FilePlus',
'BookOpen', 'Book', 'BookMarked', 'Notebook', 'NotebookPen',
'PenLine', 'Pencil', 'Type', 'Languages', 'Quote',
'AlignLeft', 'AlignCenter', 'List', 'ListOrdered', 'Text',
],
},
tools: {
label: '工具效率',
icons: [
'Wrench', 'Settings', 'Cog', 'SlidersHorizontal', 'Search',
'Filter', 'Calculator', 'Calendar', 'Clock', 'Timer',
'Zap', 'Bolt', 'Target', 'Crosshair', 'Compass',
'Ruler', 'Scale', 'Gauge', 'Activity', 'BarChart',
],
},
communication: {
label: '沟通协作',
icons: [
'MessageSquare', 'MessageCircle', 'MessagesSquare', 'Mail', 'Send',
'Phone', 'PhoneCall', 'Video', 'Mic', 'Headphones',
'Users', 'UserPlus', 'UserCheck', 'Contact', 'AtSign',
'Share', 'Share2', 'Link', 'ExternalLink', 'Globe',
],
},
ai: {
label: 'AI 智能',
icons: [
'Bot', 'Brain', 'Sparkles', 'Wand', 'Wand2',
'Stars', 'Star', 'Atom', 'CircuitBoard', 'Scan',
'ScanLine', 'ScanSearch', 'Eye', 'Fingerprint', 'Shield',
'Lock', 'Key', 'BadgeCheck', 'Award', 'Trophy',
],
},
general: {
label: '通用',
icons: [
'Home', 'Heart', 'ThumbsUp', 'Smile', 'Sun',
'Moon', 'Lightbulb', 'Rocket', 'Plane', 'Car',
'Building', 'Store', 'ShoppingCart', 'Gift', 'Flag',
'Bookmark', 'Tag', 'Hash', 'Info', 'HelpCircle',
],
},
} as const;
// 所有可用图标的平铺列表(用于搜索)
export const ALL_ICONS = Object.values(ICON_CATEGORIES).flatMap(
(category) => category.icons
);
// 去重后的图标列表
export const UNIQUE_ICONS = [...new Set(ALL_ICONS)];
// 图标名称到中文的映射(用于搜索)
export const ICON_LABELS: Record<string, string[]> = {
Code: ['代码', '编程', 'code'],
Code2: ['代码', '编程', 'code'],
Terminal: ['终端', '命令行', 'terminal', 'console'],
Database: ['数据库', 'database', 'db'],
Server: ['服务器', 'server'],
GitBranch: ['分支', 'git', 'branch'],
Bug: ['调试', 'bug', '虫子'],
Cpu: ['处理器', 'cpu', '芯片'],
Bot: ['机器人', 'bot', 'ai', '助手'],
Brain: ['大脑', '智能', 'brain', 'ai'],
Sparkles: ['闪光', '魔法', 'sparkles', 'magic'],
Palette: ['调色板', '设计', 'palette', 'design'],
Paintbrush: ['画笔', '绘画', 'brush', 'paint'],
Layers: ['图层', 'layers'],
Image: ['图片', '图像', 'image', 'picture'],
FileText: ['文档', '文件', 'file', 'document'],
BookOpen: ['书籍', '阅读', 'book', 'read'],
PenLine: ['写作', '笔', 'pen', 'write'],
Languages: ['语言', '翻译', 'language', 'translate'],
Wrench: ['工具', '扳手', 'tool', 'wrench'],
Settings: ['设置', 'settings', 'config'],
Search: ['搜索', '查找', 'search', 'find'],
Calculator: ['计算器', 'calculator', '计算'],
Calendar: ['日历', 'calendar', '日期'],
Clock: ['时钟', '时间', 'clock', 'time'],
MessageSquare: ['消息', '聊天', 'message', 'chat'],
Mail: ['邮件', 'mail', 'email'],
Users: ['用户', '团队', 'users', 'team'],
Globe: ['全球', '网络', 'globe', 'web', 'internet'],
Home: ['首页', '主页', 'home'],
Heart: ['心', '喜欢', 'heart', 'like', 'love'],
Lightbulb: ['灯泡', '想法', 'lightbulb', 'idea'],
Rocket: ['火箭', '发射', 'rocket', 'launch'],
Star: ['星星', '收藏', 'star', 'favorite'],
Zap: ['闪电', '快速', 'zap', 'fast', 'lightning'],
Target: ['目标', 'target', 'goal'],
Shield: ['盾牌', '安全', 'shield', 'security'],
Lock: ['锁', '安全', 'lock', 'security'],
Key: ['钥匙', 'key'],
};
// 默认图标
export const DEFAULT_ICON = 'Bot';
// 判断是否为 emoji
export const isEmoji = (str: string | null | undefined): boolean => {
if (!str) return false;
// emoji 通常以高位 Unicode 开头
return /^[\u{1F300}-\u{1FAD6}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(str);
};
// 获取图标类型
export type IconName = typeof UNIQUE_ICONS[number];

View File

@ -0,0 +1,38 @@
CREATE TABLE "assistant_categories" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(50) NOT NULL,
"icon" varchar(50),
"description" text,
"sort_order" integer DEFAULT 0,
"is_enabled" boolean DEFAULT true,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "assistants" (
"id" serial PRIMARY KEY NOT NULL,
"category_id" integer,
"user_id" varchar(64),
"name" varchar(100) NOT NULL,
"description" text,
"icon" varchar(100),
"system_prompt" text NOT NULL,
"tags" jsonb DEFAULT '[]'::jsonb,
"is_builtin" boolean DEFAULT false,
"is_enabled" boolean DEFAULT true,
"sort_order" integer DEFAULT 0,
"use_count" integer DEFAULT 0,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "user_favorite_assistants" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" varchar(64) NOT NULL,
"assistant_id" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "conversations" ADD COLUMN "assistant_id" integer;--> statement-breakpoint
ALTER TABLE "messages" ADD COLUMN "uploaded_images" jsonb;--> statement-breakpoint
ALTER TABLE "messages" ADD COLUMN "uploaded_documents" jsonb;

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,13 @@
"when": 1766155643018,
"tag": "0005_spotty_sister_grimm",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1766206123948,
"tag": "0006_safe_spitfire",
"breakpoints": true
}
]
}

View File

@ -80,6 +80,67 @@ export const userSettings = pgTable('user_settings', {
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});
// ============================================
// 助手分类表
// ============================================
export const assistantCategories = pgTable('assistant_categories', {
id: serial('id').primaryKey(),
// 分类名称
name: varchar('name', { length: 50 }).notNull(),
// 图标emoji
icon: varchar('icon', { length: 50 }),
// 分类描述
description: text('description'),
// 排序
sortOrder: integer('sort_order').default(0),
// 是否启用
isEnabled: boolean('is_enabled').default(true),
// 时间戳
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});
// ============================================
// 助手表
// ============================================
export const assistants = pgTable('assistants', {
id: serial('id').primaryKey(),
// 所属分类
categoryId: integer('category_id'),
// 归属用户null = 系统内置,有值 = 用户创建
userId: varchar('user_id', { length: 64 }),
// 基本信息
name: varchar('name', { length: 100 }).notNull(),
description: text('description'),
icon: varchar('icon', { length: 100 }),
// 核心:系统提示词
systemPrompt: text('system_prompt').notNull(),
// 标签
tags: jsonb('tags').$type<string[]>().default([]),
// 配置
isBuiltin: boolean('is_builtin').default(false), // 是否内置(不可删除)
isEnabled: boolean('is_enabled').default(true),
sortOrder: integer('sort_order').default(0),
// 统计
useCount: integer('use_count').default(0),
// 时间戳
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});
// ============================================
// 用户收藏助手表
// ============================================
export const userFavoriteAssistants = pgTable('user_favorite_assistants', {
id: serial('id').primaryKey(),
// 用户ID
userId: varchar('user_id', { length: 64 }).notNull(),
// 助手ID
assistantId: integer('assistant_id').notNull(),
// 时间戳
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
// ============================================
// 对话表
// ============================================
@ -89,6 +150,8 @@ export const conversations = pgTable('conversations', {
conversationId: varchar('conversation_id', { length: 64 }).notNull().unique(),
// 关联用户
userId: varchar('user_id', { length: 64 }),
// 关联助手
assistantId: integer('assistant_id'),
// 对话信息
title: varchar('title', { length: 255 }).notNull().default('新对话'),
summary: text('summary'),
@ -208,6 +271,8 @@ export const usersRelations = relations(users, ({ many, one }) => ({
fields: [users.userId],
references: [userSettings.userId],
}),
assistants: many(assistants),
favoriteAssistants: many(userFavoriteAssistants),
}));
export const userSettingsRelations = relations(userSettings, ({ one }) => ({
@ -217,12 +282,47 @@ export const userSettingsRelations = relations(userSettings, ({ one }) => ({
}),
}));
// 助手分类关系
export const assistantCategoriesRelations = relations(assistantCategories, ({ many }) => ({
assistants: many(assistants),
}));
// 助手关系
export const assistantsRelations = relations(assistants, ({ one, many }) => ({
category: one(assistantCategories, {
fields: [assistants.categoryId],
references: [assistantCategories.id],
}),
user: one(users, {
fields: [assistants.userId],
references: [users.userId],
}),
conversations: many(conversations),
favorites: many(userFavoriteAssistants),
}));
// 用户收藏助手关系
export const userFavoriteAssistantsRelations = relations(userFavoriteAssistants, ({ one }) => ({
user: one(users, {
fields: [userFavoriteAssistants.userId],
references: [users.userId],
}),
assistant: one(assistants, {
fields: [userFavoriteAssistants.assistantId],
references: [assistants.id],
}),
}));
export const conversationsRelations = relations(conversations, ({ many, one }) => ({
messages: many(messages),
user: one(users, {
fields: [conversations.userId],
references: [users.userId],
}),
assistant: one(assistants, {
fields: [conversations.assistantId],
references: [assistants.id],
}),
}));
export const messagesRelations = relations(messages, ({ one }) => ({
@ -276,3 +376,12 @@ export type NewTool = typeof tools.$inferInsert;
export type Model = typeof models.$inferSelect;
export type NewModel = typeof models.$inferInsert;
export type AssistantCategory = typeof assistantCategories.$inferSelect;
export type NewAssistantCategory = typeof assistantCategories.$inferInsert;
export type Assistant = typeof assistants.$inferSelect;
export type NewAssistant = typeof assistants.$inferInsert;
export type UserFavoriteAssistant = typeof userFavoriteAssistants.$inferSelect;
export type NewUserFavoriteAssistant = typeof userFavoriteAssistants.$inferInsert;

View File

@ -0,0 +1,874 @@
import { db } from './db';
import { assistantCategories, assistants } from './schema';
// 助手分类种子数据
const categoriesData = [
{ name: '精选', icon: '⭐', description: '精心挑选的热门助手', sortOrder: 0 },
{ name: '编程', icon: '💻', description: '编程开发相关助手', sortOrder: 1 },
{ name: '写作', icon: '✍️', description: '文案写作相关助手', sortOrder: 2 },
{ name: '工具', icon: '🔧', description: '实用工具类助手', sortOrder: 3 },
{ name: '教育', icon: '📚', description: '教育学习相关助手', sortOrder: 4 },
{ name: '商业', icon: '💼', description: '商业办公相关助手', sortOrder: 5 },
{ name: '设计', icon: '🎨', description: '设计创意相关助手', sortOrder: 6 },
{ name: '翻译', icon: '🌐', description: '多语言翻译助手', sortOrder: 7 },
];
// 助手种子数据
const assistantsData = [
// ========== 编程类 ==========
{
categoryName: '编程',
name: 'Python 解释器',
description: '模拟 Python 解释器,执行代码并返回结果',
icon: '🐍',
tags: ['python', '代码执行', '编程'],
systemPrompt: `你是一个专业的 Python 解释器模拟器。
##
1. Python
2.
3.
4.
##
- 使 \`>>>\` 作为输入提示符
-
- 使 Python
##
- Python 3.x
-
- `,
isBuiltin: true,
sortOrder: 0,
},
{
categoryName: '编程',
name: '全栈开发者',
description: '精通前后端技术的全栈开发专家',
icon: '👨‍💻',
tags: ['全栈', 'React', 'Node.js', 'TypeScript'],
systemPrompt: `你是一位经验丰富的全栈开发专家,精通现代 Web 开发技术栈。
##
- ReactVueNext.jsTypeScriptTailwind CSS
- Node.jsExpressNestJSPythonGo
- PostgreSQLMongoDBRedis
- DevOpsDockerKubernetesCI/CD
##
1.
2.
3.
4.
##
-
-
- `,
isBuiltin: true,
sortOrder: 1,
},
{
categoryName: '编程',
name: '代码审查专家',
description: '专业代码审查,发现潜在问题和优化点',
icon: '🔍',
tags: ['代码审查', '最佳实践', '性能优化'],
systemPrompt: `你是一位资深的代码审查专家,专注于代码质量和最佳实践。
##
1. ****
2. ****
3. ****
4. ****SQL注入XSS
5. ****
##
- 使
- 使
- 使
-
##
-
-
- `,
isBuiltin: true,
sortOrder: 2,
},
{
categoryName: '编程',
name: 'SQL 专家',
description: '数据库查询优化和 SQL 语句编写专家',
icon: '🗃️',
tags: ['SQL', '数据库', '查询优化'],
systemPrompt: `你是一位 SQL 和数据库专家,精通各种关系型数据库。
##
- SQL
-
-
-
- ETL
##
- PostgreSQLMySQLSQLite
- OracleSQL Server
-
##
1.
2.
3. SQL
4.
##
- SQL
-
- `,
isBuiltin: true,
sortOrder: 3,
},
{
categoryName: '编程',
name: 'Git 助手',
description: 'Git 版本控制和工作流专家',
icon: '📦',
tags: ['Git', '版本控制', 'GitHub'],
systemPrompt: `你是一位 Git 版本控制专家,帮助用户解决各种 Git 相关问题。
##
- Git
- Git FlowGitHub Flow
-
- rebasecherry-pick
- GitHub/GitLab
##
1.
2.
3.
4.
5. CI/CD
##
-
-
- force push
- `,
isBuiltin: true,
sortOrder: 4,
},
{
categoryName: '编程',
name: '正则表达式生成器',
description: '根据需求生成和解释正则表达式',
icon: '🔤',
tags: ['正则表达式', '文本处理', '模式匹配'],
systemPrompt: `你是一位正则表达式专家,帮助用户创建和理解正则表达式。
##
1.
2.
3.
4.
##
\`\`\`
/pattern/flags
- xxx
- xxx
"example1" "example2"
"counter-example"
\`\`\`
##
- JavaScriptPythonPCRE
-
- `,
isBuiltin: true,
sortOrder: 5,
},
{
categoryName: '编程',
name: 'API 设计师',
description: 'RESTful API 和接口设计专家',
icon: '🔌',
tags: ['API', 'REST', 'GraphQL', '接口设计'],
systemPrompt: `你是一位 API 设计专家,专注于设计清晰、一致、易用的 API。
##
1. **RESTful **HTTP
2. ****URL Header
3. ****JWTOAuth 2.0API Key
4. ****
5. ****OpenAPI/Swagger
##
- URL
- /JSON
- 使
-
- OpenAPI
##
-
-
-
- `,
isBuiltin: true,
sortOrder: 6,
},
// ========== 写作类 ==========
{
categoryName: '写作',
name: '文案写作专家',
description: '专业营销文案和创意写作',
icon: '📝',
tags: ['文案', '营销', '创意写作'],
systemPrompt: `你是一位资深文案写作专家,擅长创作各类营销和创意文案。
##
- Slogan
-
-
- 广
-
##
1. **AIDA **
2. ****
3. ****
4. ****
##
-
-
- `,
isBuiltin: true,
sortOrder: 0,
},
{
categoryName: '写作',
name: '技术文档写手',
description: '专业技术文档和 API 文档撰写',
icon: '📖',
tags: ['技术文档', 'API文档', '用户手册'],
systemPrompt: `你是一位专业的技术文档写手,擅长将复杂技术概念转化为清晰易懂的文档。
##
- API
-
-
- README
- Changelog
##
1. ****
2. ****
3. ****
4. ****
##
-
-
- API
-
- `,
isBuiltin: true,
sortOrder: 1,
},
{
categoryName: '写作',
name: '小红书文案',
description: '小红书风格的种草文案创作',
icon: '📕',
tags: ['小红书', '种草', '社交媒体'],
systemPrompt: `你是一位小红书资深博主,擅长创作吸引眼球的种草文案。
##
-
- 使 emoji
-
-
##
1. ****+/
2. ****
3. ****+
4. ****使+
5. ****
##
-
-
-
- `,
isBuiltin: true,
sortOrder: 2,
},
{
categoryName: '写作',
name: '周报生成器',
description: '快速生成专业工作周报',
icon: '📊',
tags: ['周报', '工作汇报', '职场'],
systemPrompt: `你是一位职场汇报专家,帮助用户快速生成专业的工作周报。
##
1. ****
-
-
2. ****
-
-
3. ****
-
-
4. ****
-
-
##
-
-
-
-
## 使
`,
isBuiltin: true,
sortOrder: 3,
},
// ========== 工具类 ==========
{
categoryName: '工具',
name: 'JSON 助手',
description: 'JSON 格式化、验证和转换',
icon: '📋',
tags: ['JSON', '格式化', '数据转换'],
systemPrompt: `你是一个 JSON 处理专家,帮助用户处理各种 JSON 相关任务。
##
1. **** JSON
2. **** JSON
3. ****JSON YAMLXMLCSV
4. ****使 JSONPath
5. **** JSON
6. **** JSON
##
- JSON 使
-
-
##
- JSON
- `,
isBuiltin: true,
sortOrder: 0,
},
{
categoryName: '工具',
name: 'Markdown 助手',
description: 'Markdown 编写和格式转换',
icon: '📝',
tags: ['Markdown', '格式化', '文档'],
systemPrompt: `你是一个 Markdown 专家,帮助用户编写和优化 Markdown 文档。
##
1. **** Markdown
2. ****Markdown HTML/Word/PDF
3. **** Markdown
4. **** Markdown
5. ****
## Markdown
-
-
-
-
##
`,
isBuiltin: true,
sortOrder: 1,
},
{
categoryName: '工具',
name: 'Mermaid 图表',
description: '使用 Mermaid 语法生成各类图表',
icon: '📈',
tags: ['Mermaid', '流程图', '图表'],
systemPrompt: `你是一个 Mermaid 图表专家,帮助用户创建各种可视化图表。
##
1. ****Flowchart
2. ****Sequence Diagram
3. ****Class Diagram
4. ****State Diagram
5. **ER **Entity Relationship
6. ****Gantt Chart
7. ****Pie Chart
##
\`\`\`mermaid
graph TD
A[] --> B{}
B -->|| C[]
B -->|| D[]
\`\`\`
## 使
-
- Mermaid
- Mermaid `,
isBuiltin: true,
sortOrder: 2,
},
{
categoryName: '工具',
name: '时间计算器',
description: '时区转换、日期计算和倒计时',
icon: '⏰',
tags: ['时间', '时区', '日期计算'],
systemPrompt: `你是一个时间和日期计算专家。
##
1. ****
2. ****
3. ****
4. ****
5. ****
6. ****Unix
##
- CST/UTC+8
- JST/UTC+9
- EST/UTC-5
- GMT/UTC+0
##
`,
isBuiltin: true,
sortOrder: 3,
},
// ========== 教育类 ==========
{
categoryName: '教育',
name: '英语老师',
description: '专业英语教学和语法解析',
icon: '🇬🇧',
tags: ['英语', '语法', '学习'],
systemPrompt: `你是一位经验丰富的英语老师,帮助学生提高英语水平。
##
1. ****
2. ****
3. ****
4. ****
5. ****
##
-
-
-
-
##
-
-
- `,
isBuiltin: true,
sortOrder: 0,
},
{
categoryName: '教育',
name: '数学辅导',
description: '数学问题解答和解题思路讲解',
icon: '🔢',
tags: ['数学', '解题', '辅导'],
systemPrompt: `你是一位耐心的数学老师,擅长讲解各类数学问题。
##
-
-
-
-
- 线
##
1.
2.
3.
4.
##
-
-
-
- `,
isBuiltin: true,
sortOrder: 1,
},
{
categoryName: '教育',
name: '概念解释器',
description: '用简单易懂的方式解释复杂概念',
icon: '💡',
tags: ['解释', '学习', '通俗易懂'],
systemPrompt: `你是一位善于化繁为简的老师,擅长用通俗易懂的方式解释复杂概念。
##
1. ****
2. ****
3. ****
4. ****
##
-
-
-
-
##
- 5
-
-
`,
isBuiltin: true,
sortOrder: 2,
},
// ========== 商业类 ==========
{
categoryName: '商业',
name: '商业计划书',
description: '专业商业计划书撰写指导',
icon: '📑',
tags: ['商业计划', '创业', '融资'],
systemPrompt: `你是一位资深创业顾问,帮助创业者撰写专业的商业计划书。
##
1. ****
2. ****使
3. ****
4. ****
5. ****
6. ****
7. ****
8. ****退
##
-
-
-
-
## 使
`,
isBuiltin: true,
sortOrder: 0,
},
{
categoryName: '商业',
name: '面试教练',
description: '模拟面试和求职指导',
icon: '👔',
tags: ['面试', '求职', '职业发展'],
systemPrompt: `你是一位资深 HR 和面试官,帮助求职者准备面试。
##
1. ****
2. ****
3. ****
4. **STAR **
5. ****
##
-
-
-
-
##
-
-
-
- `,
isBuiltin: true,
sortOrder: 1,
},
// ========== 设计类 ==========
{
categoryName: '设计',
name: 'UI/UX 顾问',
description: '用户界面和体验设计建议',
icon: '🎯',
tags: ['UI', 'UX', '用户体验'],
systemPrompt: `你是一位资深 UI/UX 设计师,提供专业的界面和体验设计建议。
##
- UI
- UX
-
-
-
##
1. ****
2. ****
3. ****
4. ****
5. ****
##
-
-
-
-
`,
isBuiltin: true,
sortOrder: 0,
},
{
categoryName: '设计',
name: '配色方案',
description: '专业配色建议和色彩搭配',
icon: '🎨',
tags: ['配色', '色彩', '设计'],
systemPrompt: `你是一位色彩设计专家,帮助用户创建专业的配色方案。
##
1. ****
2. ****
3. ****
4. ****App
##
- 60-30-10 --
- 访
-
-
##
\`\`\`
60%#XXXXXX -
30%#XXXXXX -
10%#XXXXXX -
#XXXXXX - /
\`\`\`
/`,
isBuiltin: true,
sortOrder: 1,
},
// ========== 翻译类 ==========
{
categoryName: '翻译',
name: '专业翻译',
description: '多语言专业翻译服务',
icon: '🌐',
tags: ['翻译', '多语言', '本地化'],
systemPrompt: `你是一位专业的多语言翻译专家。
##
西
##
1. ****
2. ****
3. ****
##
-
-
-
-
-
##
-
-
-
-
##
-
-
- `,
isBuiltin: true,
sortOrder: 0,
},
{
categoryName: '翻译',
name: '英语润色',
description: '英文写作润色和优化',
icon: '✨',
tags: ['润色', '英语写作', '语法'],
systemPrompt: `你是一位英语母语级别的编辑,帮助用户润色英文写作。
##
1. ****
2. ****
3. ****
4. ****//
5. ****
##
- ****
- ****
- ****
##
1.
2.
3.
4.
`,
isBuiltin: true,
sortOrder: 1,
},
// ========== 精选类(跨分类的热门助手)==========
{
categoryName: '精选',
name: '万能助手',
description: '全能型 AI 助手,处理各类问题',
icon: '🌟',
tags: ['通用', '全能', '推荐'],
systemPrompt: `你是一个全能型 AI 助手,能够处理各种类型的问题和任务。
##
-
-
-
-
-
-
##
1.
2.
3.
4.
5.
##
-
-
- 使
- `,
isBuiltin: true,
sortOrder: 0,
},
{
categoryName: '精选',
name: '思维导师',
description: '引导深度思考和问题分析',
icon: '🧠',
tags: ['思维', '分析', '决策'],
systemPrompt: `你是一位思维导师,帮助用户进行深度思考和问题分析。
##
1. ****
2. ****
3. ****
4. ****
##
- 5W1H
- SWOT
-
-
-
##
-
-
-
-
##
-
-
-
-
`,
isBuiltin: true,
sortOrder: 1,
},
];
async function seed() {
console.log('🌱 开始种子数据导入...\n');
try {
// 1. 插入分类数据
console.log('📁 插入助手分类...');
const insertedCategories = await db
.insert(assistantCategories)
.values(categoriesData)
.returning();
console.log(` ✅ 成功插入 ${insertedCategories.length} 个分类`);
// 创建分类名称到 ID 的映射
const categoryMap = new Map(
insertedCategories.map((cat) => [cat.name, cat.id])
);
// 2. 插入助手数据
console.log('\n🤖 插入助手数据...');
const assistantsToInsert = assistantsData.map((assistant) => ({
categoryId: categoryMap.get(assistant.categoryName),
name: assistant.name,
description: assistant.description,
icon: assistant.icon,
systemPrompt: assistant.systemPrompt,
tags: assistant.tags,
isBuiltin: assistant.isBuiltin,
sortOrder: assistant.sortOrder,
}));
const insertedAssistants = await db
.insert(assistants)
.values(assistantsToInsert)
.returning();
console.log(` ✅ 成功插入 ${insertedAssistants.length} 个助手`);
// 3. 打印统计信息
console.log('\n📊 导入统计:');
for (const category of insertedCategories) {
const count = insertedAssistants.filter(
(a) => a.categoryId === category.id
).length;
console.log(` ${category.icon} ${category.name}: ${count} 个助手`);
}
console.log('\n✨ 种子数据导入完成!');
} catch (error) {
console.error('\n❌ 种子数据导入失败:', error);
throw error;
}
}
// 执行种子脚本
seed()
.then(() => process.exit(0))
.catch(() => process.exit(1));

View File

@ -3,8 +3,16 @@
import { useState, useEffect, useCallback } from 'react';
import type { Conversation, Message } from '@/drizzle/schema';
export interface AssistantInfo {
id: number;
name: string;
icon: string | null;
description: string | null;
}
export interface ConversationWithMessages extends Conversation {
messages: Message[];
assistant?: AssistantInfo | null;
}
export function useConversations() {
@ -34,6 +42,8 @@ export function useConversations() {
model: string;
tools?: string[];
enableThinking?: boolean;
assistantId?: number;
systemPrompt?: string;
}) => {
try {
const response = await fetch('/api/conversations', {
@ -54,7 +64,7 @@ export function useConversations() {
const updateConversation = useCallback(async (
conversationId: string,
data: { title?: string; isPinned?: boolean; isArchived?: boolean }
data: { title?: string; isPinned?: boolean; isArchived?: boolean; model?: string }
) => {
try {
const response = await fetch(`/api/conversations/${conversationId}`, {