diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 0b4a05b..55acf81 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -8,6 +8,7 @@ import { Sidebar, SidebarToggle } from '@/components/layout/Sidebar'; import { ChatInput } from '@/components/features/ChatInput'; import { MessageBubble } from '@/components/features/MessageBubble'; import { ChatHeaderInfo } from '@/components/features/ChatHeader'; +import { SaveToNoteModal } from '@/components/features/SaveToNoteModal'; import { cn } from '@/lib/utils'; import { useConversation, useConversations } from '@/hooks/useConversations'; import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat'; @@ -39,6 +40,10 @@ export default function ChatPage({ params }: PageProps) { const titleInputRef = useRef(null); const titleMenuRef = useRef(null); + // 保存笔记状态 + const [noteModalOpen, setNoteModalOpen] = useState(false); + const [noteContent, setNoteContent] = useState(''); + // 获取数据 const { conversation, loading: conversationLoading, error: conversationError } = useConversation(chatId); const { createConversation, updateConversation, deleteConversation } = useConversations(); @@ -303,6 +308,33 @@ export default function ChatPage({ params }: PageProps) { } }; + // 打开保存笔记弹框 + const handleSaveToNote = (content: string) => { + setNoteContent(content); + setNoteModalOpen(true); + }; + + // 保存笔记 + const handleSaveNote = async (params: { title: string; content: string; tags: string[] }): Promise => { + try { + const response = await fetch('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...params, + conversationId: chatId, + }), + }); + if (!response.ok) { + throw new Error('保存失败'); + } + return true; + } catch (error) { + console.error('Failed to save note:', error); + return false; + } + }; + // 转换模型格式 const modelOptions = models.map((m) => ({ id: m.modelId, @@ -532,6 +564,8 @@ export default function ChatPage({ params }: PageProps) { uploadedDocuments={message.uploadedDocuments} pyodideStatus={message.pyodideStatus} onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined} + onSaveToNote={message.role === 'assistant' && !isStreaming ? handleSaveToNote : undefined} + conversationId={chatId} /> )) )} @@ -571,6 +605,15 @@ export default function ChatPage({ params }: PageProps) { + + {/* 保存笔记弹框 */} + setNoteModalOpen(false)} + onSave={handleSaveNote} + initialContent={noteContent} + conversationId={chatId} + /> ); } diff --git a/src/app/notes/page.tsx b/src/app/notes/page.tsx new file mode 100644 index 0000000..7f6191c --- /dev/null +++ b/src/app/notes/page.tsx @@ -0,0 +1,222 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Search, Bookmark, Loader2, Filter, Archive, Tag } from 'lucide-react'; +import { AppLayout } from '@/components/layout/AppLayout'; +import { NoteCard } from '@/components/features/NoteCard'; +import { NoteDetailModal } from '@/components/features/NoteDetailModal'; +import { useNotes, type Note } from '@/hooks/useNotes'; +import { cn } from '@/lib/utils'; + +export default function NotesPage() { + const { + notes, + loading, + fetchNotes, + updateNote, + deleteNote, + togglePin, + toggleArchive, + getAllTags, + } = useNotes(); + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTag, setSelectedTag] = useState(null); + const [showArchived, setShowArchived] = useState(false); + const [selectedNote, setSelectedNote] = useState(null); + const [showDetailModal, setShowDetailModal] = useState(false); + + // 加载笔记 + const loadNotes = useCallback(async () => { + await fetchNotes({ + search: searchQuery || undefined, + tags: selectedTag ? [selectedTag] : undefined, + isArchived: showArchived, + }); + }, [fetchNotes, searchQuery, selectedTag, showArchived]); + + useEffect(() => { + loadNotes(); + }, [loadNotes]); + + // 获取所有标签 + const allTags = getAllTags(); + + // 打开详情弹窗 + const handleOpenDetail = (note: Note) => { + setSelectedNote(note); + setShowDetailModal(true); + }; + + // 关闭详情弹窗 + const handleCloseDetail = () => { + setShowDetailModal(false); + setSelectedNote(null); + }; + + // 处理置顶 + const handleTogglePin = async (noteId: string): Promise => { + const result = await togglePin(noteId); + // 更新选中的笔记 + if (selectedNote?.noteId === noteId) { + setSelectedNote(prev => prev ? { ...prev, isPinned: !prev.isPinned } : null); + } + return result; + }; + + // 处理归档 + const handleToggleArchive = async (noteId: string): Promise => { + const result = await toggleArchive(noteId); + // 更新选中的笔记 + if (selectedNote?.noteId === noteId) { + setSelectedNote(prev => prev ? { ...prev, isArchived: !prev.isArchived } : null); + } + return result; + }; + + // 处理删除 + const handleDelete = async (noteId: string) => { + const success = await deleteNote(noteId); + return success; + }; + + return ( + +
+ {/* 头部 */} +
+
+
+
+
+ +
+
+

