memory popup

This commit is contained in:
JzoNg 2025-09-22 18:24:36 +08:00
parent f6623423dd
commit 0b1445aed5
7 changed files with 241 additions and 13 deletions

View File

@ -61,6 +61,8 @@ import { VariableValueBlockNode } from './plugins/variable-value-block/node'
import { CustomTextNode } from './plugins/custom-text/node'
import OnBlurBlock from './plugins/on-blur-or-focus-block'
import UpdateBlock from './plugins/update-block'
import MemoryPopupPlugin from './plugins/memory-popup-plugin'
import { textToEditorState } from './utils'
import type {
ContextBlockType,
@ -103,6 +105,7 @@ export type PromptEditorProps = {
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
isSupportFileVar?: boolean
isMemorySupported?: boolean
}
const PromptEditor: FC<PromptEditorProps> = ({
@ -128,6 +131,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
errorMessageBlock,
lastRunBlock,
isSupportFileVar,
isMemorySupported,
}) => {
const { eventEmitter } = useEventEmitterContextContext()
const initialConfig = {
@ -198,6 +202,9 @@ const PromptEditor: FC<PromptEditorProps> = ({
}
ErrorBoundary={LexicalErrorBoundary}
/>
{isMemorySupported && (
<MemoryPopupPlugin instanceId={instanceId} />
)}
<ComponentPickerBlock
triggerString='/'
contextBlock={contextBlock}

View File

@ -0,0 +1,177 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import {
autoUpdate,
flip,
offset,
shift,
size,
useFloating,
} from '@floating-ui/react'
import {
$getSelection,
$isRangeSelection,
} from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { MEMORY_POPUP_SHOW_BY_EVENT_EMITTER } from '@/app/components/workflow/nodes/_base/components/prompt/add-memory-button'
import cn from '@/utils/classnames'
export type MemoryPopupProps = {
className?: string
container?: Element | null
instanceId?: string
}
export default function MemoryPopupPlugin({
className,
container,
instanceId,
}: MemoryPopupProps) {
const [editor] = useLexicalComposerContext()
const { eventEmitter } = useEventEmitterContextContext()
const [open, setOpen] = useState(false)
const portalRef = useRef<HTMLDivElement | null>(null)
const lastSelectionRef = useRef<Range | null>(null)
const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container])
const useContainer = !!containerEl && containerEl !== document.body
const { refs, floatingStyles, isPositioned } = useFloating({
placement: 'bottom-start',
middleware: [
offset(0), // fix hide cursor
shift({
padding: 8,
altBoundary: true,
}),
flip(),
size({
apply({ availableWidth, availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${Math.min(400, availableWidth)}px`,
maxHeight: `${Math.min(300, availableHeight)}px`,
overflow: 'auto',
})
},
padding: 8,
}),
],
whileElementsMounted: autoUpdate,
})
const openPortal = useCallback(() => {
const domSelection = window.getSelection()
let range: Range | null = null
if (domSelection && domSelection.rangeCount > 0)
range = domSelection.getRangeAt(0).cloneRange()
else
range = lastSelectionRef.current
if (range) {
const rects = range.getClientRects()
let rect: DOMRect | null = null
if (rects && rects.length)
rect = rects[rects.length - 1]
else
rect = range.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0) {
const root = editor.getRootElement()
if (root) {
const sc = range.startContainer
const node = sc.nodeType === Node.ELEMENT_NODE
? sc as Element
: (sc.parentElement || root)
rect = node.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0)
rect = root.getBoundingClientRect()
}
}
if (rect && !(rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) {
const virtualEl = {
getBoundingClientRect() {
return rect!
},
}
refs.setReference(virtualEl as Element)
}
}
setOpen(true)
}, [setOpen])
const closePortal = useCallback(() => {
setOpen(false)
}, [setOpen])
eventEmitter?.useSubscription((v: any) => {
if (v.type === MEMORY_POPUP_SHOW_BY_EVENT_EMITTER && v.instanceId === instanceId)
openPortal()
})
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const domSelection = window.getSelection()
if (domSelection && domSelection.rangeCount > 0)
lastSelectionRef.current = domSelection.getRangeAt(0).cloneRange()
}
})
})
}, [editor])
useEffect(() => {
if (!open)
return
const onMouseDown = (e: MouseEvent) => {
if (!portalRef.current)
return
if (!portalRef.current.contains(e.target as Node))
closePortal()
}
document.addEventListener('mousedown', onMouseDown, false)
return () => document.removeEventListener('mousedown', onMouseDown, false)
}, [open, closePortal])
if (!open || !containerEl)
return null
return createPortal(
<div
ref={(node) => {
portalRef.current = node
refs.setFloating(node)
}}
className={cn(
useContainer ? '' : 'z-[999999]',
'absolute rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm',
className,
)}
style={{
...floatingStyles,
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
Memory Popup
</div>,
containerEl,
)
}

View File

@ -1,8 +1,11 @@
import { $insertNodes } from 'lexical'
import {
$insertNodes,
} from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { textToEditorState } from '../utils'
import { CustomTextNode } from './custom-text/node'
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
import { MEMORY_POPUP_SHOW_BY_EVENT_EMITTER } from '../../../workflow/nodes/_base/components/prompt/add-memory-button'
import { useEventEmitterContextContext } from '@/context/event-emitter'
export const PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER = 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER'
@ -36,6 +39,18 @@ const UpdateBlock = ({
}
})
eventEmitter?.useSubscription((v: any) => {
if (v.type === MEMORY_POPUP_SHOW_BY_EVENT_EMITTER && v.instanceId === instanceId) {
editor.focus()
editor.update(() => {
const textNode = new CustomTextNode('')
$insertNodes([textNode])
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
}
})
return null
}

View File

@ -66,7 +66,7 @@ const WorkflowVariableBlockComponent = ({
const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState<WorkflowNodesMap>(workflowNodesMap)
const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
const isEnv = isENV(variables)
const isChatVar = isConversationVar(variables) && conversationVariables?.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}` && v.type !== 'memory')
// const isChatVar = isConversationVar(variables) && conversationVariables?.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}` && v.type !== 'memory')
const isMemoryVar = isConversationVar(variables) && conversationVariables?.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}` && v.type === 'memory')
const isException = isExceptionVariable(varName, node?.type)
let variableValid = true
@ -74,7 +74,7 @@ const WorkflowVariableBlockComponent = ({
if (environmentVariables)
variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
}
else if (isChatVar) {
else if (isConversationVar(variables)) {
if (conversationVariables)
variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
}

View File

@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
export const MEMORY_POPUP_SHOW_BY_EVENT_EMITTER = 'MEMORY_POPUP_SHOW_BY_EVENT_EMITTER'
export const PROMPT_EDITOR_INSERT_QUICKLY = 'PROMPT_EDITOR_INSERT_QUICKLY'
type Props = {
onAddMemory: () => void
}
const AddMemoryButton = ({ onAddMemory }: Props) => {
const { t } = useTranslation()
return (
<div className='ml-1.5 mt-2.5'>
<Button
variant='ghost'
size='small'
className='text-text-tertiary'
onClick={onAddMemory}
>
<Memory className='h-3.5 w-3.5' />
<span className='ml-1'>{t('workflow.nodes.llm.memory.addButton')}</span>
</Button>
</div>
)
}
export default AddMemoryButton

View File

@ -36,8 +36,7 @@ import Switch from '@/app/components/base/switch'
import { Jinja } from '@/app/components/base/icons/src/vender/workflow'
import { useStore } from '@/app/components/workflow/store'
import { useWorkflowVariableType } from '@/app/components/workflow/hooks'
import Button from '@/app/components/base/button'
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
import AddMemoryButton, { MEMORY_POPUP_SHOW_BY_EVENT_EMITTER } from './add-memory-button'
type Props = {
className?: string
@ -157,6 +156,11 @@ const Editor: FC<Props> = ({
const pipelineId = useStore(s => s.pipelineId)
const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel)
const handleAddMemory = () => {
setFocus()
eventEmitter?.emit({ type: MEMORY_POPUP_SHOW_BY_EVENT_EMITTER, instanceId } as any)
}
return (
<Wrap className={cn(className, wrapClassName)} style={wrapStyle} isInNode isExpand={isExpand}>
<div ref={ref} className={cn(isFocus ? (gradientBorder && 'bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2') : 'bg-transparent', isExpand && 'h-full', '!rounded-[9px] p-0.5', containerClassName)}>
@ -296,18 +300,12 @@ const Editor: FC<Props> = ({
onFocus={setFocus}
editable={!readOnly}
isSupportFileVar={isSupportFileVar}
isMemorySupported
/>
{/* to patch Editor not support dynamic change editable status */}
{readOnly && <div className='absolute inset-0 z-10'></div>}
</div>
{isMemorySupported && isFocus && (
<div className='pl-1.5'>
<Button variant='ghost' size='small' className='text-text-tertiary'>
<Memory className='h-3.5 w-3.5' />
<span className='ml-1'>{t('workflow.nodes.llm.memory.addButton')}</span>
</Button>
</div>
)}
{isMemorySupported && <AddMemoryButton onAddMemory={handleAddMemory} />}
</>
)
: (

View File

@ -211,6 +211,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
isSupportFileVar
instanceId={`${id}-chat-workflow-llm-prompt-editor-user`}
/>
{inputs.memory.query_prompt_template && !inputs.memory.query_prompt_template.includes('{{#sys.query#}}') && (