From 5189ebb2320ff72aac28a6611c3475fae43b8f55 Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Sun, 28 Dec 2025 17:27:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(API):=20=E6=B7=BB=E5=8A=A0=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 GET /api/tags 接口获取用户所有标签统计 - 添加 GET /api/conversations/[id]/tags 获取对话标签 - 添加 PATCH /api/conversations/[id]/tags 更新对话标签 - 添加 POST /api/conversations/[id]/tags/auto AI 自动生成标签 --- .../api/conversations/[id]/tags/auto/route.ts | 219 ++++++++++++++++++ src/app/api/conversations/[id]/tags/route.ts | 106 +++++++++ src/app/api/tags/route.ts | 58 +++++ 3 files changed, 383 insertions(+) create mode 100644 src/app/api/conversations/[id]/tags/auto/route.ts create mode 100644 src/app/api/conversations/[id]/tags/route.ts create mode 100644 src/app/api/tags/route.ts diff --git a/src/app/api/conversations/[id]/tags/auto/route.ts b/src/app/api/conversations/[id]/tags/auto/route.ts new file mode 100644 index 0000000..229d45a --- /dev/null +++ b/src/app/api/conversations/[id]/tags/auto/route.ts @@ -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; + + 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 } + ); + } +} diff --git a/src/app/api/conversations/[id]/tags/route.ts b/src/app/api/conversations/[id]/tags/route.ts new file mode 100644 index 0000000..d6dc908 --- /dev/null +++ b/src/app/api/conversations/[id]/tags/route.ts @@ -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 }); + } +} diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts new file mode 100644 index 0000000..1ddf616 --- /dev/null +++ b/src/app/api/tags/route.ts @@ -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(); + + 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 }); + } +}