diff --git a/src/components/features/MessageSelector.tsx b/src/components/features/MessageSelector.tsx new file mode 100644 index 0000000..a04c2b2 --- /dev/null +++ b/src/components/features/MessageSelector.tsx @@ -0,0 +1,344 @@ +'use client'; + +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { Search, Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// 消息接口 +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; +} + +// 问答轮次接口 +interface Round { + index: number; + userMessage: Message; + assistantMessage?: Message; + preview: string; + messageIds: string[]; +} + +// 选择模式 +type SelectionMode = 'all' | 'recent_1' | 'recent_3' | 'recent_5' | 'custom'; + +interface MessageSelectorProps { + messages: Message[]; + selectedMessageIds: string[] | null; + onSelectionChange: (messageIds: string[] | null) => void; +} + +export function MessageSelector({ + messages, + selectedMessageIds, + onSelectionChange, +}: MessageSelectorProps) { + const [selectionMode, setSelectionMode] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + + // 将消息按问答轮次分组 + const rounds = useMemo(() => { + const result: Round[] = []; + let currentRound: Round | null = null; + + messages.forEach((msg) => { + if (msg.role === 'user') { + if (currentRound) { + result.push(currentRound); + } + currentRound = { + index: result.length + 1, + userMessage: msg, + preview: msg.content.slice(0, 50) + (msg.content.length > 50 ? '...' : ''), + messageIds: [msg.id], + }; + } else if (msg.role === 'assistant' && currentRound) { + currentRound.assistantMessage = msg; + currentRound.messageIds.push(msg.id); + } + }); + + if (currentRound) { + result.push(currentRound); + } + + return result; + }, [messages]); + + // 根据消息数量生成选项 + const modeOptions = useMemo(() => { + const options: { value: SelectionMode; label: string }[] = [ + { value: 'all', label: '全部对话' }, + ]; + + if (rounds.length > 1) { + options.push({ value: 'recent_1', label: '最近 1 轮' }); + } + if (rounds.length > 3) { + options.push({ value: 'recent_3', label: '最近 3 轮' }); + } + if (rounds.length > 5) { + options.push({ value: 'recent_5', label: '最近 5 轮' }); + } + if (rounds.length > 1) { + options.push({ value: 'custom', label: '自定义选择' }); + } + + return options; + }, [rounds.length]); + + // 根据选择模式获取选中的消息ID + const getMessageIdsByMode = useCallback( + (mode: SelectionMode): string[] | null => { + if (mode === 'all') return null; + if (mode === 'custom') return selectedMessageIds; + + const recentCount = parseInt(mode.replace('recent_', '')); + const recentRounds = rounds.slice(-recentCount); + return recentRounds.flatMap((r) => r.messageIds); + }, + [rounds, selectedMessageIds] + ); + + // 处理模式变化 + const handleModeChange = (mode: SelectionMode) => { + setSelectionMode(mode); + if (mode === 'custom') { + if (selectedMessageIds === null) { + onSelectionChange(messages.map((m) => m.id)); + } + } else { + onSelectionChange(getMessageIdsByMode(mode)); + } + }; + + // 当前选中的轮次 + const selectedRoundIndices = useMemo(() => { + if (selectedMessageIds === null) return new Set(rounds.map((r) => r.index)); + return new Set( + rounds + .filter((r) => r.messageIds.some((id) => selectedMessageIds.includes(id))) + .map((r) => r.index) + ); + }, [rounds, selectedMessageIds]); + + // 切换轮次选择 + const toggleRound = (round: Round) => { + if (selectionMode !== 'custom') return; + + if (selectedMessageIds === null) { + const allIds = messages.map((m) => m.id); + const newIds = allIds.filter((id) => !round.messageIds.includes(id)); + onSelectionChange(newIds); + } else { + const isSelected = round.messageIds.some((id) => selectedMessageIds.includes(id)); + if (isSelected) { + const newIds = selectedMessageIds.filter((id) => !round.messageIds.includes(id)); + onSelectionChange(newIds.length > 0 ? newIds : []); + } else { + onSelectionChange([...selectedMessageIds, ...round.messageIds]); + } + } + }; + + // 快捷操作 + const handleSelectAll = () => { + onSelectionChange(messages.map((m) => m.id)); + }; + + const handleSelectNone = () => { + onSelectionChange([]); + }; + + const handleSelectInverse = () => { + if (selectedMessageIds === null) { + onSelectionChange([]); + } else { + const allIds = messages.map((m) => m.id); + const inverseIds = allIds.filter((id) => !selectedMessageIds.includes(id)); + onSelectionChange(inverseIds); + } + }; + + // 搜索过滤 + const filteredRounds = useMemo(() => { + if (!searchQuery.trim()) return rounds; + const query = searchQuery.toLowerCase(); + return rounds.filter( + (round) => + round.userMessage.content.toLowerCase().includes(query) || + round.assistantMessage?.content.toLowerCase().includes(query) + ); + }, [rounds, searchQuery]); + + // 计算选中数量 + const selectedCount = useMemo(() => { + if (selectedMessageIds === null) return rounds.length; + return rounds.filter((r) => r.messageIds.some((id) => selectedMessageIds.includes(id))).length; + }, [rounds, selectedMessageIds]); + + // 如果只有一轮对话,不显示选择器 + if (rounds.length <= 1) { + return null; + } + + const isCustomMode = selectionMode === 'custom'; + + return ( +
+ {/* 标题行 */} +
+ + + 共 {rounds.length} 轮对话 + +
+ + {/* 左右分栏布局 */} +
+ {/* 左侧:选择模式 */} +
+ {modeOptions.map((option) => ( + + ))} +
+ + {/* 右侧:消息列表 */} +
+ {/* 搜索框和快捷操作 */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-xs bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-md text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]" + /> +
+ {isCustomMode && ( +
+ + | + + | + +
+ )} +
+ + {/* 消息列表 */} +
+ {filteredRounds.length === 0 ? ( +
+ 没有找到匹配的消息 +
+ ) : ( + filteredRounds.map((round) => { + const isSelected = selectedRoundIndices.has(round.index); + const canToggle = isCustomMode; + + return ( +
canToggle && toggleRound(round)} + className={cn( + 'flex items-center gap-2 px-3 py-2.5 border-b border-[var(--color-border)] last:border-b-0 transition-colors', + canToggle ? 'cursor-pointer hover:bg-[var(--color-bg-hover)]' : '', + isSelected && 'bg-[var(--color-primary)]/5' + )} + > + {/* 复选框 */} +
+ {isSelected && } +
+ + {/* 序号 */} + + {round.index} + + + {/* 内容预览 */} + + {round.preview} + +
+ ); + }) + )} +
+ + {/* 底部统计 */} +
+ 已选 {selectedCount}/{rounds.length} 轮 + {isCustomMode && selectedCount === 0 && ( + 请至少选择一轮 + )} +
+
+
+
+ ); +} diff --git a/src/components/features/ShareModal.tsx b/src/components/features/ShareModal.tsx new file mode 100644 index 0000000..fc565b9 --- /dev/null +++ b/src/components/features/ShareModal.tsx @@ -0,0 +1,467 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { + X, + Link2, + Copy, + Check, + Loader2, + Eye, + MessageSquare, + Wrench, + ImageIcon, + QrCode, + ExternalLink, + Trash2, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { QRCodeSVG } from 'qrcode.react'; +import { MessageSelector } from './MessageSelector'; + +// 消息接口 +interface MessageForShare { + id: string; + role: 'user' | 'assistant'; + content: string; +} + +interface ShareModalProps { + isOpen: boolean; + onClose: () => void; + conversationId: string; + conversationTitle: string; + messages?: MessageForShare[]; + onPreview?: (url: string) => void; +} + +interface ShareInfo { + shareId: string; + shareCode: string; + shareUrl: string; + title: string; + viewCount?: number; + createdAt?: string; +} + +export function ShareModal({ + isOpen, + onClose, + conversationId, + conversationTitle, + messages = [], + onPreview, +}: ShareModalProps) { + // 表单状态 + const [title, setTitle] = useState(conversationTitle); + const [includeThinking, setIncludeThinking] = useState(true); + const [includeToolCalls, setIncludeToolCalls] = useState(false); + const [includeImages, setIncludeImages] = useState(true); + const [selectedMessageIds, setSelectedMessageIds] = useState(null); // null 表示全部 + + // UI 状态 + const [isCreating, setIsCreating] = useState(false); + const [shareInfo, setShareInfo] = useState(null); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [showQrCode, setShowQrCode] = useState(false); + + // 已有分享列表 + const [existingShares, setExistingShares] = useState([]); + const [loadingShares, setLoadingShares] = useState(false); + + const modalRef = useRef(null); + + // 重置状态 + useEffect(() => { + if (isOpen) { + setTitle(conversationTitle); + setShareInfo(null); + setError(null); + setCopied(false); + setShowQrCode(false); + setSelectedMessageIds(null); // 重置为全部 + loadExistingShares(); + } + }, [isOpen, conversationTitle]); + + // 点击外部关闭 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + onClose(); + } + }; + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, onClose]); + + // ESC 关闭 + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + if (isOpen) { + document.addEventListener('keydown', handleEsc); + } + return () => { + document.removeEventListener('keydown', handleEsc); + }; + }, [isOpen, onClose]); + + // 加载已有分享 + const loadExistingShares = async () => { + try { + setLoadingShares(true); + const response = await fetch(`/api/conversations/${conversationId}/share`); + if (response.ok) { + const data = await response.json(); + setExistingShares(data.shares || []); + } + } catch (err) { + console.error('Load shares error:', err); + } finally { + setLoadingShares(false); + } + }; + + // 创建分享 + const handleCreateShare = async () => { + // 验证:自定义选择时必须至少选择一条消息 + if (selectedMessageIds !== null && selectedMessageIds.length === 0) { + setError('请至少选择一轮对话进行分享'); + return; + } + + try { + setIsCreating(true); + setError(null); + + const response = await fetch(`/api/conversations/${conversationId}/share`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + includeThinking, + includeToolCalls, + includeImages, + selectedMessageIds, // 新增:选择的消息ID,null 表示全部 + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '创建分享失败'); + } + + const data = await response.json(); + setShareInfo(data); + loadExistingShares(); + } catch (err) { + setError(err instanceof Error ? err.message : '创建分享失败'); + } finally { + setIsCreating(false); + } + }; + + // 复制链接 + const handleCopy = async () => { + if (!shareInfo?.shareUrl) return; + try { + await navigator.clipboard.writeText(shareInfo.shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Copy error:', err); + } + }; + + // 删除分享 + const handleDeleteShare = async (shareId: string) => { + if (!confirm('确定要删除此分享吗?')) return; + try { + const response = await fetch( + `/api/conversations/${conversationId}/share?shareId=${shareId}`, + { method: 'DELETE' } + ); + if (response.ok) { + setExistingShares((prev) => prev.filter((s) => s.shareId !== shareId)); + if (shareInfo?.shareId === shareId) { + setShareInfo(null); + } + } + } catch (err) { + console.error('Delete share error:', err); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* 头部 */} +
+
+ +

+ 分享对话 +

+
+ +
+ + {/* 内容 */} +
+ {shareInfo ? ( + // 分享成功后显示链接 +
+
+ + 分享链接已生成 +
+ + {/* 链接复制区域 */} +
+ + +
+ + {/* 二维码 */} +
+ + {showQrCode && ( +
+ +
+ )} +
+ + {/* 操作按钮 */} +
+ + +
+
+ ) : ( + // 创建分享表单 +
+ {/* 标题 */} +
+ + setTitle(e.target.value)} + placeholder="输入分享标题" + className="w-full px-3 py-2.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]" + /> +
+ + {/* 消息选择器 */} + {messages.length > 0 && ( + + )} + + {/* 内容选项 - 横向布局 */} +
+ +
+ + + +
+
+ + {/* 错误提示 */} + {error && ( +
+ {error} +
+ )} + + {/* 创建按钮 */} + +
+ )} + + {/* 已有分享列表 */} + {existingShares.length > 0 && !shareInfo && ( +
+

+ 已有分享 ({existingShares.length}) +

+
+ {existingShares.map((share) => ( +
+
+
+ {share.title} +
+
+ + + {share.viewCount || 0} 次查看 + +
+
+
+ + + +
+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/components/features/ShareNavigator.tsx b/src/components/features/ShareNavigator.tsx new file mode 100644 index 0000000..b7a0249 --- /dev/null +++ b/src/components/features/ShareNavigator.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { List, ChevronUp, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface MessageAnchor { + id: string; + index: number; + preview: string; +} + +interface ShareNavigatorProps { + messages: Array<{ + id: string; + role: string; + content: string; + }>; +} + +export function ShareNavigator({ messages }: ShareNavigatorProps) { + const [isOpen, setIsOpen] = useState(false); + const [showBackTop, setShowBackTop] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + + // 提取用户消息作为目录项 + const userMessages: MessageAnchor[] = messages + .filter((msg) => msg.role === 'user') + .map((msg, idx) => ({ + id: msg.id, + index: idx + 1, + preview: msg.content.slice(0, 30) + (msg.content.length > 30 ? '...' : ''), + })); + + // 监听滚动,显示/隐藏回到顶部按钮 + useEffect(() => { + const handleScroll = () => { + setShowBackTop(window.scrollY > 500); + + // 更新当前可视消息 + const messageElements = document.querySelectorAll('[data-msg-index]'); + let currentIndex: number | null = null; + + messageElements.forEach((el) => { + const rect = el.getBoundingClientRect(); + if (rect.top <= 200 && rect.bottom > 0) { + currentIndex = parseInt(el.getAttribute('data-msg-index') || '0'); + } + }); + + setActiveIndex(currentIndex); + }; + + window.addEventListener('scroll', handleScroll); + handleScroll(); // 初始化 + + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + // 跳转到指定消息 + const scrollToMessage = useCallback((id: string, index: number) => { + const element = document.getElementById(`msg-${id}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // 更新 URL hash + window.history.replaceState(null, '', `#msg-${index}`); + } + setIsOpen(false); + }, []); + + // 回到顶部 + const scrollToTop = useCallback(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + window.history.replaceState(null, '', window.location.pathname); + }, []); + + // 如果用户消息少于 2 条,不显示导航 + if (userMessages.length < 2) { + return null; + } + + return ( + <> + {/* 浮动导航按钮组 */} +
+ {/* 回到顶部按钮 */} + {showBackTop && ( + + )} + + {/* 目录按钮 */} + +
+ + {/* 目录面板 */} + {isOpen && ( + <> + {/* 背景遮罩 */} +
setIsOpen(false)} + /> + + {/* 目录内容 */} +
+ {/* 头部 */} +
+
+ + 消息目录 + + + {userMessages.length} 条问题 + +
+
+ + {/* 目录列表 */} +
+ {userMessages.map((msg) => ( + + ))} +
+
+ + )} + + ); +}