Compare commits

..

2 Commits

Author SHA1 Message Date
yyh
0220441452 refactor(web): remove redundant layout effect wrappers 2026-07-11 21:21:39 +08:00
yyh
515ecc9bb3 fix(web): correct knowledge dialog draft state 2026-07-11 21:03:29 +08:00
4 changed files with 537 additions and 520 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect as useLayoutEffectFromReact } from 'react'
import { useEffect, useLayoutEffect } from 'react'
import { useStore, useWorkflowStore } from '../store'
import {
isControlMode,
@ -9,10 +9,6 @@ import {
useWorkflowVariableInspectPanelHeightValue,
} from './local-storage-options'
const useIsoLayoutEffect = typeof document !== 'undefined'
? useLayoutEffectFromReact
: useEffect
export const WorkflowLocalStorageBridge = () => {
const storedNodePanelWidth = useWorkflowNodePanelWidthValue()
const storedPreviewPanelWidth = useDebugPreviewPanelWidthValue()
@ -26,7 +22,7 @@ export const WorkflowLocalStorageBridge = () => {
const setVariableInspectPanelHeight = useStore(state => state.setVariableInspectPanelHeight)
const setControlMode = useStore(state => state.setControlMode)
useIsoLayoutEffect(() => {
useLayoutEffect(() => {
if (!isFiniteNumber(storedNodePanelWidth))
return
@ -34,17 +30,17 @@ export const WorkflowLocalStorageBridge = () => {
setPanelWidth(storedNodePanelWidth)
}, [setNodePanelWidth, setPanelWidth, storedNodePanelWidth])
useIsoLayoutEffect(() => {
useLayoutEffect(() => {
if (isFiniteNumber(storedPreviewPanelWidth))
setPreviewPanelWidth(storedPreviewPanelWidth)
}, [setPreviewPanelWidth, storedPreviewPanelWidth])
useIsoLayoutEffect(() => {
useLayoutEffect(() => {
if (isFiniteNumber(storedVariableInspectPanelHeight))
setVariableInspectPanelHeight(storedVariableInspectPanelHeight)
}, [setVariableInspectPanelHeight, storedVariableInspectPanelHeight])
useIsoLayoutEffect(() => {
useLayoutEffect(() => {
if (isControlMode(storedControlMode))
setControlMode(storedControlMode)
}, [setControlMode, storedControlMode])

View File

@ -1,6 +1,6 @@
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useAtomValue } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -116,6 +116,12 @@ function renderKnowledgeRetrieval({
)
}
function getDialogNameEditButton(dialog: HTMLElement) {
return within(dialog).getByRole('button', {
name: /agentDetail\.configure\.knowledgeRetrieval\.edit/,
})
}
describe('AgentKnowledgeRetrieval', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -141,30 +147,6 @@ describe('AgentKnowledgeRetrieval', () => {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.remove:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}',
})).not.toBeInTheDocument()
})
it('should keep row actions out of layout until hover or focus', () => {
renderKnowledgeRetrieval()
const editButton = screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}',
})
const removeButton = screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.remove:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}',
})
const actionGroup = editButton.parentElement
expect(actionGroup).toHaveClass('hidden')
expect(actionGroup).toHaveClass(
'group-focus-within:flex',
'group-hover:flex',
)
expect(removeButton).toHaveClass(
'hover:bg-state-destructive-hover',
'hover:text-text-destructive',
'focus-visible:bg-state-destructive-hover',
'focus-visible:text-text-destructive',
)
})
})
describe('User Interactions', () => {
@ -177,10 +159,9 @@ describe('AgentKnowledgeRetrieval', () => {
const dialog = screen.getByRole('dialog', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})
const titleButton = within(dialog).getByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo',
})
const titleButton = getDialogNameEditButton(dialog)
expect(titleButton).toBeInTheDocument()
expect(titleButton).toHaveTextContent('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo')
expect(within(dialog).queryByRole('textbox', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel',
})).not.toBeInTheDocument()
@ -232,10 +213,6 @@ describe('AgentKnowledgeRetrieval', () => {
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' }))
expect(screen.queryByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo"}',
})).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('button', {
@ -318,9 +295,7 @@ describe('AgentKnowledgeRetrieval', () => {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})
await user.click(within(dialog).getByRole('button', {
name: 'FAQ Search',
}))
await user.click(getDialogNameEditButton(dialog))
const nameInput = within(dialog).getByRole('textbox', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel',
})
@ -340,9 +315,7 @@ describe('AgentKnowledgeRetrieval', () => {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})
expect(within(dialog).getByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo',
})).toBeInTheDocument()
expect(getDialogNameEditButton(dialog)).toHaveTextContent('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo')
await user.click(within(dialog).getByRole('radio', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom',
@ -401,9 +374,7 @@ describe('AgentKnowledgeRetrieval', () => {
const dialog = screen.getByRole('dialog', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})
await user.click(within(dialog).getByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne',
}))
await user.click(getDialogNameEditButton(dialog))
expect(within(dialog).getByRole('textbox', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel',
@ -443,7 +414,7 @@ describe('AgentKnowledgeRetrieval', () => {
expect(within(dialog).queryByText('appDebug.datasetConfig.knowledgeTip')).not.toBeInTheDocument()
})
it('should save the default rerank model when editing retrieval with existing knowledge', async () => {
it('should preserve retrieval settings when renaming a retrieval', async () => {
const user = userEvent.setup()
renderKnowledgeRetrieval({
showConfigSnapshot: true,
@ -477,27 +448,73 @@ describe('AgentKnowledgeRetrieval', () => {
const dialog = screen.getByRole('dialog', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})
await user.click(within(dialog).getByRole('button', {
name: 'Search Docs',
}))
await user.click(getDialogNameEditButton(dialog))
const nameInput = within(dialog).getByRole('textbox', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel',
})
await user.clear(nameInput)
await user.type(nameInput, 'Default Rerank Docs')
await user.type(nameInput, 'Renamed Docs{Enter}')
await waitFor(() => {
const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}')
expect(knowledgeConfig.sets[0].retrieval).toEqual(expect.objectContaining({
const titleButton = getDialogNameEditButton(dialog)
expect(titleButton).toHaveTextContent('Renamed Docs')
expect(titleButton).toHaveFocus()
const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}')
expect(knowledgeConfig.sets[0]).toMatchObject({
name: 'Renamed Docs',
retrieval: {
mode: 'multiple',
reranking_enable: true,
reranking_mode: RerankingModeEnum.RerankingModel,
reranking_model: {
provider: 'rerank-provider',
model: 'rerank-model',
},
}))
reranking_enable: false,
top_k: 4,
},
})
expect(knowledgeConfig.sets[0].retrieval).not.toHaveProperty('reranking_model')
})
it('should cancel name editing with Escape without closing the dialog', async () => {
const user = userEvent.setup()
renderKnowledgeRetrieval({ showConfigSnapshot: true })
await user.click(screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}',
}))
const dialog = screen.getByRole('dialog', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})
await user.click(getDialogNameEditButton(dialog))
const nameInput = within(dialog).getByRole('textbox', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel',
})
await user.clear(nameInput)
await user.type(nameInput, 'Draft name')
await user.keyboard('{Escape}')
expect(dialog).toBeInTheDocument()
const titleButton = getDialogNameEditButton(dialog)
expect(titleButton).toHaveTextContent('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne')
expect(titleButton).toHaveFocus()
const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}')
expect(knowledgeConfig.sets[0].name).toBe('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne')
})
it('should keep editing when Enter confirms an IME composition', async () => {
const user = userEvent.setup()
renderKnowledgeRetrieval()
await user.click(screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}',
}))
const dialog = screen.getByRole('dialog', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})
await user.click(getDialogNameEditButton(dialog))
const nameInput = within(dialog).getByRole('textbox', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel',
})
fireEvent.keyDown(nameInput, { key: 'Enter', isComposing: true })
expect(nameInput).toBeInTheDocument()
expect(nameInput).toHaveFocus()
})
it('should save edited retrieval data into the config snapshot', async () => {
@ -511,9 +528,7 @@ describe('AgentKnowledgeRetrieval', () => {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})
await user.click(within(dialog).getByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne',
}))
await user.click(getDialogNameEditButton(dialog))
const nameInput = within(dialog).getByRole('textbox', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel',
})
@ -571,5 +586,33 @@ describe('AgentKnowledgeRetrieval', () => {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})).not.toBeInTheDocument()
})
it('should reset an uncreated dialog draft after closing', async () => {
const user = userEvent.setup()
renderKnowledgeRetrieval()
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' }))
let dialog = screen.getByRole('dialog', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})
await user.click(within(dialog).getByRole('radio', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom',
}))
await user.type(within(dialog).getByRole('textbox', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel',
}), 'temporary query')
await user.click(within(dialog).getByRole('button', { name: 'Close' }))
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' }))
dialog = screen.getByRole('dialog', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title',
})
expect(within(dialog).getByRole('radio', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.agent',
})).toBeChecked()
expect(within(dialog).queryByRole('textbox', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel',
})).not.toBeInTheDocument()
})
})
})

