update history

This commit is contained in:
duanfuxiang 2025-06-15 15:32:38 +08:00
parent b3ef7afa0c
commit f6f14a2d64
7 changed files with 575 additions and 227 deletions

View File

@ -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<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}
/>
)
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<void>
onDelete: () => Promise<void>
onStartEdit: () => void
onFinishEdit: (title: string) => Promise<void>
}) {
const itemRef = useRef<HTMLLIElement>(null)
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.scrollIntoView({
block: 'nearest',
})
}
}, [isFocused])
return (
<li
ref={itemRef}
onClick={onSelect}
onMouseEnter={onMouseEnter}
className={isFocused ? 'selected' : ''}
>
{isEditing ? (
<TitleInput title={title} onSubmit={onFinishEdit} />
) : (
<div className="infio-chat-list-dropdown-item-title">{title}</div>
)}
<div className="infio-chat-list-dropdown-item-actions">
<div
onClick={(e) => {
e.stopPropagation()
onStartEdit()
}}
className="infio-chat-list-dropdown-item-icon"
>
<Pencil size={14} />
</div>
<div
onClick={async (e) => {
e.stopPropagation()
await onDelete()
}}
className="infio-chat-list-dropdown-item-icon"
>
<Trash2 size={14} />
</div>
</div>
</li>
)
}
export function ChatHistory({
chatList,
const ChatHistoryView = ({
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)
}: ChatHistoryViewProps) => {
const {
deleteConversation,
updateConversationTitle,
chatList,
} = useChatHistory()
useEffect(() => {
if (open) {
const currentIndex = chatList.findIndex(
(chat) => chat.id === currentConversationId,
)
setFocusedIndex(currentIndex === -1 ? 0 : currentIndex)
setEditingId(null)
}
}, [open])
// search term
const [searchTerm, setSearchTerm] = useState('')
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)
// editing conversation id
const [editingConversationId, setEditingConversationId] = useState<string | null>(null)
const titleInputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
// handle search
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
}
},
[chatList, focusedIndex, setFocusedIndex, onSelect],
// 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 (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button className={className}>{children}</button>
</Popover.Trigger>
<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>
<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)
}}
{/* 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>
))
)}
</ul>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</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 default ChatHistoryView
// Export the original ChatHistory component for backward compatibility
export { ChatHistoryView as ChatHistory }

View File

@ -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<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[]>([])
@ -1005,36 +1005,18 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
>
<Search size={18} color={tab === 'search' ? 'var(--text-accent)' : 'var(--text-color)'} />
</button>
<ChatHistory
chatList={chatList}
currentConversationId={currentConversationId}
onSelect={async (conversationId) => {
if (tab !== 'chat') {
<button
onClick={() => {
if (tab === 'history') {
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()
setTab('history')
}
}
}}
onUpdateTitle={async (conversationId, newTitle) => {
await updateConversationTitle(conversationId, newTitle)
}}
className="infio-chat-list-dropdown"
>
<History size={18} />
</ChatHistory>
<History size={18} color={tab === 'history' ? 'var(--text-accent)' : 'var(--text-color)'} />
</button>
<button
onClick={() => {
// switch between chat and prompts
@ -1211,6 +1193,33 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
<div className="infio-chat-commands">
<CustomModeView />
</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">
<McpHubView />

View File

@ -55,8 +55,10 @@ export abstract class AbstractJsonRepository<T, M> {
// List metadata for all records by parsing file names.
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)
return files.files
console.log('AbstractJsonRepository - files in directory:', files)
const result = files.files
.map((filePath) => path.basename(filePath))
.filter((fileName) => fileName.endsWith('.json'))
.map((fileName) => {
@ -66,6 +68,8 @@ export abstract class AbstractJsonRepository<T, M> {
.filter(
(metadata): metadata is M & { fileName: string } => metadata !== null,
)
console.log('AbstractJsonRepository - parsed metadata:', result)
return result
}
public async read(fileName: string): Promise<T | null> {

View File

@ -111,7 +111,12 @@ export class ChatManager extends AbstractJsonRepository<
}
public async listChats(): Promise<ChatConversationMeta[]> {
console.log('ChatManager - listChats called')
console.log('ChatManager - data directory:', this.dataDir)
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
}
}

View File

@ -26,7 +26,10 @@ export function useChatHistory(): UseChatHistory {
const [chatList, setChatList] = useState<ChatConversationMeta[]>([])
const fetchChatList = useCallback(async () => {
console.log('useChatHistory - fetching chat list...')
const conversations = await chatManager.listChats()
console.log('useChatHistory - fetched conversations:', conversations)
console.log('useChatHistory - conversations length:', conversations.length)
setChatList(conversations)
}, [chatManager])
@ -38,20 +41,24 @@ export function useChatHistory(): UseChatHistory {
() =>
debounce(
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 existingConversation = await chatManager.findById(id)
if (existingConversation) {
console.log('useChatHistory - updating existing conversation:', existingConversation.id)
if (isEqual(existingConversation.messages, serializedMessages)) {
console.log('useChatHistory - messages are identical, skipping update')
return
}
await chatManager.updateChat(existingConversation.id, {
messages: serializedMessages,
})
} else {
console.log('useChatHistory - creating new conversation')
const firstUserMessage = messages.find((v) => v.role === 'user') as ChatUserMessage
await chatManager.createChat({
const newChat = await chatManager.createChat({
id,
title: firstUserMessage?.content
? editorStateToPlainText(firstUserMessage.content).substring(
@ -61,8 +68,10 @@ export function useChatHistory(): UseChatHistory {
: 'New chat',
messages: serializedMessages,
})
console.log('useChatHistory - created new chat:', newChat)
}
console.log('useChatHistory - refreshing chat list after create/update')
await fetchChatList()
},
300,

View File

@ -20,7 +20,10 @@ export default {
conversationNotFound: "Conversation not found",
fileNotFound: "File not found: {{path}}",
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: {
changesApplied: "Changes successfully applied",
@ -30,7 +33,21 @@ export default {
noResultsFound: "No results found for '{{query}}'"
},
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: {
editInline: "Edit inline",

View File

@ -21,7 +21,10 @@ export default {
conversationNotFound: "未找到对话",
fileNotFound: "未找到文件:{{path}}",
failedToApplyEditChanges: "应用编辑更改失败",
failedToSearchAndReplace: "搜索和替换失败"
failedToSearchAndReplace: "搜索和替换失败",
failedToDeleteConversation: "删除对话失败",
titleRequired: "标题不能为空",
failedToUpdateTitle: "更新标题失败"
},
apply: {
changesApplied: "更改已成功应用",
@ -31,7 +34,21 @@ export default {
noResultsFound: "未找到 '{{query}}' 的结果"
},
history: {
noConversations: "没有对话"
title: "聊天记录",
description: "管理您的对话历史,并在不同聊天之间切换",
noConversations: "没有对话",
noSearchResults: "未找到搜索结果",
noMatchingChats: "未找到匹配的聊天",
noChats: "没有可用的聊天",
newChat: "新建聊天",
searchPlaceholder: "搜索对话...",
editTitle: "编辑标题",
deleteChat: "删除聊天",
deleteConversation: "删除对话",
save: "保存",
cancel: "取消",
yesterday: "昨天",
daysAgo: "天前"
},
shortcutInfo: {
editInline: "行内编辑",