feat(组件): 添加智能摘要 UI 组件
- SummaryButton: 摘要入口按钮,支持已生成状态展示 - SummaryOptions: 摘要选项配置面板 (长度/风格选择) - SummaryContent: 摘要内容展示组件,支持 Markdown 渲染 - SummaryModal: 摘要模态框,集成选项配置和内容展示
This commit is contained in:
parent
d61d689db0
commit
0938f4fbd2
69
src/components/features/SummaryGenerator/SummaryButton.tsx
Normal file
69
src/components/features/SummaryGenerator/SummaryButton.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/components/features/SummaryGenerator/SummaryContent.tsx
Normal file
130
src/components/features/SummaryGenerator/SummaryContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
380
src/components/features/SummaryGenerator/SummaryModal.tsx
Normal file
380
src/components/features/SummaryGenerator/SummaryModal.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/features/SummaryGenerator/SummaryOptions.tsx
Normal file
86
src/components/features/SummaryGenerator/SummaryOptions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user