View File

@ -1,7 +1,7 @@
'use client'
import type { AgentKnowledgeDatasetConfig } from '@dify/contracts/api/console/agent/types.gen'
import type { ReactNode, SetStateAction } from 'react'
import type { ReactNode } from 'react'
import type {
MetadataFilteringCondition,
MetadataFilteringModeEnum,
@ -18,7 +18,7 @@ import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { intersectionBy } from 'es-toolkit/compat'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IndexingType } from '@/app/components/datasets/create/step-two/hooks/use-indexing-config'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -50,20 +50,26 @@ type MetadataFilteringConditions = {
type KnowledgeRetrievalDialogState = {
customQuery: string
hydratedKey: string | null
isEditingName: boolean
metadataFilterMode: MetadataFilteringModeEnum
metadataFilteringConditions: MetadataFilteringConditions
metadataModelConfig?: ModelConfig
multipleRetrievalConfig: MultipleRetrievalConfig
name: string
queryMode: KnowledgeRetrievalQueryMode
rerankModelOpen: boolean
retrievalMode: typeof RETRIEVE_TYPE[keyof typeof RETRIEVE_TYPE]
selectedDatasets: DataSet[]
singleRetrievalConfig?: SingleRetrievalConfig
}
type AgentKnowledgeRetrievalDialogProps = {
item?: AgentKnowledgeRetrievalItem
initialName?: string
onItemCreate?: (item: AgentKnowledgeRetrievalItem) => void
onItemChange?: (item: AgentKnowledgeRetrievalItem) => void
open: boolean
onOpenChange: (open: boolean) => void
}
const queryModeOptions: KnowledgeRetrievalQueryMode[] = ['agent', 'custom']
const optionCardClassName = cn(
@ -191,18 +197,14 @@ const createDialogState = (
item: AgentKnowledgeRetrievalItem | undefined,
initialName: string | undefined,
fallbackName: string,
hydrationKey: string,
): KnowledgeRetrievalDialogState => ({
customQuery: item?.customQuery ?? '',
hydratedKey: hydrationKey,
isEditingName: false,
metadataFilterMode: item?.metadataFilterMode ?? WorkflowMetadataFilteringModeEnum.disabled,
metadataFilteringConditions: item?.metadataFilteringConditions ?? createDefaultMetadataFilteringConditions(),
metadataModelConfig: item?.metadataModelConfig,
multipleRetrievalConfig: item?.multipleRetrievalConfig ?? createDefaultRetrievalConfig(),
name: getDialogName(item, initialName, fallbackName),
queryMode: item?.queryMode ?? 'agent',
rerankModelOpen: false,
retrievalMode: item?.retrievalMode ?? RETRIEVE_TYPE.multiWay,
selectedDatasets: getSelectedDatasets(item),
singleRetrievalConfig: item?.singleRetrievalConfig,
@ -217,41 +219,130 @@ const createMetadataCondition = ({ id, name, type }: MetadataInDoc): MetadataFil
: ComparisonOperator.is,
})
export function AgentKnowledgeRetrievalDialog({
function EditableKnowledgeRetrievalName({
editLabel,
inputLabel,
invalid,
name,
onCommit,
}: {
editLabel: string
inputLabel: string
invalid: boolean
name: string
onCommit: (name: string) => void
}) {
const [editing, setEditing] = useState(false)
const [draftName, setDraftName] = useState(name)
const buttonRef = useRef<HTMLButtonElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const restoreButtonFocusRef = useRef(false)
useLayoutEffect(() => {
if (editing) {
inputRef.current?.focus()
inputRef.current?.select()
return
}
if (!restoreButtonFocusRef.current)
return
restoreButtonFocusRef.current = false
const button = buttonRef.current
if (!button)
return
const activeElement = button.ownerDocument.activeElement
if (!activeElement || activeElement === button.ownerDocument.body)
button.focus({ preventScroll: true })
}, [editing])
const finishEditing = (commit: boolean) => {
if (commit)
onCommit(draftName)
else
setDraftName(name)
restoreButtonFocusRef.current = true
setEditing(false)
}
if (editing) {
return (
<Input
ref={inputRef}
aria-label={inputLabel}
aria-invalid={invalid || undefined}
autoComplete="off"
className="h-7 min-w-0 flex-1 rounded-md px-1 py-0 system-xl-semibold text-text-primary"
name="knowledgeRetrievalName"
value={draftName}
onBlur={() => {
onCommit(draftName)
setEditing(false)
}}
onChange={event => setDraftName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing)
return
if (event.key === 'Enter') {
event.preventDefault()
finishEditing(true)
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
finishEditing(false)
}
}}
/>
)
}
return (
<button
ref={buttonRef}
type="button"
aria-label={editLabel}
className="flex h-7 min-w-0 flex-1 items-center rounded-md border border-transparent px-1 py-0 text-left system-xl-semibold text-text-primary hover:bg-components-input-bg-hover focus-visible:border-components-input-border-active focus-visible:bg-components-input-bg-active focus-visible:shadow-xs focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => {
setDraftName(name)
setEditing(true)
}}
>
<span className="min-w-0 truncate">
{name}
</span>
</button>
)
}
function AgentKnowledgeRetrievalDialogContent({
item,
initialName,
onItemCreate,
onItemChange,
open,
onOpenChange,
}: {
item?: AgentKnowledgeRetrievalItem
initialName?: string
onItemCreate?: (item: AgentKnowledgeRetrievalItem) => void
onItemChange?: (item: AgentKnowledgeRetrievalItem) => void
open: boolean
onOpenChange: (open: boolean) => void
}) {
}: Omit<AgentKnowledgeRetrievalDialogProps, 'open' | 'onOpenChange'>) {
const { t } = useTranslation('agentV2')
const docLink = useDocLink()
const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
const getValidationMessage = useKnowledgeValidationMessage()
const fallbackName = t($ => $['agentDetail.configure.knowledgeRetrieval.retrievalOne'])
const hydrationKey = open ? (item?.id ?? initialName ?? 'new') : null
const [dialogState, setDialogState] = useState(() => createDialogState(item, initialName, fallbackName, hydrationKey ?? 'new'))
const nameInputRef = useRef<HTMLInputElement>(null)
const [dialogState, setDialogState] = useState(() => createDialogState(item, initialName, fallbackName))
const [rerankModelOpen, setRerankModelOpen] = useState(false)
const queryModeLabelId = 'agent-knowledge-retrieval-query-mode-label'
const {
customQuery,
hydratedKey,
isEditingName,
metadataFilterMode,
metadataFilteringConditions,
metadataModelConfig,
multipleRetrievalConfig,
name,
queryMode,
rerankModelOpen,
retrievalMode,
selectedDatasets,
singleRetrievalConfig,
@ -272,11 +363,11 @@ export function AgentKnowledgeRetrievalDialog({
}
: undefined,
)
const fallbackRerankModel = useMemo(() => ({
const fallbackRerankModel = {
provider: currentRerankProvider?.provider,
model: currentRerankModel?.model,
}), [currentRerankModel?.model, currentRerankProvider?.provider])
const resolveMultipleRetrievalConfig = useCallback((
}
const resolveMultipleRetrievalConfig = (
config: MultipleRetrievalConfig,
nextSelectedDatasets = selectedDatasets,
originalDatasets = selectedDatasets,
@ -285,112 +376,56 @@ export function AgentKnowledgeRetrievalDialog({
nextSelectedDatasets,
originalDatasets,
fallbackRerankModel,
), [fallbackRerankModel, selectedDatasets])
const effectiveMultipleRetrievalConfig = useMemo(() => {
if (retrievalMode !== RETRIEVE_TYPE.multiWay || selectedDatasets.length === 0)
return multipleRetrievalConfig
return resolveMultipleRetrievalConfig(multipleRetrievalConfig)
}, [multipleRetrievalConfig, resolveMultipleRetrievalConfig, retrievalMode, selectedDatasets.length])
const patchDialogState = (patch: Partial<KnowledgeRetrievalDialogState>) => {
setDialogState(current => ({
...current,
...patch,
}))
}
const setMetadataFilteringConditions = (update: SetStateAction<MetadataFilteringConditions>) => {
setDialogState((current) => {
const nextMetadataFilteringConditions = typeof update === 'function'
? update(current.metadataFilteringConditions)
: update
return {
...current,
metadataFilteringConditions: nextMetadataFilteringConditions,
}
})
}
const setMetadataModelConfig = (update: SetStateAction<ModelConfig | undefined>) => {
setDialogState((current) => {
const nextMetadataModelConfig = typeof update === 'function'
? update(current.metadataModelConfig)
: update
return {
...current,
metadataModelConfig: nextMetadataModelConfig,
}
})
}
const setSingleRetrievalConfig = (update: SetStateAction<SingleRetrievalConfig | undefined>) => {
setDialogState((current) => {
const nextSingleRetrievalConfig = typeof update === 'function'
? update(current.singleRetrievalConfig)
: update
return {
...current,
singleRetrievalConfig: nextSingleRetrievalConfig,
}
})
}
const createItemFromDialogState = (patch: Partial<AgentKnowledgeRetrievalItem>): AgentKnowledgeRetrievalItem => ({
id: globalThis.crypto?.randomUUID?.() ?? `retrieval-${Date.now()}`,
name,
queryMode,
customQuery,
selectedDatasets,
retrievalMode,
multipleRetrievalConfig: effectiveMultipleRetrievalConfig,
singleRetrievalConfig,
metadataFilterMode,
metadataFilteringConditions,
metadataModelConfig,
...patch,
)
const effectiveMultipleRetrievalConfig = retrievalMode === RETRIEVE_TYPE.multiWay && selectedDatasets.length > 0
? resolveMultipleRetrievalConfig(multipleRetrievalConfig)
: multipleRetrievalConfig
const createItemFromDialogState = (
state: KnowledgeRetrievalDialogState,
id = globalThis.crypto?.randomUUID?.() ?? `retrieval-${Date.now()}`,
): AgentKnowledgeRetrievalItem => ({
id,
name: state.name,
queryMode: state.queryMode,
customQuery: state.customQuery,
selectedDatasets: state.selectedDatasets,
retrievalMode: state.retrievalMode,
multipleRetrievalConfig: state.multipleRetrievalConfig,
singleRetrievalConfig: state.singleRetrievalConfig,
metadataFilterMode: state.metadataFilterMode,
metadataFilteringConditions: state.metadataFilteringConditions,
metadataModelConfig: state.metadataModelConfig,
})
const updateItem = (patch: Partial<AgentKnowledgeRetrievalItem>) => {
if (!item)
return
onItemChange?.({
...item,
name,
queryMode,
customQuery,
selectedDatasets,
retrievalMode,
multipleRetrievalConfig: effectiveMultipleRetrievalConfig,
singleRetrievalConfig,
metadataFilterMode,
metadataFilteringConditions,
metadataModelConfig,
const applyDialogStatePatch = (patch: Partial<KnowledgeRetrievalDialogState>) => {
const nextState = {
...dialogState,
...patch,
})
}
setDialogState(nextState)
if (item) {
onItemChange?.({
...item,
...createItemFromDialogState(nextState, item.id),
})
}
return nextState
}
const handleSelectedDatasetsChange = (nextDatasets: DataSet[]) => {
const nextMultipleRetrievalConfig = retrievalMode === RETRIEVE_TYPE.multiWay && nextDatasets.length > 0
? resolveMultipleRetrievalConfig(multipleRetrievalConfig, nextDatasets)
: multipleRetrievalConfig
patchDialogState({
const nextState = applyDialogStatePatch({
multipleRetrievalConfig: nextMultipleRetrievalConfig,
selectedDatasets: nextDatasets,
})
if (item) {
updateItem({
multipleRetrievalConfig: nextMultipleRetrievalConfig,
selectedDatasets: nextDatasets,
})
if (item)
return
}
if (nextDatasets.length > 0) {
onItemCreate?.(createItemFromDialogState({
multipleRetrievalConfig: nextMultipleRetrievalConfig,
selectedDatasets: nextDatasets,
}))
}
if (nextDatasets.length > 0)
onItemCreate?.(createItemFromDialogState(nextState))
}
const metadataList = useMemo(() => {
const datasetsWithMetadata = selectedDatasets.filter(dataset => !!dataset.doc_metadata)
@ -410,336 +445,284 @@ export function AgentKnowledgeRetrievalDialog({
? undefined
: getValidationMessage(itemValidation?.metadata)
useEffect(() => {
if (hydratedKey !== hydrationKey) {
// eslint-disable-next-line react/set-state-in-effect
setDialogState(current => hydrationKey
? createDialogState(item, initialName, fallbackName, hydrationKey)
: {
...current,
hydratedKey: null,
isEditingName: false,
})
}
}, [fallbackName, hydratedKey, hydrationKey, initialName, item])
return (
<>
<DialogTitle className="sr-only">
{t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.title'])}
</DialogTitle>
<div className="flex items-center gap-2 px-4 pt-3">
<KnowledgeRetrievalDialogIcon />
<EditableKnowledgeRetrievalName
editLabel={t($ => $['agentDetail.configure.knowledgeRetrieval.edit'], { name })}
inputLabel={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.nameLabel'])}
invalid={!!nameError}
name={name}
onCommit={nextName => applyDialogStatePatch({ name: nextName })}
/>
<DialogCloseButton className="static size-7 shrink-0 rounded-md" />
</div>
{nameError && (
<div role="alert" className="px-4 pt-1 system-xs-regular text-text-destructive">
{nameError}
</div>
)}
useEffect(() => {
if (!isEditingName)
return
<div className="flex min-h-0 flex-1 flex-col gap-1 py-2">
<div className="flex flex-col gap-1 px-4 py-2">
<DialogFormLabel id={queryModeLabelId}>
{t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.label'])}
</DialogFormLabel>
<RadioGroup<KnowledgeRetrievalQueryMode>
aria-labelledby={queryModeLabelId}
className="w-full gap-2"
value={queryMode}
onValueChange={(nextMode) => {
if (nextMode)
applyDialogStatePatch({ queryMode: nextMode })
}}
>
{queryModeOptions.map(mode => (
<RadioItem<KnowledgeRetrievalQueryMode>
key={mode}
value={mode}
nativeButton
render={<button type="button" className={optionCardClassName} />}
>
<span className="min-w-0 truncate">
{t($ => $[`agentDetail.configure.knowledgeRetrieval.dialog.query.${mode}`])}
</span>
</RadioItem>
))}
</RadioGroup>
{queryMode === 'custom'
? (
<>
<div className="pt-1">
<Textarea
aria-label={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel'])}
aria-invalid={queryError ? true : undefined}
className="h-20 resize-none rounded-lg px-3 py-2 system-sm-regular"
placeholder={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customPlaceholder'])}
value={customQuery}
onValueChange={nextQuery => applyDialogStatePatch({ customQuery: nextQuery })}
/>
</div>
<p className="system-xs-regular text-text-tertiary">
{t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customDescription'])}
</p>
{queryError && (
<p role="alert" className="system-xs-regular text-text-destructive">
{queryError}
</p>
)}
</>
)
: (
<p className="pt-1 system-xs-regular text-text-tertiary">
{t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.agentDescription'])}
</p>
)}
</div>
nameInputRef.current?.focus()
nameInputRef.current?.select()
}, [isEditingName])
<div className="px-4 py-2">
<Field
title={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label'])}
required
operations={(
<div className="flex items-center space-x-1">
<RetrievalConfig
payload={{
retrieval_mode: retrievalMode,
multiple_retrieval_config: effectiveMultipleRetrievalConfig,
single_retrieval_config: singleRetrievalConfig,
}}
onRetrievalModeChange={(nextRetrievalMode) => {
const nextMultipleRetrievalConfig = nextRetrievalMode === RETRIEVE_TYPE.multiWay
? resolveMultipleRetrievalConfig(multipleRetrievalConfig)
: multipleRetrievalConfig
applyDialogStatePatch({
multipleRetrievalConfig: nextMultipleRetrievalConfig,
retrievalMode: nextRetrievalMode,
})
}}
onMultipleRetrievalConfigChange={(nextMultipleRetrievalConfig) => {
const normalizedMultipleRetrievalConfig = resolveMultipleRetrievalConfig(nextMultipleRetrievalConfig)
applyDialogStatePatch({ multipleRetrievalConfig: normalizedMultipleRetrievalConfig })
}}
singleRetrievalModelConfig={singleRetrievalConfig?.model}
onSingleRetrievalModelChange={(model) => {
applyDialogStatePatch({
singleRetrievalConfig: {
model: {
provider: model.provider,
name: model.modelId,
mode: model.mode ?? singleRetrievalConfig?.model.mode ?? AppModeEnum.CHAT,
completion_params: singleRetrievalConfig?.model.completion_params ?? { temperature: 0.7 },
},
},
})
}}
onSingleRetrievalModelParamsChange={(completionParams) => {
applyDialogStatePatch({
singleRetrievalConfig: {
model: {
provider: singleRetrievalConfig?.model.provider ?? '',
name: singleRetrievalConfig?.model.name ?? '',
mode: singleRetrievalConfig?.model.mode ?? AppModeEnum.CHAT,
completion_params: completionParams,
},
},
})
}}
readonly={!selectedDatasets.length}
modal
rerankModalOpen={rerankModelOpen}
onRerankModelOpenChange={setRerankModelOpen}
selectedDatasets={selectedDatasets}
/>
<div className="h-3 w-px bg-divider-regular" />
<AddKnowledge
selectedIds={selectedDatasets.map(dataset => dataset.id)}
modal
onChange={handleSelectedDatasetsChange}
/>
</div>
)}
>
<>
{selectedDatasets.length > 0 && (
<DatasetList
list={selectedDatasets}
onChange={handleSelectedDatasetsChange}
settingsDrawerBackdropClassName="bg-background-overlay"
settingsDrawerBackdropForceRender
settingsDrawerPopupClassName="data-[swipe-direction=right]:top-6 data-[swipe-direction=right]:bottom-6"
settingsModalHeight="100%"
/>
)}
{(datasetsError || retrievalError) && (
<div role="alert" className="pt-2 system-xs-regular text-text-destructive">
{datasetsError ?? retrievalError}
</div>
)}
</>
</Field>
</div>
<div className="py-2">
<MetadataFilter
metadataList={metadataList}
selectedDatasetsLoaded
metadataFilterMode={metadataFilterMode}
metadataFilteringConditions={metadataFilteringConditions}
handleMetadataFilterModeChange={nextMode => applyDialogStatePatch({ metadataFilterMode: nextMode })}
handleAddCondition={(metadataItem) => {
applyDialogStatePatch({
metadataFilteringConditions: {
...metadataFilteringConditions,
conditions: [
...metadataFilteringConditions.conditions,
createMetadataCondition(metadataItem),
],
},
})
}}
handleRemoveCondition={(conditionId) => {
applyDialogStatePatch({
metadataFilteringConditions: {
...metadataFilteringConditions,
conditions: metadataFilteringConditions.conditions.filter(condition => condition.id !== conditionId),
},
})
}}
handleToggleConditionLogicalOperator={() => {
applyDialogStatePatch({
metadataFilteringConditions: {
...metadataFilteringConditions,
logical_operator: metadataFilteringConditions.logical_operator === LogicalOperator.and
? LogicalOperator.or
: LogicalOperator.and,
},
})
}}
handleUpdateCondition={(conditionId, nextCondition) => {
applyDialogStatePatch({
metadataFilteringConditions: {
...metadataFilteringConditions,
conditions: metadataFilteringConditions.conditions.map(condition => condition.id === conditionId ? nextCondition : condition),
},
})
}}
metadataModelConfig={metadataModelConfig}
handleMetadataModelChange={(model) => {
applyDialogStatePatch({
metadataModelConfig: {
provider: model.provider,
name: model.modelId,
mode: model.mode ?? metadataModelConfig?.mode ?? AppModeEnum.CHAT,
completion_params: metadataModelConfig?.completion_params ?? { temperature: 0.7 },
},
})
}}
handleMetadataCompletionParamsChange={(completionParams) => {
applyDialogStatePatch({
metadataModelConfig: {
provider: metadataModelConfig?.provider ?? '',
name: metadataModelConfig?.name ?? '',
mode: metadataModelConfig?.mode ?? AppModeEnum.CHAT,
completion_params: completionParams,
},
})
}}
/>
{metadataError && (
<div role="alert" className="px-4 pt-2 system-xs-regular text-text-destructive">
{metadataError}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-2 px-4 pb-4">
<div aria-hidden className="h-2 w-8 border-b border-divider-regular" />
<a
href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-w-0 items-center gap-1 system-xs-regular text-text-tertiary hover:text-text-secondary hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-ri-book-read-line size-3 shrink-0" />
<span className="min-w-0 truncate">
{t($ => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })}
</span>
</a>
</div>
</>
)
}
export function AgentKnowledgeRetrievalDialog({
item,
initialName,
onItemCreate,
onItemChange,
open,
onOpenChange,
}: AgentKnowledgeRetrievalDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
backdropProps={{ forceRender: true }}
backdropClassName="fixed"
className="flex h-[520px] max-h-[calc(100dvh-2rem)] w-[400px] flex-col overflow-hidden p-0"
className="flex h-130 max-h-[calc(100dvh-2rem)] w-[400px] flex-col overflow-hidden p-0"
>
<DialogTitle className="sr-only">
{t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.title'])}
</DialogTitle>
<div className="flex items-center gap-2 px-4 pt-3">
<KnowledgeRetrievalDialogIcon />
{isEditingName
? (
<Input
ref={nameInputRef}
aria-label={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.nameLabel'])}
aria-invalid={nameError ? true : undefined}
className="h-7 min-w-0 flex-1 rounded-md px-1 py-0 system-xl-semibold text-text-primary"
value={name}
onBlur={() => patchDialogState({ isEditingName: false })}
onChange={(event) => {
const nextName = event.currentTarget.value
patchDialogState({ name: nextName })
updateItem({ name: nextName })
}}
onKeyDown={(event) => {
if (event.key === 'Enter')
patchDialogState({ isEditingName: false })
}}
/>
)
: (
<button
type="button"
className="flex h-7 min-w-0 flex-1 items-center rounded-md px-1 py-0 text-left system-xl-semibold text-text-primary hover:bg-components-input-bg-hover focus-visible:border focus-visible:border-components-input-border-active focus-visible:bg-components-input-bg-active focus-visible:shadow-xs focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => patchDialogState({ isEditingName: true })}
>
<span className="min-w-0 truncate">
{name}
</span>
</button>
)}
<DialogCloseButton className="static size-7 shrink-0 rounded-md" />
</div>
{nameError && (
<div role="alert" className="px-4 pt-1 system-xs-regular text-text-destructive">
{nameError}
</div>
)}
<div className="flex min-h-0 flex-1 flex-col gap-1 py-2">
<div className="flex flex-col gap-1 px-4 py-2">
<DialogFormLabel id={queryModeLabelId}>
{t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.label'])}
</DialogFormLabel>
<RadioGroup<KnowledgeRetrievalQueryMode>
aria-labelledby={queryModeLabelId}
className="w-full gap-2"
value={queryMode}
onValueChange={(nextMode) => {
if (nextMode) {
patchDialogState({ queryMode: nextMode })
updateItem({ queryMode: nextMode })
}
}}
>
{queryModeOptions.map(mode => (
<RadioItem<KnowledgeRetrievalQueryMode>
key={mode}
value={mode}
nativeButton
render={<button type="button" className={optionCardClassName} />}
>
<span className="min-w-0 truncate">
{t($ => $[`agentDetail.configure.knowledgeRetrieval.dialog.query.${mode}`])}
</span>
</RadioItem>
))}
</RadioGroup>
{queryMode === 'custom'
? (
<>
<div className="pt-1">
<Textarea
aria-label={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel'])}
aria-invalid={queryError ? true : undefined}
className="h-20 resize-none rounded-lg px-3 py-2 system-sm-regular"
placeholder={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customPlaceholder'])}
value={customQuery}
onValueChange={(nextQuery) => {
patchDialogState({ customQuery: nextQuery })
updateItem({ customQuery: nextQuery })
}}
/>
</div>
<p className="system-xs-regular text-text-tertiary">
{t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.customDescription'])}
</p>
{queryError && (
<p role="alert" className="system-xs-regular text-text-destructive">
{queryError}
</p>
)}
</>
)
: (
<p className="pt-1 system-xs-regular text-text-tertiary">
{t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.query.agentDescription'])}
</p>
)}
</div>
<div className="px-4 py-2">
<Field
title={t($ => $['agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label'])}
required
operations={(
<div className="flex items-center space-x-1">
<RetrievalConfig
payload={{
retrieval_mode: retrievalMode,
multiple_retrieval_config: effectiveMultipleRetrievalConfig,
single_retrieval_config: singleRetrievalConfig,
}}
onRetrievalModeChange={(nextRetrievalMode) => {
const nextMultipleRetrievalConfig = nextRetrievalMode === RETRIEVE_TYPE.multiWay
? resolveMultipleRetrievalConfig(multipleRetrievalConfig)
: multipleRetrievalConfig
patchDialogState({
multipleRetrievalConfig: nextMultipleRetrievalConfig,
retrievalMode: nextRetrievalMode,
})
updateItem({
multipleRetrievalConfig: nextMultipleRetrievalConfig,
retrievalMode: nextRetrievalMode,
})
}}
onMultipleRetrievalConfigChange={(nextMultipleRetrievalConfig) => {
const normalizedMultipleRetrievalConfig = resolveMultipleRetrievalConfig(nextMultipleRetrievalConfig)
patchDialogState({ multipleRetrievalConfig: normalizedMultipleRetrievalConfig })
updateItem({ multipleRetrievalConfig: normalizedMultipleRetrievalConfig })
}}
singleRetrievalModelConfig={singleRetrievalConfig?.model}
onSingleRetrievalModelChange={(model) => {
setSingleRetrievalConfig((current) => {
const nextSingleRetrievalConfig = {
model: {
provider: model.provider,
name: model.modelId,
mode: model.mode ?? current?.model.mode ?? AppModeEnum.CHAT,
completion_params: current?.model.completion_params ?? { temperature: 0.7 },
},
}
updateItem({ singleRetrievalConfig: nextSingleRetrievalConfig })
return nextSingleRetrievalConfig
})
}}
onSingleRetrievalModelParamsChange={(completionParams) => {
setSingleRetrievalConfig((current) => {
const nextSingleRetrievalConfig = {
model: {
provider: current?.model.provider ?? '',
name: current?.model.name ?? '',
mode: current?.model.mode ?? AppModeEnum.CHAT,
completion_params: completionParams,
},
}
updateItem({ singleRetrievalConfig: nextSingleRetrievalConfig })
return nextSingleRetrievalConfig
})
}}
readonly={!selectedDatasets.length}
modal
rerankModalOpen={rerankModelOpen}
onRerankModelOpenChange={nextOpen => patchDialogState({ rerankModelOpen: nextOpen })}
selectedDatasets={selectedDatasets}
/>
<div className="h-3 w-px bg-divider-regular" />
<AddKnowledge
selectedIds={selectedDatasets.map(dataset => dataset.id)}
modal
onChange={handleSelectedDatasetsChange}
/>
</div>
)}
>
<>
{selectedDatasets.length > 0 && (
<DatasetList
list={selectedDatasets}
onChange={handleSelectedDatasetsChange}
settingsDrawerBackdropClassName="bg-background-overlay"
settingsDrawerBackdropForceRender
settingsDrawerPopupClassName="data-[swipe-direction=right]:top-6 data-[swipe-direction=right]:bottom-6"
settingsModalHeight="100%"
/>
)}
{(datasetsError || retrievalError) && (
<div role="alert" className="pt-2 system-xs-regular text-text-destructive">
{datasetsError ?? retrievalError}
</div>
)}
</>
</Field>
</div>
<div className="py-2">
<MetadataFilter
metadataList={metadataList}
selectedDatasetsLoaded
metadataFilterMode={metadataFilterMode}
metadataFilteringConditions={metadataFilteringConditions}
handleMetadataFilterModeChange={(nextMode) => {
patchDialogState({ metadataFilterMode: nextMode })
updateItem({ metadataFilterMode: nextMode })
}}
handleAddCondition={(metadataItem) => {
setMetadataFilteringConditions((current) => {
const nextConditions = {
...current,
conditions: [...current.conditions, createMetadataCondition(metadataItem)],
}
updateItem({ metadataFilteringConditions: nextConditions })
return nextConditions
})
}}
handleRemoveCondition={(conditionId) => {
setMetadataFilteringConditions((current) => {
const nextConditions = {
...current,
conditions: current.conditions.filter(condition => condition.id !== conditionId),
}
updateItem({ metadataFilteringConditions: nextConditions })
return nextConditions
})
}}
handleToggleConditionLogicalOperator={() => {
setMetadataFilteringConditions((current) => {
const nextConditions = {
...current,
logical_operator: current.logical_operator === LogicalOperator.and
? LogicalOperator.or
: LogicalOperator.and,
}
updateItem({ metadataFilteringConditions: nextConditions })
return nextConditions
})
}}
handleUpdateCondition={(conditionId, nextCondition) => {
setMetadataFilteringConditions((current) => {
const nextConditions = {
...current,
conditions: current.conditions.map(condition => condition.id === conditionId ? nextCondition : condition),
}
updateItem({ metadataFilteringConditions: nextConditions })
return nextConditions
})
}}
metadataModelConfig={metadataModelConfig}
handleMetadataModelChange={(model) => {
setMetadataModelConfig((current) => {
const nextMetadataModelConfig = {
provider: model.provider,
name: model.modelId,
mode: model.mode ?? current?.mode ?? AppModeEnum.CHAT,
completion_params: current?.completion_params ?? { temperature: 0.7 },
}
updateItem({ metadataModelConfig: nextMetadataModelConfig })
return nextMetadataModelConfig
})
}}
handleMetadataCompletionParamsChange={(completionParams) => {
setMetadataModelConfig((current) => {
const nextMetadataModelConfig = {
provider: current?.provider ?? '',
name: current?.name ?? '',
mode: current?.mode ?? AppModeEnum.CHAT,
completion_params: completionParams,
}
updateItem({ metadataModelConfig: nextMetadataModelConfig })
return nextMetadataModelConfig
})
}}
/>
{metadataError && (
<div role="alert" className="px-4 pt-2 system-xs-regular text-text-destructive">
{metadataError}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-2 px-4 pb-4">
<div aria-hidden className="h-2 w-8 border-b border-divider-regular" />
<a
href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-w-0 items-center gap-1 system-xs-regular text-text-tertiary hover:text-text-secondary hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-ri-book-read-line size-3 shrink-0" />
<span className="min-w-0 truncate">
{t($ => $['form.retrievalSetting.learnMore'], { ns: 'datasetSettings' })}
</span>
</a>
</div>
<AgentKnowledgeRetrievalDialogContent
item={item}
initialName={initialName}
onItemCreate={onItemCreate}
onItemChange={onItemChange}
/>
</DialogContent>
</Dialog>
)

View File

@ -23,7 +23,7 @@ import {
COMMAND_PRIORITY_LOW,
SELECTION_CHANGE_COMMAND,
} from 'lexical'
import { useCallback, useEffect, useMemo, useLayoutEffect as useReactLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import PromptEditor from '@/app/components/base/prompt-editor'
@ -44,11 +44,6 @@ import { useAgentPromptToolIconResolver } from './hooks'
import { insertTokenAtTextRange, replaceTrailingSlashWithToken } from './options'
import { AgentPromptSlashMenu } from './slash'
const noopLayoutEffect: typeof useReactLayoutEffect = () => {}
const useIsoLayoutEffect = typeof document !== 'undefined'
? useReactLayoutEffect
: noopLayoutEffect
function AgentPromptPlaceholder({
insertLabel,
text,
@ -870,7 +865,7 @@ export function AgentPromptEditor() {
}
}, [closeSlashMenu, isSlashMenuOpen])
useIsoLayoutEffect(() => {
useEffect(() => {
if (!isSlashMenuOpen)
return
@ -948,7 +943,7 @@ export function AgentPromptEditor() {
}
}, [activateSlashMenuItem, closeSlashMenu, focusPromptEditor, getSlashMenuItems, isSlashMenuOpen, returnToSlashMenuMain, setActiveSlashMenuItem, slashMenuView])
useIsoLayoutEffect(() => {
useLayoutEffect(() => {
if (!isSlashMenuOpen)
return