+ 我的笔记 +

+

+ 保存的对话精华,随时回顾 +

+
+
+ +
+ + {/* 搜索栏 */} +
+ + setSearchQuery(e.target.value)} + placeholder="搜索笔记标题或内容..." + className="w-full pl-10 pr-4 py-2.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)] transition-colors" + /> +
+
+
+ + {/* 标签筛选 */} + {allTags.length > 0 && ( +
+
+ + {allTags.map((tag) => ( + + ))} +
+
+ )} + + {/* 笔记列表 */} +
+
+ {loading ? ( +
+ +
+ ) : notes.length === 0 ? ( +
+
+ +
+

+ {showArchived ? '暂无已归档的笔记' : '暂无笔记'} +

+

+ {showArchived + ? '归档的笔记会显示在这里' + : searchQuery + ? '未找到匹配的笔记,试试其他关键词' + : '在对话中点击"保存到笔记"按钮,即可将精彩内容保存到这里'} +

+
+ ) : ( +
+ {notes.map((note) => ( + handleOpenDetail(note)} + onTogglePin={handleTogglePin} + onToggleArchive={handleToggleArchive} + onDelete={handleDelete} + /> + ))} +
+ )} +
+
+
+ + {/* 详情弹窗 */} + +
+ ); +} diff --git a/src/components/features/MessageBubble.tsx b/src/components/features/MessageBubble.tsx index 33c8658..fd38da2 100644 --- a/src/components/features/MessageBubble.tsx +++ b/src/components/features/MessageBubble.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode } from 'lucide-react'; +import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode, Bookmark } from 'lucide-react'; import { Avatar } from '@/components/ui/Avatar'; import { AILogo } from '@/components/ui/AILogo'; import { Tooltip } from '@/components/ui/Tooltip'; @@ -33,6 +33,10 @@ interface MessageBubbleProps { }; /** 重新生成回调(仅对 AI 消息有效),传入消息 ID */ onRegenerate?: (messageId: string) => void; + /** 保存到笔记回调(仅对 AI 消息有效),传入消息内容 */ + onSaveToNote?: (content: string) => void; + /** 对话 ID(用于关联笔记来源) */ + conversationId?: string; } // 格式化文件大小 @@ -52,7 +56,7 @@ function getDocumentIcon(type: string) { return FileText; } -export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus, onRegenerate }: MessageBubbleProps) { +export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus, onRegenerate, onSaveToNote, conversationId }: MessageBubbleProps) { const isUser = message.role === 'user'; const [thinkingExpanded, setThinkingExpanded] = useState(false); const [copied, setCopied] = useState(false); @@ -328,6 +332,17 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err )} + {/* 保存到笔记按钮 */} + {onSaveToNote && ( + + + + )} {/* 免责声明 */} diff --git a/src/components/features/NoteCard.tsx b/src/components/features/NoteCard.tsx new file mode 100644 index 0000000..16a7348 --- /dev/null +++ b/src/components/features/NoteCard.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { Pin, Archive, Trash2, MoreVertical, Tag, Calendar, MessageSquare } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Note } from '@/hooks/useNotes'; + +interface NoteCardProps { + note: Note; + onClick: () => void; + onTogglePin: (noteId: string) => void; + onToggleArchive: (noteId: string) => void; + onDelete: (noteId: string) => void; +} + +// 格式化日期 +function formatDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) { + return '今天'; + } else if (days === 1) { + return '昨天'; + } else if (days < 7) { + return `${days}天前`; + } else { + return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); + } +} + +// 截取内容预览 +function getContentPreview(content: string, maxLength: number = 100): string { + // 移除 Markdown 标记 + const plainText = content + .replace(/```[\s\S]*?```/g, '[代码块]') + .replace(/`[^`]+`/g, '[代码]') + .replace(/#+\s*/g, '') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '[图片]') + .replace(/\n+/g, ' ') + .trim(); + + if (plainText.length <= maxLength) return plainText; + return plainText.slice(0, maxLength) + '...'; +} + +export function NoteCard({ note, onClick, onTogglePin, onToggleArchive, onDelete }: NoteCardProps) { + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + // 点击外部关闭菜单 + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // 处理删除确认 + const handleDelete = () => { + if (confirm('确定要删除这条笔记吗?')) { + onDelete(note.noteId); + } + setMenuOpen(false); + }; + + return ( +
+ {/* 置顶标记 */} + {note.isPinned && ( +
+ +
+ )} + + {/* 标题 */} +

