update template name -> command name

This commit is contained in:
duanfuxiang 2025-04-15 23:24:35 +08:00
parent 43599fca47
commit bde7df8b77
20 changed files with 622 additions and 225 deletions

View File

@ -3,7 +3,7 @@ import { ItemView, WorkspaceLeaf } from 'obsidian'
import React from 'react' import React from 'react'
import { Root, createRoot } from 'react-dom/client' 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 { CHAT_VIEW_TYPE } from './constants'
import { AppProvider } from './contexts/AppContext' import { AppProvider } from './contexts/AppContext'
import { DarkModeProvider } from './contexts/DarkModeContext' import { DarkModeProvider } from './contexts/DarkModeContext'

View File

@ -1,5 +1,6 @@
import * as path from 'path' import * as path from 'path'
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { CircleStop, History, Plus, SquareSlash } from 'lucide-react' import { CircleStop, History, Plus, SquareSlash } from 'lucide-react'
import { App, Notice } from 'obsidian' import { App, Notice } from 'obsidian'
@ -51,14 +52,13 @@ import { fetchUrlsContent, webSearch } from '../../utils/web-search'
import { ModeSelect } from './chat-input/ModeSelect' import { ModeSelect } from './chat-input/ModeSelect'
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 './ChatHistory' import { ChatHistory } from './ChatHistoryView'
import CommandsView from './CommandsView' import CommandsView from './CommandsView'
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock' import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
import QueryProgress, { QueryProgressState } from './QueryProgress' import QueryProgress, { QueryProgressState } from './QueryProgress'
import ReactMarkdown from './ReactMarkdown' import ReactMarkdown from './ReactMarkdown'
import ShortcutInfo from './ShortcutInfo' import ShortcutInfo from './ShortcutInfo'
import SimilaritySearchResults from './SimilaritySearchResults' import SimilaritySearchResults from './SimilaritySearchResults'
// Add an empty line here // Add an empty line here
const getNewInputMessage = (app: App, defaultMention: string): ChatUserMessage => { const getNewInputMessage = (app: App, defaultMention: string): ChatUserMessage => {
const mentionables: Mentionable[] = []; const mentionables: Mentionable[] = [];
@ -161,7 +161,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
} }
} }
const [tab, setTab] = useState<'chat' | 'commands'>('commands') const [tab, setTab] = useState<'chat' | 'commands'>('chat')
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<BaseSerializedNode[]>([])
useEffect(() => { useEffect(() => {
const scrollContainer = chatMessagesRef.current const scrollContainer = chatMessagesRef.current
@ -184,6 +185,11 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
return () => scrollContainer.removeEventListener('scroll', handleScroll) return () => scrollContainer.removeEventListener('scroll', handleScroll)
}, [chatMessages]) }, [chatMessages])
const handleCreateCommand = (serializedNodes: BaseSerializedNode[]) => {
setSelectedSerializedNodes(serializedNodes)
setTab('commands')
}
const handleScrollToBottom = () => { const handleScrollToBottom = () => {
if (chatMessagesRef.current) { if (chatMessagesRef.current) {
const scrollContainer = chatMessagesRef.current const scrollContainer = chatMessagesRef.current
@ -890,6 +896,9 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
chatList={chatList} chatList={chatList}
currentConversationId={currentConversationId} currentConversationId={currentConversationId}
onSelect={async (conversationId) => { onSelect={async (conversationId) => {
if (tab !== 'chat') {
setTab('chat')
}
if (conversationId === currentConversationId) return if (conversationId === currentConversationId) return
await handleLoadConversation(conversationId) await handleLoadConversation(conversationId)
}} }}
@ -969,6 +978,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
onFocus={() => { onFocus={() => {
setFocusedMessageId(message.id) setFocusedMessageId(message.id)
}} }}
onCreateCommand={handleCreateCommand}
mentionables={message.mentionables} mentionables={message.mentionables}
setMentionables={(mentionables) => { setMentionables={(mentionables) => {
setChatMessages((prevChatHistory) => setChatMessages((prevChatHistory) =>
@ -1025,6 +1035,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
onFocus={() => { onFocus={() => {
setFocusedMessageId(inputMessage.id) setFocusedMessageId(inputMessage.id)
}} }}
onCreateCommand={handleCreateCommand}
mentionables={inputMessage.mentionables} mentionables={inputMessage.mentionables}
setMentionables={(mentionables) => { setMentionables={(mentionables) => {
setInputMessage((prevInputMessage) => ({ setInputMessage((prevInputMessage) => ({
@ -1038,7 +1049,9 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
</> </>
) : ( ) : (
<div className="infio-chat-commands"> <div className="infio-chat-commands">
<CommandsView /> <CommandsView
selectedSerializedNodes={selectedSerializedNodes}
/>
</div> </div>
)} )}
</div> </div>

View File

@ -1,162 +1,197 @@
import { Pencil, Save, Search, Trash2 } from 'lucide-react' import { $generateNodesFromSerializedNodes } from '@lexical/clipboard'
import { useEffect, useRef, useState } from 'react' import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { v4 as uuidv4 } from 'uuid' 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 id: string
title: string name: string
content: string content: TemplateContent
createdAt: Date | undefined
updatedAt: Date | undefined
} }
const CommandsView = () => { const CommandsView = (
const [commands, setCommands] = useState<Command[]>([]) {
const [newCommand, setNewCommand] = useState<Command>({ selectedSerializedNodes
id: uuidv4(), }: {
title: '', selectedSerializedNodes?: BaseSerializedNode[]
content: '' }
) => {
const [commands, setCommands] = useState<QuickCommand[]>([])
const { getDatabaseManager } = useDatabase()
const getManager = useCallback(async (): Promise<DBManager> => {
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('') const [searchTerm, setSearchTerm] = useState('')
// editing command id
const [editingCommandId, setEditingCommandId] = useState<string | null>(null) const [editingCommandId, setEditingCommandId] = useState<string | null>(null)
const titleInputRefs = useRef<Map<string, HTMLInputElement>>(new Map()) const nameInputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
const contentInputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map()) const contentEditorRefs = useRef<Map<string, LexicalEditor>>(new Map())
const contentEditableRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// 从本地存储加载commands // new command content's editor state
useEffect(() => { const initialEditorState: InitialEditorStateType = (
const savedCommands = localStorage.getItem('commands') editor: LexicalEditor,
if (savedCommands) { ) => {
try { if (!selectedSerializedNodes) return
const parsedData = JSON.parse(savedCommands) editor.update(() => {
const parsedNodes = $generateNodesFromSerializedNodes(
// 验证解析的数据是否为符合Prompt接口的数组 selectedSerializedNodes,
if (Array.isArray(parsedData) && parsedData.every(isCommand)) { )
setCommands(parsedData) $insertNodes(parsedNodes)
}
} 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<HTMLInputElement>) => {
setNewCommand({ ...newCommand, title: e.target.value })
}
// 处理新command的内容变化
const handleNewCommandContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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
const editorRef = useRef<LexicalEditor | null>(null)
// new command content's editable
const contentEditableRef = useRef<HTMLDivElement | null>(null)
// 删除command // Create new command
const handleDeleteCommand = (id: string) => { const handleAddCommand = async () => {
setCommands(commands.filter(command => command.id !== id)) const serializedEditorState = editorRef.current.toJSON()
if (editingCommandId === id) { const nodes = serializedEditorState.editorState.root.children
setEditingCommandId(null) 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 // delete command
const handleEditCommand = (command: Command) => { const handleDeleteCommand = async (id: string) => {
const dbManager = await getManager()
await dbManager.getCommandManager().deleteCommand(id)
}
// edit command
const handleEditCommand = (command: QuickCommand) => {
setEditingCommandId(command.id) setEditingCommandId(command.id)
} }
// 保存编辑后的command // save edited command
const handleSaveEdit = (id: string) => { const handleSaveEdit = async (id: string) => {
const titleInput = titleInputRefs.current.get(id) const nameInput = nameInputRefs.current.get(id)
const contentInput = contentInputRefs.current.get(id) const currContentEditorRef = contentEditorRefs.current.get(id)
if (!currContentEditorRef) {
if (titleInput && contentInput) { new Notice('Please enter a content for your template')
setCommands( return
commands.map(command => }
command.id === id const serializedEditorState = currContentEditorRef.toJSON()
? { ...command, title: titleInput.value, content: contentInput.value } const nodes = serializedEditorState.editorState.root.children
: command 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) setEditingCommandId(null)
} }
}
// 处理搜索 // handle search
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value) setSearchTerm(e.target.value)
} }
// 过滤commands列表 // filter commands list
const filteredCommands = commands.filter( const filteredCommands = commands.filter(
command => command =>
command.title.toLowerCase().includes(searchTerm.toLowerCase()) || command.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
command.content.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 ( return (
<div className="infio-commands-container"> <div className="infio-commands-container">
{/* header */} {/* header */}
<div className="infio-commands-header"> <div className="infio-commands-header">
<div className="infio-commands-new"> <div className="infio-commands-new">
<h2 className="infio-commands-header-title">Create Quick Command</h2> <h2 className="infio-commands-header-name">Create Quick Command</h2>
<div className="infio-commands-label">Name</div> <div className="infio-commands-label">Name</div>
<input <input
type="text" type="text"
placeholder="Input Command Name" value={newCommandName}
value={newCommand.title} onChange={(e) => setNewCommandName(e.target.value)}
onChange={handleNewCommandTitleChange}
className="infio-commands-input" className="infio-commands-input"
/> />
<div className="infio-commands-label">Content</div> <div className="infio-commands-label">Content</div>
<textarea <div className="infio-commands-textarea">
placeholder="Input Command Content" <LexicalContentEditable
value={newCommand.content} initialEditorState={initialEditorState}
onChange={handleNewCommandContentChange} editorRef={editorRef}
className="infio-commands-textarea" contentEditableRef={contentEditableRef}
/> />
{/* <div className="infio-commands-hint">English identifier (lowercase letters + numbers + hyphens)</div> */} </div>
<button <button
onClick={handleAddCommand} onClick={handleAddCommand}
className="infio-commands-add-btn" className="infio-commands-add-btn"
disabled={!newCommand.title.trim() || !newCommand.content.trim()} disabled={!newCommandName.trim()}
> >
<span>Create Command</span> <span>Create Command</span>
</button> </button>
@ -183,39 +218,43 @@ const CommandsView = () => {
</div> </div>
) : ( ) : (
filteredCommands.map(command => ( filteredCommands.map(command => (
<div key={command.id} className="infio-commands-item"> <div key={command.name} className="infio-commands-item">
{editingCommandId === command.id ? ( {editingCommandId === command.id ? (
// edit mode // edit mode
<div className="infio-commands-edit-mode"> <div className="infio-commands-edit-mode">
<input <input
type="text" type="text"
defaultValue={command.title} defaultValue={command.name}
className="infio-commands-edit-title" className="infio-commands-edit-name"
ref={(el) => { ref={(el) => {
if (el) titleInputRefs.current.set(command.id, el) if (el) nameInputRefs.current.set(command.id, el)
}} }}
/> />
<textarea <div className="infio-commands-textarea">
defaultValue={command.content} <LexicalContentEditable
className="infio-commands-textarea" initialEditorState={getCommandEditorState(command.content)}
ref={(el) => { editorRef={(editor: LexicalEditor) => {
if (el) contentInputRefs.current.set(command.id, el) if (editor) contentEditorRefs.current.set(command.id, editor)
}}
contentEditableRef={(el: HTMLDivElement) => {
if (el) contentEditableRefs.current.set(command.id, el)
}} }}
/> />
</div>
<div className="infio-commands-actions"> <div className="infio-commands-actions">
<button <button
onClick={() => handleSaveEdit(command.id)} onClick={() => handleSaveEdit(command.id)}
className="infio-commands-btn" className="infio-commands-add-btn"
> >
<Save size={16} /> <span>Update Command</span>
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
// view mode // view mode
<div className="infio-commands-view-mode"> <div className="infio-commands-view-mode">
<div className="infio-commands-title">{command.title}</div> <div className="infio-commands-name">{command.name}</div>
<div className="infio-commands-content">{command.content}</div> <div className="infio-commands-content">{command.content.nodes.map(lexicalNodeToPlainText).join('')}</div>
<div className="infio-commands-actions"> <div className="infio-commands-actions">
<button <button
onClick={() => handleEditCommand(command)} onClick={() => handleEditCommand(command)}

View File

@ -1,3 +1,4 @@
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { import {
InitialConfigType, InitialConfigType,
InitialEditorStateType, InitialEditorStateType,
@ -26,8 +27,8 @@ import OnEnterPlugin from './plugins/on-enter/OnEnterPlugin'
import OnMutationPlugin, { import OnMutationPlugin, {
NodeMutations, NodeMutations,
} from './plugins/on-mutation/OnMutationPlugin' } from './plugins/on-mutation/OnMutationPlugin'
// import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin' import CreateCommandPopoverPlugin from './plugins/command/CreateCommandPopoverPlugin'
// import TemplatePlugin from './plugins/template/TemplatePlugin' import CommandPlugin from './plugins/command/CommandPlugin'
export type LexicalContentEditableProps = { export type LexicalContentEditableProps = {
editorRef: RefObject<LexicalEditor> editorRef: RefObject<LexicalEditor>
@ -43,8 +44,9 @@ export type LexicalContentEditableProps = {
onEnter?: { onEnter?: {
onVaultChat: () => void onVaultChat: () => void
} }
templatePopover?: { commandPopover?: {
anchorElement: HTMLElement | null anchorElement: HTMLElement | null
onCreateCommand: (nodes: BaseSerializedNode[]) => void
} }
} }
} }
@ -141,13 +143,14 @@ export default function LexicalContentEditable({
<AutoLinkMentionPlugin /> <AutoLinkMentionPlugin />
<ImagePastePlugin onCreateImageMentionables={onCreateImageMentionables} /> <ImagePastePlugin onCreateImageMentionables={onCreateImageMentionables} />
<DragDropPaste onCreateImageMentionables={onCreateImageMentionables} /> <DragDropPaste onCreateImageMentionables={onCreateImageMentionables} />
{/* <TemplatePlugin /> */} <CommandPlugin />
{/* {plugins?.templatePopover && ( {plugins?.commandPopover && (
<CreateTemplatePopoverPlugin <CreateCommandPopoverPlugin
anchorElement={plugins.templatePopover.anchorElement} anchorElement={plugins.commandPopover.anchorElement}
contentEditableElement={contentEditableRef.current} contentEditableElement={contentEditableRef.current}
onCreateCommand={plugins.commandPopover.onCreateCommand}
/> />
)} */} )}
</LexicalComposer> </LexicalComposer>
) )
} }

View File

@ -1,3 +1,4 @@
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { $nodesOfType, LexicalEditor, SerializedEditorState } from 'lexical' import { $nodesOfType, LexicalEditor, SerializedEditorState } from 'lexical'
import { import {
@ -30,7 +31,7 @@ import { ImageUploadButton } from './ImageUploadButton'
import LexicalContentEditable from './LexicalContentEditable' import LexicalContentEditable from './LexicalContentEditable'
import MentionableBadge from './MentionableBadge' import MentionableBadge from './MentionableBadge'
import { ModelSelect } from './ModelSelect' import { ModelSelect } from './ModelSelect'
import { ModeSelect } from './ModeSelect' // import { ModeSelect } from './ModeSelect'
import { MentionNode } from './plugins/mention/MentionNode' import { MentionNode } from './plugins/mention/MentionNode'
import { NodeMutations } from './plugins/on-mutation/OnMutationPlugin' import { NodeMutations } from './plugins/on-mutation/OnMutationPlugin'
import { SubmitButton } from './SubmitButton' import { SubmitButton } from './SubmitButton'
@ -44,6 +45,7 @@ export type ChatUserInputProps = {
onChange?: (content: SerializedEditorState) => void onChange?: (content: SerializedEditorState) => void
onSubmit: (content: SerializedEditorState, useVaultSearch?: boolean) => void onSubmit: (content: SerializedEditorState, useVaultSearch?: boolean) => void
onFocus: () => void onFocus: () => void
onCreateCommand: (nodes: BaseSerializedNode[]) => void
mentionables: Mentionable[] mentionables: Mentionable[]
setMentionables: (mentionables: Mentionable[]) => void setMentionables: (mentionables: Mentionable[]) => void
autoFocus?: boolean autoFocus?: boolean
@ -57,6 +59,7 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
onChange, onChange,
onSubmit, onSubmit,
onFocus, onFocus,
onCreateCommand,
mentionables, mentionables,
setMentionables, setMentionables,
autoFocus = false, autoFocus = false,
@ -266,6 +269,10 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
handleSubmit({ useVaultSearch: true }) handleSubmit({ useVaultSearch: true })
}, },
}, },
commandPopover: {
anchorElement: containerRef.current,
onCreateCommand: onCreateCommand,
},
}} }}
/> />

View File

@ -0,0 +1,189 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import clsx from 'clsx'
import {
$parseSerializedNode,
COMMAND_PRIORITY_NORMAL, SerializedLexicalNode, TextNode
} from 'lexical'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
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 { MenuOption } from '../shared/LexicalMenu'
import {
LexicalTypeaheadMenuPlugin,
useBasicTypeaheadTriggerMatch,
} from '../typeahead-menu/LexicalTypeaheadMenuPlugin'
export type Command = {
id: string
name: string
content: { nodes: SerializedLexicalNode[] }
createdAt: Date
updatedAt: Date
}
class CommandTypeaheadOption extends MenuOption {
name: string
command: Command
constructor(name: string, command: Command) {
super(name)
this.name = name
this.command = command
}
}
function CommandMenuItem({
index,
isSelected,
onClick,
onMouseEnter,
option,
}: {
index: number
isSelected: boolean
onClick: () => void
onMouseEnter: () => void
option: CommandTypeaheadOption
}) {
return (
<li
key={option.key}
tabIndex={-1}
className={clsx('item', isSelected && 'selected')}
ref={(el) => option.setRefElement(el)}
role="option"
aria-selected={isSelected}
id={`typeahead-item-${index}`}
onMouseEnter={onMouseEnter}
onClick={onClick}
>
<div className="smtcmp-template-menu-item">
<div className="text">{option.name}</div>
</div>
</li>
)
}
export default function CommandPlugin() {
const [editor] = useLexicalComposerContext()
const [commands, setCommands] = useState<Command[]>([])
const { getDatabaseManager } = useDatabase()
const getManager = useCallback(async (): Promise<DBManager> => {
return await getDatabaseManager()
}, [getDatabaseManager])
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])
const [queryString, setQueryString] = useState<string | null>(null)
const [searchResults, setSearchResults] = useState<Command[]>([])
useEffect(() => {
if (queryString == null) return
const filteredCommands = commands.filter(
command =>
command.name.toLowerCase().includes(queryString.toLowerCase()) ||
command.content.nodes.map(lexicalNodeToPlainText).join('').toLowerCase().includes(queryString.toLowerCase())
)
setSearchResults(filteredCommands)
}, [queryString, commands])
const options = useMemo(
() =>
searchResults.map(
(result) => new CommandTypeaheadOption(result.name, result),
),
[searchResults],
)
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
})
const onSelectOption = useCallback(
(
selectedOption: CommandTypeaheadOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
const parsedNodes = selectedOption.command.content.nodes.map((node) =>
$parseSerializedNode(node),
)
if (nodeToRemove) {
const parent = nodeToRemove.getParentOrThrow()
parent.splice(nodeToRemove.getIndexWithinParent(), 1, parsedNodes)
const lastNode = parsedNodes[parsedNodes.length - 1]
lastNode.selectEnd()
}
closeMenu()
})
},
[editor],
)
return (
<LexicalTypeaheadMenuPlugin<CommandTypeaheadOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
commandPriority={COMMAND_PRIORITY_NORMAL}
menuRenderFn={(
anchorElementRef,
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) =>
anchorElementRef.current && searchResults.length
? createPortal(
<div
className="smtcmp-popover"
style={{
position: 'fixed',
}}
>
<ul>
{options.map((option, i: number) => (
<CommandMenuItem
index={i}
isSelected={selectedIndex === i}
onClick={() => {
setHighlightedIndex(i)
selectOptionAndCleanUp(option)
}}
onMouseEnter={() => {
setHighlightedIndex(i)
}}
key={option.key}
option={option}
/>
))}
</ul>
</div>,
anchorElementRef.current,
)
: null
}
/>
)
}

View File

@ -0,0 +1,127 @@
import { $generateJSONFromSelectedNodes } from '@lexical/clipboard'
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$getSelection,
COMMAND_PRIORITY_LOW,
SELECTION_CHANGE_COMMAND,
} from 'lexical'
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
export default function CreateCommandPopoverPlugin({
anchorElement,
contentEditableElement,
onCreateCommand,
}: {
anchorElement: HTMLElement | null
contentEditableElement: HTMLElement | null
onCreateCommand: (nodes: BaseSerializedNode[]) => void
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
const [popoverStyle, setPopoverStyle] = useState<CSSProperties | null>(null)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const popoverRef = useRef<HTMLButtonElement>(null)
const getSelectedSerializedNodes = useCallback(():
| BaseSerializedNode[]
| null => {
if (!editor) return null
let selectedNodes: BaseSerializedNode[] | null = null
editor.update(() => {
const selection = $getSelection()
if (!selection) return
selectedNodes = $generateJSONFromSelectedNodes(editor, selection).nodes
if (selectedNodes.length === 0) return null
})
return selectedNodes
}, [editor])
const updatePopoverPosition = useCallback(() => {
if (!anchorElement || !contentEditableElement) return
const nativeSelection = document.getSelection()
const range = nativeSelection?.getRangeAt(0)
if (!range || range.collapsed) {
setIsPopoverOpen(false)
return
}
if (!contentEditableElement.contains(range.commonAncestorContainer)) {
setIsPopoverOpen(false)
return
}
const rects = Array.from(range.getClientRects())
if (rects.length === 0) {
setIsPopoverOpen(false)
return
}
const anchorRect = anchorElement.getBoundingClientRect()
const idealLeft = rects[rects.length - 1].right - anchorRect.left
const paddingX = 8
const paddingY = 4
const minLeft = (popoverRef.current?.offsetWidth ?? 0) + paddingX
const finalLeft = Math.max(minLeft, idealLeft)
setPopoverStyle({
top: rects[rects.length - 1].bottom - anchorRect.top + paddingY,
left: finalLeft,
transform: 'translate(-100%, 0)',
})
setIsPopoverOpen(true)
}, [anchorElement, contentEditableElement])
useEffect(() => {
const removeSelectionChangeListener = editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updatePopoverPosition()
return false
},
COMMAND_PRIORITY_LOW,
)
return () => {
removeSelectionChangeListener()
}
}, [editor, updatePopoverPosition])
useEffect(() => {
// Update popover position when the content is cleared
// (Selection change event doesn't fire in this case)
if (!isPopoverOpen) return
const removeTextContentChangeListener = editor.registerTextContentListener(
() => {
updatePopoverPosition()
},
)
return () => {
removeTextContentChangeListener()
}
}, [editor, isPopoverOpen, updatePopoverPosition])
useEffect(() => {
if (!contentEditableElement) return
const handleScroll = () => {
updatePopoverPosition()
}
contentEditableElement.addEventListener('scroll', handleScroll)
return () => {
contentEditableElement.removeEventListener('scroll', handleScroll)
}
}, [contentEditableElement, updatePopoverPosition])
return (
<button
ref={popoverRef}
style={{
position: 'absolute',
visibility: isPopoverOpen ? 'visible' : 'hidden',
...popoverStyle,
}}
onClick={() => {
onCreateCommand(getSelectedSerializedNodes() ?? [])
setIsPopoverOpen(false)
}}
>
create command
</button>
)
}

View File

@ -6,7 +6,7 @@ export function editorStateToPlainText(
return lexicalNodeToPlainText(editorState.root) return lexicalNodeToPlainText(editorState.root)
} }
function lexicalNodeToPlainText(node: SerializedLexicalNode): string { export function lexicalNodeToPlainText(node: SerializedLexicalNode): string {
if ('children' in node) { if ('children' in node) {
// Process children recursively and join their results // Process children recursively and join their results
return (node.children as SerializedLexicalNode[]) return (node.children as SerializedLexicalNode[])

View File

@ -7,13 +7,13 @@ import {
} from 'react' } from 'react'
import { DBManager } from '../database/database-manager' import { DBManager } from '../database/database-manager'
import { TemplateManager } from '../database/modules/template/template-manager' import { CommandManager } from '../database/modules/command/command-manager'
import { VectorManager } from '../database/modules/vector/vector-manager' import { VectorManager } from '../database/modules/vector/vector-manager'
type DatabaseContextType = { type DatabaseContextType = {
getDatabaseManager: () => Promise<DBManager> getDatabaseManager: () => Promise<DBManager>
getVectorManager: () => Promise<VectorManager> getVectorManager: () => Promise<VectorManager>
getTemplateManager: () => Promise<TemplateManager> getTemplateManager: () => Promise<CommandManager>
} }
const DatabaseContext = createContext<DatabaseContextType | null>(null) const DatabaseContext = createContext<DatabaseContextType | null>(null)
@ -30,7 +30,7 @@ export function DatabaseProvider({
}, [getDatabaseManager]) }, [getDatabaseManager])
const getTemplateManager = useCallback(async () => { const getTemplateManager = useCallback(async () => {
return (await getDatabaseManager()).getTemplateManager() return (await getDatabaseManager()).getCommandManager()
}, [getDatabaseManager]) }, [getDatabaseManager])
useEffect(() => { useEffect(() => {

View File

@ -22,7 +22,6 @@ export class OpenAICompatibleProvider implements BaseLLMProvider {
private baseURL: string private baseURL: string
constructor(apiKey: string, baseURL: string) { constructor(apiKey: string, baseURL: string) {
console.log('OpenAICompatibleProvider constructor', apiKey, baseURL)
this.adapter = new OpenAIMessageAdapter() this.adapter = new OpenAIMessageAdapter()
this.client = new OpenAI({ this.client = new OpenAI({
apiKey: apiKey, apiKey: apiKey,
@ -38,7 +37,6 @@ export class OpenAICompatibleProvider implements BaseLLMProvider {
request: LLMRequestNonStreaming, request: LLMRequestNonStreaming,
options?: LLMOptions, options?: LLMOptions,
): Promise<LLMResponseNonStreaming> { ): Promise<LLMResponseNonStreaming> {
console.log('OpenAICompatibleProvider generateResponse', this.baseURL, this.apiKey)
if (!this.baseURL || !this.apiKey) { if (!this.baseURL || !this.apiKey) {
throw new LLMBaseUrlNotSetException( throw new LLMBaseUrlNotSetException(
'OpenAI Compatible base URL or API key is missing. Please set it in settings menu.', 'OpenAI Compatible base URL or API key is missing. Please set it in settings menu.',

View File

@ -6,7 +6,7 @@ import { App } from 'obsidian'
import { createAndInitDb } from '../pgworker' import { createAndInitDb } from '../pgworker'
import { ConversationManager } from './modules/conversation/conversation-manager' import { ConversationManager } from './modules/conversation/conversation-manager'
import { TemplateManager } from './modules/template/template-manager' import { CommandManager as CommandManager } from './modules/command/command-manager'
import { VectorManager } from './modules/vector/vector-manager' import { VectorManager } from './modules/vector/vector-manager'
// import { pgliteResources } from './pglite-resources' // import { pgliteResources } from './pglite-resources'
// import { migrations } from './sql' // import { migrations } from './sql'
@ -17,7 +17,7 @@ export class DBManager {
private db: PGliteWithLive | null = null private db: PGliteWithLive | null = null
// private db: PgliteDatabase | null = null // private db: PgliteDatabase | null = null
private vectorManager: VectorManager private vectorManager: VectorManager
private templateManager: TemplateManager private CommandManager: CommandManager
private conversationManager: ConversationManager private conversationManager: ConversationManager
constructor(app: App) { constructor(app: App) {
@ -30,7 +30,7 @@ export class DBManager {
dbManager.db = await createAndInitDb() dbManager.db = await createAndInitDb()
dbManager.vectorManager = new VectorManager(app, dbManager) dbManager.vectorManager = new VectorManager(app, dbManager)
dbManager.templateManager = new TemplateManager(app, dbManager) dbManager.CommandManager = new CommandManager(app, dbManager)
dbManager.conversationManager = new ConversationManager(app, dbManager) dbManager.conversationManager = new ConversationManager(app, dbManager)
return dbManager return dbManager
@ -44,8 +44,8 @@ export class DBManager {
return this.vectorManager return this.vectorManager
} }
getTemplateManager() { getCommandManager() {
return this.templateManager return this.CommandManager
} }
getConversationManager() { getConversationManager() {

View File

@ -0,0 +1,67 @@
import fuzzysort from 'fuzzysort'
import { App } from 'obsidian'
import { DBManager } from '../../database-manager'
import { DuplicateTemplateException } from '../../exception'
import { InsertTemplate, SelectTemplate, UpdateTemplate } from '../../schema'
import { CommandRepository } from './command-repository'
export class CommandManager {
private app: App
private repository: CommandRepository
private dbManager: DBManager
constructor(app: App, dbManager: DBManager) {
this.app = app
this.dbManager = dbManager
this.repository = new CommandRepository(app, dbManager.getPgClient())
}
async createCommand(command: InsertTemplate): Promise<SelectTemplate> {
const existingTemplate = await this.repository.findByName(command.name)
if (existingTemplate) {
throw new DuplicateTemplateException(command.name)
}
const created = await this.repository.create(command)
return created
}
async updateCommand(id: string, template: UpdateTemplate): Promise<SelectTemplate> {
const updated = await this.repository.update(id, template)
return updated
}
async findAllCommands(): Promise<SelectTemplate[]> {
return await this.repository.findAll()
}
getAllCommands(callback: (templates: SelectTemplate[]) => void): void {
const db = this.dbManager.getPgClient()
db?.live.query('SELECT * FROM template ORDER BY updated_at DESC', [], (results: { rows: Array<SelectTemplate> }) => {
callback(results.rows.map(row => ({
id: row.id,
name: row.name,
content: row.content,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
})))
})
}
async searchCommands(query: string): Promise<SelectTemplate[]> {
const templates = await this.findAllCommands()
const results = fuzzysort.go(query, templates, {
keys: ['name'],
threshold: 0.2,
limit: 20,
all: true,
})
return results.map((result) => result.obj)
}
async deleteCommand(id: string): Promise<boolean> {
const deleted = await this.repository.delete(id)
return deleted
}
}

View File

@ -2,9 +2,9 @@ import { PGliteInterface } from '@electric-sql/pglite'
import { App } from 'obsidian' import { App } from 'obsidian'
import { DatabaseNotInitializedException } from '../../exception' import { DatabaseNotInitializedException } from '../../exception'
import { type InsertTemplate, type SelectTemplate } from '../../schema' import { type InsertTemplate, type SelectTemplate, type UpdateTemplate } from '../../schema'
export class TemplateRepository { export class CommandRepository {
private app: App private app: App
private db: PGliteInterface | null private db: PGliteInterface | null
@ -13,7 +13,7 @@ export class TemplateRepository {
this.db = pgClient this.db = pgClient
} }
async create(template: InsertTemplate): Promise<SelectTemplate> { async create(command: InsertTemplate): Promise<SelectTemplate> {
if (!this.db) { if (!this.db) {
throw new DatabaseNotInitializedException() throw new DatabaseNotInitializedException()
} }
@ -22,7 +22,7 @@ export class TemplateRepository {
`INSERT INTO "template" (name, content) `INSERT INTO "template" (name, content)
VALUES ($1, $2) VALUES ($1, $2)
RETURNING *`, RETURNING *`,
[template.name, template.content] [command.name, command.content]
) )
return result.rows[0] return result.rows[0]
} }
@ -31,7 +31,7 @@ export class TemplateRepository {
if (!this.db) { if (!this.db) {
throw new DatabaseNotInitializedException() throw new DatabaseNotInitializedException()
} }
const result = await this.db.query<SelectTemplate>( const result = await this.db.liveQuery<SelectTemplate>(
`SELECT * FROM "template"` `SELECT * FROM "template"`
) )
return result.rows return result.rows
@ -50,7 +50,7 @@ export class TemplateRepository {
async update( async update(
id: string, id: string,
template: Partial<InsertTemplate>, command: UpdateTemplate,
): Promise<SelectTemplate | null> { ): Promise<SelectTemplate | null> {
if (!this.db) { if (!this.db) {
throw new DatabaseNotInitializedException() throw new DatabaseNotInitializedException()
@ -60,15 +60,15 @@ export class TemplateRepository {
const params: any[] = [] const params: any[] = []
let paramIndex = 1 let paramIndex = 1
if (template.name !== undefined) { if (command.name !== undefined) {
setClauses.push(`name = $${paramIndex}`) setClauses.push(`name = $${paramIndex}`)
params.push(template.name) params.push(command.name)
paramIndex++ paramIndex++
} }
if (template.content !== undefined) { if (command.content !== undefined) {
setClauses.push(`content = $${paramIndex}`) setClauses.push(`content = $${paramIndex}`)
params.push(template.content) params.push(command.content)
paramIndex++ paramIndex++
} }

View File

@ -1,49 +0,0 @@
import fuzzysort from 'fuzzysort'
import { App } from 'obsidian'
import { DBManager } from '../../database-manager'
import { DuplicateTemplateException } from '../../exception'
import { InsertTemplate, SelectTemplate } from '../../schema'
import { TemplateRepository } from './template-repository'
export class TemplateManager {
private app: App
private repository: TemplateRepository
private dbManager: DBManager
constructor(app: App, dbManager: DBManager) {
this.app = app
this.dbManager = dbManager
this.repository = new TemplateRepository(app, dbManager.getPgClient())
}
async createTemplate(template: InsertTemplate): Promise<SelectTemplate> {
const existingTemplate = await this.repository.findByName(template.name)
if (existingTemplate) {
throw new DuplicateTemplateException(template.name)
}
const created = await this.repository.create(template)
return created
}
async findAllTemplates(): Promise<SelectTemplate[]> {
return await this.repository.findAll()
}
async searchTemplates(query: string): Promise<SelectTemplate[]> {
const templates = await this.findAllTemplates()
const results = fuzzysort.go(query, templates, {
keys: ['name'],
threshold: 0.2,
limit: 20,
all: true,
})
return results.map((result) => result.obj)
}
async deleteTemplate(id: string): Promise<boolean> {
const deleted = await this.repository.delete(id)
return deleted
}
}

View File

@ -102,7 +102,7 @@ export type TemplateRecord = {
export type SelectTemplate = TemplateRecord export type SelectTemplate = TemplateRecord
export type InsertTemplate = Omit<TemplateRecord, 'id' | 'createdAt' | 'updatedAt'> export type InsertTemplate = Omit<TemplateRecord, 'id' | 'createdAt' | 'updatedAt'>
export type UpdateTemplate = Partial<InsertTemplate>
export const templateTable: TableDefinition = { export const templateTable: TableDefinition = {
name: 'template', name: 'template',
columns: { columns: {

View File

@ -18,7 +18,6 @@ type UseChatHistory = {
export function useChatHistory(): UseChatHistory { export function useChatHistory(): UseChatHistory {
const { getDatabaseManager } = useDatabase() const { getDatabaseManager } = useDatabase()
// 这里更新有点繁琐, 但是能保持 chatList 实时更新
const [chatList, setChatList] = useState<ChatConversationMeta[]>([]) const [chatList, setChatList] = useState<ChatConversationMeta[]>([])
const getManager = useCallback(async (): Promise<DBManager> => { const getManager = useCallback(async (): Promise<DBManager> => {

View File

@ -5,7 +5,7 @@ import { Editor, MarkdownView, Notice, Plugin, TFile } from 'obsidian'
import { ApplyView } from './ApplyView' import { ApplyView } from './ApplyView'
import { ChatView } from './ChatView' import { ChatView } from './ChatView'
import { ChatProps } from './components/chat-view/Chat' import { ChatProps } from './components/chat-view/ChatView'
import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE } from './constants' import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE } from './constants'
import { getDiffStrategy } from "./core/diff/DiffStrategy" import { getDiffStrategy } from "./core/diff/DiffStrategy"
import { InlineEdit } from './core/edit/inline-edit-processor' import { InlineEdit } from './core/edit/inline-edit-processor'

View File

@ -32,7 +32,6 @@ function cosineSimilarity(vecA: number[], vecB: number[]): number {
async function serperSearch(query: string, serperApiKey: string, serperSearchEngine: string): Promise<SearchResult[]> { async function serperSearch(query: string, serperApiKey: string, serperSearchEngine: string): Promise<SearchResult[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = `${SERPER_BASE_URL}?q=${encodeURIComponent(query)}&engine=${serperSearchEngine}&api_key=${serperApiKey}&num=20`; const url = `${SERPER_BASE_URL}?q=${encodeURIComponent(query)}&engine=${serperSearchEngine}&api_key=${serperApiKey}&num=20`;
// console.log("serper search url: ", url)
https.get(url, (res: any) => { https.get(url, (res: any) => {
let data = ''; let data = '';

View File

@ -1878,7 +1878,7 @@ button.infio-chat-input-model-select {
*/ */
.infio-chat-commands { .infio-chat-commands {
overflow-y: scroll; overflow-y: auto;
} }
.infio-commands-container { .infio-commands-container {
@ -2039,7 +2039,7 @@ button.infio-chat-input-model-select {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.infio-commands-title { .infio-commands-name {
font-weight: var(--font-medium); font-weight: var(--font-medium);
margin-bottom: var(--size-2-3); margin-bottom: var(--size-2-3);
font-size: var(--font-ui-medium); font-size: var(--font-ui-medium);
@ -2054,6 +2054,11 @@ button.infio-chat-input-model-select {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
user-select: text; user-select: text;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
} }
.infio-commands-actions { .infio-commands-actions {
@ -2088,7 +2093,7 @@ button.infio-chat-input-model-select {
gap: var(--size-4-2); gap: var(--size-4-2);
} }
.infio-commands-edit-title { .infio-commands-edit-name {
background-color: #333 !important; background-color: #333 !important;
border: none; border: none;
border-radius: var(--radius-s); border-radius: var(--radius-s);