update chat view, handl tool & new md component

This commit is contained in:
duanfuxiang 2025-03-12 21:38:31 +08:00
parent c0c81bd1d8
commit 23e7a5d5d7
6 changed files with 360 additions and 449 deletions

View File

@ -1,3 +1,5 @@
import * as path from 'path'
import { useMutation } from '@tanstack/react-query'
import { CircleStop, History, Plus } from 'lucide-react'
import { App, Notice } from 'obsidian'
@ -24,23 +26,31 @@ import {
LLMBaseUrlNotSetException,
LLMModelNotSetException,
} from '../../core/llm/exception'
import { regexSearchFiles } from '../../core/services/ripgrep'
import { useChatHistory } from '../../hooks/use-chat-history'
import { ApplyStatus, ToolArgs } from '../../types/apply'
import { ChatMessage, ChatUserMessage } from '../../types/chat'
import {
MentionableBlock,
MentionableBlockData,
MentionableCurrentFile,
} from '../../types/mentionable'
import { manualApplyChangesToFile } from '../../utils/apply'
import { ApplyEditToFile, SearchAndReplace } from '../../utils/apply'
import { listFilesAndFolders } from '../../utils/glob-utils'
import {
getMentionableKey,
serializeMentionable,
} from '../../utils/mentionable'
import { readTFileContent } from '../../utils/obsidian'
import { openSettingsModalWithError } from '../../utils/open-settings-modal'
import { PromptGenerator } from '../../utils/prompt-generator'
import { PromptGenerator, addLineNumbers } from '../../utils/prompt-generator'
// Simple file reading function that returns a placeholder content for testing
const readFileContent = (filePath: string): string => {
// In a real implementation, this would use filePath to read the actual file
return `Content of file: ${filePath}`;
}
import AssistantMessageActions from './AssistantMessageActions'
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
import { ChatHistory } from './ChatHistory'
@ -54,6 +64,7 @@ import SimilaritySearchResults from './SimilaritySearchResults'
const getNewInputMessage = (app: App): ChatUserMessage => {
return {
role: 'user',
applyStatus: ApplyStatus.Idle,
content: null,
promptContent: null,
id: uuidv4(),
@ -239,19 +250,6 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
})
const responseMessageId = uuidv4()
setChatMessages([
...newChatHistory,
{
role: 'assistant',
content: '',
reasoningContent: '',
id: responseMessageId,
metadata: {
usage: undefined,
model: undefined,
},
},
])
try {
const abortController = new AbortController()
@ -271,6 +269,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
...compiledMessages,
{
role: 'assistant',
applyStatus: ApplyStatus.Idle,
content: '',
reasoningContent: '',
id: responseMessageId,
@ -284,6 +283,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
chatModel,
{
model: chatModel.modelId,
temperature: 0.5,
messages: requestMessages,
stream: true,
},
@ -348,45 +348,214 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
submitMutation.mutate({ newChatHistory, useVaultSearch })
}
const applyMutation = useMutation({
mutationFn: async ({
blockInfo,
}: {
blockInfo: {
content: string
filename?: string
startLine?: number
endLine?: number
}
}) => {
const activeFile = app.workspace.getActiveFile()
if (!activeFile) {
throw new Error(
'No file is currently open to apply changes. Please open a file and try again.',
)
}
const activeFileContent = await readTFileContent(activeFile, app.vault)
const applyMutation = useMutation<
{
type: string;
applyMsgId: string;
applyStatus: ApplyStatus;
returnMsg?: ChatUserMessage
},
Error,
{ applyMsgId: string, toolArgs: ToolArgs }
>({
mutationFn: async ({ applyMsgId, toolArgs }) => {
try {
const activeFile = app.workspace.getActiveFile()
if (!activeFile) {
throw new Error(
'No file is currently open to apply changes. Please open a file and try again.',
)
}
const updatedFileContent = await manualApplyChangesToFile(
blockInfo.content,
activeFile,
activeFileContent,
blockInfo.startLine,
blockInfo.endLine
)
if (!updatedFileContent) {
throw new Error('Failed to apply changes')
}
const activeFileContent = await readTFileContent(activeFile, app.vault)
await app.workspace.getLeaf(true).setViewState({
type: APPLY_VIEW_TYPE,
active: true,
state: {
file: activeFile,
originalContent: activeFileContent,
newContent: updatedFileContent,
} satisfies ApplyViewState,
})
if (toolArgs.type === 'write_to_file' || toolArgs.type === 'insert_content') {
const applyRes = await ApplyEditToFile(
activeFile,
activeFileContent,
toolArgs.content,
toolArgs.startLine,
toolArgs.endLine
)
if (!applyRes) {
throw new Error('Failed to apply edit changes')
}
// 返回一个Promise该Promise会在用户做出选择后解析
return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => {
app.workspace.getLeaf(true).setViewState({
type: APPLY_VIEW_TYPE,
active: true,
state: {
file: activeFile,
originalContent: activeFileContent,
newContent: applyRes,
onClose: (applied: boolean) => {
const applyStatus = applied ? ApplyStatus.Applied : ApplyStatus.Rejected
const applyEditContent = applied ? 'Changes successfully applied'
: 'User rejected changes'
resolve({
type: 'write_to_file',
applyMsgId,
applyStatus,
returnMsg: {
role: 'user',
applyStatus: ApplyStatus.Idle,
content: null,
promptContent: `[write_to_file for '${toolArgs.filepath}'] Result:\n${applyEditContent}\n`,
id: uuidv4(),
mentionables: [],
}
});
}
} satisfies ApplyViewState,
})
})
} else if (toolArgs.type === 'search_and_replace') {
const fileContent = activeFile.path === toolArgs.filepath ? activeFileContent : readFileContent(toolArgs.filepath)
const applyRes = await SearchAndReplace(
activeFile,
fileContent,
toolArgs.operations
)
// 返回一个Promise该Promise会在用户做出选择后解析
return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => {
app.workspace.getLeaf(true).setViewState({
type: APPLY_VIEW_TYPE,
active: true,
state: {
file: activeFile,
originalContent: activeFileContent,
newContent: applyRes,
onClose: (applied: boolean) => {
const applyStatus = applied ? ApplyStatus.Applied : ApplyStatus.Rejected
const applyEditContent = applied ? 'Changes successfully applied'
: 'User rejected changes'
resolve({
type: 'search_and_replace',
applyMsgId,
applyStatus,
returnMsg: {
role: 'user',
applyStatus: ApplyStatus.Idle,
content: null,
promptContent: `[search_and_replace for '${toolArgs.filepath}'] Result:\n${applyEditContent}\n`,
id: uuidv4(),
mentionables: [],
}
});
}
} satisfies ApplyViewState,
})
})
} else if (toolArgs.type === 'read_file') {
const fileContent = activeFile.path === toolArgs.filepath ? activeFileContent : readFileContent(toolArgs.filepath)
const formattedContent = `[read_file for '${toolArgs.filepath}'] Result:\n${addLineNumbers(fileContent)}\n`;
return {
type: 'read_file',
applyMsgId,
applyStatus: ApplyStatus.Applied,
returnMsg: {
role: 'user',
applyStatus: ApplyStatus.Idle,
content: null,
promptContent: formattedContent,
id: uuidv4(),
mentionables: [],
}
};
} else if (toolArgs.type === 'list_files') {
const files = await listFilesAndFolders(app.vault, toolArgs.filepath)
const formattedContent = `[list_files for '${toolArgs.filepath}'] Result:\n${files.join('\n')}\n`;
return {
type: 'list_files',
applyMsgId,
applyStatus: ApplyStatus.Applied,
returnMsg: {
role: 'user',
applyStatus: ApplyStatus.Idle,
content: null,
promptContent: formattedContent,
id: uuidv4(),
mentionables: [],
}
}
} else if (toolArgs.type === 'regex_search_files') {
const baseVaultPath = app.vault.adapter.getBasePath()
const absolutePath = path.join(baseVaultPath, toolArgs.filepath)
console.log("absolutePath", absolutePath)
const results = await regexSearchFiles(absolutePath, toolArgs.regex)
console.log("results", results)
const formattedContent = `[regex_search_files for '${toolArgs.filepath}'] Result:\n${results}\n`;
return {
type: 'regex_search_files',
applyMsgId,
applyStatus: ApplyStatus.Applied,
returnMsg: {
role: 'user',
applyStatus: ApplyStatus.Idle,
content: null,
promptContent: formattedContent,
id: uuidv4(),
mentionables: [],
}
}
} else if (toolArgs.type === 'semantic_search_files') {
const scope_folders = toolArgs.filepath
&& toolArgs.filepath !== ''
&& toolArgs.filepath !== '.'
&& toolArgs.filepath !== '/'
? { files: [], folders: [toolArgs.filepath] }
: undefined
const results = await (await getRAGEngine()).processQuery({
query: toolArgs.query,
scope: scope_folders,
})
console.log("results", results)
let snippets = results.map(({ path, content, metadata }) => {
const contentWithLineNumbers = addLineNumbers(content, metadata.startLine)
return `<file_block_content location="${path}#L${metadata.startLine}-${metadata.endLine}">\n${contentWithLineNumbers}\n</file_block_content>`
}).join('\n\n')
if (snippets.length === 0) {
snippets = `No results found for '${toolArgs.query}'`
}
const formattedContent = `[semantic_search_files for '${toolArgs.filepath}'] Result:\n${snippets}\n`;
return {
type: 'semantic_search_files',
applyMsgId,
applyStatus: ApplyStatus.Applied,
returnMsg: {
role: 'user',
applyStatus: ApplyStatus.Idle,
content: null,
promptContent: formattedContent,
id: uuidv4(),
mentionables: [],
}
}
}
} catch (error) {
console.error('Failed to apply changes', error)
throw error
}
},
onSuccess: (result) => {
if (result.applyMsgId || result.returnMsg) {
let newChatMessages = [...chatMessages];
if (result.applyMsgId) {
newChatMessages = newChatMessages.map((message) =>
message.role === 'assistant' && message.id === result.applyMsgId ? {
...message,
applyStatus: result.applyStatus
} : message,
);
}
setChatMessages(newChatMessages);
if (result.returnMsg) {
handleSubmit([...newChatMessages, result.returnMsg], false);
}
}
},
onError: (error) => {
if (
@ -404,13 +573,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
})
const handleApply = useCallback(
(blockInfo: {
content: string
filename?: string
startLine?: number
endLine?: number
}) => {
applyMutation.mutate({ blockInfo })
(applyMsgId: string, toolArgs: ToolArgs) => {
applyMutation.mutate({ applyMsgId, toolArgs })
},
[applyMutation],
)
@ -420,7 +584,6 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
//
useEffect(() => {
const updateConversationAsync = async () => {
try {
@ -597,8 +760,10 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
}
{chatMessages.map((message, index) =>
message.role === 'user' ? (
<div key={message.id} className="infio-chat-messages-user">
message.content &&
<div key={"user-" + message.id} className="infio-chat-messages-user">
<PromptInputWithActions
key={"input-" + message.id}
ref={(ref) => registerChatUserInputRef(message.id, ref)}
initialSerializedEditorState={message.content}
onSubmit={(content, useVaultSearch) => {
@ -608,6 +773,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
...chatMessages.slice(0, index),
{
role: 'user',
applyStatus: ApplyStatus.Idle,
content: content,
promptContent: null,
id: message.id,
@ -632,20 +798,24 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
/>
{message.similaritySearchResults && (
<SimilaritySearchResults
key={"similarity-search-" + message.id}
similaritySearchResults={message.similaritySearchResults}
/>
)}
</div>
) : (
<div key={message.id} className="infio-chat-messages-assistant">
<MarkdownReasoningBlock reasoningContent={message.reasoningContent} />
<div key={"assistant-" + message.id} className="infio-chat-messages-assistant">
<MarkdownReasoningBlock
key={"reasoning-" + message.id}
reasoningContent={message.reasoningContent} />
<ReactMarkdownItem
handleApply={handleApply}
isApplying={applyMutation.isPending}
key={"content-" + message.id}
handleApply={(toolArgs) => handleApply(message.id, toolArgs)}
applyStatus={message.applyStatus}
>
{message.content}
</ReactMarkdownItem>
{message.content && <AssistantMessageActions message={message} />}
{/* {message.content && <AssistantMessageActions key={"actions-" + message.id} message={message} />} */}
</div>
),
)}
@ -690,20 +860,19 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
function ReactMarkdownItem({
handleApply,
isApplying,
applyStatus,
// applyMutation,
children,
}: {
handleApply: (blockInfo: {
content: string
filename?: string
startLine?: number
endLine?: number
}) => void
isApplying: boolean
handleApply: (toolArgs: ToolArgs) => void
applyStatus: ApplyStatus
children: string
}) {
return (
<ReactMarkdown onApply={handleApply} isApplying={isApplying}>
<ReactMarkdown
applyStatus={applyStatus}
onApply={handleApply}
>
{children}
</ReactMarkdown>
)

View File

@ -1,67 +1,140 @@
import React, { useMemo } from 'react'
import Markdown from 'react-markdown'
import { ApplyStatus, ToolArgs } from '../../types/apply'
import {
InfioBlockAction,
ParsedInfioBlock,
parseinfioBlocks,
ParsedMsgBlock,
parseMsgBlocks,
} from '../../utils/parse-infio-block'
import MarkdownActionBlock from './MarkdownActionBlock'
import MarkdownReferenceBlock from './MarkdownReferenceBlock'
import MarkdownEditFileBlock from './MarkdownEditFileBlock'
import MarkdownListFilesBlock from './MarkdownListFilesBlock'
import MarkdownReadFileBlock from './MarkdownReadFileBlock'
import MarkdownReasoningBlock from './MarkdownReasoningBlock'
import MarkdownRegexSearchFilesBlock from './MarkdownRegexSearchFilesBlock'
import MarkdownSearchAndReplace from './MarkdownSearchAndReplace'
import MarkdownSemanticSearchFilesBlock from './MarkdownSemanticSearchFilesBlock'
import MarkdownWithIcons from './MarkdownWithIcon'
function ReactMarkdown({
applyStatus,
onApply,
isApplying,
children,
}: {
onApply: (blockInfo: {
content: string
filename?: string
startLine?: number
endLine?: number
}) => void
applyStatus: ApplyStatus
onApply: (toolArgs: ToolArgs) => void
children: string
isApplying: boolean
}) {
const blocks: ParsedInfioBlock[] = useMemo(
() => parseinfioBlocks(children),
const blocks: ParsedMsgBlock[] = useMemo(
() => parseMsgBlocks(children),
[children],
)
return (
<>
{blocks.map((block, index) =>
block.type === 'string' ? (
<Markdown key={index} className="infio-markdown">
block.type === 'thinking' ? (
<Markdown key={"markdown-" + index} className="infio-markdown">
{block.content}
</Markdown>
) : block.type === 'think' ? (
<MarkdownReasoningBlock
key={index}
reasoningContent={block.content}
<MarkdownReasoningBlock
key={"reasoning-" + index}
reasoningContent={block.content}
/>
) : block.startLine && block.endLine && block.filename && block.action === InfioBlockAction.Reference ? (
<MarkdownReferenceBlock
key={index}
filename={block.filename}
startLine={block.startLine}
endLine={block.endLine}
/>
) : (
<MarkdownActionBlock
key={index}
) : block.type === 'write_to_file' ? (
<MarkdownEditFileBlock
key={"write-to-file-" + index}
applyStatus={applyStatus}
mode={block.type}
onApply={onApply}
isApplying={isApplying}
language={block.language}
filename={block.filename}
startLine={block.startLine}
endLine={block.endLine}
action={block.action}
path={block.path}
startLine={1}
>
{block.content}
</MarkdownActionBlock>
</MarkdownEditFileBlock>
) : block.type === 'insert_content' ? (
<MarkdownEditFileBlock
key={"insert-content-" + index}
applyStatus={applyStatus}
mode={block.type}
onApply={onApply}
path={block.path}
startLine={block.startLine}
endLine={block.startLine} // 插入内容时endLine 和 startLine 相同
>
{block.content}
</MarkdownEditFileBlock>
) : block.type === 'search_and_replace' ? (
<MarkdownSearchAndReplace
key={"search-and-replace-" + index}
applyStatus={applyStatus}
onApply={onApply}
path={block.path}
operations={block.operations.map(op => ({
search: op.search,
replace: op.replace,
startLine: op.start_line,
endLine: op.end_line,
useRegex: op.use_regex,
ignoreCase: op.ignore_case,
regexFlags: op.regex_flags,
}))}
finish={block.finish}
/>
) : block.type === 'read_file' ? (
<MarkdownReadFileBlock
key={"read-file-" + index}
applyStatus={applyStatus}
onApply={onApply}
path={block.path}
finish={block.finish}
/>
) : block.type === 'list_files' ? (
<MarkdownListFilesBlock
key={"list-files-" + index}
applyStatus={applyStatus}
onApply={onApply}
path={block.path}
recursive={block.recursive}
finish={block.finish}
/>
) : block.type === 'regex_search_files' ? (
<MarkdownRegexSearchFilesBlock
key={"regex-search-files-" + index}
applyStatus={applyStatus}
onApply={onApply}
path={block.path}
regex={block.regex}
finish={block.finish}
/>
) : block.type === 'semantic_search_files' ? (
<MarkdownSemanticSearchFilesBlock
key={"semantic-search-files-" + index}
applyStatus={applyStatus}
onApply={onApply}
path={block.path}
query={block.query}
finish={block.finish}
/>
) : block.type === 'attempt_completion' ? (
<MarkdownWithIcons
key={"attempt-completion-" + index}
className="infio-markdown infio-attempt-completion"
markdownContent={
`<icon name='attempt_completion' size={14} className="infio-markdown-icon" />
${block.result && block.result.trimStart()}`} />
) : block.type === 'ask_followup_question' ? (
<MarkdownWithIcons
key={"ask-followup-question-" + index}
className="infio-markdown infio-followup-question"
markdownContent={
`<icon name='ask_followup_question' size={14} className="infio-markdown-icon" />
${block.question && block.question.trimStart()}`} />
) : (
<Markdown key={"markdown-" + index} className="infio-markdown">
{block.content}
</Markdown>
),
)}
</>

View File

@ -26,8 +26,8 @@ import OnEnterPlugin from './plugins/on-enter/OnEnterPlugin'
import OnMutationPlugin, {
NodeMutations,
} from './plugins/on-mutation/OnMutationPlugin'
import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin'
import TemplatePlugin from './plugins/template/TemplatePlugin'
// import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin'
// import TemplatePlugin from './plugins/template/TemplatePlugin'
export type LexicalContentEditableProps = {
editorRef: RefObject<LexicalEditor>
@ -141,13 +141,13 @@ export default function LexicalContentEditable({
<AutoLinkMentionPlugin />
<ImagePastePlugin onCreateImageMentionables={onCreateImageMentionables} />
<DragDropPaste onCreateImageMentionables={onCreateImageMentionables} />
<TemplatePlugin />
{plugins?.templatePopover && (
{/* <TemplatePlugin /> */}
{/* {plugins?.templatePopover && (
<CreateTemplatePopoverPlugin
anchorElement={plugins.templatePopover.anchorElement}
contentEditableElement={contentEditableRef.current}
/>
)}
)} */}
</LexicalComposer>
)
}

View File

@ -265,9 +265,6 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
handleSubmit({ useVaultSearch: true })
},
},
templatePopover: {
anchorElement: containerRef.current,
},
}}
/>

View File

@ -1,146 +0,0 @@
import { $generateJSONFromSelectedNodes } from '@lexical/clipboard'
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import * as Dialog from '@radix-ui/react-dialog'
import {
$getSelection,
COMMAND_PRIORITY_LOW,
SELECTION_CHANGE_COMMAND,
} from 'lexical'
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
import CreateTemplateDialogContent from '../../../CreateTemplateDialog'
export default function CreateTemplatePopoverPlugin({
anchorElement,
contentEditableElement,
}: {
anchorElement: HTMLElement | null
contentEditableElement: HTMLElement | null
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
const [popoverStyle, setPopoverStyle] = useState<CSSProperties | null>(null)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<
BaseSerializedNode[] | null
>(null)
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 (
<Dialog.Root
modal={false}
open={isDialogOpen}
onOpenChange={(open) => {
if (open) {
setSelectedSerializedNodes(getSelectedSerializedNodes())
}
setIsDialogOpen(open)
setIsPopoverOpen(false)
}}
>
<Dialog.Trigger asChild>
<button
ref={popoverRef}
style={{
position: 'absolute',
visibility: isPopoverOpen ? 'visible' : 'hidden',
...popoverStyle,
}}
>
Create template
</button>
</Dialog.Trigger>
<CreateTemplateDialogContent
selectedSerializedNodes={selectedSerializedNodes}
onClose={() => setIsDialogOpen(false)}
/>
</Dialog.Root>
)
}

View File

@ -1,182 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import clsx from 'clsx'
import {
$parseSerializedNode,
COMMAND_PRIORITY_NORMAL,
TextNode,
} from 'lexical'
import { Trash2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { useDatabase } from '../../../../../contexts/DatabaseContext'
import { SelectTemplate } from '../../../../../database/schema'
import { MenuOption } from '../shared/LexicalMenu'
import {
LexicalTypeaheadMenuPlugin,
useBasicTypeaheadTriggerMatch,
} from '../typeahead-menu/LexicalTypeaheadMenuPlugin'
class TemplateTypeaheadOption extends MenuOption {
name: string
template: SelectTemplate
constructor(name: string, template: SelectTemplate) {
super(name)
this.name = name
this.template = template
}
}
function TemplateMenuItem({
index,
isSelected,
onClick,
onDelete,
onMouseEnter,
option,
}: {
index: number
isSelected: boolean
onClick: () => void
onDelete: () => void
onMouseEnter: () => void
option: TemplateTypeaheadOption
}) {
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="infio-chat-template-menu-item">
<div className="text">{option.name}</div>
<div
onClick={(evt) => {
evt.stopPropagation()
evt.preventDefault()
onDelete()
}}
className="infio-chat-template-menu-item-delete"
>
<Trash2 size={12} />
</div>
</div>
</li>
)
}
export default function TemplatePlugin() {
const [editor] = useLexicalComposerContext()
const { getTemplateManager } = useDatabase()
const [queryString, setQueryString] = useState<string | null>(null)
const [searchResults, setSearchResults] = useState<SelectTemplate[]>([])
useEffect(() => {
if (queryString == null) return
getTemplateManager().then((templateManager) =>
templateManager.searchTemplates(queryString).then(setSearchResults),
)
}, [queryString, getTemplateManager])
const options = useMemo(
() =>
searchResults.map(
(result) => new TemplateTypeaheadOption(result.name, result),
),
[searchResults],
)
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
})
const onSelectOption = useCallback(
(
selectedOption: TemplateTypeaheadOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
const parsedNodes = selectedOption.template.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],
)
const handleDelete = useCallback(
async (option: TemplateTypeaheadOption) => {
await (await getTemplateManager()).deleteTemplate(option.template.id)
if (queryString !== null) {
const updatedResults = await (
await getTemplateManager()
).searchTemplates(queryString)
setSearchResults(updatedResults)
}
},
[getTemplateManager, queryString],
)
return (
<LexicalTypeaheadMenuPlugin<TemplateTypeaheadOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
commandPriority={COMMAND_PRIORITY_NORMAL}
menuRenderFn={(
anchorElementRef,
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) =>
anchorElementRef.current && searchResults.length
? createPortal(
<div
className="infio-popover"
style={{
position: 'fixed',
}}
>
<ul>
{options.map((option, i: number) => (
<TemplateMenuItem
index={i}
isSelected={selectedIndex === i}
onClick={() => {
setHighlightedIndex(i)
selectOptionAndCleanUp(option)
}}
onDelete={() => {
handleDelete(option)
}}
onMouseEnter={() => {
setHighlightedIndex(i)
}}
key={option.key}
option={option}
/>
))}
</ul>
</div>,
anchorElementRef.current,
)
: null
}
/>
)
}