Merge pull request #905 from SuanmoSuanyangTechnology/fix/v0.3.0_zy

fix(web): prompt editor
This commit is contained in:
yingzhao
2026-04-15 14:41:16 +08:00
committed by GitHub
4 changed files with 165 additions and 82 deletions

View File

@@ -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<PromptChatPanelRef, PromptChatPanelProps>((props, ref) => {
const [chatList, setChatList] = useState<ChatItem[]>([])
useImperativeHandle(ref, () => ({
append: (item) => setChatList(prev => [...prev, item]),
clear: () => setChatList([]),
}))
return (
<ChatContent
classNames={props.classNames}
contentClassNames={props.contentClassNames}
empty={props.empty}
data={chatList}
streamLoading={false}
labelPosition="top"
labelFormat={props.labelFormat}
/>
)
})
export default PromptChatPanel

View File

@@ -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<AiPromptModalRef, AiPromptModalProps>(({
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false)
const [form] = Form.useForm<AiPromptForm>()
const [chatList, setChatList] = useState<ChatItem[]>([])
const [variables, setVariables] = useState<string[]>([])
const [promptSession, setPromptSession] = useState<string | null>(null)
const [hasPrompt, setHasPrompt] = useState(false)
const aiPromptVariableModalRef = useRef<AiPromptVariableModalRef>(null)
const chatPanelRef = useRef<PromptChatPanelRef>(null)
const editorRef = useRef<any>(null)
const currentPromptValueRef = useRef<string>('')
const isStreamingRef = useRef(false)
const values = Form.useWatch([], form)
@@ -68,12 +69,13 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
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<AiPromptModalRef, AiPromptModalProps>(({
}
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<AiPromptModalRef, AiPromptModalProps>(({
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<AiPromptModalRef, AiPromptModalProps>(({
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<AiPromptModalRef, AiPromptModalProps>(({
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<AiPromptModalRef, AiPromptModalProps>(({
setIsFocus(false)
}
console.log(values)
return (
<RbModal
title={t(`${source}.AIPromptAssistant`)}
@@ -207,7 +206,7 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
body: 'rb:p-0! rb:border-t rb:border-t-[#EBEBEB]'
}}
>
<Form form={form} className="rb:mx-4!">
<Form form={form} className="rb:mx-4! rb:h-[calc(100vh-202px)]">
<div className="rb:grid rb:grid-cols-2">
<div className="rb:border-r rb:border-r-[#EBEBEB] rb:pr-4 rb:pt-3 rb:pb-4">
<Form.Item
@@ -220,13 +219,11 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
/>
</Form.Item>
<ChatContent
classNames="rb:h-105.5 rb:pb-[15px]!"
<PromptChatPanel
ref={chatPanelRef}
classNames="rb:h-[calc(100vh-330px)] rb:pb-[15px]!"
contentClassNames="rb:max-w-75!"
empty={<Empty url={ConversationEmptyIcon} title={t(`${source}.promptChatEmpty`)} isNeedSubTitle={false} size={[140, 100]} className="rb:h-full" />}
data={chatList || []}
streamLoading={false}
labelPosition="top"
labelFormat={(item) => item.role === 'user' ? t(`${source}.you`) : t(`${source}.ai`)}
/>
<Flex align="center" gap={12} justify="space-between"
@@ -288,16 +285,21 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
</Space>
</Flex>
<Form.Item name="current_prompt" noStyle>
{values?.current_prompt
? <Editor
{hasPrompt
? <Form.Item name="current_prompt" noStyle>
<Editor
ref={editorRef}
className="rb:h-119 rb:bg-white! rb:border-none! rb:p-0!"
onChange={(value) => 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)
}
}}
/>
: <Empty url={analysisEmptyIcon} title={t(`${source}.promptOptimizationEmpty`)} isNeedSubTitle={false} size={[270, 170]} className="rb:h-119 rb:w-70 rb:mx-auto! rb:text-center! rb:text-[12px]! rb:leading-4!" />
}
</Form.Item>
</Form.Item>
: <Empty url={analysisEmptyIcon} title={t(`${source}.promptOptimizationEmpty`)} isNeedSubTitle={false} size={[270, 170]} className="rb:h-119 rb:w-70 rb:mx-auto! rb:text-center! rb:text-[12px]! rb:leading-4!" />
}
</div>
</div>
</Form>

View File

@@ -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<EditorRef, LexicalEditorProps>(({
value,
placeholder = "Please enter content...",
onChange,
disabled
disabled,
height
}, ref) => {
const [editor] = useLexicalComposerContext();
const scrollRef = useRef<HTMLDivElement>(null);
const pendingTextRef = useRef<string>('');
const rafRef = useRef<number | null>(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<EditorRef, LexicalEditorProps>(({
});
},
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<EditorRef, LexicalEditorProps>(({
root.append(paragraph);
});
},
scrollToBottom: () => {
const editorElement = editor.getRootElement();
if (editorElement) {
editorElement.scrollTop = editorElement.scrollHeight;
}
}
scrollToBottom,
}), [editor]);
return (
<div style={{ position: 'relative' }}>
<div ref={scrollRef} style={{ position: 'relative' }} className={height ? `${height} rb:overflow-y-auto` : ''}>
<RichTextPlugin
contentEditable={
<ContentEditable
className={clsx(
"rb:outline-none rb:resize-none rb:text-[14px] rb:leading-5 rb:px-4 rb:py-5 rb:bg-[#FBFDFF] rb-border rb:rounded-lg rb:overflow-auto",
"rb:outline-none rb:resize-none rb:text-[14px] rb:leading-5 rb:px-4 rb:py-5 rb:bg-[#FBFDFF] rb-border rb:rounded-lg",
disabled && "rb:cursor-not-allowed rb:bg-[#F6F8FC] rb:text-[#5B6167]",
className
)}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 17:44:15
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-27 15:14:58
* @Last Modified time: 2026-04-15 14:25:17
*/
/**
* Prompt Editor Component
@@ -18,10 +18,9 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { updatePromptMessages, createPromptSessions } from '@/api/prompt'
import type { PromptVariableModalRef, AiPromptForm, HistoryItem, PromptSaveModalRef } from './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 PromptVariableModal from './components/PromptVariableModal'
import { type SSEMessage } from '@/utils/stream'
@@ -39,13 +38,15 @@ const Prompt: FC = () => {
const { message } = App.useApp()
const [loading, setLoading] = useState(false)
const [form] = Form.useForm<AiPromptForm>()
const [chatList, setChatList] = useState<ChatItem[]>([])
const [variables, setVariables] = useState<string[]>([])
const [promptSession, setPromptSession] = useState<string | null>(null)
const aiPromptVariableModalRef = useRef<PromptVariableModalRef>(null)
const promptSaveModalRef = useRef<PromptSaveModalRef>(null)
const chatPanelRef = useRef<PromptChatPanelRef>(null)
const editorRef = useRef<any>(null)
const currentPromptValueRef = useRef<string>(undefined)
const isStreamingRef = useRef(false)
const [hasPrompt, setHasPrompt] = useState(false)
const values = Form.useWatch([], form)
const [editVo, setEditVo] = useState<HistoryItem | null>(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!"
>
<ChatContent
<PromptChatPanel
ref={chatPanelRef}
classNames="rb:h-[calc(100vh-257px)] rb:mb-[12px]!"
contentClassNames="rb:max-w-75!"
empty={<Empty url={ConversationEmptyIcon} title={t(`prompt.promptChatEmpty`)} isNeedSubTitle={false} size={[140, 100]} className="rb:h-full" />}
data={chatList || []}
streamLoading={false}
labelPosition="top"
labelFormat={(item) => item.role === 'user' ? t(`prompt.you`) : t(`prompt.ai`)}
/>
<Flex align="center" gap={12} justify="space-between"
@@ -275,16 +275,21 @@ const Prompt: FC = () => {
></Button>
</Space>}
>
<Form.Item name="current_prompt" noStyle>
{values?.current_prompt
? <Editor
{hasPrompt
? <Form.Item name="current_prompt" noStyle>
<Editor
ref={editorRef}
className="rb:h-[calc(100vh-193px)] rb:bg-white! rb:border-none! rb:p-0! rb:text-[#212332] rb:leading-5"
onChange={(value) => 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)
}
}}
/>
: <Empty url={analysisEmptyIcon} title={t(`prompt.promptPlaceholder`)} isNeedSubTitle={false} size={[270, 170]} className="rb:h-[calc(100vh-193px)] rb:mx-auto! rb:text-center! rb:text-[12px]! rb:leading-4!" />
}
</Form.Item>
</Form.Item>
: <Empty url={analysisEmptyIcon} title={t(`prompt.promptPlaceholder`)} isNeedSubTitle={false} size={[270, 170]} className="rb:h-[calc(100vh-193px)] rb:mx-auto! rb:text-center! rb:text-[12px]! rb:leading-4!" />
}
</RbCard>
</div>
</div>