feat(API): 添加标签管理接口
- 添加 GET /api/tags 接口获取用户所有标签统计 - 添加 GET /api/conversations/[id]/tags 获取对话标签 - 添加 PATCH /api/conversations/[id]/tags 更新对话标签 - 添加 POST /api/conversations/[id]/tags/auto AI 自动生成标签
This commit is contained in:
parent
c946f8608c
commit
5189ebb232
219
src/app/api/conversations/[id]/tags/auto/route.ts
Normal file
219
src/app/api/conversations/[id]/tags/auto/route.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/drizzle/db';
|
||||||
|
import { userSettings, conversations, messages } from '@/drizzle/schema';
|
||||||
|
import { eq, and, asc } from 'drizzle-orm';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { decryptApiKey } from '@/lib/crypto';
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签生成的 Prompt 模板
|
||||||
|
function buildTagPrompt(conversationTitle: string, messageContents: string[]): string {
|
||||||
|
const conversationText = messageContents.join('\n\n');
|
||||||
|
|
||||||
|
return `你是一个专业的对话标签分析助手。请根据以下对话内容,生成合适的标签来描述这段对话的主题和内容。
|
||||||
|
|
||||||
|
## 对话标题
|
||||||
|
${conversationTitle}
|
||||||
|
|
||||||
|
## 对话内容
|
||||||
|
${conversationText}
|
||||||
|
|
||||||
|
## 标签要求
|
||||||
|
1. 生成 3-5 个最相关的标签
|
||||||
|
2. 每个标签应该简洁明了(2-6 个字)
|
||||||
|
3. 标签应该反映对话的主要主题、技术栈、问题类型等
|
||||||
|
4. 优先使用常见的分类词汇,如:Python、React、调试、性能、架构、API、数据库等
|
||||||
|
5. 为每个标签提供一个置信度分数(0-100)
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
请严格按照以下 JSON 格式输出,不要有任何其他文字:
|
||||||
|
|
||||||
|
{
|
||||||
|
"tags": [
|
||||||
|
{ "name": "标签名称", "confidence": 95 },
|
||||||
|
{ "name": "标签名称", "confidence": 88 }
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范化 URL
|
||||||
|
function normalizeBaseUrl(url: string): string {
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/conversations/[id]/tags/auto - AI 自动生成标签
|
||||||
|
export async function POST(request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// 获取对话信息
|
||||||
|
const conversation = await db.query.conversations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(conversations.conversationId, id),
|
||||||
|
eq(conversations.userId, user.userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取对话消息
|
||||||
|
const messageList = await db.query.messages.findMany({
|
||||||
|
where: eq(messages.conversationId, id),
|
||||||
|
orderBy: [asc(messages.createdAt)],
|
||||||
|
columns: {
|
||||||
|
role: true,
|
||||||
|
content: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (messageList.length === 0) {
|
||||||
|
return NextResponse.json({ error: '对话没有消息内容' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户设置
|
||||||
|
const settings = await db.query.userSettings.findFirst({
|
||||||
|
where: eq(userSettings.userId, user.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings?.cchApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '请先在设置中配置您的 API Key' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密 API Key
|
||||||
|
const decryptedApiKey = decryptApiKey(settings.cchApiKey);
|
||||||
|
const cchUrl = settings.cchUrl || process.env.CCH_DEFAULT_URL || 'https://claude.leocoder.cn/';
|
||||||
|
const apiFormat = (settings.apiFormat as 'claude' | 'openai') || 'claude';
|
||||||
|
|
||||||
|
// 构建消息内容(限制长度,避免 token 过多)
|
||||||
|
const messageContents = messageList.slice(0, 20).map((msg) => {
|
||||||
|
const prefix = msg.role === 'user' ? '用户' : 'AI助手';
|
||||||
|
// 限制每条消息的长度
|
||||||
|
const content = msg.content.length > 500 ? msg.content.slice(0, 500) + '...' : msg.content;
|
||||||
|
return `${prefix}: ${content}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建 Prompt
|
||||||
|
const tagPrompt = buildTagPrompt(conversation.title, messageContents);
|
||||||
|
|
||||||
|
// 调用 AI API
|
||||||
|
let apiUrl: string;
|
||||||
|
let requestBody: object;
|
||||||
|
let headers: Record<string, string>;
|
||||||
|
|
||||||
|
if (apiFormat === 'openai') {
|
||||||
|
apiUrl = `${normalizeBaseUrl(cchUrl)}/v1/chat/completions`;
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${decryptedApiKey}`,
|
||||||
|
};
|
||||||
|
requestBody = {
|
||||||
|
model: settings.defaultModel || 'claude-sonnet-4-5-20250929',
|
||||||
|
messages: [{ role: 'user', content: tagPrompt }],
|
||||||
|
max_tokens: 512,
|
||||||
|
temperature: 0.3, // 低温度,输出更稳定
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
apiUrl = `${normalizeBaseUrl(cchUrl)}/v1/messages`;
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': decryptedApiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
};
|
||||||
|
requestBody = {
|
||||||
|
model: settings.defaultModel || 'claude-sonnet-4-5-20250929',
|
||||||
|
max_tokens: 512,
|
||||||
|
messages: [{ role: 'user', content: tagPrompt }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[API/tags/auto] 开始生成标签,对话ID:', id);
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('[API/tags/auto] API 调用失败:', response.status, errorText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '生成标签失败,请稍后重试' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 提取 AI 响应内容
|
||||||
|
let aiContent: string;
|
||||||
|
if (apiFormat === 'openai') {
|
||||||
|
aiContent = data.choices?.[0]?.message?.content || '';
|
||||||
|
} else {
|
||||||
|
aiContent = data.content?.[0]?.text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[API/tags/auto] AI 响应:', aiContent);
|
||||||
|
|
||||||
|
// 解析 JSON 响应
|
||||||
|
let suggestedTags: Array<{ name: string; confidence: number }> = [];
|
||||||
|
try {
|
||||||
|
// 尝试提取 JSON 部分(AI 可能会添加额外的文字)
|
||||||
|
const jsonMatch = aiContent.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
|
if (Array.isArray(parsed.tags)) {
|
||||||
|
suggestedTags = parsed.tags
|
||||||
|
.filter((tag: { name?: string; confidence?: number }) =>
|
||||||
|
tag.name && typeof tag.name === 'string' && tag.name.length <= 20
|
||||||
|
)
|
||||||
|
.map((tag: { name: string; confidence?: number }) => ({
|
||||||
|
name: tag.name.trim(),
|
||||||
|
confidence: Math.min(100, Math.max(0, Math.round(tag.confidence || 80))),
|
||||||
|
}))
|
||||||
|
.slice(0, 8); // 最多返回 8 个标签
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('[API/tags/auto] 解析 AI 响应失败:', parseError);
|
||||||
|
// 如果解析失败,尝试简单提取
|
||||||
|
const words = aiContent.match(/[\u4e00-\u9fa5a-zA-Z]+/g) || [];
|
||||||
|
suggestedTags = words
|
||||||
|
.filter((w) => w.length >= 2 && w.length <= 10)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((name) => ({ name, confidence: 70 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestedTags.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '无法生成有效的标签建议' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回推荐的标签(不自动保存,让用户选择)
|
||||||
|
return NextResponse.json({
|
||||||
|
suggestedTags,
|
||||||
|
currentTags: conversation.tags || [],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API/tags/auto] 生成标签错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '生成标签时发生错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/app/api/conversations/[id]/tags/route.ts
Normal file
106
src/app/api/conversations/[id]/tags/route.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/drizzle/db';
|
||||||
|
import { conversations } from '@/drizzle/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/conversations/[id]/tags - 获取对话标签
|
||||||
|
export async function GET(request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const conversation = await db.query.conversations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(conversations.conversationId, id),
|
||||||
|
eq(conversations.userId, user.userId)
|
||||||
|
),
|
||||||
|
columns: {
|
||||||
|
tags: true,
|
||||||
|
autoTaggedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tags: conversation.tags || [],
|
||||||
|
autoTaggedAt: conversation.autoTaggedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签失败:', error);
|
||||||
|
return NextResponse.json({ error: '获取标签失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/conversations/[id]/tags - 更新对话标签
|
||||||
|
export async function PATCH(request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const { tags } = body;
|
||||||
|
|
||||||
|
// 验证标签格式
|
||||||
|
if (!Array.isArray(tags)) {
|
||||||
|
return NextResponse.json({ error: '标签格式错误' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每个标签
|
||||||
|
const validTags = tags.filter(
|
||||||
|
(tag: unknown) => typeof tag === 'string' && tag.trim().length > 0 && tag.length <= 20
|
||||||
|
).map((tag: string) => tag.trim());
|
||||||
|
|
||||||
|
// 限制标签数量
|
||||||
|
if (validTags.length > 10) {
|
||||||
|
return NextResponse.json({ error: '标签数量不能超过10个' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重
|
||||||
|
const uniqueTags = [...new Set(validTags)];
|
||||||
|
|
||||||
|
// 验证对话存在且属于当前用户
|
||||||
|
const existingConversation = await db.query.conversations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(conversations.conversationId, id),
|
||||||
|
eq(conversations.userId, user.userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingConversation) {
|
||||||
|
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新标签
|
||||||
|
const [updated] = await db
|
||||||
|
.update(conversations)
|
||||||
|
.set({
|
||||||
|
tags: uniqueTags,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(conversations.conversationId, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tags: updated.tags,
|
||||||
|
updatedAt: updated.updatedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新标签失败:', error);
|
||||||
|
return NextResponse.json({ error: '更新标签失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/api/tags/route.ts
Normal file
58
src/app/api/tags/route.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/drizzle/db';
|
||||||
|
import { conversations } from '@/drizzle/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
// 标签统计类型
|
||||||
|
interface TagStats {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tags - 获取当前用户所有对话的标签统计
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户所有对话的标签
|
||||||
|
const userConversations = await db.query.conversations.findMany({
|
||||||
|
where: eq(conversations.userId, user.userId),
|
||||||
|
columns: {
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
orderBy: [desc(conversations.lastMessageAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统计标签出现次数
|
||||||
|
const tagCountMap = new Map<string, number>();
|
||||||
|
|
||||||
|
userConversations.forEach((conversation) => {
|
||||||
|
const tags = conversation.tags as string[] | null;
|
||||||
|
if (tags && Array.isArray(tags)) {
|
||||||
|
tags.forEach((tag) => {
|
||||||
|
const normalizedTag = tag.trim();
|
||||||
|
if (normalizedTag) {
|
||||||
|
tagCountMap.set(normalizedTag, (tagCountMap.get(normalizedTag) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换为数组并按出现次数排序
|
||||||
|
const tagStats: TagStats[] = Array.from(tagCountMap.entries())
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tags: tagStats,
|
||||||
|
totalConversations: userConversations.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签统计失败:', error);
|
||||||
|
return NextResponse.json({ error: '获取标签统计失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user