feat(API): 添加摘要生成接口

- 实现 POST /api/summary/generate 流式摘要生成
- 支持 Claude 和 OpenAI 两种 API 格式
- 配置摘要长度和风格的 Prompt 模板
- 自动保存生成的摘要到数据库
This commit is contained in:
gaoziman 2025-12-28 01:31:07 +08:00
parent 0938f4fbd2
commit 61fe53915c

View 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 }
);
}
}