feat(API): 添加摘要生成接口
- 实现 POST /api/summary/generate 流式摘要生成 - 支持 Claude 和 OpenAI 两种 API 格式 - 配置摘要长度和风格的 Prompt 模板 - 自动保存生成的摘要到数据库
This commit is contained in:
parent
0938f4fbd2
commit
61fe53915c
293
src/app/api/summary/generate/route.ts
Normal file
293
src/app/api/summary/generate/route.ts
Normal file
@ -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<string, string>;
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user