diff --git a/web/containers/ModelDropdown/index.tsx b/web/containers/ModelDropdown/index.tsx index 0ac016a609..59f19586ae 100644 --- a/web/containers/ModelDropdown/index.tsx +++ b/web/containers/ModelDropdown/index.tsx @@ -306,7 +306,7 @@ const ModelDropdown = ({ className={twMerge('relative', disabled && 'pointer-events-none')} data-testid="model-selector" > -
+
{chatInputMode ? ( + +const RichTextEditor = ({ + className, + style, + disabled, + placeholder, + spellCheck, +}: RichTextEditorProps) => { + const [editor] = useState(() => withHistory(withReact(createEditor()))) + const currentLanguage = useRef('plaintext') + const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom) + const textareaRef = useRef(null) + const activeThreadId = useAtomValue(getActiveThreadIdAtom) + const activeSettingInputBox = useAtomValue(activeSettingInputBoxAtom) + const messages = useAtomValue(getCurrentChatMessagesAtom) + const { sendChatMessage } = useSendChatMessage() + const { stopInference } = useActiveModel() + + // The decorate function identifies code blocks and marks the ranges + const decorate = useCallback( + (entry: [any, any]) => { + const ranges: any[] = [] + const [node, path] = entry + + if (Editor.isBlock(editor, node) && node.type === 'paragraph') { + node.children.forEach((child: { text: any }, childIndex: number) => { + const text = child.text + + // Match bold text pattern *text* + const boldMatches = [...text.matchAll(/(\*.*?\*)/g)] // Find bold patterns + boldMatches.forEach((match) => { + const startOffset = match.index + 1 || 0 + const length = match[0].length - 2 + + ranges.push({ + anchor: { path: [...path, childIndex], offset: startOffset }, + focus: { + path: [...path, childIndex], + offset: startOffset + length, + }, + format: 'italic', + className: 'italic', + }) + }) + }) + } + + if (Editor.isBlock(editor, node) && node.type === 'paragraph') { + node.children.forEach((child: { text: any }, childIndex: number) => { + const text = child.text + + // Match bold text pattern **text** + const boldMatches = [...text.matchAll(/(\*\*.*?\*\*)/g)] // Find bold patterns + boldMatches.forEach((match) => { + const startOffset = match.index + 2 || 0 + const length = match[0].length - 4 + + ranges.push({ + anchor: { path: [...path, childIndex], offset: startOffset }, + focus: { + path: [...path, childIndex], + offset: startOffset + length, + }, + format: 'bold', + className: 'font-bold', + }) + }) + }) + } + + if (Editor.isBlock(editor, node) && node.type === 'code') { + node.children.forEach((child: { text: any }, childIndex: number) => { + const text = child.text + + // Match code block start and end + const startMatch = text.match(/^```(\w*)$/) + const endMatch = text.match(/^```$/) + const inlineMatch = text.match(/^`([^`]+)`$/) // Match inline code + + if (startMatch) { + // If it's the start of a code block, store the language + currentLanguage.current = startMatch[1] || 'plaintext' + } else if (endMatch) { + // Reset language when code block ends + currentLanguage.current = 'plaintext' + } else if (inlineMatch) { + // Apply syntax highlighting to inline code + const codeContent = inlineMatch[1] // Get the content within the backticks + try { + hljs.highlight(codeContent, { + language: + currentLanguage.current.length > 1 + ? currentLanguage.current + : 'plaintext', + }).value + } catch (err) { + hljs.highlight(codeContent, { + language: 'javascript', + }).value + } + + // Calculate the range for the inline code + const length = codeContent.length + ranges.push({ + anchor: { + path: [...path, childIndex], + offset: inlineMatch.index + 1, + }, + focus: { + path: [...path, childIndex], + offset: inlineMatch.index + 1 + length, + }, + type: 'code', + code: true, + language: currentLanguage.current, + className: '', // Specify class name if needed + }) + } else if (currentLanguage.current !== 'plaintext') { + // Highlight entire code line if in a code block + const leadingSpaces = text.match(/^\s*/)?.[0] ?? '' // Capture leading spaces + const codeContent = text.trimStart() // Remove leading spaces for highlighting + + let highlighted = '' + highlighted = hljs.highlightAuto(codeContent).value + try { + highlighted = hljs.highlight(codeContent, { + language: + currentLanguage.current.length > 1 + ? currentLanguage.current + : 'plaintext', + }).value + } catch (err) { + highlighted = hljs.highlight(codeContent, { + language: 'javascript', + }).value + } + + const parser = new DOMParser() + const doc = parser.parseFromString(highlighted, 'text/html') + + let slateTextIndex = 0 + + // Adjust to include leading spaces in the ranges and preserve formatting + ranges.push({ + anchor: { path: [...path, childIndex], offset: 0 }, + focus: { + path: [...path, childIndex], + offset: leadingSpaces.length, + }, + type: 'code', + code: true, + language: currentLanguage.current, + className: '', // No class for leading spaces + }) + + doc.body.childNodes.forEach((childNode) => { + const childText = childNode.textContent || '' + const length = childText.length + const className = + childNode.nodeType === Node.ELEMENT_NODE + ? (childNode as HTMLElement).className + : '' + + ranges.push({ + anchor: { + path: [...path, childIndex], + offset: slateTextIndex + leadingSpaces.length, + }, + focus: { + path: [...path, childIndex], + offset: slateTextIndex + leadingSpaces.length + length, + }, + type: 'code', + code: true, + language: currentLanguage.current, + className, + }) + + slateTextIndex += length + }) + } else { + ranges.push({ + anchor: { path: [...path, childIndex], offset: 0 }, + focus: { path: [...path, childIndex], offset: text.length }, + type: 'paragraph', // Treat as a paragraph + code: false, + }) + } + }) + } + + return ranges + }, + [editor] + ) + + // RenderLeaf applies the decoration styles + const renderLeaf = useCallback( + ({ attributes, children, leaf }: RenderLeafProps) => { + if (leaf.format === 'italic') { + return ( + + {children} + + ) + } + if (leaf.format === 'bold') { + return ( + + {children} + + ) + } + if (leaf.code) { + // Apply syntax highlighting to code blocks + return ( + + {children} + + ) + } + + return {children} + }, + [] + ) + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.focus() + } + }, [activeThreadId]) + + useEffect(() => { + if (textareaRef.current?.clientHeight) { + textareaRef.current.style.height = activeSettingInputBox + ? '100px' + : '40px' + textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px' + textareaRef.current.style.overflow = + textareaRef.current.clientHeight >= 390 ? 'auto' : 'hidden' + } + }, [textareaRef.current?.clientHeight, currentPrompt, activeSettingInputBox]) + + const onStopInferenceClick = async () => { + stopInference() + } + + const resetEditor = useCallback(() => { + Transforms.delete(editor, { + at: { + anchor: Editor.start(editor, []), + focus: Editor.end(editor, []), + }, + }) + + // Adjust the height of the textarea to its initial state + if (textareaRef.current) { + textareaRef.current.style.height = '40px' // Reset to the initial height or your desired height + textareaRef.current.style.overflow = 'hidden' // Reset overflow style + } + + // Ensure the editor re-renders decorations + editor.onChange() + }, [editor]) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + if (messages[messages.length - 1]?.status !== MessageStatus.Pending) { + sendChatMessage(currentPrompt) + resetEditor() + } else onStopInferenceClick() + } + + if (event.key === '`') { + // Determine whether any of the currently selected blocks are code blocks. + const [match] = Editor.nodes(editor, { + match: (n) => + Element.isElement(n) && (n as CustomElement).type === 'code', + }) + // Toggle the block type dependsing on whether there's already a match. + Transforms.setNodes( + editor, + { type: match ? 'paragraph' : 'code' }, + { match: (n) => Element.isElement(n) && Editor.isBlock(editor, n) } + ) + } + + if (event.key === 'Tab') { + const [match] = Editor.nodes(editor, { + match: (n) => { + return (n as CustomElement).type === 'code' + }, + mode: 'lowest', + }) + + if (match) { + event.preventDefault() + // Insert a tab character + Editor.insertText(editor, ' ') // Insert 2 spaces + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [currentPrompt, editor, messages] + ) + + return ( + { + const combinedText = value + .map((block) => { + if ('children' in block) { + return block.children.map((child) => child.text).join('') + } + return '' + }) + .join('\n') + + setCurrentPrompt(combinedText) + }} + > + + + ) +} + +export default RichTextEditor diff --git a/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx index a7c5ad1216..83a68fa8a9 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatInput/index.tsx @@ -29,6 +29,8 @@ import { isLocalEngine } from '@/utils/modelEngine' import FileUploadPreview from '../FileUploadPreview' import ImageUploadPreview from '../ImageUploadPreview' +import RichTextEditor from './RichTextEditor' + import { showRightPanelAtom } from '@/helpers/atoms/App.atom' import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' @@ -40,7 +42,6 @@ import { getActiveThreadIdAtom, isGeneratingResponseAtom, threadStatesAtom, - waitingToSendMessage, } from '@/helpers/atoms/Thread.atom' import { activeTabThreadRightPanelAtom } from '@/helpers/atoms/ThreadRightPanel.atom' @@ -48,7 +49,6 @@ const ChatInput = () => { const activeThread = useAtomValue(activeThreadAtom) const { stateModel } = useActiveModel() const messages = useAtomValue(getCurrentChatMessagesAtom) - // const [activeSetting, setActiveSetting] = useState(false) const spellCheck = useAtomValue(spellCheckAtom) const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom) @@ -59,7 +59,6 @@ const ChatInput = () => { const selectedModel = useAtomValue(selectedModelAtom) const activeThreadId = useAtomValue(getActiveThreadIdAtom) - const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage) const [fileUpload, setFileUpload] = useAtom(fileUploadAtom) const [showAttacmentMenus, setShowAttacmentMenus] = useState(false) const textareaRef = useRef(null) @@ -78,52 +77,15 @@ const ChatInput = () => { (threadState) => threadState.waitingForResponse ) - const onPromptChange = (e: React.ChangeEvent) => { - setCurrentPrompt(e.target.value) - } - const refAttachmentMenus = useClickOutside(() => setShowAttacmentMenus(false)) const [showRightPanel, setShowRightPanel] = useAtom(showRightPanelAtom) - useEffect(() => { - if (isWaitingToSend && activeThreadId) { - setIsWaitingToSend(false) - sendChatMessage(currentPrompt) - } - }, [ - activeThreadId, - isWaitingToSend, - currentPrompt, - setIsWaitingToSend, - sendChatMessage, - ]) - useEffect(() => { if (textareaRef.current) { textareaRef.current.focus() } }, [activeThreadId]) - useEffect(() => { - if (textareaRef.current?.clientHeight) { - textareaRef.current.style.height = activeSettingInputBox - ? '100px' - : '40px' - textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px' - textareaRef.current.style.overflow = - textareaRef.current.clientHeight >= 390 ? 'auto' : 'hidden' - } - }, [textareaRef.current?.clientHeight, currentPrompt, activeSettingInputBox]) - - const onKeyDown = async (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { - e.preventDefault() - if (messages[messages.length - 1]?.status !== MessageStatus.Pending) - sendChatMessage(currentPrompt) - else onStopInferenceClick() - } - } - const onStopInferenceClick = async () => { stopInference() } @@ -163,22 +125,25 @@ const ChatInput = () => {
{renderPreview(fileUpload)} -