diff --git a/web/src/components/Chat/PromptChatPanel.tsx b/web/src/components/Chat/PromptChatPanel.tsx new file mode 100644 index 00000000..51343ae1 --- /dev/null +++ b/web/src/components/Chat/PromptChatPanel.tsx @@ -0,0 +1,39 @@ +import { forwardRef, useImperativeHandle, useState } from 'react' +import ChatContent from './ChatContent' +import type { ChatItem } from './types' +import type { ReactNode } from 'react' + +export interface PromptChatPanelRef { + append: (item: ChatItem) => void + clear: () => void +} + +interface PromptChatPanelProps { + classNames?: string + contentClassNames?: string + empty: ReactNode + labelFormat: (item: ChatItem) => any +} + +const PromptChatPanel = forwardRef((props, ref) => { + const [chatList, setChatList] = useState([]) + + useImperativeHandle(ref, () => ({ + append: (item) => setChatList(prev => [...prev, item]), + clear: () => setChatList([]), + })) + + return ( + + ) +}) + +export default PromptChatPanel diff --git a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx index 1666e075..fd2dc595 100644 --- a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx +++ b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:26:44 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-20 13:53:05 + * @Last Modified time: 2026-04-15 14:21:55 */ /** * AI Prompt Assistant Modal @@ -20,10 +20,9 @@ import { updatePromptMessages, createPromptSessions } from '@/api/prompt' import type { AiPromptModalRef, AiPromptVariableModalRef, AiPromptForm } from '../types' import RbModal from '@/components/RbModal' import type { Model } from '@/views/ModelManagement/types' -import ChatContent from '@/components/Chat/ChatContent' import Empty from '@/components/Empty' import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpty.svg' -import type { ChatItem } from '@/components/Chat/types' +import PromptChatPanel, { type PromptChatPanelRef } from '@/components/Chat/PromptChatPanel' import ModelSelect from '@/components/ModelSelect' import AiPromptVariableModal from './AiPromptVariableModal' import { type SSEMessage } from '@/utils/stream' @@ -55,12 +54,14 @@ const AiPromptModal = forwardRef(({ const [visible, setVisible] = useState(false); const [loading, setLoading] = useState(false) const [form] = Form.useForm() - const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [promptSession, setPromptSession] = useState(null) + const [hasPrompt, setHasPrompt] = useState(false) const aiPromptVariableModalRef = useRef(null) + const chatPanelRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef('') + const isStreamingRef = useRef(false) const values = Form.useWatch([], form) @@ -68,12 +69,13 @@ const AiPromptModal = forwardRef(({ const handleClose = () => { setVisible(false); setLoading(false) - setChatList([]) + chatPanelRef.current?.clear() setVariables([]) form.setFieldsValue({ message: undefined, current_prompt: undefined, }) + setHasPrompt(false) }; /** Open modal and create new prompt session */ @@ -102,9 +104,7 @@ const AiPromptModal = forwardRef(({ } const messageContent = values.message setLoading(true) - setChatList(prev => { - return [...prev, { role: 'user', content: messageContent}] - }) + chatPanelRef.current?.append({ role: 'user', content: messageContent }) form.setFieldsValue({ message: undefined, current_prompt: undefined }) const handleStreamMessage = (data: SSEMessage[]) => { @@ -114,6 +114,8 @@ const AiPromptModal = forwardRef(({ switch (item.event) { case 'start': currentPromptValueRef.current = '' + isStreamingRef.current = true + setHasPrompt(true) if (editorRef.current?.clear) { editorRef.current.clear(); } @@ -123,15 +125,12 @@ const AiPromptModal = forwardRef(({ currentPromptValueRef.current += content; if (editorRef.current?.appendText) { editorRef.current.appendText(content); - editorRef.current.scrollToBottom(); } else { form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) } } if (desc) { - setChatList(prev => { - return [...prev, { role: 'assistant', content: desc }] - }) + chatPanelRef.current?.append({ role: 'assistant', content: desc }) } if (variables) { setVariables(variables) @@ -139,6 +138,7 @@ const AiPromptModal = forwardRef(({ break; case 'end': setLoading(false) + isStreamingRef.current = false // Sync form value when stream ends form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) break @@ -193,7 +193,6 @@ const AiPromptModal = forwardRef(({ setIsFocus(false) } - console.log(values) return ( (({ body: 'rb:p-0! rb:border-t rb:border-t-[#EBEBEB]' }} > -
+
(({ /> - } - data={chatList || []} - streamLoading={false} - labelPosition="top" labelFormat={(item) => item.role === 'user' ? t(`${source}.you`) : t(`${source}.ai`)} /> (({ - - {values?.current_prompt - ? + form.setFieldValue('current_prompt', value)} + height="rb:h-[calc(100vh-276px)]" + className={clsx('rb:bg-white! rb:border-none! rb:p-0!')} + onChange={(value) => { + if (!isStreamingRef.current) { + form.setFieldValue('current_prompt', value) + } + }} /> - : - } - + + : + }
diff --git a/web/src/views/ApplicationConfig/components/Editor/index.tsx b/web/src/views/ApplicationConfig/components/Editor/index.tsx index ab89a610..c3edc1fc 100644 --- a/web/src/views/ApplicationConfig/components/Editor/index.tsx +++ b/web/src/views/ApplicationConfig/components/Editor/index.tsx @@ -2,14 +2,14 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:25:17 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-26 11:18:04 + * @Last Modified time: 2026-04-15 14:00:07 */ /** * Rich text editor component using Lexical framework * Provides text editing with insert, append, clear, and scroll capabilities */ -import {forwardRef, useImperativeHandle } from 'react'; +import {forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import clsx from 'clsx'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; @@ -50,7 +50,7 @@ interface LexicalEditorProps { /** Callback when content changes */ onChange?: (value: string) => void; /** Editor height in pixels */ - height?: number; + height?: string; disabled?: boolean; } @@ -73,9 +73,42 @@ const EditorContent = forwardRef(({ value, placeholder = "Please enter content...", onChange, - disabled + disabled, + height }, ref) => { const [editor] = useLexicalComposerContext(); + const scrollRef = useRef(null); + const pendingTextRef = useRef(''); + const rafRef = useRef(null); + const isAppendingRef = useRef(false); + const scrollTopRef = useRef(0); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onPointerDown = () => { + if (!isAppendingRef.current) scrollTopRef.current = el.scrollTop; + }; + el.addEventListener('pointerdown', onPointerDown); + return () => el.removeEventListener('pointerdown', onPointerDown); + }, []); + + useEffect(() => { + return editor.registerUpdateListener(({ tags }) => { + if (!scrollRef.current) return; + if (tags.has('append-text')) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } else { + scrollRef.current.scrollTop = scrollTopRef.current; + } + }); + }, [editor]); + + const scrollToBottom = () => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }; /** * Expose editor methods to parent component @@ -94,24 +127,33 @@ const EditorContent = forwardRef(({ }); }, appendText: (text: string) => { - editor.update(() => { - const root = $getRoot(); - const lastChild = root.getLastChild(); - if (lastChild && $isParagraphNode(lastChild)) { - const lastTextNode = lastChild.getLastChild(); - if (lastTextNode && $isTextNode(lastTextNode)) { - const currentText = lastTextNode.getTextContent(); - lastTextNode.setTextContent(currentText + text); + pendingTextRef.current += text; + if (rafRef.current !== null) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + const batch = pendingTextRef.current; + pendingTextRef.current = ''; + if (scrollRef.current) scrollTopRef.current = scrollRef.current.scrollTop; + isAppendingRef.current = true; + editor.update(() => { + const root = $getRoot(); + const lastChild = root.getLastChild(); + if (lastChild && $isParagraphNode(lastChild)) { + const lastTextNode = lastChild.getLastChild(); + if (lastTextNode && $isTextNode(lastTextNode)) { + lastTextNode.setTextContent(lastTextNode.getTextContent() + batch); + } else { + lastChild.append($createTextNode(batch)); + } } else { - const textNode = $createTextNode(text); - lastChild.append(textNode); + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(batch)); + root.append(paragraph); } - } else { - const paragraph = $createParagraphNode(); - const textNode = $createTextNode(text); - paragraph.append(textNode); - root.append(paragraph); - } + }, { + tag: 'append-text', + onUpdate: () => { isAppendingRef.current = false; } + }); }); }, clear: () => { @@ -122,21 +164,16 @@ const EditorContent = forwardRef(({ root.append(paragraph); }); }, - scrollToBottom: () => { - const editorElement = editor.getRootElement(); - if (editorElement) { - editorElement.scrollTop = editorElement.scrollHeight; - } - } + scrollToBottom, }), [editor]); return ( -
+
{ const { message } = App.useApp() const [loading, setLoading] = useState(false) const [form] = Form.useForm() - const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [promptSession, setPromptSession] = useState(null) const aiPromptVariableModalRef = useRef(null) const promptSaveModalRef = useRef(null) + const chatPanelRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef(undefined) + const isStreamingRef = useRef(false) + const [hasPrompt, setHasPrompt] = useState(false) const values = Form.useWatch([], form) const [editVo, setEditVo] = useState(null) @@ -56,14 +57,14 @@ const Prompt: FC = () => { useEffect(() => { if (editVo?.id) { form.setFieldValue('current_prompt', editVo.prompt) - setChatList([]) + setHasPrompt(true) + chatPanelRef.current?.clear() } updateSession() }, [editVo]) /** Update session ID */ const updateSession = () => { - console.log('updateSession') createPromptSessions().then(res => { const response = res as { id: string } setPromptSession(response.id) @@ -83,9 +84,7 @@ const Prompt: FC = () => { } const messageContent = values.message setLoading(true) - setChatList(prev => { - return [...prev, { role: 'user', content: messageContent}] - }) + chatPanelRef.current?.append({ role: 'user', content: messageContent }) form.setFieldsValue({ message: undefined, current_prompt: undefined }) const handleStreamMessage = (data: SSEMessage[]) => { @@ -95,33 +94,35 @@ const Prompt: FC = () => { switch (item.event) { case 'start': currentPromptValueRef.current = '' + isStreamingRef.current = true + setHasPrompt(true) if (editorRef.current?.clear) { editorRef.current.clear(); } break; case 'message': - if (typeof content === 'string') { + if (content) { currentPromptValueRef.current += content; if (editorRef.current?.appendText) { editorRef.current.appendText(content); - editorRef.current.scrollToBottom(); } else { form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) } } if (desc) { - setChatList(prev => { - return [...prev, { role: 'assistant', content: desc }] - }) + chatPanelRef.current?.append({ role: 'assistant', content: desc }) } if (variables) { setVariables(variables) } + console.log('currentPromptValueRef.current', currentPromptValueRef.current) break; case 'end': setLoading(false) + isStreamingRef.current = false // Sync form values when stream ends form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) + console.log('currentPromptValueRef.current', currentPromptValueRef.current) break } }) @@ -164,7 +165,8 @@ const Prompt: FC = () => { const handleRefresh = () => { form.setFieldValue('current_prompt', undefined) currentPromptValueRef.current = undefined; - setChatList([]) + setHasPrompt(false) + chatPanelRef.current?.clear() setEditVo(null) updateSession() } @@ -193,13 +195,11 @@ const Prompt: FC = () => { headerType="borderless" bodyClassName="rb:px-4! rb:pt-0! rb:pb-3!" > - } - data={chatList || []} - streamLoading={false} - labelPosition="top" labelFormat={(item) => item.role === 'user' ? t(`prompt.you`) : t(`prompt.ai`)} /> { > } > - - {values?.current_prompt - ? + form.setFieldValue('current_prompt', value)} + height="rb:h-[calc(100vh-193px)]" + className="rb:bg-white! rb:border-none! rb:p-0! rb:text-[#212332] rb:leading-5" + onChange={(value) => { + if (!isStreamingRef.current) { + form.setFieldValue('current_prompt', value) + } + }} /> - : - } - + + : + }