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} 条消息... +

+
+
+ )} + + {/* 状态提示 */} +
+
+ + AI 正在分析对话内容... + +
+
+ )} + + {/* 生成完成状态 */} + {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 ( + + ); + })} +
+
+
+ ); +}