+ {note.title} +

+ + {/* 内容预览 */} +

+ {getContentPreview(note.content)} +

+ + {/* 标签 */} + {note.tags && note.tags.length > 0 && ( +
+ {note.tags.slice(0, 3).map((tag) => ( + + + {tag} + + ))} + {note.tags.length > 3 && ( + + +{note.tags.length - 3} + + )} +
+ )} + + {/* 底部信息 */} +
+
+ + + {formatDate(note.createdAt)} + + {note.conversationId && ( + + + 来自对话 + + )} +
+ + {/* 更多操作 */} +
e.stopPropagation()} + > + + + {/* 下拉菜单 */} + {menuOpen && ( +
+ + +
+ +
+ )} +
+
+
+ ); +} diff --git a/src/components/features/NoteDetailModal.tsx b/src/components/features/NoteDetailModal.tsx new file mode 100644 index 0000000..17b8b35 --- /dev/null +++ b/src/components/features/NoteDetailModal.tsx @@ -0,0 +1,274 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { X, Pin, Archive, Trash2, Copy, Tag, Calendar, MessageSquare, Check, Edit3, Save } from 'lucide-react'; +import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer'; +import { cn } from '@/lib/utils'; +import type { Note, UpdateNoteParams } from '@/hooks/useNotes'; + +interface NoteDetailModalProps { + isOpen: boolean; + onClose: () => void; + note: Note | null; + onUpdate: (noteId: string, params: UpdateNoteParams) => Promise; + onTogglePin: (noteId: string) => Promise; + onToggleArchive: (noteId: string) => Promise; + onDelete: (noteId: string) => Promise; +} + +// 格式化日期 +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export function NoteDetailModal({ + isOpen, + onClose, + note, + onUpdate, + onTogglePin, + onToggleArchive, + onDelete, +}: NoteDetailModalProps) { + const [isEditing, setIsEditing] = useState(false); + const [editTitle, setEditTitle] = useState(''); + const [editContent, setEditContent] = useState(''); + const [copied, setCopied] = useState(false); + const modalRef = useRef(null); + + // 初始化编辑状态 + useEffect(() => { + if (note) { + setEditTitle(note.title); + setEditContent(note.content); + setIsEditing(false); + } + }, [note]); + + // 点击外部关闭 + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + if (!isEditing) onClose(); + } + } + + function handleEscape(event: KeyboardEvent) { + if (event.key === 'Escape') { + if (isEditing) { + setIsEditing(false); + setEditTitle(note?.title || ''); + setEditContent(note?.content || ''); + } else { + onClose(); + } + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + document.body.style.overflow = 'hidden'; + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + document.body.style.overflow = ''; + }; + }, [isOpen, isEditing, note, onClose]); + + // 复制内容 + const handleCopy = async () => { + if (!note) return; + try { + await navigator.clipboard.writeText(note.content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Failed to copy:', error); + } + }; + + // 保存编辑 + const handleSave = async () => { + if (!note || !editTitle.trim() || !editContent.trim()) return; + await onUpdate(note.noteId, { + title: editTitle.trim(), + content: editContent.trim(), + }); + setIsEditing(false); + }; + + // 删除笔记 + const handleDelete = async () => { + if (!note) return; + if (confirm('确定要删除这条笔记吗?')) { + const success = await onDelete(note.noteId); + if (success) { + onClose(); + } + } + }; + + if (!isOpen || !note) return null; + + return ( +
+
+ {/* Header */} +
+
+ {note.isPinned && ( + + )} + {isEditing ? ( + setEditTitle(e.target.value)} + className="flex-1 text-lg font-semibold text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg px-3 py-1.5 focus:outline-none focus:border-[var(--color-primary)]" + /> + ) : ( +

+ {note.title} +

+ )} +
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + + + + )} +
+ +
+
+ + {/* Meta info */} +
+
+ + + {formatDate(note.createdAt)} + + {note.conversationId && ( + + + 来自对话 + + )} + {note.tags && note.tags.length > 0 && ( +
+ +
+ {note.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} +
+
+ + {/* Content */} +
+ {isEditing ? ( +