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)}
-
+
)}
- {isUser ? (
- <>
- {editMessage === props.id ? (
-
-
-
- ) : (
-
- {text}
-
- )}
- >
- ) : (
-
+ {editMessage === props.id && (
+
+
+
)}
+
+
>