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