diff --git a/src/components/features/SummaryGenerator/SummaryButton.tsx b/src/components/features/SummaryGenerator/SummaryButton.tsx
new file mode 100644
index 0000000..39f6379
--- /dev/null
+++ b/src/components/features/SummaryGenerator/SummaryButton.tsx
@@ -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 (
+ <>
+
+
+
+
+ {/* 摘要 Modal */}
+
+ >
+ );
+}
diff --git a/src/components/features/SummaryGenerator/SummaryContent.tsx b/src/components/features/SummaryGenerator/SummaryContent.tsx
new file mode 100644
index 0000000..a87b5f8
--- /dev/null
+++ b/src/components/features/SummaryGenerator/SummaryContent.tsx
@@ -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 (
+
+ {/* 摘要内容区域 */}
+
+ {isStreaming ? (
+
+
+ {/* 流式光标 */}
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* 底部操作栏 */}
+ {!isStreaming && (
+
+ {/* 元信息 */}
+
+
+
+ {formatTime(summary.generatedAt)}
+
+
+
+ {summary.messageCount} 条消息
+
+
+
+ {/* 操作按钮 */}
+
+
+
+ {onSaveToNote && (
+
+ )}
+
+ {onRegenerate && (
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/features/SummaryGenerator/SummaryModal.tsx b/src/components/features/SummaryGenerator/SummaryModal.tsx
new file mode 100644
index 0000000..6b42b68
--- /dev/null
+++ b/src/components/features/SummaryGenerator/SummaryModal.tsx
@@ -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(
+
+ {/* 背景遮罩 */}
+
+
+ {/* Modal 内容 */}
+
+ {/* 头部 */}
+
+
+ {/* 内容区域 */}
+ {status === 'idle' && viewMode === 'view' && existingSummary && (
+
+ {/* 标签页切换 */}
+
+
+
+
+
+ {/* 已有摘要内容 */}
+
setViewMode('generate')}
+ conversationId={conversationId}
+ />
+
+ )}
+
+ {status === 'idle' && (viewMode === 'generate' || !existingSummary) && (
+
+ {/* 如果有现有摘要,显示标签页切换 */}
+ {existingSummary && (
+
+
+
+
+ )}
+
+ {/* 选项配置 */}
+
+
+ {/* 生成按钮 */}
+
+
+ )}
+
+ {/* 生成中状态 */}
+ {status === 'generating' && (
+
+ {/* 进度条 */}
+
+
+ {/* 流式内容 */}
+ {streamingContent ? (
+
+
+
+ ) : (
+
+
+
+
+ 正在分析 {messages.length} 条消息...
+
+
+
+ )}
+
+ {/* 状态提示 */}
+
+
+ )}
+
+ {/* 生成完成状态 */}
+ {status === 'completed' && summary && (
+
+ )}
+
+ {/* 错误状态 */}
+ {status === 'error' && (
+
+
+
+
+ 生成失败
+
+
+ {error || '生成摘要时发生错误,请重试'}
+
+
+
+
+ )}
+
+ {/* 底部信息(仅在生成新摘要状态显示) */}
+ {status === 'idle' && (viewMode === 'generate' || !existingSummary) && (
+
+
+
+ 将分析
+ {messages.length}
+ 条消息
+
+
+ )}
+
+
+ {/* 全局动画样式 - 使用 global style 替代 styled-jsx */}
+
+
,
+ document.body
+ );
+}
diff --git a/src/components/features/SummaryGenerator/SummaryOptions.tsx b/src/components/features/SummaryGenerator/SummaryOptions.tsx
new file mode 100644
index 0000000..c5c99c4
--- /dev/null
+++ b/src/components/features/SummaryGenerator/SummaryOptions.tsx
@@ -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) => 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 (
+
+ {/* 摘要长度选择 */}
+
+
+
+ {lengthOptions.map((length) => {
+ const config = SUMMARY_LENGTH_CONFIG[length];
+ const isActive = options.length === length;
+
+ return (
+
+ );
+ })}
+
+
+
+ {/* 摘要风格选择 */}
+
+
+
+ {styleOptions.map((style) => {
+ const config = SUMMARY_STYLE_CONFIG[style];
+ const isActive = options.style === style;
+
+ return (
+
+ );
+ })}
+
+
+
+ );
+}