update template name -> command name
This commit is contained in:
parent
43599fca47
commit
bde7df8b77
@ -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'
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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
|
|
||||||
? { ...command, title: titleInput.value, content: contentInput.value }
|
|
||||||
: command
|
|
||||||
)
|
|
||||||
)
|
|
||||||
setEditingCommandId(null)
|
|
||||||
}
|
}
|
||||||
|
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<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,49 +218,53 @@ 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
|
|
||||||
defaultValue={command.content}
|
|
||||||
className="infio-commands-textarea"
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) contentInputRefs.current.set(command.id, el)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="infio-commands-textarea">
|
||||||
|
<LexicalContentEditable
|
||||||
|
initialEditorState={getCommandEditorState(command.content)}
|
||||||
|
editorRef={(editor: LexicalEditor) => {
|
||||||
|
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)}
|
||||||
className="infio-commands-btn"
|
className="infio-commands-btn"
|
||||||
>
|
>
|
||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteCommand(command.id)}
|
onClick={() => handleDeleteCommand(command.id)}
|
||||||
className="infio-commands-btn"
|
className="infio-commands-btn"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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[])
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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.',
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
67
src/database/modules/command/command-manager.ts
Normal file
67
src/database/modules/command/command-manager.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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++
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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: {
|
||||||
|
|||||||
@ -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> => {
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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 = '';
|
||||||
|
|
||||||
|
|||||||
11
styles.css
11
styles.css
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user