From f6f14a2d64b7c572dbda865189504c6f40776592 Mon Sep 17 00:00:00 2001 From: duanfuxiang Date: Sun, 15 Jun 2025 15:32:38 +0800 Subject: [PATCH] update history --- src/components/chat-view/ChatHistoryView.tsx | 673 +++++++++++++------ src/components/chat-view/ChatView.tsx | 63 +- src/database/json/base.ts | 6 +- src/database/json/chat/ChatManager.ts | 7 +- src/hooks/use-chat-history.ts | 11 +- src/lang/locale/en.ts | 21 +- src/lang/locale/zh-cn.ts | 21 +- 7 files changed, 575 insertions(+), 227 deletions(-) diff --git a/src/components/chat-view/ChatHistoryView.tsx b/src/components/chat-view/ChatHistoryView.tsx index c489035..daf3785 100644 --- a/src/components/chat-view/ChatHistoryView.tsx +++ b/src/components/chat-view/ChatHistoryView.tsx @@ -1,204 +1,491 @@ -import * as Popover from '@radix-ui/react-popover' -import { Pencil, Trash2 } from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { Clock, MessageSquare, Pencil, Search, Trash2 } from 'lucide-react' +import { Notice } from 'obsidian' +import React, { useMemo, useRef, useState } from 'react' +import { useChatHistory } from '../../hooks/use-chat-history' import { t } from '../../lang/helpers' import { ChatConversationMeta } from '../../types/chat' - -function TitleInput({ - title, - onSubmit, -}: { - title: string - onSubmit: (title: string) => Promise -}) { - const [value, setValue] = useState(title) - const inputRef = useRef(null) - - useEffect(() => { - if (inputRef.current) { - inputRef.current.select() - inputRef.current.scrollLeft = 0 - } - }, []) - - return ( - e.stopPropagation()} - onChange={(e) => setValue(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation() - if (e.key === 'Enter') { - onSubmit(value) - } - }} - autoFocus - maxLength={100} - /> - ) +export interface ChatHistoryViewProps { + currentConversationId?: string + onSelect?: (conversationId: string) => void + onDelete?: (conversationId: string) => void + onUpdateTitle?: (conversationId: string, newTitle: string) => void } -function ChatListItem({ - title, - isFocused, - isEditing, - onMouseEnter, - onSelect, - onDelete, - onStartEdit, - onFinishEdit, -}: { - title: string - isFocused: boolean - isEditing: boolean - onMouseEnter: () => void - onSelect: () => Promise - onDelete: () => Promise - onStartEdit: () => void - onFinishEdit: (title: string) => Promise -}) { - const itemRef = useRef(null) +const ChatHistoryView = ({ + currentConversationId, + onSelect, + onDelete, + onUpdateTitle, +}: ChatHistoryViewProps) => { + const { + deleteConversation, + updateConversationTitle, + chatList, + } = useChatHistory() - useEffect(() => { - if (isFocused && itemRef.current) { - itemRef.current.scrollIntoView({ - block: 'nearest', - }) - } - }, [isFocused]) + // search term + const [searchTerm, setSearchTerm] = useState('') - return ( -
  • - {isEditing ? ( - - ) : ( -
    {title}
    - )} -
    -
    { - e.stopPropagation() - onStartEdit() - }} - className="infio-chat-list-dropdown-item-icon" - > - -
    -
    { - e.stopPropagation() - await onDelete() - }} - className="infio-chat-list-dropdown-item-icon" - > - -
    -
    -
  • - ) + // editing conversation id + const [editingConversationId, setEditingConversationId] = useState(null) + + const titleInputRefs = useRef>(new Map()) + + // handle search + const handleSearch = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value) + } + + // filter conversations list + const filteredConversations = useMemo(() => { + if (!searchTerm.trim()) { + return chatList + } + return chatList.filter( + conversation => + conversation.title.toLowerCase().includes(searchTerm.toLowerCase()) + ) + }, [chatList, searchTerm]) + + // delete conversation + const handleDeleteConversation = async (id: string) => { + try { + await deleteConversation(id) + onDelete?.(id) + } catch (error) { + new Notice(String(t('chat.errors.failedToDeleteConversation'))) + console.error('Failed to delete conversation', error) + } + } + + // edit conversation title + const handleEditConversation = (conversation: ChatConversationMeta) => { + setEditingConversationId(conversation.id) + } + + // save edited title + const handleSaveEdit = async (id: string) => { + const titleInput = titleInputRefs.current.get(id) + if (!titleInput || !titleInput.value.trim()) { + new Notice(String(t('chat.errors.titleRequired'))) + return + } + + try { + await updateConversationTitle(id, titleInput.value.trim()) + onUpdateTitle?.(id, titleInput.value.trim()) + setEditingConversationId(null) + } catch (error) { + new Notice(String(t('chat.errors.failedToUpdateTitle'))) + console.error('Failed to update conversation title', error) + } + } + + // select conversation + const handleSelectConversation = (conversationId: string) => { + onSelect?.(conversationId) + } + + // format date + const formatDate = (timestamp: number) => { + const date = new Date(timestamp) + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000) + + if (date >= today) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + } else if (date >= yesterday) { + return t('chat.history.yesterday') + } else { + return date.toLocaleDateString() + } + } + + return ( +
    + {/* header */} +
    +
    +

    {t('chat.history.title')}

    +
    +
    + + {/* description */} +
    + {t('chat.history.description')} +
    + + {/* search bar */} +
    + + +
    + + {/* conversations list */} +
    + {filteredConversations.length === 0 ? ( +
    + +

    {searchTerm ? t('chat.history.noMatchingChats') : t('chat.history.noChats')}

    +
    + ) : ( + filteredConversations.map(conversation => ( +
    + {editingConversationId === conversation.id ? ( + // edit mode +
    + { + if (el) titleInputRefs.current.set(conversation.id, el) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveEdit(conversation.id) + } else if (e.key === 'Escape') { + setEditingConversationId(null) + } + }} + autoFocus + /> +
    + + +
    +
    + ) : ( + // view mode +
    handleSelectConversation(conversation.id)} + > +
    +
    + + {formatDate(conversation.updatedAt)} +
    +
    {conversation.title}
    +
    +
    + + +
    +
    + )} +
    + )) + )} +
    + + {/* Styles */} + +
    + ) } -export function ChatHistory({ - chatList, - currentConversationId, - onSelect, - onDelete, - onUpdateTitle, - className, - children, -}: { - chatList: ChatConversationMeta[] - currentConversationId: string - onSelect: (conversationId: string) => Promise - onDelete: (conversationId: string) => Promise - onUpdateTitle: (conversationId: string, newTitle: string) => Promise - className?: string - children: React.ReactNode -}) { - const [open, setOpen] = useState(false) - const [focusedIndex, setFocusedIndex] = useState(0) - const [editingId, setEditingId] = useState(null) +export default ChatHistoryView - useEffect(() => { - if (open) { - const currentIndex = chatList.findIndex( - (chat) => chat.id === currentConversationId, - ) - setFocusedIndex(currentIndex === -1 ? 0 : currentIndex) - setEditingId(null) - } - }, [open]) - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'ArrowUp') { - setFocusedIndex(Math.max(0, focusedIndex - 1)) - } else if (e.key === 'ArrowDown') { - setFocusedIndex(Math.min(chatList.length - 1, focusedIndex + 1)) - } else if (e.key === 'Enter') { - onSelect(chatList[focusedIndex].id) - setOpen(false) - } - }, - [chatList, focusedIndex, setFocusedIndex, onSelect], - ) - - return ( - - - - - - - -
      - {chatList.length === 0 ? ( -
    • - {t('chat.history.noConversations')} -
    • - ) : ( - chatList.map((chat, index) => ( - { - setFocusedIndex(index) - }} - onSelect={async () => { - await onSelect(chat.id) - setOpen(false) - }} - onDelete={async () => { - await onDelete(chat.id) - }} - onStartEdit={() => { - setEditingId(chat.id) - }} - onFinishEdit={async (title) => { - await onUpdateTitle(chat.id, title) - setEditingId(null) - }} - /> - )) - )} -
    -
    -
    -
    - ) -} +// Export the original ChatHistory component for backward compatibility +export { ChatHistoryView as ChatHistory } diff --git a/src/components/chat-view/ChatView.tsx b/src/components/chat-view/ChatView.tsx index f1e0ab1..8477143 100644 --- a/src/components/chat-view/ChatView.tsx +++ b/src/components/chat-view/ChatView.tsx @@ -59,7 +59,7 @@ import { fetchUrlsContent, onEnt, webSearch } from '../../utils/web-search' import { ModeSelect } from './chat-input/ModeSelect'; // Start of new group import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions' import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text' -import { ChatHistory } from './ChatHistoryView' +import ChatHistoryView from './ChatHistoryView' import CommandsView from './CommandsView' import CustomModeView from './CustomModeView' import FileReadResults from './FileReadResults' @@ -177,7 +177,7 @@ const Chat = forwardRef((props, ref) => { } } - const [tab, setTab] = useState<'chat' | 'commands' | 'custom-mode' | 'mcp' | 'search'>('chat') + const [tab, setTab] = useState<'chat' | 'commands' | 'custom-mode' | 'mcp' | 'search' | 'history'>('chat') const [selectedSerializedNodes, setSelectedSerializedNodes] = useState([]) @@ -1005,36 +1005,18 @@ const Chat = forwardRef((props, ref) => { > - { - if (tab !== 'chat') { +