This commit is contained in:
zxhlyh 2025-08-26 18:29:58 +08:00
parent 158da1ce6e
commit 01f0ee339e
11 changed files with 469 additions and 13 deletions

View File

@ -13,6 +13,7 @@ type CollapseProps = {
onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
hideCollapseIcon?: boolean
triggerClassName?: string
}
const Collapse = ({
disabled,
@ -22,6 +23,7 @@ const Collapse = ({
onCollapse,
operations,
hideCollapseIcon,
triggerClassName,
}: CollapseProps) => {
const [collapsedLocal, setCollapsedLocal] = useState(true)
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
@ -42,7 +44,7 @@ const Collapse = ({
<>
<div className='group/collapse flex items-center'>
<div
className='ml-4 flex grow items-center'
className={cn('ml-4 flex grow items-center', triggerClassName)}
onClick={() => {
if (!disabled) {
setCollapsedLocal(!collapsedMerged)

View File

@ -1,3 +1,5 @@
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks'
import type { CommonNodeType } from '@/app/components/workflow/types'
const useNodeCrud = <T>(id: string, data: CommonNodeType<T>) => {
@ -16,4 +18,28 @@ const useNodeCrud = <T>(id: string, data: CommonNodeType<T>) => {
}
}
export const useNodeUpdate = (id: string) => {
const store = useStoreApi()
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const getNodeData = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
return nodes.find(node => node.id === id)
}, [store, id])
const handleNodeDataUpdate = useCallback((data: Partial<CommonNodeType>) => {
handleNodeDataUpdateWithSyncDraft({
id,
data,
})
}, [id, handleNodeDataUpdateWithSyncDraft])
return {
getNodeData,
handleNodeDataUpdate,
}
}
export default useNodeCrud

View File

@ -0,0 +1,40 @@
import {
useCallback,
useMemo,
useState,
} from 'react'
import type { LLMNodeType } from '../../types'
import { useNodeUpdate } from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
export const useMemory = (
id: string,
data: LLMNodeType,
) => {
const { memory } = data
const initCollapsed = useMemo(() => {
if (!memory?.enabled)
return true
return false
}, [memory])
const [collapsed, setCollapsed] = useState(initCollapsed)
const { getNodeData } = useNodeUpdate(id)
const handleMemoryTypeChange = useCallback((value: string) => {
const nodeData = getNodeData()
console.log('nodeData', nodeData)
if (value === 'disabled')
console.log('disabled')
if (value === 'linear')
setCollapsed(true)
if (value === 'block')
setCollapsed(true)
}, [getNodeData])
return {
collapsed,
setCollapsed,
handleMemoryTypeChange,
}
}

View File

@ -0,0 +1,76 @@
import {
memo,
} from 'react'
import { useTranslation } from 'react-i18next'
import Collapse from '@/app/components/workflow/nodes/_base/components/collapse'
import type {
Node,
} from '@/app/components/workflow/types'
import Tooltip from '@/app/components/base/tooltip'
import MemorySelector from './memory-selector'
import LinearMemory from './linear-memory'
import type { Memory } from '@/app/components/workflow/types'
import type { LLMNodeType } from '../../types'
import { useMemory } from './hooks'
type MemoryProps = Pick<Node, 'id' | 'data'>
const MemorySystem = ({
id,
data,
}: MemoryProps) => {
const { t } = useTranslation()
const { memory } = data as LLMNodeType
const {
collapsed,
setCollapsed,
handleMemoryTypeChange,
} = useMemory(id, data as LLMNodeType)
return (
<>
<div className='py-4'>
<Collapse
disabled={!memory?.enabled}
collapsed={collapsed}
onCollapse={setCollapsed}
hideCollapseIcon
triggerClassName='ml-0 system-sm-semibold-uppercase'
trigger={
collapseIcon => (
<div className='flex grow items-center justify-between'>
<div className='flex items-center'>
<div className='system-sm-semibold-uppercase mr-0.5 text-text-secondary'>
{t('workflow.nodes.common.errorHandle.title')}
</div>
<Tooltip
popupContent={t('workflow.nodes.common.errorHandle.tip')}
triggerClassName='w-4 h-4'
/>
{collapseIcon}
</div>
<MemorySelector
value='linear'
onSelected={handleMemoryTypeChange}
/>
</div>
)}
>
<>
{
(memory?.mode === 'linear' || !memory?.mode) && !collapsed && (
<LinearMemory
payload={memory as Memory}
onChange={() => {
console.log('onChange')
}}
/>
)
}
</>
</Collapse>
</div>
</>
)
}
export default memo(MemorySystem)

View File

@ -0,0 +1,179 @@
import {
memo,
useCallback,
} from 'react'
import { produce } from 'immer'
import { useTranslation } from 'react-i18next'
import Switch from '@/app/components/base/switch'
import Slider from '@/app/components/base/slider'
import Input from '@/app/components/base/input'
import type { Memory } from '@/app/components/workflow/types'
import { MemoryRole } from '@/app/components/workflow/types'
const WINDOW_SIZE_MIN = 1
const WINDOW_SIZE_MAX = 100
const WINDOW_SIZE_DEFAULT = 50
const MEMORY_DEFAULT: Memory = {
window: { enabled: false, size: WINDOW_SIZE_DEFAULT },
query_prompt_template: '{{#sys.query#}}\n\n{{#sys.files#}}',
}
type RoleItemProps = {
readonly: boolean
title: string
value: string
onChange: (value: string) => void
}
const RoleItem = ({
readonly,
title,
value,
onChange,
}: RoleItemProps) => {
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}, [onChange])
return (
<div className='flex items-center justify-between'>
<div className='text-[13px] font-normal text-text-secondary'>{title}</div>
<Input
readOnly={readonly}
value={value}
onChange={handleChange}
className='h-8 w-[200px]'
type='text' />
</div>
)
}
type LinearMemoryProps = {
payload: Memory
readonly?: boolean
onChange: (payload: Memory) => void
canSetRoleName?: boolean
}
const LinearMemory = ({
payload,
readonly,
onChange,
canSetRoleName,
}: LinearMemoryProps) => {
const i18nPrefix = 'workflow.nodes.common.memory'
const { t } = useTranslation()
const handleWindowEnabledChange = useCallback((enabled: boolean) => {
const newPayload = produce(payload || MEMORY_DEFAULT, (draft) => {
if (!draft.window)
draft.window = { enabled: false, size: WINDOW_SIZE_DEFAULT }
draft.window.enabled = enabled
})
onChange(newPayload)
}, [payload, onChange])
const handleWindowSizeChange = useCallback((size: number | string) => {
const newPayload = produce(payload || MEMORY_DEFAULT, (draft) => {
if (!draft.window)
draft.window = { enabled: true, size: WINDOW_SIZE_DEFAULT }
let limitedSize: null | string | number = size
if (limitedSize === '') {
limitedSize = null
}
else {
limitedSize = Number.parseInt(limitedSize as string, 10)
if (Number.isNaN(limitedSize))
limitedSize = WINDOW_SIZE_DEFAULT
if (limitedSize < WINDOW_SIZE_MIN)
limitedSize = WINDOW_SIZE_MIN
if (limitedSize > WINDOW_SIZE_MAX)
limitedSize = WINDOW_SIZE_MAX
}
draft.window.size = limitedSize as number
})
onChange(newPayload)
}, [payload, onChange])
const handleBlur = useCallback(() => {
if (!payload)
return
if (payload.window.size === '' || payload.window.size === null)
handleWindowSizeChange(WINDOW_SIZE_DEFAULT)
}, [handleWindowSizeChange, payload])
const handleRolePrefixChange = useCallback((role: MemoryRole) => {
return (value: string) => {
const newPayload = produce(payload || MEMORY_DEFAULT, (draft) => {
if (!draft.role_prefix) {
draft.role_prefix = {
user: '',
assistant: '',
}
}
draft.role_prefix[role] = value
})
onChange(newPayload)
}
}, [payload, onChange])
return (
<>
<div className='flex justify-between'>
<div className='flex h-8 items-center space-x-2'>
<Switch
defaultValue={payload?.window?.enabled}
onChange={handleWindowEnabledChange}
size='md'
disabled={readonly}
/>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${i18nPrefix}.windowSize`)}</div>
</div>
<div className='flex h-8 items-center space-x-2'>
<Slider
className='w-[144px]'
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
min={WINDOW_SIZE_MIN}
max={WINDOW_SIZE_MAX}
step={1}
onChange={handleWindowSizeChange}
disabled={readonly || !payload.window?.enabled}
/>
<Input
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
wrapperClassName='w-12'
className='appearance-none pr-0'
type='number'
min={WINDOW_SIZE_MIN}
max={WINDOW_SIZE_MAX}
step={1}
onChange={e => handleWindowSizeChange(e.target.value)}
onBlur={handleBlur}
disabled={readonly || !payload.window?.enabled}
/>
</div>
</div>
{canSetRoleName && (
<div className='mt-4'>
<div className='text-xs font-medium uppercase leading-6 text-text-tertiary'>{t(`${i18nPrefix}.conversationRoleName`)}</div>
<div className='mt-1 space-y-2'>
<RoleItem
readonly={!!readonly}
title={t(`${i18nPrefix}.user`)}
value={payload.role_prefix?.user || ''}
onChange={handleRolePrefixChange(MemoryRole.user)}
/>
<RoleItem
readonly={!!readonly}
title={t(`${i18nPrefix}.assistant`)}
value={payload.role_prefix?.assistant || ''}
onChange={handleRolePrefixChange(MemoryRole.assistant)}
/>
</div>
</div>
)}
</>
)
}
export default memo(LinearMemory)

View File

@ -0,0 +1,99 @@
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
type MemorySelectorProps = {
value: string
onSelected: (value: string) => void
}
const MemorySelector = ({
value,
onSelected,
}: MemorySelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = [
{
value: 'disabled',
label: t('workflow.nodes.common.memory.disabled.title'),
description: t('workflow.nodes.common.memory.disabled.desc'),
},
{
value: 'linear',
label: t('workflow.nodes.common.memory.linear.title'),
description: t('workflow.nodes.common.memory.linear.desc'),
},
{
value: 'block',
label: t('workflow.nodes.common.memory.block.title'),
description: t('workflow.nodes.common.memory.block.desc'),
},
]
const selectedOption = options.find(option => option.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={4}
>
<PortalToFollowElemTrigger onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setOpen(v => !v)
}}>
<Button
size='small'
>
{selectedOption?.label}
<RiArrowDownSLine className='h-3.5 w-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
{
options.map(option => (
<div
key={option.value}
className='flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onSelected(option.value)
setOpen(false)
}}
>
<div className='mr-1 w-4 shrink-0'>
{
value === option.value && (
<RiCheckLine className='h-4 w-4 text-text-accent' />
)
}
</div>
<div className='grow'>
<div className='system-sm-semibold mb-0.5 text-text-secondary'>{option.label}</div>
<div className='system-xs-regular text-text-tertiary'>{option.description}</div>
</div>
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(MemorySelector)

View File

@ -21,6 +21,7 @@ import Switch from '@/app/components/base/switch'
import { RiAlertFill, RiQuestionLine } from '@remixicon/react'
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
import Toast from '@/app/components/base/toast'
import MemorySystem from './components/memory-system'
const i18nPrefix = 'workflow.nodes.llm'
@ -224,7 +225,11 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
readonly={readOnly}
config={{ data: inputs.memory }}
onChange={handleMemoryChange}
canSetRoleName={isCompletionModel}
canSetRoleName={!isCompletionModel}
/>
<MemorySystem
id={id}
data={data}
/>
</>
)}

View File

@ -1,6 +1,7 @@
import React, { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { v4 as uuid4 } from 'uuid'
import {
useForm as useTanstackForm,
useStore as useTanstackStore,
@ -63,26 +64,27 @@ const ChatVariableModal = ({
}) || { isCheckValidated: false, values: {} }
const {
name,
type,
value_type,
value,
editInJSON,
...rest
} = values
console.log(values, 'xxx')
if (!isCheckValidated)
return
if (!checkVariableName(name))
return
if (!chatVar && varList.some(chatVar => chatVar.name === name))
return notify({ type: 'error', message: 'name is existed' })
if (type === ChatVarType.Object && value.some((item: any) => !item.key && !!item.value))
if (value_type === ChatVarType.Object && value.some((item: any) => !item.key && !!item.value))
return notify({ type: 'error', message: 'object key can not be empty' })
// onSave({
// id: chatVar ? chatVar.id : uuid4(),
// name,
// value_type: type,
// value: values,
// description,
// })
onSave({
id: chatVar ? chatVar.id : uuid4(),
name,
value_type,
value: editInJSON ? JSON.parse(value) : value,
...rest,
})
onClose()
}, [onClose, notify, t, varList, chatVar, checkVariableName])

View File

@ -173,7 +173,7 @@ export type ConversationVariable = {
name: string
value_type: ChatVarType
value: any
description: string
description?: string
} & MemoryVariable
export type GlobalVariable = {
@ -259,12 +259,15 @@ export type RolePrefix = {
}
export type Memory = {
enabled?: boolean
role_prefix?: RolePrefix
window: {
enabled: boolean
size: number | string | null
}
query_prompt_template: string
block_id?: string
mode?: 'linear' | 'block'
}
export enum VarType {

View File

@ -340,6 +340,18 @@ const translation = {
conversationRoleName: 'Conversation Role Name',
user: 'User prefix',
assistant: 'Assistant prefix',
disabled: {
title: 'Disabled',
desc: 'AI won\'t remember anything from previous conversations',
},
linear: {
title: 'Linear',
desc: 'AI remembers the conversation flow and previous messages in order',
},
block: {
title: 'Memory Block',
desc: 'AI remembers specific information you define using custom templates',
},
},
memories: {
title: 'Memories',

View File

@ -340,6 +340,18 @@ const translation = {
conversationRoleName: '对话角色名',
user: '用户前缀',
assistant: '助手前缀',
disabled: {
title: '禁用',
desc: 'AI 不会记住任何之前的对话',
},
linear: {
title: '线性',
desc: 'AI 会记住对话的顺序和之前的消息',
},
block: {
title: '记忆块',
desc: 'AI 会记住你定义的特定信息',
},
},
memories: {
title: '记忆',