update history
This commit is contained in:
parent
b3ef7afa0c
commit
f6f14a2d64
@ -1,204 +1,491 @@
|
|||||||
import * as Popover from '@radix-ui/react-popover'
|
import { Clock, MessageSquare, Pencil, Search, Trash2 } from 'lucide-react'
|
||||||
import { Pencil, Trash2 } from 'lucide-react'
|
import { Notice } from 'obsidian'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import React, { useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { useChatHistory } from '../../hooks/use-chat-history'
|
||||||
import { t } from '../../lang/helpers'
|
import { t } from '../../lang/helpers'
|
||||||
import { ChatConversationMeta } from '../../types/chat'
|
import { ChatConversationMeta } from '../../types/chat'
|
||||||
|
|
||||||
|
export interface ChatHistoryViewProps {
|
||||||
function TitleInput({
|
currentConversationId?: string
|
||||||
title,
|
onSelect?: (conversationId: string) => void
|
||||||
onSubmit,
|
onDelete?: (conversationId: string) => void
|
||||||
}: {
|
onUpdateTitle?: (conversationId: string, newTitle: string) => void
|
||||||
title: string
|
|
||||||
onSubmit: (title: string) => Promise<void>
|
|
||||||
}) {
|
|
||||||
const [value, setValue] = useState(title)
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.select()
|
|
||||||
inputRef.current.scrollLeft = 0
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
className="infio-chat-list-dropdown-item-title-input"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
onSubmit(value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
maxLength={100}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatListItem({
|
const ChatHistoryView = ({
|
||||||
title,
|
currentConversationId,
|
||||||
isFocused,
|
onSelect,
|
||||||
isEditing,
|
onDelete,
|
||||||
onMouseEnter,
|
onUpdateTitle,
|
||||||
onSelect,
|
}: ChatHistoryViewProps) => {
|
||||||
onDelete,
|
const {
|
||||||
onStartEdit,
|
deleteConversation,
|
||||||
onFinishEdit,
|
updateConversationTitle,
|
||||||
}: {
|
chatList,
|
||||||
title: string
|
} = useChatHistory()
|
||||||
isFocused: boolean
|
|
||||||
isEditing: boolean
|
|
||||||
onMouseEnter: () => void
|
|
||||||
onSelect: () => Promise<void>
|
|
||||||
onDelete: () => Promise<void>
|
|
||||||
onStartEdit: () => void
|
|
||||||
onFinishEdit: (title: string) => Promise<void>
|
|
||||||
}) {
|
|
||||||
const itemRef = useRef<HTMLLIElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
// search term
|
||||||
if (isFocused && itemRef.current) {
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
itemRef.current.scrollIntoView({
|
|
||||||
block: 'nearest',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [isFocused])
|
|
||||||
|
|
||||||
return (
|
// editing conversation id
|
||||||
<li
|
const [editingConversationId, setEditingConversationId] = useState<string | null>(null)
|
||||||
ref={itemRef}
|
|
||||||
onClick={onSelect}
|
const titleInputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
className={isFocused ? 'selected' : ''}
|
// handle search
|
||||||
>
|
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
{isEditing ? (
|
setSearchTerm(e.target.value)
|
||||||
<TitleInput title={title} onSubmit={onFinishEdit} />
|
}
|
||||||
) : (
|
|
||||||
<div className="infio-chat-list-dropdown-item-title">{title}</div>
|
// filter conversations list
|
||||||
)}
|
const filteredConversations = useMemo(() => {
|
||||||
<div className="infio-chat-list-dropdown-item-actions">
|
if (!searchTerm.trim()) {
|
||||||
<div
|
return chatList
|
||||||
onClick={(e) => {
|
}
|
||||||
e.stopPropagation()
|
return chatList.filter(
|
||||||
onStartEdit()
|
conversation =>
|
||||||
}}
|
conversation.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
className="infio-chat-list-dropdown-item-icon"
|
)
|
||||||
>
|
}, [chatList, searchTerm])
|
||||||
<Pencil size={14} />
|
|
||||||
</div>
|
// delete conversation
|
||||||
<div
|
const handleDeleteConversation = async (id: string) => {
|
||||||
onClick={async (e) => {
|
try {
|
||||||
e.stopPropagation()
|
await deleteConversation(id)
|
||||||
await onDelete()
|
onDelete?.(id)
|
||||||
}}
|
} catch (error) {
|
||||||
className="infio-chat-list-dropdown-item-icon"
|
new Notice(String(t('chat.errors.failedToDeleteConversation')))
|
||||||
>
|
console.error('Failed to delete conversation', error)
|
||||||
<Trash2 size={14} />
|
}
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
</li>
|
// 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 (
|
||||||
|
<div className="infio-chat-history-container">
|
||||||
|
{/* header */}
|
||||||
|
<div className="infio-chat-history-header">
|
||||||
|
<div className="infio-chat-history-title">
|
||||||
|
<h2>{t('chat.history.title')}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div className="infio-chat-history-tip">
|
||||||
|
{t('chat.history.description')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* search bar */}
|
||||||
|
<div className="infio-chat-history-search">
|
||||||
|
<Search size={18} className="infio-chat-history-search-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('chat.history.searchPlaceholder')}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={handleSearch}
|
||||||
|
className="infio-chat-history-search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* conversations list */}
|
||||||
|
<div className="infio-chat-history-list">
|
||||||
|
{filteredConversations.length === 0 ? (
|
||||||
|
<div className="infio-chat-history-empty">
|
||||||
|
<MessageSquare size={48} className="infio-chat-history-empty-icon" />
|
||||||
|
<p>{searchTerm ? t('chat.history.noMatchingChats') : t('chat.history.noChats')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredConversations.map(conversation => (
|
||||||
|
<div
|
||||||
|
key={conversation.id}
|
||||||
|
className={`infio-chat-history-item ${currentConversationId === conversation.id ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{editingConversationId === conversation.id ? (
|
||||||
|
// edit mode
|
||||||
|
<div className="infio-chat-history-edit-mode">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={conversation.title}
|
||||||
|
className="infio-chat-history-edit-title"
|
||||||
|
ref={(el) => {
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
<div className="infio-chat-history-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveEdit(conversation.id)}
|
||||||
|
className="infio-chat-history-save-btn"
|
||||||
|
>
|
||||||
|
<span>{t('chat.history.save')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingConversationId(null)}
|
||||||
|
className="infio-chat-history-cancel-btn"
|
||||||
|
>
|
||||||
|
<span>{t('chat.history.cancel')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// view mode
|
||||||
|
<div
|
||||||
|
className="infio-chat-history-view-mode"
|
||||||
|
onClick={() => handleSelectConversation(conversation.id)}
|
||||||
|
>
|
||||||
|
<div className="infio-chat-history-content">
|
||||||
|
<div className="infio-chat-history-date">
|
||||||
|
<Clock size={12} />
|
||||||
|
{formatDate(conversation.updatedAt)}
|
||||||
|
</div>
|
||||||
|
<div className="infio-chat-history-conversation-title">{conversation.title}</div>
|
||||||
|
</div>
|
||||||
|
<div className="infio-chat-history-actions">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEditConversation(conversation)
|
||||||
|
}}
|
||||||
|
className="infio-chat-history-btn"
|
||||||
|
title={t('chat.history.editTitle')}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteConversation(conversation.id)
|
||||||
|
}}
|
||||||
|
className="infio-chat-history-btn infio-chat-history-delete-btn"
|
||||||
|
title={t('chat.history.deleteConversation')}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Styles */}
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.infio-chat-history-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 16px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* 隐藏滚动条 */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-container::-webkit-scrollbar {
|
||||||
|
display: none; /* Webkit browsers */
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-tip {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-add-btn:disabled {
|
||||||
|
background-color: var(--background-modifier-form-field);
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--background-primary) !important;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
padding: 6px 12px;
|
||||||
|
margin-bottom: var(--size-4-3);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
height: 36px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-search:focus-within {
|
||||||
|
border-color: var(--background-modifier-border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-search-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: 8px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-search-input {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
color: var(--text-normal);
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
height: 24px;
|
||||||
|
&:focus {
|
||||||
|
outline: none !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-search-input::placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* 隐藏滚动条 */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-list::-webkit-scrollbar {
|
||||||
|
display: none; /* Webkit browsers */
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-empty-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-item {
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-item:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
border-color: var(--background-modifier-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-item.active {
|
||||||
|
background-color: var(--background-modifier-active);
|
||||||
|
border-color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-view-mode {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-conversation-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-normal);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-date {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-item:hover .infio-chat-history-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-modifier-hover) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-btn:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-delete-btn:hover {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
color: var(--text-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-edit-mode {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-edit-title {
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
color: var(--text-normal);
|
||||||
|
padding: var(--size-4-2);
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-edit-title:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-save-btn,
|
||||||
|
.infio-chat-history-cancel-btn {
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
color: var(--text-normal);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-save-btn {
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-save-btn:hover {
|
||||||
|
background-color: var(--interactive-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-cancel-btn {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-cancel-btn:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHistory({
|
export default ChatHistoryView
|
||||||
chatList,
|
|
||||||
currentConversationId,
|
|
||||||
onSelect,
|
|
||||||
onDelete,
|
|
||||||
onUpdateTitle,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
chatList: ChatConversationMeta[]
|
|
||||||
currentConversationId: string
|
|
||||||
onSelect: (conversationId: string) => Promise<void>
|
|
||||||
onDelete: (conversationId: string) => Promise<void>
|
|
||||||
onUpdateTitle: (conversationId: string, newTitle: string) => Promise<void>
|
|
||||||
className?: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [focusedIndex, setFocusedIndex] = useState<number>(0)
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Export the original ChatHistory component for backward compatibility
|
||||||
if (open) {
|
export { ChatHistoryView as ChatHistory }
|
||||||
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 (
|
|
||||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
|
||||||
<Popover.Trigger asChild>
|
|
||||||
<button className={className}>{children}</button>
|
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Portal>
|
|
||||||
<Popover.Content
|
|
||||||
className="infio-popover infio-chat-list-dropdown-content"
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<ul>
|
|
||||||
{chatList.length === 0 ? (
|
|
||||||
<li className="infio-chat-list-dropdown-empty">
|
|
||||||
{t('chat.history.noConversations')}
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
chatList.map((chat, index) => (
|
|
||||||
<ChatListItem
|
|
||||||
key={chat.id}
|
|
||||||
title={chat.title}
|
|
||||||
isFocused={focusedIndex === index}
|
|
||||||
isEditing={editingId === chat.id}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
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)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</Popover.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -59,7 +59,7 @@ import { fetchUrlsContent, onEnt, webSearch } from '../../utils/web-search'
|
|||||||
import { ModeSelect } from './chat-input/ModeSelect'; // Start of new group
|
import { ModeSelect } from './chat-input/ModeSelect'; // Start of new group
|
||||||
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
|
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
|
||||||
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
||||||
import { ChatHistory } from './ChatHistoryView'
|
import ChatHistoryView from './ChatHistoryView'
|
||||||
import CommandsView from './CommandsView'
|
import CommandsView from './CommandsView'
|
||||||
import CustomModeView from './CustomModeView'
|
import CustomModeView from './CustomModeView'
|
||||||
import FileReadResults from './FileReadResults'
|
import FileReadResults from './FileReadResults'
|
||||||
@ -177,7 +177,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((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<BaseSerializedNode[]>([])
|
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<BaseSerializedNode[]>([])
|
||||||
|
|
||||||
@ -1005,36 +1005,18 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
>
|
>
|
||||||
<Search size={18} color={tab === 'search' ? 'var(--text-accent)' : 'var(--text-color)'} />
|
<Search size={18} color={tab === 'search' ? 'var(--text-accent)' : 'var(--text-color)'} />
|
||||||
</button>
|
</button>
|
||||||
<ChatHistory
|
<button
|
||||||
chatList={chatList}
|
onClick={() => {
|
||||||
currentConversationId={currentConversationId}
|
if (tab === 'history') {
|
||||||
onSelect={async (conversationId) => {
|
|
||||||
if (tab !== 'chat') {
|
|
||||||
setTab('chat')
|
setTab('chat')
|
||||||
|
} else {
|
||||||
|
setTab('history')
|
||||||
}
|
}
|
||||||
if (conversationId === currentConversationId) return
|
|
||||||
await handleLoadConversation(conversationId)
|
|
||||||
}}
|
|
||||||
onDelete={async (conversationId) => {
|
|
||||||
await deleteConversation(conversationId)
|
|
||||||
if (conversationId === currentConversationId) {
|
|
||||||
const nextConversation = chatList.find(
|
|
||||||
(chat) => chat.id !== conversationId,
|
|
||||||
)
|
|
||||||
if (nextConversation) {
|
|
||||||
void handleLoadConversation(nextConversation.id)
|
|
||||||
} else {
|
|
||||||
handleNewChat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onUpdateTitle={async (conversationId, newTitle) => {
|
|
||||||
await updateConversationTitle(conversationId, newTitle)
|
|
||||||
}}
|
}}
|
||||||
className="infio-chat-list-dropdown"
|
className="infio-chat-list-dropdown"
|
||||||
>
|
>
|
||||||
<History size={18} />
|
<History size={18} color={tab === 'history' ? 'var(--text-accent)' : 'var(--text-color)'} />
|
||||||
</ChatHistory>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// switch between chat and prompts
|
// switch between chat and prompts
|
||||||
@ -1211,6 +1193,33 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
<div className="infio-chat-commands">
|
<div className="infio-chat-commands">
|
||||||
<CustomModeView />
|
<CustomModeView />
|
||||||
</div>
|
</div>
|
||||||
|
) : tab === 'history' ? (
|
||||||
|
<div className="infio-chat-commands">
|
||||||
|
<ChatHistoryView
|
||||||
|
currentConversationId={currentConversationId}
|
||||||
|
onSelect={async (conversationId) => {
|
||||||
|
setTab('chat')
|
||||||
|
if (conversationId === currentConversationId) return
|
||||||
|
await handleLoadConversation(conversationId)
|
||||||
|
}}
|
||||||
|
onDelete={async (conversationId) => {
|
||||||
|
await deleteConversation(conversationId)
|
||||||
|
if (conversationId === currentConversationId) {
|
||||||
|
const nextConversation = chatList.find(
|
||||||
|
(chat) => chat.id !== conversationId,
|
||||||
|
)
|
||||||
|
if (nextConversation) {
|
||||||
|
void handleLoadConversation(nextConversation.id)
|
||||||
|
} else {
|
||||||
|
handleNewChat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onUpdateTitle={async (conversationId, newTitle) => {
|
||||||
|
await updateConversationTitle(conversationId, newTitle)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="infio-chat-commands">
|
<div className="infio-chat-commands">
|
||||||
<McpHubView />
|
<McpHubView />
|
||||||
|
|||||||
@ -55,8 +55,10 @@ export abstract class AbstractJsonRepository<T, M> {
|
|||||||
|
|
||||||
// List metadata for all records by parsing file names.
|
// List metadata for all records by parsing file names.
|
||||||
public async listMetadata(): Promise<(M & { fileName: string })[]> {
|
public async listMetadata(): Promise<(M & { fileName: string })[]> {
|
||||||
|
console.log('AbstractJsonRepository - listMetadata called for dataDir:', this.dataDir)
|
||||||
const files = await this.app.vault.adapter.list(this.dataDir)
|
const files = await this.app.vault.adapter.list(this.dataDir)
|
||||||
return files.files
|
console.log('AbstractJsonRepository - files in directory:', files)
|
||||||
|
const result = files.files
|
||||||
.map((filePath) => path.basename(filePath))
|
.map((filePath) => path.basename(filePath))
|
||||||
.filter((fileName) => fileName.endsWith('.json'))
|
.filter((fileName) => fileName.endsWith('.json'))
|
||||||
.map((fileName) => {
|
.map((fileName) => {
|
||||||
@ -66,6 +68,8 @@ export abstract class AbstractJsonRepository<T, M> {
|
|||||||
.filter(
|
.filter(
|
||||||
(metadata): metadata is M & { fileName: string } => metadata !== null,
|
(metadata): metadata is M & { fileName: string } => metadata !== null,
|
||||||
)
|
)
|
||||||
|
console.log('AbstractJsonRepository - parsed metadata:', result)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(fileName: string): Promise<T | null> {
|
public async read(fileName: string): Promise<T | null> {
|
||||||
|
|||||||
@ -111,7 +111,12 @@ export class ChatManager extends AbstractJsonRepository<
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async listChats(): Promise<ChatConversationMeta[]> {
|
public async listChats(): Promise<ChatConversationMeta[]> {
|
||||||
|
console.log('ChatManager - listChats called')
|
||||||
|
console.log('ChatManager - data directory:', this.dataDir)
|
||||||
const metadata = await this.listMetadata()
|
const metadata = await this.listMetadata()
|
||||||
return metadata.sort((a, b) => b.updatedAt - a.updatedAt)
|
console.log('ChatManager - raw metadata:', metadata)
|
||||||
|
const sorted = metadata.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
console.log('ChatManager - sorted chats:', sorted)
|
||||||
|
return sorted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,10 @@ export function useChatHistory(): UseChatHistory {
|
|||||||
const [chatList, setChatList] = useState<ChatConversationMeta[]>([])
|
const [chatList, setChatList] = useState<ChatConversationMeta[]>([])
|
||||||
|
|
||||||
const fetchChatList = useCallback(async () => {
|
const fetchChatList = useCallback(async () => {
|
||||||
|
console.log('useChatHistory - fetching chat list...')
|
||||||
const conversations = await chatManager.listChats()
|
const conversations = await chatManager.listChats()
|
||||||
|
console.log('useChatHistory - fetched conversations:', conversations)
|
||||||
|
console.log('useChatHistory - conversations length:', conversations.length)
|
||||||
setChatList(conversations)
|
setChatList(conversations)
|
||||||
}, [chatManager])
|
}, [chatManager])
|
||||||
|
|
||||||
@ -38,20 +41,24 @@ export function useChatHistory(): UseChatHistory {
|
|||||||
() =>
|
() =>
|
||||||
debounce(
|
debounce(
|
||||||
async (id: string, messages: ChatMessage[]): Promise<void> => {
|
async (id: string, messages: ChatMessage[]): Promise<void> => {
|
||||||
|
console.log('useChatHistory - createOrUpdateConversation called with id:', id, 'messages length:', messages.length)
|
||||||
const serializedMessages = messages.map(serializeChatMessage)
|
const serializedMessages = messages.map(serializeChatMessage)
|
||||||
const existingConversation = await chatManager.findById(id)
|
const existingConversation = await chatManager.findById(id)
|
||||||
|
|
||||||
if (existingConversation) {
|
if (existingConversation) {
|
||||||
|
console.log('useChatHistory - updating existing conversation:', existingConversation.id)
|
||||||
if (isEqual(existingConversation.messages, serializedMessages)) {
|
if (isEqual(existingConversation.messages, serializedMessages)) {
|
||||||
|
console.log('useChatHistory - messages are identical, skipping update')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await chatManager.updateChat(existingConversation.id, {
|
await chatManager.updateChat(existingConversation.id, {
|
||||||
messages: serializedMessages,
|
messages: serializedMessages,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
console.log('useChatHistory - creating new conversation')
|
||||||
const firstUserMessage = messages.find((v) => v.role === 'user') as ChatUserMessage
|
const firstUserMessage = messages.find((v) => v.role === 'user') as ChatUserMessage
|
||||||
|
|
||||||
await chatManager.createChat({
|
const newChat = await chatManager.createChat({
|
||||||
id,
|
id,
|
||||||
title: firstUserMessage?.content
|
title: firstUserMessage?.content
|
||||||
? editorStateToPlainText(firstUserMessage.content).substring(
|
? editorStateToPlainText(firstUserMessage.content).substring(
|
||||||
@ -61,8 +68,10 @@ export function useChatHistory(): UseChatHistory {
|
|||||||
: 'New chat',
|
: 'New chat',
|
||||||
messages: serializedMessages,
|
messages: serializedMessages,
|
||||||
})
|
})
|
||||||
|
console.log('useChatHistory - created new chat:', newChat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('useChatHistory - refreshing chat list after create/update')
|
||||||
await fetchChatList()
|
await fetchChatList()
|
||||||
},
|
},
|
||||||
300,
|
300,
|
||||||
|
|||||||
@ -20,7 +20,10 @@ export default {
|
|||||||
conversationNotFound: "Conversation not found",
|
conversationNotFound: "Conversation not found",
|
||||||
fileNotFound: "File not found: {{path}}",
|
fileNotFound: "File not found: {{path}}",
|
||||||
failedToApplyEditChanges: "Failed to apply edit changes",
|
failedToApplyEditChanges: "Failed to apply edit changes",
|
||||||
failedToSearchAndReplace: "Failed to search and replace"
|
failedToSearchAndReplace: "Failed to search and replace",
|
||||||
|
failedToDeleteConversation: "Failed to delete conversation",
|
||||||
|
titleRequired: "Title is required",
|
||||||
|
failedToUpdateTitle: "Failed to update title"
|
||||||
},
|
},
|
||||||
apply: {
|
apply: {
|
||||||
changesApplied: "Changes successfully applied",
|
changesApplied: "Changes successfully applied",
|
||||||
@ -30,7 +33,21 @@ export default {
|
|||||||
noResultsFound: "No results found for '{{query}}'"
|
noResultsFound: "No results found for '{{query}}'"
|
||||||
},
|
},
|
||||||
history: {
|
history: {
|
||||||
noConversations: "No conversations"
|
title: "Chat History",
|
||||||
|
description: "Manage your conversation history and switch between different chats",
|
||||||
|
noConversations: "No conversations",
|
||||||
|
noSearchResults: "No search results found",
|
||||||
|
noMatchingChats: "No matching chats found",
|
||||||
|
noChats: "No chats available",
|
||||||
|
newChat: "New Chat",
|
||||||
|
searchPlaceholder: "Search conversations...",
|
||||||
|
editTitle: "Edit title",
|
||||||
|
deleteChat: "Delete chat",
|
||||||
|
deleteConversation: "Delete conversation",
|
||||||
|
save: "Save",
|
||||||
|
cancel: "Cancel",
|
||||||
|
yesterday: "Yesterday",
|
||||||
|
daysAgo: "days ago"
|
||||||
},
|
},
|
||||||
shortcutInfo: {
|
shortcutInfo: {
|
||||||
editInline: "Edit inline",
|
editInline: "Edit inline",
|
||||||
|
|||||||
@ -21,7 +21,10 @@ export default {
|
|||||||
conversationNotFound: "未找到对话",
|
conversationNotFound: "未找到对话",
|
||||||
fileNotFound: "未找到文件:{{path}}",
|
fileNotFound: "未找到文件:{{path}}",
|
||||||
failedToApplyEditChanges: "应用编辑更改失败",
|
failedToApplyEditChanges: "应用编辑更改失败",
|
||||||
failedToSearchAndReplace: "搜索和替换失败"
|
failedToSearchAndReplace: "搜索和替换失败",
|
||||||
|
failedToDeleteConversation: "删除对话失败",
|
||||||
|
titleRequired: "标题不能为空",
|
||||||
|
failedToUpdateTitle: "更新标题失败"
|
||||||
},
|
},
|
||||||
apply: {
|
apply: {
|
||||||
changesApplied: "更改已成功应用",
|
changesApplied: "更改已成功应用",
|
||||||
@ -31,7 +34,21 @@ export default {
|
|||||||
noResultsFound: "未找到 '{{query}}' 的结果"
|
noResultsFound: "未找到 '{{query}}' 的结果"
|
||||||
},
|
},
|
||||||
history: {
|
history: {
|
||||||
noConversations: "没有对话"
|
title: "聊天记录",
|
||||||
|
description: "管理您的对话历史,并在不同聊天之间切换",
|
||||||
|
noConversations: "没有对话",
|
||||||
|
noSearchResults: "未找到搜索结果",
|
||||||
|
noMatchingChats: "未找到匹配的聊天",
|
||||||
|
noChats: "没有可用的聊天",
|
||||||
|
newChat: "新建聊天",
|
||||||
|
searchPlaceholder: "搜索对话...",
|
||||||
|
editTitle: "编辑标题",
|
||||||
|
deleteChat: "删除聊天",
|
||||||
|
deleteConversation: "删除对话",
|
||||||
|
save: "保存",
|
||||||
|
cancel: "取消",
|
||||||
|
yesterday: "昨天",
|
||||||
|
daysAgo: "天前"
|
||||||
},
|
},
|
||||||
shortcutInfo: {
|
shortcutInfo: {
|
||||||
editInline: "行内编辑",
|
editInline: "行内编辑",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user