From bde7df8b770b234f22eb6554bd5b9f434ef04f1b Mon Sep 17 00:00:00 2001 From: duanfuxiang Date: Tue, 15 Apr 2025 23:24:35 +0800 Subject: [PATCH] update template name -> command name --- src/ChatView.tsx | 2 +- .../{ChatHistory.tsx => ChatHistoryView.tsx} | 0 .../chat-view/{Chat.tsx => ChatView.tsx} | 21 +- src/components/chat-view/CommandsView.tsx | 307 ++++++++++-------- .../chat-input/LexicalContentEditable.tsx | 19 +- .../chat-input/PromptInputWithActions.tsx | 9 +- .../plugins/command/CommandPlugin.tsx | 189 +++++++++++ .../command/CreateCommandPopoverPlugin.tsx | 127 ++++++++ .../utils/editor-state-to-plain-text.ts | 2 +- src/contexts/DatabaseContext.tsx | 6 +- src/core/llm/openai-compatible.ts | 2 - src/database/database-manager.ts | 10 +- .../modules/command/command-manager.ts | 67 ++++ .../command-repository.ts} | 20 +- .../modules/template/template-manager.ts | 49 --- src/database/schema.ts | 2 +- src/hooks/use-chat-history.ts | 1 - src/main.ts | 2 +- src/utils/web-search.ts | 1 - styles.css | 11 +- 20 files changed, 622 insertions(+), 225 deletions(-) rename src/components/chat-view/{ChatHistory.tsx => ChatHistoryView.tsx} (100%) rename src/components/chat-view/{Chat.tsx => ChatView.tsx} (98%) create mode 100644 src/components/chat-view/chat-input/plugins/command/CommandPlugin.tsx create mode 100644 src/components/chat-view/chat-input/plugins/command/CreateCommandPopoverPlugin.tsx create mode 100644 src/database/modules/command/command-manager.ts rename src/database/modules/{template/template-repository.ts => command/command-repository.ts} (81%) delete mode 100644 src/database/modules/template/template-manager.ts diff --git a/src/ChatView.tsx b/src/ChatView.tsx index d576319..6dac0af 100644 --- a/src/ChatView.tsx +++ b/src/ChatView.tsx @@ -3,7 +3,7 @@ import { ItemView, WorkspaceLeaf } from 'obsidian' import React from 'react' import { Root, createRoot } from 'react-dom/client' -import Chat, { ChatProps, ChatRef } from './components/chat-view/Chat' +import Chat, { ChatProps, ChatRef } from './components/chat-view/ChatView' import { CHAT_VIEW_TYPE } from './constants' import { AppProvider } from './contexts/AppContext' import { DarkModeProvider } from './contexts/DarkModeContext' diff --git a/src/components/chat-view/ChatHistory.tsx b/src/components/chat-view/ChatHistoryView.tsx similarity index 100% rename from src/components/chat-view/ChatHistory.tsx rename to src/components/chat-view/ChatHistoryView.tsx diff --git a/src/components/chat-view/Chat.tsx b/src/components/chat-view/ChatView.tsx similarity index 98% rename from src/components/chat-view/Chat.tsx rename to src/components/chat-view/ChatView.tsx index 9fae66c..00ef47b 100644 --- a/src/components/chat-view/Chat.tsx +++ b/src/components/chat-view/ChatView.tsx @@ -1,5 +1,6 @@ import * as path from 'path' +import { BaseSerializedNode } from '@lexical/clipboard/clipboard' import { useMutation } from '@tanstack/react-query' import { CircleStop, History, Plus, SquareSlash } from 'lucide-react' import { App, Notice } from 'obsidian' @@ -51,14 +52,13 @@ import { fetchUrlsContent, webSearch } from '../../utils/web-search' import { ModeSelect } from './chat-input/ModeSelect' import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions' import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text' -import { ChatHistory } from './ChatHistory' +import { ChatHistory } from './ChatHistoryView' import CommandsView from './CommandsView' import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock' import QueryProgress, { QueryProgressState } from './QueryProgress' import ReactMarkdown from './ReactMarkdown' import ShortcutInfo from './ShortcutInfo' import SimilaritySearchResults from './SimilaritySearchResults' - // Add an empty line here const getNewInputMessage = (app: App, defaultMention: string): ChatUserMessage => { const mentionables: Mentionable[] = []; @@ -161,7 +161,8 @@ const Chat = forwardRef((props, ref) => { } } - const [tab, setTab] = useState<'chat' | 'commands'>('commands') + const [tab, setTab] = useState<'chat' | 'commands'>('chat') + const [selectedSerializedNodes, setSelectedSerializedNodes] = useState([]) useEffect(() => { const scrollContainer = chatMessagesRef.current @@ -184,6 +185,11 @@ const Chat = forwardRef((props, ref) => { return () => scrollContainer.removeEventListener('scroll', handleScroll) }, [chatMessages]) + const handleCreateCommand = (serializedNodes: BaseSerializedNode[]) => { + setSelectedSerializedNodes(serializedNodes) + setTab('commands') + } + const handleScrollToBottom = () => { if (chatMessagesRef.current) { const scrollContainer = chatMessagesRef.current @@ -890,6 +896,9 @@ const Chat = forwardRef((props, ref) => { chatList={chatList} currentConversationId={currentConversationId} onSelect={async (conversationId) => { + if (tab !== 'chat') { + setTab('chat') + } if (conversationId === currentConversationId) return await handleLoadConversation(conversationId) }} @@ -969,6 +978,7 @@ const Chat = forwardRef((props, ref) => { onFocus={() => { setFocusedMessageId(message.id) }} + onCreateCommand={handleCreateCommand} mentionables={message.mentionables} setMentionables={(mentionables) => { setChatMessages((prevChatHistory) => @@ -1025,6 +1035,7 @@ const Chat = forwardRef((props, ref) => { onFocus={() => { setFocusedMessageId(inputMessage.id) }} + onCreateCommand={handleCreateCommand} mentionables={inputMessage.mentionables} setMentionables={(mentionables) => { setInputMessage((prevInputMessage) => ({ @@ -1038,7 +1049,9 @@ const Chat = forwardRef((props, ref) => { ) : (
- +
)} diff --git a/src/components/chat-view/CommandsView.tsx b/src/components/chat-view/CommandsView.tsx index 5f77480..61995b7 100644 --- a/src/components/chat-view/CommandsView.tsx +++ b/src/components/chat-view/CommandsView.tsx @@ -1,162 +1,197 @@ -import { Pencil, Save, Search, Trash2 } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' -import { v4 as uuidv4 } from 'uuid' +import { $generateNodesFromSerializedNodes } from '@lexical/clipboard' +import { BaseSerializedNode } from '@lexical/clipboard/clipboard' +import { InitialEditorStateType } from '@lexical/react/LexicalComposer' +import { $getRoot, $insertNodes, LexicalEditor } from 'lexical' +import { Pencil, Search, Trash2 } from 'lucide-react' +import { Notice } from 'obsidian' +import { useCallback, useEffect, useRef, useState } from 'react' +// import { v4 as uuidv4 } from 'uuid' -export interface Command { +import { lexicalNodeToPlainText } from '../../components/chat-view/chat-input/utils/editor-state-to-plain-text' +import { useDatabase } from '../../contexts/DatabaseContext' +import { DBManager } from '../../database/database-manager' +import { TemplateContent } from '../../database/schema' + +import LexicalContentEditable from './chat-input/LexicalContentEditable' + +export interface QuickCommand { id: string - title: string - content: string + name: string + content: TemplateContent + createdAt: Date | undefined + updatedAt: Date | undefined } -const CommandsView = () => { - const [commands, setCommands] = useState([]) - const [newCommand, setNewCommand] = useState({ - id: uuidv4(), - title: '', - content: '' - }) +const CommandsView = ( + { + selectedSerializedNodes + }: { + selectedSerializedNodes?: BaseSerializedNode[] + } +) => { + const [commands, setCommands] = useState([]) + + const { getDatabaseManager } = useDatabase() + const getManager = useCallback(async (): Promise => { + return await getDatabaseManager() + }, [getDatabaseManager]) + + // init get all commands + const fetchCommands = useCallback(async () => { + const dbManager = await getManager() + dbManager.getCommandManager().getAllCommands((rows) => { + setCommands(rows.map((row) => ({ + id: row.id, + name: row.name, + content: row.content, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }))) + }) + }, [getManager]) + + useEffect(() => { + void fetchCommands() + }, [fetchCommands]) + + // new command name + const [newCommandName, setNewCommandName] = useState('') + + // search term const [searchTerm, setSearchTerm] = useState('') + + // editing command id const [editingCommandId, setEditingCommandId] = useState(null) - const titleInputRefs = useRef>(new Map()) - const contentInputRefs = useRef>(new Map()) + const nameInputRefs = useRef>(new Map()) + const contentEditorRefs = useRef>(new Map()) + const contentEditableRefs = useRef>(new Map()) - // 从本地存储加载commands - useEffect(() => { - const savedCommands = localStorage.getItem('commands') - if (savedCommands) { - try { - const parsedData = JSON.parse(savedCommands) - - // 验证解析的数据是否为符合Prompt接口的数组 - if (Array.isArray(parsedData) && parsedData.every(isCommand)) { - setCommands(parsedData) - } - } catch (error) { - console.error('无法解析保存的命令', error) - } - } - }, []) - - // 类型守卫函数,用于验证对象是否符合Command接口 - function isCommand(item: unknown): item is Command { - if (!item || typeof item !== 'object') { - return false; - } - - // 使用in操作符检查属性存在 - if (!('id' in item) || !('title' in item) || !('content' in item)) { - return false; - } - - // 使用JavaScript的hasOwnProperty和typeof来检查属性类型 - return ( - Object.prototype.hasOwnProperty.call(item, 'id') && - Object.prototype.hasOwnProperty.call(item, 'title') && - Object.prototype.hasOwnProperty.call(item, 'content') && - typeof Reflect.get(item, 'id') === 'string' && - typeof Reflect.get(item, 'title') === 'string' && - typeof Reflect.get(item, 'content') === 'string' - ); - } - - // 保存commands到本地存储 - useEffect(() => { - localStorage.setItem('commands', JSON.stringify(commands)) - }, [commands]) - - // 处理新command的标题变化 - const handleNewCommandTitleChange = (e: React.ChangeEvent) => { - setNewCommand({ ...newCommand, title: e.target.value }) - } - - // 处理新command的内容变化 - const handleNewCommandContentChange = (e: React.ChangeEvent) => { - setNewCommand({ ...newCommand, content: e.target.value }) - } - - // 添加新command - const handleAddCommand = () => { - if (newCommand.title.trim() === '' || newCommand.content.trim() === '') { - return - } - - setCommands([...commands, newCommand]) - setNewCommand({ - id: uuidv4(), - title: '', - content: '' + // new command content's editor state + const initialEditorState: InitialEditorStateType = ( + editor: LexicalEditor, + ) => { + if (!selectedSerializedNodes) return + editor.update(() => { + const parsedNodes = $generateNodesFromSerializedNodes( + selectedSerializedNodes, + ) + $insertNodes(parsedNodes) }) } + // new command content's editor + const editorRef = useRef(null) + // new command content's editable + const contentEditableRef = useRef(null) - // 删除command - const handleDeleteCommand = (id: string) => { - setCommands(commands.filter(command => command.id !== id)) - if (editingCommandId === id) { - setEditingCommandId(null) + // Create new command + const handleAddCommand = async () => { + const serializedEditorState = editorRef.current.toJSON() + const nodes = serializedEditorState.editorState.root.children + if (nodes.length === 0) { + new Notice('Please enter a content for your template') + return } + if (newCommandName.trim().length === 0) { + new Notice('Please enter a name for your template') + return + } + const dbManager = await getManager() + dbManager.getCommandManager().createCommand({ + name: newCommandName, + content: { nodes }, + }) + + // clear editor content + editorRef.current.update(() => { + const root = $getRoot() + root.clear() + }) + + setNewCommandName('') } - // 编辑command - const handleEditCommand = (command: Command) => { + // delete command + const handleDeleteCommand = async (id: string) => { + const dbManager = await getManager() + await dbManager.getCommandManager().deleteCommand(id) + } + + // edit command + const handleEditCommand = (command: QuickCommand) => { setEditingCommandId(command.id) } - // 保存编辑后的command - const handleSaveEdit = (id: string) => { - const titleInput = titleInputRefs.current.get(id) - const contentInput = contentInputRefs.current.get(id) - - if (titleInput && contentInput) { - setCommands( - commands.map(command => - command.id === id - ? { ...command, title: titleInput.value, content: contentInput.value } - : command - ) - ) - setEditingCommandId(null) + // save edited command + const handleSaveEdit = async (id: string) => { + const nameInput = nameInputRefs.current.get(id) + const currContentEditorRef = contentEditorRefs.current.get(id) + if (!currContentEditorRef) { + new Notice('Please enter a content for your template') + return } + const serializedEditorState = currContentEditorRef.toJSON() + const nodes = serializedEditorState.editorState.root.children + if (nodes.length === 0) { + new Notice('Please enter a content for your template') + return + } + const dbManager = await getManager() + await dbManager.getCommandManager().updateCommand(id, { + name: nameInput.value, + content: { nodes }, + }) + setEditingCommandId(null) } - // 处理搜索 + // handle search const handleSearch = (e: React.ChangeEvent) => { setSearchTerm(e.target.value) } - // 过滤commands列表 + // filter commands list const filteredCommands = commands.filter( command => - command.title.toLowerCase().includes(searchTerm.toLowerCase()) || - command.content.toLowerCase().includes(searchTerm.toLowerCase()) + command.name.toLowerCase().includes(searchTerm.toLowerCase()) || + command.content.nodes.map(lexicalNodeToPlainText).join('').toLowerCase().includes(searchTerm.toLowerCase()) ) + const getCommandEditorState = (commandContent: TemplateContent): InitialEditorStateType => { + return (editor: LexicalEditor) => { + editor.update(() => { + const parsedNodes = $generateNodesFromSerializedNodes( + commandContent.nodes, + ) + $insertNodes(parsedNodes) + }) + } + } + return (
{/* header */}
-

Create Quick Command

+

Create Quick Command

Name
setNewCommandName(e.target.value)} className="infio-commands-input" />
Content
-