import { isDesktop } from '@lobechat/const'; import { HotkeyEnum, KeyEnum } from '@lobechat/const/hotkeys'; import { chainInputCompletion } from '@lobechat/prompts'; import { isCommandPressed, merge } from '@lobechat/utils'; import { INSERT_MENTION_COMMAND, ReactAutoCompletePlugin, ReactMathPlugin } from '@lobehub/editor'; import { Editor, FloatMenu, useEditorState } from '@lobehub/editor/react'; import { combineKeys } from '@lobehub/ui'; import { css, cx } from 'antd-style'; import Fuse from 'fuse.js'; import { KEY_ESCAPE_COMMAND } from 'lexical'; import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'; import { useHotkeysContext } from 'react-hotkeys-hook'; import { usePasteFile, useUploadFiles } from '@/components/DragUploadZone'; import { useIMECompositionEvent } from '@/hooks/useIMECompositionEvent'; import { chatService } from '@/services/chat'; import { useAgentStore } from '@/store/agent'; import { agentByIdSelectors } from '@/store/agent/selectors'; import { useUserStore } from '@/store/user'; import { labPreferSelectors, preferenceSelectors, settingsSelectors, systemAgentSelectors, } from '@/store/user/selectors'; import { useAgentId } from '../hooks/useAgentId'; import { useChatInputStore, useStoreApi } from '../store'; import { INSERT_ACTION_TAG_COMMAND, type InsertActionTagPayload, useSlashActionItems, } from './ActionTag'; import { createMentionMenu } from './MentionMenu'; import type { MentionMenuState } from './MentionMenu/types'; import Placeholder from './Placeholder'; import { CHAT_INPUT_EMBED_PLUGINS, createChatInputRichPlugins } from './plugins'; import { INSERT_REFER_TOPIC_COMMAND } from './ReferTopic'; import { useMentionCategories } from './useMentionCategories'; const className = cx(css` p { margin-block-end: 0; } `); const InputEditor = memo<{ defaultRows?: number; placeholder?: ReactNode }>( ({ defaultRows = 2, placeholder }) => { const [editor, slashMenuRef, send, updateMarkdownContent, expand, slashPlacement] = useChatInputStore((s) => [ s.editor, s.slashMenuRef, s.handleSendButton, s.updateMarkdownContent, s.expand, s.slashPlacement ?? 'top', ]); const storeApi = useStoreApi(); const state = useEditorState(editor); const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.AddUserMessage)); const { enableScope, disableScope } = useHotkeysContext(); const { compositionProps, isComposingRef } = useIMECompositionEvent(); const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend); // --- Category-based mention system --- const categories = useMentionCategories(); const stateRef = useRef({ isSearch: false, matchingString: '' }); const categoriesRef = useRef(categories); categoriesRef.current = categories; const allMentionItems = useMemo(() => categories.flatMap((c) => c.items), [categories]); const fuse = useMemo( () => new Fuse(allMentionItems, { keys: ['key', 'label', 'metadata.topicTitle'], threshold: 0.3, }), [allMentionItems], ); const mentionItemsFn = useCallback( async ( search: { leadOffset: number; matchingString: string; replaceableString: string } | null, ) => { if (search?.matchingString) { stateRef.current = { isSearch: true, matchingString: search.matchingString }; return fuse.search(search.matchingString).map((r) => r.item); } stateRef.current = { isSearch: false, matchingString: '' }; return [...allMentionItems]; }, [allMentionItems, fuse], ); const MentionMenuComp = useMemo(() => createMentionMenu(stateRef, categoriesRef), []); const enableMention = allMentionItems.length > 0; // Get agent's model info for vision support check and handle paste upload const agentId = useAgentId(); const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s)); const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s)); const { handleUploadFiles } = useUploadFiles({ model, provider }); // Listen to editor's paste event for file uploads usePasteFile(editor, handleUploadFiles); useEffect(() => { const fn = (e: BeforeUnloadEvent) => { if (!state.isEmpty) { // set returnValue to trigger alert modal // Note: No matter what value is set, the browser will display the standard text e.returnValue = 'You are typing something, are you sure you want to leave?'; } }; window.addEventListener('beforeunload', fn); return () => { window.removeEventListener('beforeunload', fn); }; }, [state.isEmpty]); const enableRichRender = useUserStore(labPreferSelectors.enableInputMarkdown); const slashActionItems = useSlashActionItems(); const slashItems = useCallback( async ( search: { leadOffset: number; matchingString: string; replaceableString: string } | null, ) => { const actionItems = typeof slashActionItems === 'function' ? await slashActionItems(search) : slashActionItems; return actionItems; }, [slashActionItems], ); // --- Auto-completion --- const inputCompletionConfig = useUserStore(systemAgentSelectors.inputCompletion); const isAutoCompleteEnabled = inputCompletionConfig.enabled; const getMessagesRef = useRef(storeApi.getState().getMessages); useEffect(() => { return storeApi.subscribe((s) => { getMessagesRef.current = s.getMessages; }); }, [storeApi]); const handleAutoComplete = useCallback( async ({ abortSignal, afterText, input, }: { abortSignal: AbortSignal; afterText: string; editor: any; input: string; selectionType: string; }): Promise => { // Skip autocomplete during IME composition (e.g. Chinese input method) if (isComposingRef.current) return null; if (!input.trim()) return null; // Skip when cursor is not at end of paragraph — inserting a placeholder // mid-text causes nested editor updates that freeze the input if (afterText.trim()) return null; const { enabled: _, ...config } = systemAgentSelectors.inputCompletion( useUserStore.getState(), ); const context = getMessagesRef.current?.(); const chainParams = chainInputCompletion(input, afterText, context); const abortController = new AbortController(); abortSignal.addEventListener('abort', () => abortController.abort()); let result = ''; try { await chatService.fetchPresetTaskResult({ abortController, onMessageHandle: (chunk) => { if (chunk.type === 'text') { result += chunk.text; } }, params: merge(config, chainParams), }); } catch { return null; } if (abortSignal.aborted) return null; return result.trimEnd() || null; }, [], ); const autoCompletePlugin = useMemo( () => isAutoCompleteEnabled ? Editor.withProps(ReactAutoCompletePlugin, { delay: 600, onAutoComplete: handleAutoComplete, }) : null, [isAutoCompleteEnabled, handleAutoComplete], ); // --- Stable mentionOption & slashOption to prevent infinite re-render on paste --- const mentionMarkdownWriter = useCallback((mention: any) => { if (mention.metadata?.type === 'topic') { return ``; } return ``; }, []); const mentionOnSelect = useCallback((editor: any, option: any) => { if (option.metadata?.type === 'topic') { editor.dispatchCommand(INSERT_REFER_TOPIC_COMMAND, { topicId: option.metadata.topicId as string, topicTitle: String(option.metadata.topicTitle ?? option.label), }); } else if (option.metadata?.type === 'skill' || option.metadata?.type === 'tool') { const payload: InsertActionTagPayload = { category: option.metadata.actionCategory as 'skill' | 'tool', label: String(option.label), type: String(option.metadata.actionType), }; editor.dispatchCommand(INSERT_ACTION_TAG_COMMAND, payload); } else { editor.dispatchCommand(INSERT_MENTION_COMMAND, { label: String(option.label), metadata: option.metadata, }); } }, []); const mentionOption = useMemo( () => enableMention ? { items: mentionItemsFn, markdownWriter: mentionMarkdownWriter, maxLength: 50, onSelect: mentionOnSelect, renderComp: MentionMenuComp, } : undefined, [enableMention, mentionItemsFn, mentionMarkdownWriter, mentionOnSelect, MentionMenuComp], ); const slashOption = useMemo(() => ({ items: slashItems }), [slashItems]); const richRenderProps = useMemo(() => { const basePlugins = !enableRichRender ? CHAT_INPUT_EMBED_PLUGINS : createChatInputRichPlugins({ mathPlugin: Editor.withProps(ReactMathPlugin, { renderComp: expand ? undefined : (props) => ( (slashMenuRef as any)?.current} /> ), }), }); const plugins = autoCompletePlugin ? [...basePlugins, autoCompletePlugin] : basePlugins; return !enableRichRender ? { enablePasteMarkdown: false, markdownOption: false, plugins } : { plugins }; }, [enableRichRender, expand, slashMenuRef, autoCompletePlugin]); return ( } slashOption={slashOption} type={'text'} variant={'chat'} style={{ minHeight: defaultRows > 1 ? defaultRows * 23 : undefined, }} onCompositionEnd={({ event }) => compositionProps.onCompositionEnd(event)} onBlur={() => { disableScope(HotkeyEnum.AddUserMessage); }} onChange={() => { updateMarkdownContent(); }} onCompositionStart={({ event }) => { compositionProps.onCompositionStart(event); // Clear autocomplete placeholder nodes before IME composition starts — // composing next to placeholder inline nodes freezes the editor. if (isAutoCompleteEnabled) { editor?.dispatchCommand( KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown', { key: 'Escape' }), ); } }} onContextMenu={async ({ event: e, editor }) => { if (isDesktop) { e.preventDefault(); const { electronSystemService } = await import('@/services/electron/system'); const selectionText = editor.getSelectionDocument('markdown') as unknown as string; await electronSystemService.showContextMenu('editor', { selectionText: selectionText || undefined, }); } }} onFocus={() => { enableScope(HotkeyEnum.AddUserMessage); }} onInit={(editor) => { const saved = storeApi.getState()._savedEditorState; storeApi.setState({ _savedEditorState: undefined, editor }); if (saved) { requestAnimationFrame(() => { editor.setDocument('json', saved); }); } }} onPressEnter={({ event: e }) => { if (e.shiftKey || isComposingRef.current) return; // when user like alt + enter to add ai message if (e.altKey && hotkey === combineKeys([KeyEnum.Alt, KeyEnum.Enter])) return true; const commandKey = isCommandPressed(e); // In fullscreen mode, Enter inserts newline; only Cmd/Ctrl+Enter sends if (expand) { if (commandKey) { send(); return true; } return; } // when user like cmd + enter to send message if (useCmdEnterToSend) { if (commandKey) { send(); return true; } } else { if (!commandKey) { send(); return true; } } }} /> ); }, ); InputEditor.displayName = 'InputEditor'; export default InputEditor;