From 61fe53915c6026830ebff0fca935d1788462ea62 Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Sun, 28 Dec 2025 01:31:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(API):=20=E6=B7=BB=E5=8A=A0=E6=91=98?= =?UTF-8?q?=E8=A6=81=E7=94=9F=E6=88=90=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 POST /api/summary/generate 流式摘要生成 - 支持 Claude 和 OpenAI 两种 API 格式 - 配置摘要长度和风格的 Prompt 模板 - 自动保存生成的摘要到数据库 --- src/app/api/summary/generate/route.ts | 293 ++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 src/app/api/summary/generate/route.ts diff --git a/src/app/api/summary/generate/route.ts b/src/app/api/summary/generate/route.ts new file mode 100644 index 0000000..ae01925 --- /dev/null +++ b/src/app/api/summary/generate/route.ts @@ -0,0 +1,293 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/drizzle/db'; +import { userSettings, conversations } from '@/drizzle/schema'; +import { eq } from 'drizzle-orm'; +import { getCurrentUser } from '@/lib/auth'; +import { decryptApiKey } from '@/lib/crypto'; + +interface SummaryMessage { + role: 'user' | 'assistant'; + content: string; +} + +interface SummaryOptions { + length: 'short' | 'standard' | 'detailed'; + style: 'bullet' | 'narrative'; +} + +interface GenerateSummaryRequest { + conversationId: string; + messages: SummaryMessage[]; + options: SummaryOptions; +} + +// 摘要长度配置 +const LENGTH_CONFIG = { + short: { maxWords: 50, description: '50字左右的核心要点' }, + standard: { maxWords: 150, description: '150字左右的详细摘要' }, + detailed: { maxWords: 300, description: '300字左右的完整分析' }, +}; + +// 摘要风格配置 +const STYLE_CONFIG = { + bullet: { description: '使用结构化的要点列表,每个要点独立成行' }, + narrative: { description: '使用连贯的段落描述,像讲故事一样自然流畅' }, +}; + +// 生成摘要 Prompt +function buildSummaryPrompt(messages: SummaryMessage[], options: SummaryOptions): string { + const lengthConfig = LENGTH_CONFIG[options.length]; + const styleConfig = STYLE_CONFIG[options.style]; + + // 将消息格式化为对话文本 + const conversationText = messages + .map((msg) => `${msg.role === 'user' ? '用户' : 'AI助手'}: ${msg.content}`) + .join('\n\n'); + + return `你是一个专业的对话摘要生成器。请根据以下对话内容生成摘要。 + +## 摘要要求 + +**长度要求**: ${lengthConfig.description} +**风格要求**: ${styleConfig.description} + +## 输出格式 + +请按照以下 Markdown 格式输出摘要: + +${options.style === 'bullet' ? ` +## 对话摘要 + +[一句话概述对话的主题和目的] + +### 关键要点 + +- [要点1:具体的讨论内容或结论] +- [要点2:具体的讨论内容或结论] +- [要点3:具体的讨论内容或结论] +${options.length === 'detailed' ? `- [要点4] +- [要点5]` : ''} + +### 结论 + +[总结性的结论或建议] +` : ` +## 对话摘要 + +[用连贯的段落描述对话的主要内容,包括讨论的主题、关键观点和最终结论。语言要自然流畅,像在向别人转述这段对话一样。] +`} + +## 对话内容 + +${conversationText} + +--- + +请直接输出摘要内容,不要有任何开场白或结束语。`; +} + +// 规范化 URL +function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +// POST /api/summary/generate - 生成对话摘要 +export async function POST(request: Request) { + try { + const body: GenerateSummaryRequest = await request.json(); + const { conversationId, messages, options } = body; + + // 验证请求参数 + if (!conversationId || !messages || messages.length === 0) { + return NextResponse.json( + { error: '缺少必要参数' }, + { status: 400 } + ); + } + + // 获取当前登录用户 + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json( + { error: '请先登录' }, + { status: 401 } + ); + } + + // 获取用户设置 + 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'; + + // 构建摘要 Prompt + const summaryPrompt = buildSummaryPrompt(messages, options); + + // 创建流式响应 + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + try { + let apiUrl: string; + let requestBody: object; + let headers: Record; + + if (apiFormat === 'openai') { + // 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: summaryPrompt }, + ], + stream: true, + max_tokens: 2048, + temperature: 0.7, + }; + } else { + // Claude 原生格式 + 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: 2048, + messages: [ + { role: 'user', content: summaryPrompt }, + ], + stream: true, + }; + } + + console.log('[API/summary] 开始生成摘要,消息数量:', messages.length); + + const response = await fetch(apiUrl, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[API/summary] API 调用失败:', response.status, errorText); + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'error', error: '生成摘要失败' })}\n\n`) + ); + controller.close(); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'error', error: '无法读取响应' })}\n\n`) + ); + controller.close(); + return; + } + + const decoder = new TextDecoder(); + let buffer = ''; + let fullContent = ''; + + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + + const data = line.slice(6).trim(); + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data); + + if (apiFormat === 'openai') { + // OpenAI 格式响应 + const delta = parsed.choices?.[0]?.delta?.content; + if (delta) { + fullContent += delta; + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'content', content: delta })}\n\n`) + ); + } + } else { + // Claude 格式响应 + if (parsed.type === 'content_block_delta') { + const delta = parsed.delta?.text; + if (delta) { + fullContent += delta; + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'content', content: delta })}\n\n`) + ); + } + } + } + } catch { + // 忽略解析错误 + } + } + } + + // 保存摘要到数据库 + if (fullContent) { + await db + .update(conversations) + .set({ summary: fullContent, updatedAt: new Date() }) + .where(eq(conversations.conversationId, conversationId)); + + console.log('[API/summary] 摘要已保存到数据库'); + } + + // 发送完成信号 + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + } catch (error) { + console.error('[API/summary] 生成摘要错误:', error); + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'error', error: '生成摘要时发生错误' })}\n\n`) + ); + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } catch (error) { + console.error('[API/summary] 请求处理错误:', error); + return NextResponse.json( + { error: '处理请求时发生错误' }, + { status: 500 } + ); + } +}