Compare commits
7 Commits
66a58a2d3d
...
5307255844
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5307255844 | ||
|
|
a5fcc9edae | ||
|
|
2d4bdfb7f5 | ||
|
|
c987fcf909 | ||
|
|
34aa3e50cf | ||
|
|
ee112a5ea3 | ||
|
|
bcb2141915 |
106
src/app/api/assistants/[id]/favorite/route.ts
Normal file
106
src/app/api/assistants/[id]/favorite/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
230
src/app/api/assistants/[id]/route.ts
Normal file
230
src/app/api/assistants/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/assistants/categories/route.ts
Normal file
23
src/app/api/assistants/categories/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
130
src/app/api/assistants/recent/route.ts
Normal file
130
src/app/api/assistants/recent/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
172
src/app/api/assistants/route.ts
Normal file
172
src/app/api/assistants/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
369
src/app/assistants/page.tsx
Normal file
@ -0,0 +1,369 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, Plus, Loader2, Heart, Filter } from 'lucide-react';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { AssistantCard } from '@/components/assistants/AssistantCard';
|
||||
import { AssistantDetailModal } from '@/components/assistants/AssistantDetailModal';
|
||||
import { AssistantEditModal, AssistantFormData } from '@/components/assistants/AssistantEditModal';
|
||||
import { IconRenderer } from '@/components/ui/IconRenderer';
|
||||
import { useConversations } from '@/hooks/useConversations';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { useAuth } from '@/providers/AuthProvider';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface Assistant {
|
||||
id: number;
|
||||
categoryId: number | null;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
systemPrompt: string;
|
||||
tags: string[];
|
||||
isBuiltin: boolean | null;
|
||||
isFavorited: boolean;
|
||||
useCount: number | null;
|
||||
createdAt: Date | string | null;
|
||||
categoryName?: string | null;
|
||||
categoryIcon?: string | null;
|
||||
}
|
||||
|
||||
export default function AssistantsPage() {
|
||||
const router = useRouter();
|
||||
const { createConversation } = useConversations();
|
||||
const { settings } = useSettings();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | 'all' | 'favorites'>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedAssistant, setSelectedAssistant] = useState<Assistant | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingAssistant, setEditingAssistant] = useState<Partial<AssistantFormData> | undefined>(undefined);
|
||||
|
||||
// 加载分类数据
|
||||
const loadCategories = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/assistants/categories');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCategories(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加载助手数据
|
||||
const loadAssistants = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// 传递用户 ID 以获取收藏状态
|
||||
if (user?.id) {
|
||||
params.set('userId', user.id);
|
||||
}
|
||||
|
||||
if (selectedCategoryId !== 'all' && selectedCategoryId !== 'favorites') {
|
||||
params.set('categoryId', selectedCategoryId.toString());
|
||||
}
|
||||
if (selectedCategoryId === 'favorites') {
|
||||
params.set('favorites', 'true');
|
||||
}
|
||||
if (searchQuery) {
|
||||
params.set('search', searchQuery);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/assistants?${params.toString()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAssistants(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load assistants:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedCategoryId, searchQuery, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAssistants();
|
||||
}, [loadAssistants]);
|
||||
|
||||
// 收藏/取消收藏
|
||||
const handleFavoriteToggle = async (assistant: Assistant) => {
|
||||
if (!user?.id) {
|
||||
console.error('User not logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (assistant.isFavorited) {
|
||||
await fetch(`/api/assistants/${assistant.id}/favorite?userId=${user.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} else {
|
||||
await fetch(`/api/assistants/${assistant.id}/favorite`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id }),
|
||||
});
|
||||
}
|
||||
// 更新本地状态
|
||||
setAssistants((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === assistant.id ? { ...a, isFavorited: !a.isFavorited } : a
|
||||
)
|
||||
);
|
||||
if (selectedAssistant?.id === assistant.id) {
|
||||
setSelectedAssistant({ ...selectedAssistant, isFavorited: !assistant.isFavorited });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle favorite:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 使用助手创建新对话
|
||||
const handleUseAssistant = async (assistant: Assistant) => {
|
||||
try {
|
||||
// 增加使用次数
|
||||
await fetch(`/api/assistants/${assistant.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'incrementUseCount' }),
|
||||
});
|
||||
|
||||
// 创建新对话
|
||||
const newConversation = await createConversation({
|
||||
model: settings?.defaultModel || 'claude-sonnet-4-20250514',
|
||||
tools: settings?.defaultTools || [],
|
||||
enableThinking: settings?.enableThinking || false,
|
||||
assistantId: assistant.id,
|
||||
systemPrompt: assistant.systemPrompt,
|
||||
});
|
||||
|
||||
router.push(`/chat/${newConversation.conversationId}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to use assistant:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存助手
|
||||
const handleSaveAssistant = async (data: AssistantFormData) => {
|
||||
if (!user?.id) {
|
||||
console.error('User not logged in');
|
||||
throw new Error('User not logged in');
|
||||
}
|
||||
|
||||
try {
|
||||
if (data.id) {
|
||||
// 更新
|
||||
await fetch(`/api/assistants/${data.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...data, userId: user.id }),
|
||||
});
|
||||
} else {
|
||||
// 创建
|
||||
await fetch('/api/assistants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...data, userId: user.id }),
|
||||
});
|
||||
}
|
||||
loadAssistants();
|
||||
} catch (error) {
|
||||
console.error('Failed to save assistant:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 打开创建助手弹窗
|
||||
const handleCreateAssistant = () => {
|
||||
setEditingAssistant(undefined);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// 打开详情弹窗
|
||||
const handleOpenDetail = (assistant: Assistant) => {
|
||||
setSelectedAssistant(assistant);
|
||||
setShowDetailModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden w-full">
|
||||
{/* 头部 */}
|
||||
<header className="shrink-0 px-6 py-4 border-b border-[var(--color-border-light)]">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">
|
||||
助手库
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mt-1">
|
||||
选择或创建专属 AI 助手,提升工作效率
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateAssistant}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Plus size={18} />
|
||||
创建助手
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="relative max-w-md">
|
||||
<Search
|
||||
size={18}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索助手..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 分类标签 */}
|
||||
<div className="shrink-0 px-6 py-3 border-b border-[var(--color-border-light)] overflow-x-auto">
|
||||
<div className="max-w-7xl mx-auto flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedCategoryId('all')}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm rounded-full whitespace-nowrap transition-colors',
|
||||
selectedCategoryId === 'all'
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'bg-white border border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
|
||||
)}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedCategoryId('favorites')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-4 py-2 text-sm rounded-full whitespace-nowrap transition-colors',
|
||||
selectedCategoryId === 'favorites'
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-white border border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-red-400 hover:text-red-500'
|
||||
)}
|
||||
>
|
||||
<Heart size={14} />
|
||||
我的收藏
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setSelectedCategoryId(category.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-4 py-2 text-sm rounded-full whitespace-nowrap transition-colors',
|
||||
selectedCategoryId === category.id
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'bg-white border border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
|
||||
)}
|
||||
>
|
||||
{category.icon && <IconRenderer icon={category.icon} size={14} />}
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 助手列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-[var(--color-text-tertiary)]" />
|
||||
</div>
|
||||
) : assistants.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-6xl mb-4">🤖</div>
|
||||
<p className="text-[var(--color-text-tertiary)]">
|
||||
{selectedCategoryId === 'favorites'
|
||||
? '暂无收藏的助手'
|
||||
: searchQuery
|
||||
? '未找到匹配的助手'
|
||||
: '暂无助手'}
|
||||
</p>
|
||||
{selectedCategoryId !== 'favorites' && (
|
||||
<button
|
||||
onClick={handleCreateAssistant}
|
||||
className="mt-4 text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
创建第一个助手
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{assistants.map((assistant) => (
|
||||
<AssistantCard
|
||||
key={assistant.id}
|
||||
{...assistant}
|
||||
onClick={() => handleOpenDetail(assistant)}
|
||||
onFavoriteToggle={() => handleFavoriteToggle(assistant)}
|
||||
onUse={() => handleUseAssistant(assistant)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{selectedAssistant && (
|
||||
<AssistantDetailModal
|
||||
assistant={selectedAssistant}
|
||||
isOpen={showDetailModal}
|
||||
onClose={() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedAssistant(null);
|
||||
}}
|
||||
onUse={() => {
|
||||
setShowDetailModal(false);
|
||||
handleUseAssistant(selectedAssistant);
|
||||
}}
|
||||
onFavoriteToggle={() => handleFavoriteToggle(selectedAssistant)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<AssistantEditModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingAssistant(undefined);
|
||||
}}
|
||||
onSave={handleSaveAssistant}
|
||||
categories={categories}
|
||||
initialData={editingAssistant}
|
||||
isEditing={!!editingAssistant?.id}
|
||||
/>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
响应式设计
|
||||
======================================== */
|
||||
|
||||
126
src/components/assistants/AssistantCard.tsx
Normal file
126
src/components/assistants/AssistantCard.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { Heart, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { IconRenderer } from '@/components/ui/IconRenderer';
|
||||
|
||||
export interface AssistantCardProps {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
tags: string[];
|
||||
categoryName?: string | null;
|
||||
categoryIcon?: string | null;
|
||||
isBuiltin: boolean | null;
|
||||
isFavorited: boolean;
|
||||
useCount: number | null;
|
||||
onClick?: () => void;
|
||||
onFavoriteToggle?: () => void;
|
||||
onUse?: () => void;
|
||||
}
|
||||
|
||||
export function AssistantCard({
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
tags,
|
||||
categoryName,
|
||||
isBuiltin,
|
||||
isFavorited,
|
||||
useCount,
|
||||
onClick,
|
||||
onFavoriteToggle,
|
||||
onUse,
|
||||
}: AssistantCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="group relative bg-[var(--color-bg-primary)] border border-[var(--color-border-light)] rounded p-4 hover:border-[var(--color-primary)] hover:shadow-md transition-all cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 内置标签 */}
|
||||
{isBuiltin && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 px-2 py-0.5 bg-gradient-to-r from-amber-500/10 to-orange-500/10 text-amber-600 dark:text-amber-400 text-xs rounded-full">
|
||||
<Sparkles size={12} />
|
||||
<span>内置</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 头部:图标和名称 */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="w-12 h-12 flex items-center justify-center bg-[var(--color-bg-secondary)] rounded text-[var(--color-text-secondary)]">
|
||||
<IconRenderer icon={icon} size={24} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-[var(--color-text-primary)] truncate pr-16">
|
||||
{name}
|
||||
</h3>
|
||||
{categoryName && (
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{categoryName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="text-sm text-[var(--color-text-secondary)] line-clamp-2 mb-3 min-h-[40px]">
|
||||
{description || '暂无描述'}
|
||||
</p>
|
||||
|
||||
{/* 标签 */}
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{tags.slice(0, 3).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-0.5 bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<span className="px-2 py-0.5 text-[var(--color-text-tertiary)] text-xs">
|
||||
+{tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部:使用次数和操作按钮 */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--color-border-light)]">
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
使用 {useCount || 0} 次
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 收藏按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFavoriteToggle?.();
|
||||
}}
|
||||
className={cn(
|
||||
'p-1.5 rounded transition-colors',
|
||||
isFavorited
|
||||
? 'text-red-500 bg-red-50 dark:bg-red-500/10'
|
||||
: 'text-[var(--color-text-tertiary)] hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10'
|
||||
)}
|
||||
title={isFavorited ? '取消收藏' : '收藏'}
|
||||
>
|
||||
<Heart size={16} fill={isFavorited ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{/* 使用按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUse?.();
|
||||
}}
|
||||
className="px-3 py-1.5 bg-[var(--color-primary)] text-white text-xs rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
使用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
src/components/assistants/AssistantDetailModal.tsx
Normal file
181
src/components/assistants/AssistantDetailModal.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { X, Heart, Sparkles, Copy, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { IconRenderer } from '@/components/ui/IconRenderer';
|
||||
|
||||
export interface AssistantDetailModalProps {
|
||||
assistant: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
systemPrompt: string;
|
||||
tags: string[];
|
||||
categoryName?: string | null;
|
||||
categoryIcon?: string | null;
|
||||
isBuiltin: boolean | null;
|
||||
isFavorited: boolean;
|
||||
useCount: number | null;
|
||||
createdAt: Date | string | null;
|
||||
};
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUse: () => void;
|
||||
onFavoriteToggle: () => void;
|
||||
}
|
||||
|
||||
export function AssistantDetailModal({
|
||||
assistant,
|
||||
isOpen,
|
||||
onClose,
|
||||
onUse,
|
||||
onFavoriteToggle,
|
||||
}: AssistantDetailModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopyPrompt = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(assistant.systemPrompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 遮罩 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<div className="relative w-full max-w-2xl max-h-[90vh] mx-4 bg-[var(--color-bg-primary)] rounded-2xl shadow-2xl overflow-hidden flex flex-col">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start gap-4 p-6 border-b border-[var(--color-border-light)]">
|
||||
<div className="w-16 h-16 flex items-center justify-center bg-[var(--color-bg-secondary)] rounded-xl shrink-0 text-[var(--color-text-secondary)]">
|
||||
<IconRenderer icon={assistant.icon} size={32} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
|
||||
{assistant.name}
|
||||
</h2>
|
||||
{assistant.isBuiltin && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-gradient-to-r from-amber-500/10 to-orange-500/10 text-amber-600 dark:text-amber-400 text-xs rounded-full">
|
||||
<Sparkles size={12} />
|
||||
内置
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{assistant.categoryName && (
|
||||
<span className="flex items-center gap-1 text-sm text-[var(--color-text-tertiary)]">
|
||||
{assistant.categoryIcon && <IconRenderer icon={assistant.categoryIcon} size={14} />}
|
||||
{assistant.categoryName}
|
||||
</span>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
|
||||
{assistant.description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* 标签 */}
|
||||
{assistant.tags && assistant.tags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||
标签
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assistant.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] text-sm rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 系统提示词 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
系统提示词
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCopyPrompt}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={14} />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={14} />
|
||||
复制
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--color-bg-secondary)] rounded-xl max-h-[300px] overflow-y-auto">
|
||||
<pre className="text-sm text-[var(--color-text-secondary)] whitespace-pre-wrap font-mono">
|
||||
{assistant.systemPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用统计 */}
|
||||
<div className="flex items-center gap-6 text-sm text-[var(--color-text-tertiary)]">
|
||||
<span>使用次数:{assistant.useCount || 0}</span>
|
||||
{assistant.createdAt && (
|
||||
<span>
|
||||
创建时间:{new Date(assistant.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-[var(--color-border-light)]">
|
||||
<button
|
||||
onClick={onFavoriteToggle}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg transition-colors',
|
||||
assistant.isFavorited
|
||||
? 'text-red-500 bg-red-50 dark:bg-red-500/10'
|
||||
: 'text-[var(--color-text-secondary)] border border-[var(--color-border)] hover:border-red-300 hover:text-red-500'
|
||||
)}
|
||||
>
|
||||
<Heart size={18} fill={assistant.isFavorited ? 'currentColor' : 'none'} />
|
||||
{assistant.isFavorited ? '已收藏' : '收藏'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUse}
|
||||
className="px-6 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:opacity-90 transition-opacity font-medium"
|
||||
>
|
||||
立即使用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
418
src/components/assistants/AssistantEditModal.tsx
Normal file
418
src/components/assistants/AssistantEditModal.tsx
Normal file
@ -0,0 +1,418 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, Loader2, ChevronDown, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { IconPicker } from '@/components/ui/IconPicker';
|
||||
import { DEFAULT_ICON } from '@/components/ui/icons';
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface AssistantFormData {
|
||||
id?: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
categoryId: number | null;
|
||||
systemPrompt: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface AssistantEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: AssistantFormData) => Promise<void>;
|
||||
categories: Category[];
|
||||
initialData?: Partial<AssistantFormData>;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export function AssistantEditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
categories,
|
||||
initialData,
|
||||
isEditing = false,
|
||||
}: AssistantEditModalProps) {
|
||||
const [formData, setFormData] = useState<AssistantFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: DEFAULT_ICON,
|
||||
categoryId: null,
|
||||
systemPrompt: '',
|
||||
tags: [],
|
||||
});
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
|
||||
const categoryPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setFormData({
|
||||
id: initialData.id,
|
||||
name: initialData.name || '',
|
||||
description: initialData.description || '',
|
||||
icon: initialData.icon || DEFAULT_ICON,
|
||||
categoryId: initialData.categoryId ?? null,
|
||||
systemPrompt: initialData.systemPrompt || '',
|
||||
tags: initialData.tags || [],
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: DEFAULT_ICON,
|
||||
categoryId: null,
|
||||
systemPrompt: '',
|
||||
tags: [],
|
||||
});
|
||||
}
|
||||
setTagInput('');
|
||||
setErrors({});
|
||||
setShowCategoryPicker(false);
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
// 点击外部关闭 category picker
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (categoryPickerRef.current && !categoryPickerRef.current.contains(event.target as Node)) {
|
||||
setShowCategoryPicker(false);
|
||||
}
|
||||
};
|
||||
if (showCategoryPicker) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showCategoryPicker]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAddTag = () => {
|
||||
const tag = tagInput.trim();
|
||||
if (tag && !formData.tags.includes(tag) && formData.tags.length < 5) {
|
||||
setFormData({ ...formData, tags: [...formData.tags, tag] });
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((tag) => tag !== tagToRemove),
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTag();
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = '请输入助手名称';
|
||||
}
|
||||
if (!formData.systemPrompt.trim()) {
|
||||
newErrors.systemPrompt = '请输入系统提示词';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await onSave(formData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to save assistant:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategorySelect = (categoryId: number | null) => {
|
||||
setFormData({ ...formData, categoryId });
|
||||
setShowCategoryPicker(false);
|
||||
};
|
||||
|
||||
// 获取当前选中的分类
|
||||
const selectedCategory = categories.find((c) => c.id === formData.categoryId);
|
||||
|
||||
// 输入框基础样式
|
||||
const inputBaseClass = 'w-full px-3 py-2 bg-[var(--color-bg-secondary)] border rounded text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)] transition-colors';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 遮罩 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<div className="relative w-full max-w-3xl max-h-[85vh] mx-4 bg-[var(--color-bg-primary)] rounded shadow-2xl overflow-hidden flex flex-col animate-scale-in">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border-light)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{isEditing ? '编辑助手' : '创建助手'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表单内容 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{/* 第一行:图标 + 名称 */}
|
||||
<div className="grid grid-cols-[140px_1fr] gap-5 mb-5">
|
||||
{/* 图标选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||
图标
|
||||
</label>
|
||||
<IconPicker
|
||||
value={formData.icon}
|
||||
onChange={(icon) => setFormData({ ...formData, icon })}
|
||||
triggerSize={48}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||
名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="给助手起个名字"
|
||||
className={cn(
|
||||
inputBaseClass,
|
||||
errors.name ? 'border-red-500' : 'border-[var(--color-border)]'
|
||||
)}
|
||||
maxLength={100}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:分类 + 描述 */}
|
||||
<div className="grid grid-cols-2 gap-5 mb-5">
|
||||
{/* 分类 - 自定义下拉 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||
分类
|
||||
</label>
|
||||
<div className="relative" ref={categoryPickerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCategoryPicker(!showCategoryPicker)}
|
||||
className={cn(
|
||||
inputBaseClass,
|
||||
'border-[var(--color-border)] flex items-center justify-between gap-2 text-left cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'truncate',
|
||||
!selectedCategory && 'text-[var(--color-text-tertiary)]'
|
||||
)}>
|
||||
{selectedCategory ? (
|
||||
<>
|
||||
{selectedCategory.icon && <span className="mr-1.5">{selectedCategory.icon}</span>}
|
||||
{selectedCategory.name}
|
||||
</>
|
||||
) : (
|
||||
'选择分类(可选)'
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn(
|
||||
'flex-shrink-0 text-[var(--color-text-tertiary)] transition-transform',
|
||||
showCategoryPicker && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
{showCategoryPicker && (
|
||||
<div className="absolute left-0 top-full mt-1 w-full bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded shadow-lg z-20 py-1 max-h-[200px] overflow-y-auto">
|
||||
{/* 空选项 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCategorySelect(null)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-sm text-left flex items-center justify-between gap-2 transition-colors',
|
||||
formData.categoryId === null
|
||||
? 'bg-[var(--color-primary-light)] text-[var(--color-primary)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
|
||||
)}
|
||||
>
|
||||
<span className="text-[var(--color-text-tertiary)]">无分类</span>
|
||||
{formData.categoryId === null && (
|
||||
<Check size={14} className="text-[var(--color-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 分类选项 */}
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => handleCategorySelect(category.id)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-sm text-left flex items-center justify-between gap-2 transition-colors',
|
||||
formData.categoryId === category.id
|
||||
? 'bg-[var(--color-primary-light)] text-[var(--color-primary)]'
|
||||
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
{category.icon && <span>{category.icon}</span>}
|
||||
<span>{category.name}</span>
|
||||
</span>
|
||||
{formData.categoryId === category.id && (
|
||||
<Check size={14} className="flex-shrink-0 text-[var(--color-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||
描述
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
placeholder="简单描述这个助手的功能"
|
||||
className={cn(inputBaseClass, 'border-[var(--color-border)]')}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第三行:标签(全宽) */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||
标签(最多5个)
|
||||
</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 已添加的标签 */}
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-[var(--color-text-tertiary)] hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{/* 输入框和添加按钮 */}
|
||||
{formData.tags.length < 5 && (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入标签后回车添加"
|
||||
className={cn(inputBaseClass, 'border-[var(--color-border)] flex-1')}
|
||||
maxLength={20}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTag}
|
||||
disabled={!tagInput.trim()}
|
||||
className="px-3 py-2 text-xs font-medium text-[var(--color-primary)] border border-[var(--color-primary)] rounded hover:bg-[var(--color-primary)] hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第四行:系统提示词(全宽) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||
系统提示词 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.systemPrompt}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, systemPrompt: e.target.value })
|
||||
}
|
||||
placeholder="定义助手的角色、能力和行为规则..."
|
||||
rows={6}
|
||||
className={cn(
|
||||
inputBaseClass,
|
||||
'resize-none font-mono',
|
||||
errors.systemPrompt ? 'border-red-500' : 'border-[var(--color-border)]'
|
||||
)}
|
||||
/>
|
||||
{errors.systemPrompt && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.systemPrompt}</p>
|
||||
)}
|
||||
<p className="mt-1.5 text-xs text-[var(--color-text-tertiary)]">
|
||||
提示:详细描述助手的角色、专长领域、回复风格等
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--color-border-light)] bg-[var(--color-bg-secondary)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--color-text-secondary)] border border-[var(--color-border)] rounded hover:bg-[var(--color-bg-hover)] transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-[var(--color-primary)] text-white rounded hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{isEditing ? '保存' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
270
src/components/assistants/AssistantSelector.tsx
Normal file
270
src/components/assistants/AssistantSelector.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Search, Loader2, Heart, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { IconRenderer } from '@/components/ui/IconRenderer';
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
interface Assistant {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
systemPrompt: string;
|
||||
tags: string[];
|
||||
categoryId: number | null;
|
||||
categoryName?: string | null;
|
||||
categoryIcon?: string | null;
|
||||
isBuiltin: boolean | null;
|
||||
isFavorited: boolean;
|
||||
useCount: number | null;
|
||||
}
|
||||
|
||||
interface AssistantSelectorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (assistant: Assistant | null) => void;
|
||||
currentAssistantId?: number | null;
|
||||
}
|
||||
|
||||
export function AssistantSelector({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentAssistantId,
|
||||
}: AssistantSelectorProps) {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | 'all' | 'favorites'>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 加载分类和助手数据
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [categoriesRes, assistantsRes] = await Promise.all([
|
||||
fetch('/api/assistants/categories'),
|
||||
fetch('/api/assistants'),
|
||||
]);
|
||||
|
||||
if (categoriesRes.ok) {
|
||||
const data = await categoriesRes.json();
|
||||
setCategories(data);
|
||||
}
|
||||
|
||||
if (assistantsRes.ok) {
|
||||
const data = await assistantsRes.json();
|
||||
setAssistants(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 过滤助手
|
||||
const filteredAssistants = assistants.filter((assistant) => {
|
||||
// 分类过滤
|
||||
if (selectedCategoryId === 'favorites') {
|
||||
if (!assistant.isFavorited) return false;
|
||||
} else if (selectedCategoryId !== 'all') {
|
||||
if (assistant.categoryId !== selectedCategoryId) return false;
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
assistant.name.toLowerCase().includes(query) ||
|
||||
assistant.description?.toLowerCase().includes(query) ||
|
||||
assistant.tags?.some((tag) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 遮罩 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<div className="relative w-full max-w-3xl max-h-[80vh] mx-4 bg-[var(--color-bg-primary)] rounded-2xl shadow-2xl overflow-hidden flex flex-col">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--color-border-light)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
选择助手
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="p-4 border-b border-[var(--color-border-light)]">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={18}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索助手..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类标签 */}
|
||||
<div className="flex gap-2 px-4 py-3 border-b border-[var(--color-border-light)] overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setSelectedCategoryId('all')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm rounded-full whitespace-nowrap transition-colors',
|
||||
selectedCategoryId === 'all'
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
|
||||
)}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedCategoryId('favorites')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 text-sm rounded-full whitespace-nowrap transition-colors',
|
||||
selectedCategoryId === 'favorites'
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
|
||||
)}
|
||||
>
|
||||
<Heart size={14} />
|
||||
我的收藏
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setSelectedCategoryId(category.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full whitespace-nowrap transition-colors',
|
||||
selectedCategoryId === category.id
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
|
||||
)}
|
||||
>
|
||||
{category.icon && <IconRenderer icon={category.icon} size={14} />}
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 助手列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-[var(--color-text-tertiary)]" />
|
||||
</div>
|
||||
) : filteredAssistants.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--color-text-tertiary)]">
|
||||
暂无助手
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* 默认助手选项 */}
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 p-3 rounded-xl text-left transition-colors',
|
||||
currentAssistantId === null
|
||||
? 'bg-[var(--color-primary)]/10 border border-[var(--color-primary)]'
|
||||
: 'bg-[var(--color-bg-secondary)] hover:bg-[var(--color-bg-tertiary)] border border-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="w-10 h-10 flex items-center justify-center bg-[var(--color-bg-tertiary)] rounded-lg text-[var(--color-text-secondary)]">
|
||||
<IconRenderer icon="Sparkles" size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-[var(--color-text-primary)]">
|
||||
默认助手
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-tertiary)] truncate">
|
||||
使用系统默认提示词
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 助手列表 */}
|
||||
{filteredAssistants.map((assistant) => (
|
||||
<button
|
||||
key={assistant.id}
|
||||
onClick={() => onSelect(assistant)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 p-3 rounded-xl text-left transition-colors',
|
||||
currentAssistantId === assistant.id
|
||||
? 'bg-[var(--color-primary)]/10 border border-[var(--color-primary)]'
|
||||
: 'bg-[var(--color-bg-secondary)] hover:bg-[var(--color-bg-tertiary)] border border-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="w-10 h-10 flex items-center justify-center bg-[var(--color-bg-tertiary)] rounded-lg text-[var(--color-text-secondary)]">
|
||||
<IconRenderer icon={assistant.icon} size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-[var(--color-text-primary)]">
|
||||
{assistant.name}
|
||||
</span>
|
||||
{assistant.isBuiltin && (
|
||||
<span className="flex items-center gap-0.5 px-1.5 py-0.5 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs rounded">
|
||||
<Sparkles size={10} />
|
||||
内置
|
||||
</span>
|
||||
)}
|
||||
{assistant.isFavorited && (
|
||||
<Heart size={14} className="text-red-500" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-tertiary)] truncate">
|
||||
{assistant.description || '暂无描述'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{assistant.useCount || 0} 次
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="px-4 py-3 border-t border-[var(--color-border-light)] text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
选择助手后,其提示词将与默认提示词叠加使用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/components/assistants/index.ts
Normal file
10
src/components/assistants/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export { AssistantCard } from './AssistantCard';
|
||||
export type { AssistantCardProps } from './AssistantCard';
|
||||
|
||||
export { AssistantDetailModal } from './AssistantDetailModal';
|
||||
export type { AssistantDetailModalProps } from './AssistantDetailModal';
|
||||
|
||||
export { AssistantEditModal } from './AssistantEditModal';
|
||||
export type { AssistantFormData } from './AssistantEditModal';
|
||||
|
||||
export { AssistantSelector } from './AssistantSelector';
|
||||
170
src/components/features/ChatHeader.tsx
Normal file
170
src/components/features/ChatHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
462
src/components/features/NewChatModal.tsx
Normal file
462
src/components/features/NewChatModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
204
src/components/ui/IconPicker.tsx
Normal file
204
src/components/ui/IconPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/ui/IconRenderer.tsx
Normal file
72
src/components/ui/IconRenderer.tsx
Normal 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
142
src/components/ui/icons.ts
Normal 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];
|
||||
38
src/drizzle/migrations/0006_safe_spitfire.sql
Normal file
38
src/drizzle/migrations/0006_safe_spitfire.sql
Normal 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;
|
||||
1023
src/drizzle/migrations/meta/0006_snapshot.json
Normal file
1023
src/drizzle/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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;
|
||||
|
||||
874
src/drizzle/seed-assistants.ts
Normal file
874
src/drizzle/seed-assistants.ts
Normal 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 开发技术栈。
|
||||
|
||||
## 技术专长
|
||||
- 前端:React、Vue、Next.js、TypeScript、Tailwind CSS
|
||||
- 后端:Node.js、Express、NestJS、Python、Go
|
||||
- 数据库:PostgreSQL、MongoDB、Redis
|
||||
- DevOps:Docker、Kubernetes、CI/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
|
||||
|
||||
## 支持的数据库
|
||||
- PostgreSQL、MySQL、SQLite
|
||||
- Oracle、SQL 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 Flow、GitHub Flow)
|
||||
- 合并冲突解决
|
||||
- 提交历史管理(rebase、cherry-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"
|
||||
\`\`\`
|
||||
|
||||
## 注意事项
|
||||
- 提供多种正则引擎的兼容性说明(JavaScript、Python、PCRE)
|
||||
- 考虑边界情况和性能问题
|
||||
- 给出可能的替代方案`,
|
||||
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. **认证授权**:JWT、OAuth 2.0、API 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 ↔ 其他格式(YAML、XML、CSV)
|
||||
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));
|
||||
@ -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}`, {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user