Compare commits

...

7 Commits

Author SHA1 Message Date
gaoziman
039a9b6b49 style(样式): 优化全局背景色样式
- 为 html 元素添加背景色变量
- 设置最小高度为 100%
2025-12-28 01:32:03 +08:00
gaoziman
7d7678084f feat(页面): 聊天页面集成智能摘要功能
- 在工具栏添加智能摘要按钮
- 优化主内容区背景色样式
- 调整底部输入框背景样式
2025-12-28 01:31:44 +08:00
gaoziman
94d97ace04 feat(API): 添加对话摘要管理接口
- 实现 GET /api/conversations/[id]/summary 获取摘要
- 实现 PUT /api/conversations/[id]/summary 更新摘要
- 添加用户权限验证
2025-12-28 01:31:26 +08:00
gaoziman
61fe53915c feat(API): 添加摘要生成接口
- 实现 POST /api/summary/generate 流式摘要生成
- 支持 Claude 和 OpenAI 两种 API 格式
- 配置摘要长度和风格的 Prompt 模板
- 自动保存生成的摘要到数据库
2025-12-28 01:31:07 +08:00
gaoziman
0938f4fbd2 feat(组件): 添加智能摘要 UI 组件
- SummaryButton: 摘要入口按钮,支持已生成状态展示
- SummaryOptions: 摘要选项配置面板 (长度/风格选择)
- SummaryContent: 摘要内容展示组件,支持 Markdown 渲染
- SummaryModal: 摘要模态框,集成选项配置和内容展示
2025-12-28 01:30:47 +08:00
gaoziman
d61d689db0 feat(Hook): 实现智能摘要 useSummary Hook
- 实现流式摘要生成逻辑
- 支持摘要选项配置 (长度/风格)
- 添加生成状态管理
- 支持摘要更新和保存
2025-12-28 01:30:27 +08:00
gaoziman
5d301364fb feat(类型): 添加智能摘要功能类型定义
- 定义摘要长度类型 (short/standard/detailed)
- 定义摘要风格类型 (bullet/narrative)
- 添加摘要状态、数据等接口定义
- 配置摘要长度和风格的默认选项
- 导出模块入口文件
2025-12-28 01:30:05 +08:00
11 changed files with 1373 additions and 8 deletions

View File

@ -0,0 +1,118 @@
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 }
);
}
}

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

View File

@ -13,6 +13,7 @@ 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';
@ -441,7 +442,7 @@ export default function ChatPage({ params }: PageProps) {
} }
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen bg-[var(--color-bg-secondary)]">
{/* 侧边栏 */} {/* 侧边栏 */}
<Sidebar <Sidebar
isOpen={sidebarOpen} isOpen={sidebarOpen}
@ -451,7 +452,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', 'flex-1 flex flex-col min-h-screen transition-all duration-300 bg-[var(--color-bg-secondary)]',
sidebarOpen ? 'ml-[var(--sidebar-width)]' : 'ml-0' sidebarOpen ? 'ml-[var(--sidebar-width)]' : 'ml-0'
)} )}
> >
@ -557,6 +558,17 @@ 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"
@ -659,12 +671,7 @@ export default function ChatPage({ params }: PageProps) {
</div> </div>
{/* 固定底部输入框 */} {/* 固定底部输入框 */}
<div <div className="sticky bottom-0 pt-4 z-20 bg-[var(--color-bg-secondary)]">
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">

View File

@ -168,6 +168,8 @@
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 {

View File

@ -0,0 +1,69 @@
'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}
/>
</>
);
}

View File

@ -0,0 +1,130 @@
'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>
);
}

View File

@ -0,0 +1,380 @@
'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
);
}

View File

@ -0,0 +1,86 @@
'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>
);
}

View File

@ -0,0 +1,31 @@
/**
*
*
* 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';

View File

@ -0,0 +1,80 @@
/**
*
*/
// 摘要长度选项
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';

View File

@ -0,0 +1,169 @@
'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,
};
}