Compare commits
No commits in common. "039a9b6b4917325a816f4f04d20a804cd2a2e6bb" and "c48d05885ad110546e7f1cfc06991191b0b9bb4b" have entirely different histories.
039a9b6b49
...
c48d05885a
@ -1,118 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { db } from '@/drizzle/db';
|
|
||||||
import { conversations } from '@/drizzle/schema';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { getCurrentUser } from '@/lib/auth';
|
|
||||||
|
|
||||||
interface UpdateSummaryRequest {
|
|
||||||
summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT /api/conversations/[id]/summary - 更新对话摘要
|
|
||||||
export async function PUT(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id: conversationId } = await params;
|
|
||||||
const body: UpdateSummaryRequest = await request.json();
|
|
||||||
const { summary } = body;
|
|
||||||
|
|
||||||
// 获取当前登录用户
|
|
||||||
const user = await getCurrentUser();
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '请先登录' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证对话是否存在且属于当前用户
|
|
||||||
const conversation = await db.query.conversations.findFirst({
|
|
||||||
where: eq(conversations.conversationId, conversationId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!conversation) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '对话不存在' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conversation.userId !== user.userId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '无权访问此对话' },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新摘要
|
|
||||||
await db
|
|
||||||
.update(conversations)
|
|
||||||
.set({
|
|
||||||
summary,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(conversations.conversationId, conversationId));
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: '摘要已更新',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[API/conversations/summary] 更新摘要错误:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '更新摘要失败' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/conversations/[id]/summary - 获取对话摘要
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id: conversationId } = await params;
|
|
||||||
|
|
||||||
// 获取当前登录用户
|
|
||||||
const user = await getCurrentUser();
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '请先登录' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取对话
|
|
||||||
const conversation = await db.query.conversations.findFirst({
|
|
||||||
where: eq(conversations.conversationId, conversationId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!conversation) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '对话不存在' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conversation.userId !== user.userId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '无权访问此对话' },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
summary: conversation.summary,
|
|
||||||
hasSummary: !!conversation.summary,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[API/conversations/summary] 获取摘要错误:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '获取摘要失败' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,293 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,7 +13,6 @@ import { PromptOptimizer } from '@/components/features/PromptOptimizer';
|
|||||||
import { LinkPreviewModal } from '@/components/features/LinkPreviewModal';
|
import { LinkPreviewModal } from '@/components/features/LinkPreviewModal';
|
||||||
import { ExportDropdown } from '@/components/features/ExportDropdown';
|
import { ExportDropdown } from '@/components/features/ExportDropdown';
|
||||||
import { ShareModal } from '@/components/features/ShareModal';
|
import { ShareModal } from '@/components/features/ShareModal';
|
||||||
import { SummaryButton } from '@/components/features/SummaryGenerator';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConversation, useConversations } from '@/hooks/useConversations';
|
import { useConversation, useConversations } from '@/hooks/useConversations';
|
||||||
import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat';
|
import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat';
|
||||||
@ -442,7 +441,7 @@ export default function ChatPage({ params }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-[var(--color-bg-secondary)]">
|
<div className="flex min-h-screen">
|
||||||
{/* 侧边栏 */}
|
{/* 侧边栏 */}
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isOpen={sidebarOpen}
|
isOpen={sidebarOpen}
|
||||||
@ -452,7 +451,7 @@ export default function ChatPage({ params }: PageProps) {
|
|||||||
{/* 主内容区 */}
|
{/* 主内容区 */}
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 flex flex-col min-h-screen transition-all duration-300 bg-[var(--color-bg-secondary)]',
|
'flex-1 flex flex-col min-h-screen transition-all duration-300',
|
||||||
sidebarOpen ? 'ml-[var(--sidebar-width)]' : 'ml-0'
|
sidebarOpen ? 'ml-[var(--sidebar-width)]' : 'ml-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -558,17 +557,6 @@ export default function ChatPage({ params }: PageProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 智能摘要按钮 */}
|
|
||||||
<SummaryButton
|
|
||||||
conversationId={chatId}
|
|
||||||
messages={messages.map(m => ({
|
|
||||||
role: m.role,
|
|
||||||
content: m.content,
|
|
||||||
}))}
|
|
||||||
hasSummary={!!conversation?.summary}
|
|
||||||
existingSummary={conversation?.summary}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShareModalOpen(true)}
|
onClick={() => setShareModalOpen(true)}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-lg transition-colors"
|
||||||
@ -671,7 +659,12 @@ export default function ChatPage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 固定底部输入框 */}
|
{/* 固定底部输入框 */}
|
||||||
<div className="sticky bottom-0 pt-4 z-20 bg-[var(--color-bg-secondary)]">
|
<div
|
||||||
|
className="sticky bottom-0 pt-4 z-20"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to top, var(--color-bg-secondary) 0%, var(--color-bg-secondary) 80%, transparent 100%)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="max-w-[900px] mx-auto px-4 pb-4 pl-[60px]">
|
<div className="max-w-[900px] mx-auto px-4 pb-4 pl-[60px]">
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<div className="flex justify-center mb-3">
|
<div className="flex justify-center mb-3">
|
||||||
|
|||||||
@ -168,8 +168,6 @@
|
|||||||
html {
|
html {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: var(--color-bg-secondary);
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Sparkles } from 'lucide-react';
|
|
||||||
import { SummaryModal } from './SummaryModal';
|
|
||||||
import type { SummaryMessage } from './types';
|
|
||||||
import { Tooltip } from '@/components/ui/Tooltip';
|
|
||||||
|
|
||||||
interface SummaryButtonProps {
|
|
||||||
conversationId: string;
|
|
||||||
messages: SummaryMessage[];
|
|
||||||
hasSummary?: boolean;
|
|
||||||
existingSummary?: string | null;
|
|
||||||
onSummaryGenerated?: (summary: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 智能摘要触发按钮
|
|
||||||
* 集成到对话头部
|
|
||||||
*/
|
|
||||||
export function SummaryButton({
|
|
||||||
conversationId,
|
|
||||||
messages,
|
|
||||||
hasSummary = false,
|
|
||||||
existingSummary,
|
|
||||||
onSummaryGenerated,
|
|
||||||
className = '',
|
|
||||||
}: SummaryButtonProps) {
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Tooltip content="智能摘要" position="bottom">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClick}
|
|
||||||
className={`relative p-2 rounded text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-secondary)] hover:text-[var(--color-primary)] transition-all duration-150 ${className}`}
|
|
||||||
aria-label="生成智能摘要"
|
|
||||||
>
|
|
||||||
<Sparkles size={18} />
|
|
||||||
|
|
||||||
{/* 已有摘要指示器 */}
|
|
||||||
{hasSummary && (
|
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-[var(--color-primary)] rounded-full border-2 border-[var(--color-bg-primary)] animate-pulse" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* 摘要 Modal */}
|
|
||||||
<SummaryModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={handleClose}
|
|
||||||
conversationId={conversationId}
|
|
||||||
messages={messages}
|
|
||||||
existingSummary={existingSummary}
|
|
||||||
onSummaryGenerated={onSummaryGenerated}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Clock, MessageSquare, Copy, Check, BookmarkPlus, RefreshCw } from 'lucide-react';
|
|
||||||
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
|
|
||||||
import type { SummaryData } from './types';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
interface SummaryContentProps {
|
|
||||||
summary: SummaryData;
|
|
||||||
isStreaming?: boolean;
|
|
||||||
streamingContent?: string;
|
|
||||||
onRegenerate?: () => void;
|
|
||||||
onSaveToNote?: () => void;
|
|
||||||
conversationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 摘要内容展示组件
|
|
||||||
* 支持 Markdown 渲染和操作按钮
|
|
||||||
*/
|
|
||||||
export function SummaryContent({
|
|
||||||
summary,
|
|
||||||
isStreaming = false,
|
|
||||||
streamingContent = '',
|
|
||||||
onRegenerate,
|
|
||||||
onSaveToNote,
|
|
||||||
}: SummaryContentProps) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
// 复制摘要内容
|
|
||||||
const handleCopy = async () => {
|
|
||||||
const content = isStreaming ? streamingContent : summary.content;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(content);
|
|
||||||
setCopied(true);
|
|
||||||
toast.success('已复制到剪贴板');
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
toast.error('复制失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (date: Date) => {
|
|
||||||
const now = new Date();
|
|
||||||
const diff = now.getTime() - date.getTime();
|
|
||||||
const minutes = Math.floor(diff / 60000);
|
|
||||||
|
|
||||||
if (minutes < 1) return '刚刚生成';
|
|
||||||
if (minutes < 60) return `${minutes} 分钟前`;
|
|
||||||
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours} 小时前`;
|
|
||||||
|
|
||||||
return date.toLocaleDateString('zh-CN');
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayContent = isStreaming ? streamingContent : summary.content;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
{/* 摘要内容区域 */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5 max-h-[500px] summary-content-wrapper">
|
|
||||||
{isStreaming ? (
|
|
||||||
<div className="prose prose-sm max-w-none summary-prose">
|
|
||||||
<MarkdownRenderer content={displayContent} />
|
|
||||||
{/* 流式光标 */}
|
|
||||||
<span className="inline-block w-0.5 h-5 bg-[var(--color-primary)] ml-0.5 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="prose prose-sm max-w-none summary-prose">
|
|
||||||
<MarkdownRenderer content={displayContent} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部操作栏 */}
|
|
||||||
{!isStreaming && (
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-[var(--color-border)] rounded-b bg-[var(--color-bg-tertiary)]">
|
|
||||||
{/* 元信息 */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-text-tertiary)]">
|
|
||||||
<Clock size={14} />
|
|
||||||
<span>{formatTime(summary.generatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-text-tertiary)]">
|
|
||||||
<MessageSquare size={14} />
|
|
||||||
<span>{summary.messageCount} 条消息</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-[13px] font-medium border transition-all duration-150 ${copied ? 'bg-green-500 border-green-500 text-white' : 'bg-[var(--color-bg-primary)] border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'}`}
|
|
||||||
>
|
|
||||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
|
||||||
{copied ? '已复制' : '复制'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{onSaveToNote && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSaveToNote}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-[13px] font-medium border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-secondary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-all duration-150"
|
|
||||||
>
|
|
||||||
<BookmarkPlus size={14} />
|
|
||||||
保存笔记
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onRegenerate && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRegenerate}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-[13px] font-medium bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] transition-all duration-150"
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} />
|
|
||||||
重新生成
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,380 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useCallback, useState } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { X, Sparkles, MessageSquare, Loader2, AlertCircle, Eye, RefreshCw } from 'lucide-react';
|
|
||||||
import { SummaryOptions } from './SummaryOptions';
|
|
||||||
import { SummaryContent } from './SummaryContent';
|
|
||||||
import { useSummary } from './useSummary';
|
|
||||||
import type { SummaryMessage } from './types';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
interface SummaryModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
conversationId: string;
|
|
||||||
messages: SummaryMessage[];
|
|
||||||
existingSummary?: string | null;
|
|
||||||
onSummaryGenerated?: (summary: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 智能摘要 Modal 组件
|
|
||||||
* 包含选项配置、生成中状态、生成结果展示
|
|
||||||
*/
|
|
||||||
export function SummaryModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
conversationId,
|
|
||||||
messages,
|
|
||||||
existingSummary,
|
|
||||||
onSummaryGenerated,
|
|
||||||
}: SummaryModalProps) {
|
|
||||||
const {
|
|
||||||
status,
|
|
||||||
summary,
|
|
||||||
error,
|
|
||||||
streamingContent,
|
|
||||||
options,
|
|
||||||
setOptions,
|
|
||||||
generate,
|
|
||||||
reset,
|
|
||||||
saveSummary,
|
|
||||||
} = useSummary();
|
|
||||||
|
|
||||||
// 视图模式:'view' 查看已有摘要,'generate' 生成新摘要
|
|
||||||
const [viewMode, setViewMode] = useState<'view' | 'generate'>(
|
|
||||||
existingSummary ? 'view' : 'generate'
|
|
||||||
);
|
|
||||||
|
|
||||||
// 客户端挂载状态(用于 Portal SSR 安全)
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
// 组件挂载后设置 mounted 状态
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
return () => setMounted(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 当 modal 打开时,根据是否有已有摘要设置默认视图
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setViewMode(existingSummary ? 'view' : 'generate');
|
|
||||||
}
|
|
||||||
}, [isOpen, existingSummary]);
|
|
||||||
|
|
||||||
// 关闭时重置状态
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
if (status !== 'generating') {
|
|
||||||
reset();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [status, reset, onClose]);
|
|
||||||
|
|
||||||
// ESC 键关闭
|
|
||||||
useEffect(() => {
|
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape' && status !== 'generating') {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener('keydown', handleEsc);
|
|
||||||
return () => document.removeEventListener('keydown', handleEsc);
|
|
||||||
}
|
|
||||||
}, [isOpen, status, handleClose]);
|
|
||||||
|
|
||||||
// 阻止背景滚动
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// 开始生成摘要
|
|
||||||
const handleGenerate = async () => {
|
|
||||||
await generate(conversationId, messages);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 重新生成
|
|
||||||
const handleRegenerate = async () => {
|
|
||||||
reset();
|
|
||||||
await generate(conversationId, messages);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 保存到笔记
|
|
||||||
const handleSaveToNote = async () => {
|
|
||||||
if (!summary) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/notes', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: '对话摘要',
|
|
||||||
content: summary.content,
|
|
||||||
conversationId,
|
|
||||||
tags: ['摘要', '自动生成'],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
toast.success('已保存到笔记');
|
|
||||||
} else {
|
|
||||||
toast.error('保存失败');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error('保存失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 保存摘要到对话
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === 'completed' && summary) {
|
|
||||||
saveSummary(conversationId);
|
|
||||||
onSummaryGenerated?.(summary.content);
|
|
||||||
}
|
|
||||||
}, [status, summary, conversationId, saveSummary, onSummaryGenerated]);
|
|
||||||
|
|
||||||
// SSR 安全检查:未挂载或未打开时不渲染
|
|
||||||
if (!mounted || !isOpen) return null;
|
|
||||||
|
|
||||||
// 使用 Portal 将 Modal 渲染到 body,脱离组件树的层叠上下文
|
|
||||||
return createPortal(
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
{/* 背景遮罩 */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
||||||
style={{ animation: 'fadeIn 0.2s ease-out' }}
|
|
||||||
onClick={handleClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal 内容 */}
|
|
||||||
<div
|
|
||||||
className="relative w-full max-w-[800px] mx-4 bg-[var(--color-bg-primary)] rounded-[4px] border border-[var(--color-border)] shadow-2xl overflow-hidden"
|
|
||||||
style={{ animation: 'scaleIn 0.2s ease-out' }}
|
|
||||||
>
|
|
||||||
{/* 头部 */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-5 border-b border-[var(--color-border)]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 rounded-[4px] bg-[var(--color-primary)]/10">
|
|
||||||
<Sparkles size={20} className="text-[var(--color-primary)]" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-[18px] font-semibold text-[var(--color-text-primary)]">
|
|
||||||
智能摘要
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={status === 'generating'}
|
|
||||||
className="p-2 rounded-[4px] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-secondary)] hover:text-[var(--color-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-150"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 内容区域 */}
|
|
||||||
{status === 'idle' && viewMode === 'view' && existingSummary && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{/* 标签页切换 */}
|
|
||||||
<div className="flex items-center gap-2 px-6 pt-4 pb-3 border-b border-[var(--color-border)]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode('view')}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[4px] text-[13px] font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)] border border-[var(--color-primary)]/30"
|
|
||||||
>
|
|
||||||
<Eye size={14} />
|
|
||||||
查看摘要
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode('generate')}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[4px] text-[13px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-secondary)] border border-transparent hover:border-[var(--color-border)] transition-all duration-150"
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} />
|
|
||||||
重新生成
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 已有摘要内容 */}
|
|
||||||
<SummaryContent
|
|
||||||
summary={{
|
|
||||||
content: existingSummary,
|
|
||||||
generatedAt: new Date(),
|
|
||||||
messageCount: messages.length,
|
|
||||||
options,
|
|
||||||
}}
|
|
||||||
onRegenerate={() => setViewMode('generate')}
|
|
||||||
conversationId={conversationId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'idle' && (viewMode === 'generate' || !existingSummary) && (
|
|
||||||
<div className="p-6">
|
|
||||||
{/* 如果有现有摘要,显示标签页切换 */}
|
|
||||||
{existingSummary && (
|
|
||||||
<div className="flex items-center gap-2 mb-5 pb-4 border-b border-[var(--color-border)]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode('view')}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[4px] text-[13px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-secondary)] border border-transparent hover:border-[var(--color-border)] transition-all duration-150"
|
|
||||||
>
|
|
||||||
<Eye size={14} />
|
|
||||||
查看摘要
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode('generate')}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[4px] text-[13px] font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)] border border-[var(--color-primary)]/30"
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} />
|
|
||||||
重新生成
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 选项配置 */}
|
|
||||||
<SummaryOptions
|
|
||||||
options={options}
|
|
||||||
onChange={setOptions}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 生成按钮 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={messages.length === 0}
|
|
||||||
className="w-full mt-6 py-3.5 rounded text-[15px] font-semibold bg-gradient-to-r from-[var(--color-primary)] to-[#FF8A5C] text-white shadow-lg shadow-[var(--color-primary)]/25 hover:shadow-xl hover:shadow-[var(--color-primary)]/30 hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 transition-all duration-200 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Sparkles size={18} />
|
|
||||||
开始生成
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 生成中状态 */}
|
|
||||||
{status === 'generating' && (
|
|
||||||
<div className="p-6">
|
|
||||||
{/* 进度条 */}
|
|
||||||
<div className="h-1 rounded-full bg-[var(--color-bg-secondary)] overflow-hidden mb-6">
|
|
||||||
<div
|
|
||||||
className="h-full bg-gradient-to-r from-[var(--color-primary)] via-[#FF8A5C] to-[var(--color-primary)]"
|
|
||||||
style={{
|
|
||||||
width: '60%',
|
|
||||||
backgroundSize: '200% 100%',
|
|
||||||
animation: 'shimmer 1.5s linear infinite',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 流式内容 */}
|
|
||||||
{streamingContent ? (
|
|
||||||
<div className="prose prose-sm max-w-none mb-6 max-h-[400px] overflow-y-auto">
|
|
||||||
<SummaryContent
|
|
||||||
summary={{
|
|
||||||
content: streamingContent,
|
|
||||||
generatedAt: new Date(),
|
|
||||||
messageCount: messages.length,
|
|
||||||
options,
|
|
||||||
}}
|
|
||||||
isStreaming={true}
|
|
||||||
streamingContent={streamingContent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="text-center">
|
|
||||||
<Loader2 size={32} className="mx-auto mb-3 text-[var(--color-primary)] animate-spin" />
|
|
||||||
<p className="text-[14px] text-[var(--color-text-secondary)]">
|
|
||||||
正在分析 {messages.length} 条消息...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 状态提示 */}
|
|
||||||
<div className="flex items-center gap-2.5 px-4 py-3 rounded bg-[var(--color-bg-secondary)]">
|
|
||||||
<div className="w-4 h-4 rounded-full border-2 border-[var(--color-border)] border-t-[var(--color-primary)] animate-spin" />
|
|
||||||
<span className="text-[13px] text-[var(--color-text-secondary)]">
|
|
||||||
AI 正在分析对话内容...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 生成完成状态 */}
|
|
||||||
{status === 'completed' && summary && (
|
|
||||||
<SummaryContent
|
|
||||||
summary={summary}
|
|
||||||
onRegenerate={handleRegenerate}
|
|
||||||
onSaveToNote={handleSaveToNote}
|
|
||||||
conversationId={conversationId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 错误状态 */}
|
|
||||||
{status === 'error' && (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex flex-col items-center py-8">
|
|
||||||
<div className="p-3 rounded-full bg-red-500/10 mb-4">
|
|
||||||
<AlertCircle size={32} className="text-red-500" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-[16px] font-medium text-[var(--color-text-primary)] mb-2">
|
|
||||||
生成失败
|
|
||||||
</h3>
|
|
||||||
<p className="text-[14px] text-[var(--color-text-secondary)] text-center mb-6">
|
|
||||||
{error || '生成摘要时发生错误,请重试'}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRegenerate}
|
|
||||||
className="px-6 py-2.5 rounded text-[14px] font-medium bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] transition-all duration-150"
|
|
||||||
>
|
|
||||||
重新尝试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 底部信息(仅在生成新摘要状态显示) */}
|
|
||||||
{status === 'idle' && (viewMode === 'generate' || !existingSummary) && (
|
|
||||||
<div className="px-6 py-4 border-t border-[var(--color-border)] rounded-b bg-[var(--color-bg-tertiary)]">
|
|
||||||
<div className="flex items-center gap-1.5 text-[13px] text-[var(--color-text-secondary)]">
|
|
||||||
<MessageSquare size={14} className="text-[var(--color-primary)]" />
|
|
||||||
<span>将分析</span>
|
|
||||||
<span className="font-semibold text-[var(--color-primary)]">{messages.length}</span>
|
|
||||||
<span>条消息</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 全局动画样式 - 使用 global style 替代 styled-jsx */}
|
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes scaleIn {
|
|
||||||
from { opacity: 0; transform: scale(0.95); }
|
|
||||||
to { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { AlignLeft, FileText } from 'lucide-react';
|
|
||||||
import type { SummaryLength, SummaryStyle, SummaryOptions as SummaryOptionsType } from './types';
|
|
||||||
import { SUMMARY_LENGTH_CONFIG, SUMMARY_STYLE_CONFIG } from './types';
|
|
||||||
|
|
||||||
interface SummaryOptionsProps {
|
|
||||||
options: SummaryOptionsType;
|
|
||||||
onChange: (options: Partial<SummaryOptionsType>) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 摘要选项配置组件
|
|
||||||
* 包含长度选择和风格选择
|
|
||||||
*/
|
|
||||||
export function SummaryOptions({ options, onChange, disabled = false }: SummaryOptionsProps) {
|
|
||||||
const lengthOptions: SummaryLength[] = ['short', 'standard', 'detailed'];
|
|
||||||
const styleOptions: SummaryStyle[] = ['bullet', 'narrative'];
|
|
||||||
|
|
||||||
const getButtonClass = (isActive: boolean) => {
|
|
||||||
const base = 'px-4 py-2 rounded text-[13px] font-medium border transition-all duration-150';
|
|
||||||
const active = 'bg-[var(--color-primary)]/10 border-[var(--color-primary)] text-[var(--color-primary)]';
|
|
||||||
const inactive = 'bg-[var(--color-bg-primary)] border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]';
|
|
||||||
const disabledClass = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer';
|
|
||||||
return `${base} ${isActive ? active : inactive} ${disabledClass}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* 摘要长度选择 */}
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center gap-1.5 text-[13px] font-medium text-[var(--color-text-secondary)] mb-3">
|
|
||||||
<AlignLeft size={14} />
|
|
||||||
摘要长度
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{lengthOptions.map((length) => {
|
|
||||||
const config = SUMMARY_LENGTH_CONFIG[length];
|
|
||||||
const isActive = options.length === length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={length}
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onChange({ length })}
|
|
||||||
className={getButtonClass(isActive)}
|
|
||||||
title={config.description}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 摘要风格选择 */}
|
|
||||||
<div>
|
|
||||||
<label className="flex items-center gap-1.5 text-[13px] font-medium text-[var(--color-text-secondary)] mb-3">
|
|
||||||
<FileText size={14} />
|
|
||||||
摘要风格
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{styleOptions.map((style) => {
|
|
||||||
const config = SUMMARY_STYLE_CONFIG[style];
|
|
||||||
const isActive = options.style === style;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={style}
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onChange({ style })}
|
|
||||||
className={getButtonClass(isActive)}
|
|
||||||
title={config.description}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* 智能摘要功能模块
|
|
||||||
*
|
|
||||||
* 提供 AI 自动生成对话摘要的能力
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 组件导出
|
|
||||||
export { SummaryButton } from './SummaryButton';
|
|
||||||
export { SummaryModal } from './SummaryModal';
|
|
||||||
export { SummaryOptions } from './SummaryOptions';
|
|
||||||
export { SummaryContent } from './SummaryContent';
|
|
||||||
|
|
||||||
// Hook 导出
|
|
||||||
export { useSummary } from './useSummary';
|
|
||||||
|
|
||||||
// 类型导出
|
|
||||||
export type {
|
|
||||||
SummaryLength,
|
|
||||||
SummaryStyle,
|
|
||||||
SummaryOptions as SummaryOptionsType,
|
|
||||||
SummaryStatus,
|
|
||||||
SummaryData,
|
|
||||||
SummaryMessage,
|
|
||||||
GenerateSummaryRequest,
|
|
||||||
GenerateSummaryResponse,
|
|
||||||
UseSummaryReturn,
|
|
||||||
ModalView,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
// 常量导出
|
|
||||||
export { SUMMARY_LENGTH_CONFIG, SUMMARY_STYLE_CONFIG } from './types';
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* 智能摘要功能类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 摘要长度选项
|
|
||||||
export type SummaryLength = 'short' | 'standard' | 'detailed';
|
|
||||||
|
|
||||||
// 摘要风格选项
|
|
||||||
export type SummaryStyle = 'bullet' | 'narrative';
|
|
||||||
|
|
||||||
// 摘要生成选项
|
|
||||||
export interface SummaryOptions {
|
|
||||||
length: SummaryLength;
|
|
||||||
style: SummaryStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 摘要长度配置
|
|
||||||
export const SUMMARY_LENGTH_CONFIG: Record<SummaryLength, { label: string; description: string; maxWords: number }> = {
|
|
||||||
short: { label: '简短', description: '约50字的核心要点', maxWords: 50 },
|
|
||||||
standard: { label: '标准', description: '约150字的详细摘要', maxWords: 150 },
|
|
||||||
detailed: { label: '详细', description: '约300字的完整分析', maxWords: 300 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 摘要风格配置
|
|
||||||
export const SUMMARY_STYLE_CONFIG: Record<SummaryStyle, { label: string; description: string }> = {
|
|
||||||
bullet: { label: '要点式', description: '结构化的要点列表' },
|
|
||||||
narrative: { label: '叙述式', description: '连贯的段落描述' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 摘要生成状态
|
|
||||||
export type SummaryStatus = 'idle' | 'generating' | 'completed' | 'error';
|
|
||||||
|
|
||||||
// 摘要数据
|
|
||||||
export interface SummaryData {
|
|
||||||
content: string; // 摘要内容(Markdown)
|
|
||||||
generatedAt: Date; // 生成时间
|
|
||||||
messageCount: number; // 分析的消息数
|
|
||||||
options: SummaryOptions; // 生成时使用的选项
|
|
||||||
}
|
|
||||||
|
|
||||||
// 摘要生成请求
|
|
||||||
export interface GenerateSummaryRequest {
|
|
||||||
conversationId: string;
|
|
||||||
options: SummaryOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 摘要生成响应
|
|
||||||
export interface GenerateSummaryResponse {
|
|
||||||
success: boolean;
|
|
||||||
summary?: string;
|
|
||||||
error?: string;
|
|
||||||
messageCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// useSummary Hook 返回类型
|
|
||||||
export interface UseSummaryReturn {
|
|
||||||
// 状态
|
|
||||||
status: SummaryStatus;
|
|
||||||
summary: SummaryData | null;
|
|
||||||
error: string | null;
|
|
||||||
streamingContent: string;
|
|
||||||
|
|
||||||
// 选项
|
|
||||||
options: SummaryOptions;
|
|
||||||
setOptions: (options: Partial<SummaryOptions>) => void;
|
|
||||||
|
|
||||||
// 操作
|
|
||||||
generate: (conversationId: string, messages: SummaryMessage[]) => Promise<void>;
|
|
||||||
reset: () => void;
|
|
||||||
saveSummary: (conversationId: string) => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用于摘要生成的消息格式
|
|
||||||
export interface SummaryMessage {
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal 状态
|
|
||||||
export type ModalView = 'options' | 'generating' | 'result';
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import type {
|
|
||||||
SummaryOptions,
|
|
||||||
SummaryStatus,
|
|
||||||
SummaryData,
|
|
||||||
SummaryMessage,
|
|
||||||
UseSummaryReturn,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 智能摘要核心 Hook
|
|
||||||
* 管理摘要生成的状态和操作
|
|
||||||
*/
|
|
||||||
export function useSummary(): UseSummaryReturn {
|
|
||||||
// 生成状态
|
|
||||||
const [status, setStatus] = useState<SummaryStatus>('idle');
|
|
||||||
const [summary, setSummary] = useState<SummaryData | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [streamingContent, setStreamingContent] = useState<string>('');
|
|
||||||
|
|
||||||
// 摘要选项
|
|
||||||
const [options, setOptionsState] = useState<SummaryOptions>({
|
|
||||||
length: 'standard',
|
|
||||||
style: 'bullet',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 更新选项
|
|
||||||
const setOptions = useCallback((newOptions: Partial<SummaryOptions>) => {
|
|
||||||
setOptionsState(prev => ({ ...prev, ...newOptions }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 生成摘要
|
|
||||||
const generate = useCallback(async (conversationId: string, messages: SummaryMessage[]) => {
|
|
||||||
if (messages.length === 0) {
|
|
||||||
setError('暂无对话内容');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('generating');
|
|
||||||
setError(null);
|
|
||||||
setStreamingContent('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/summary/generate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
conversationId,
|
|
||||||
messages,
|
|
||||||
options,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || '生成摘要失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理流式响应
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
if (!reader) {
|
|
||||||
throw new Error('无法读取响应');
|
|
||||||
}
|
|
||||||
|
|
||||||
let fullContent = '';
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
|
||||||
const lines = chunk.split('\n');
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
const data = line.slice(6);
|
|
||||||
|
|
||||||
if (data === '[DONE]') {
|
|
||||||
// 流式传输完成
|
|
||||||
setStatus('completed');
|
|
||||||
setSummary({
|
|
||||||
content: fullContent,
|
|
||||||
generatedAt: new Date(),
|
|
||||||
messageCount: messages.length,
|
|
||||||
options,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
|
|
||||||
if (parsed.type === 'content') {
|
|
||||||
fullContent += parsed.content;
|
|
||||||
setStreamingContent(fullContent);
|
|
||||||
} else if (parsed.type === 'error') {
|
|
||||||
throw new Error(parsed.error);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 忽略解析错误,可能是不完整的 JSON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有收到 [DONE],也设置为完成
|
|
||||||
if (fullContent) {
|
|
||||||
setStatus('completed');
|
|
||||||
setSummary({
|
|
||||||
content: fullContent,
|
|
||||||
generatedAt: new Date(),
|
|
||||||
messageCount: messages.length,
|
|
||||||
options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setStatus('error');
|
|
||||||
setError(err instanceof Error ? err.message : '生成摘要时发生错误');
|
|
||||||
}
|
|
||||||
}, [options]);
|
|
||||||
|
|
||||||
// 重置状态
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setStatus('idle');
|
|
||||||
setSummary(null);
|
|
||||||
setError(null);
|
|
||||||
setStreamingContent('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 保存摘要到数据库
|
|
||||||
const saveSummary = useCallback(async (conversationId: string): Promise<boolean> => {
|
|
||||||
if (!summary) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/conversations/${conversationId}/summary`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
summary: summary.content,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [summary]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
summary,
|
|
||||||
error,
|
|
||||||
streamingContent,
|
|
||||||
options,
|
|
||||||
setOptions,
|
|
||||||
generate,
|
|
||||||
reset,
|
|
||||||
saveSummary,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user