mirror of
https://github.com/langgenius/dify.git
synced 2026-03-19 05:37:42 +08:00
feat: support save settings
This commit is contained in:
@ -16,6 +16,7 @@ const EditorTabs: FC = () => {
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const previewTabId = useStore(s => s.previewTabId)
|
||||
const dirtyContents = useStore(s => s.dirtyContents)
|
||||
const dirtyMetadataIds = useStore(s => s.dirtyMetadataIds)
|
||||
const storeApi = useWorkflowStore()
|
||||
const { data: nodeMap } = useSkillAssetNodeMap()
|
||||
|
||||
@ -32,15 +33,16 @@ const EditorTabs: FC = () => {
|
||||
const closeTab = useCallback((fileId: string) => {
|
||||
storeApi.getState().closeTab(fileId)
|
||||
storeApi.getState().clearDraftContent(fileId)
|
||||
storeApi.getState().clearFileMetadata(fileId)
|
||||
}, [storeApi])
|
||||
|
||||
const handleTabClose = useCallback((fileId: string) => {
|
||||
if (dirtyContents.has(fileId)) {
|
||||
if (dirtyContents.has(fileId) || dirtyMetadataIds.has(fileId)) {
|
||||
setPendingCloseId(fileId)
|
||||
return
|
||||
}
|
||||
closeTab(fileId)
|
||||
}, [dirtyContents, closeTab])
|
||||
}, [dirtyContents, dirtyMetadataIds, closeTab])
|
||||
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
if (pendingCloseId) {
|
||||
@ -67,7 +69,7 @@ const EditorTabs: FC = () => {
|
||||
const node = nodeMap?.get(fileId)
|
||||
const name = node?.name ?? fileId
|
||||
const isActive = activeTabId === fileId
|
||||
const isDirty = dirtyContents.has(fileId)
|
||||
const isDirty = dirtyContents.has(fileId) || dirtyMetadataIds.has(fileId)
|
||||
const isPreview = previewTabId === fileId
|
||||
|
||||
return (
|
||||
|
||||
@ -8,9 +8,13 @@ import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { useSelectOrDelete } from '@/app/components/base/prompt-editor/hooks'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import ToolAuthorizationSection from '@/app/components/plugins/plugin-detail-panel/tool-selector/sections/tool-authorization-section'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { generateFormValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
|
||||
import ToolSettingsSection from '@/app/components/workflow/skill/editor/skill-editor/tool-setting/tool-settings-section'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import {
|
||||
@ -43,6 +47,33 @@ const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
|
||||
return icon
|
||||
}
|
||||
|
||||
type ToolConfigField = {
|
||||
id: string
|
||||
value: unknown
|
||||
auto: boolean
|
||||
}
|
||||
|
||||
type ToolConfigMetadata = {
|
||||
type: 'mcp' | 'builtin'
|
||||
configuration: {
|
||||
fields: ToolConfigField[]
|
||||
}
|
||||
}
|
||||
|
||||
type SkillFileMetadata = {
|
||||
tools?: Record<string, ToolConfigMetadata>
|
||||
}
|
||||
|
||||
const getVarKindType = (type: FormTypeEnum) => {
|
||||
if (type === FormTypeEnum.file || type === FormTypeEnum.files)
|
||||
return VarKindType.variable
|
||||
if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object)
|
||||
return VarKindType.constant
|
||||
if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput)
|
||||
return VarKindType.mixed
|
||||
return VarKindType.constant
|
||||
}
|
||||
|
||||
const ToolBlockComponent: FC<ToolBlockComponentProps> = ({
|
||||
nodeKey,
|
||||
provider,
|
||||
@ -59,6 +90,9 @@ const ToolBlockComponent: FC<ToolBlockComponentProps> = ({
|
||||
const [isSettingOpen, setIsSettingOpen] = useState(false)
|
||||
const [toolValue, setToolValue] = useState<ToolValue | null>(null)
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null)
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const fileMetadata = useStore(s => s.fileMetadata)
|
||||
const storeApi = useWorkflowStore()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
@ -93,6 +127,13 @@ const ToolBlockComponent: FC<ToolBlockComponentProps> = ({
|
||||
}
|
||||
}, [currentProvider, currentTool, language, tool])
|
||||
|
||||
const toolConfigFromMetadata = useMemo(() => {
|
||||
if (!activeTabId)
|
||||
return undefined
|
||||
const metadata = fileMetadata.get(activeTabId) as SkillFileMetadata | undefined
|
||||
return metadata?.tools?.[configId]
|
||||
}, [activeTabId, configId, fileMetadata])
|
||||
|
||||
const defaultToolValue = useMemo(() => {
|
||||
if (!currentProvider || !currentTool)
|
||||
return null
|
||||
@ -115,12 +156,64 @@ const ToolBlockComponent: FC<ToolBlockComponentProps> = ({
|
||||
} as ToolValue
|
||||
}, [currentProvider, currentTool, language, tool])
|
||||
|
||||
const configuredToolValue = useMemo(() => {
|
||||
if (!defaultToolValue || !currentTool)
|
||||
return defaultToolValue
|
||||
const fields = toolConfigFromMetadata?.configuration?.fields ?? []
|
||||
if (!fields.length)
|
||||
return defaultToolValue
|
||||
|
||||
const fieldsById = new Map(fields.map(field => [field.id, field]))
|
||||
const settingsSchemas = toolParametersToFormSchemas(currentTool.parameters?.filter(param => param.form !== 'llm') || [])
|
||||
const paramsSchemas = toolParametersToFormSchemas(currentTool.parameters?.filter(param => param.form === 'llm') || [])
|
||||
|
||||
const applyFields = (schemas: any[]) => {
|
||||
const nextValue: Record<string, any> = {}
|
||||
schemas.forEach((schema: any) => {
|
||||
const field = fieldsById.get(schema.variable)
|
||||
if (!field)
|
||||
return
|
||||
const isAuto = Boolean(field.auto)
|
||||
if (isAuto) {
|
||||
nextValue[schema.variable] = { auto: 1, value: null }
|
||||
return
|
||||
}
|
||||
nextValue[schema.variable] = {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: getVarKindType(schema.type as FormTypeEnum),
|
||||
value: field.value ?? null,
|
||||
},
|
||||
}
|
||||
})
|
||||
return nextValue
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultToolValue,
|
||||
settings: {
|
||||
...(defaultToolValue.settings || {}),
|
||||
...applyFields(settingsSchemas),
|
||||
},
|
||||
parameters: {
|
||||
...(defaultToolValue.parameters || {}),
|
||||
...applyFields(paramsSchemas),
|
||||
},
|
||||
}
|
||||
}, [currentTool, defaultToolValue, toolConfigFromMetadata])
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultToolValue)
|
||||
if (!configuredToolValue)
|
||||
return
|
||||
if (!toolValue || toolValue.tool_name !== defaultToolValue.tool_name || toolValue.provider_name !== defaultToolValue.provider_name)
|
||||
setToolValue(defaultToolValue)
|
||||
}, [defaultToolValue, toolValue])
|
||||
if (!toolValue || toolValue.tool_name !== configuredToolValue.tool_name || toolValue.provider_name !== configuredToolValue.provider_name)
|
||||
setToolValue(configuredToolValue)
|
||||
}, [configuredToolValue, toolValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingOpen || !configuredToolValue)
|
||||
return
|
||||
setToolValue(configuredToolValue)
|
||||
}, [configuredToolValue, isSettingOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const containerFromRef = ref.current?.closest('[data-skill-editor-root="true"]') as HTMLElement | null
|
||||
@ -192,6 +285,35 @@ const ToolBlockComponent: FC<ToolBlockComponentProps> = ({
|
||||
|
||||
const handleToolValueChange = (nextValue: ToolValue) => {
|
||||
setToolValue(nextValue)
|
||||
if (!activeTabId || !currentProvider || !currentTool)
|
||||
return
|
||||
const metadata = (fileMetadata.get(activeTabId) || {}) as SkillFileMetadata
|
||||
const toolType = currentProvider.type === CollectionType.mcp ? 'mcp' : 'builtin'
|
||||
const buildFields = (value: Record<string, any> | undefined) => {
|
||||
if (!value)
|
||||
return []
|
||||
return Object.entries(value).map(([id, field]) => {
|
||||
const auto = Boolean((field as any)?.auto)
|
||||
const rawValue = auto ? null : (field as any)?.value?.value ?? null
|
||||
return { id, value: rawValue, auto }
|
||||
})
|
||||
}
|
||||
const fields = [
|
||||
...buildFields(nextValue.settings),
|
||||
...buildFields(nextValue.parameters),
|
||||
]
|
||||
const nextMetadata: SkillFileMetadata = {
|
||||
...metadata,
|
||||
tools: {
|
||||
...(metadata.tools || {}),
|
||||
[configId]: {
|
||||
type: toolType,
|
||||
configuration: { fields },
|
||||
},
|
||||
},
|
||||
}
|
||||
storeApi.getState().setDraftMetadata(activeTabId, nextMetadata)
|
||||
storeApi.getState().pinTab(activeTabId)
|
||||
}
|
||||
|
||||
const handleAuthorizationItemClick = (id: string) => {
|
||||
|
||||
@ -36,6 +36,8 @@ const SkillDocEditor: FC = () => {
|
||||
|
||||
const activeTabId = useStore(s => s.activeTabId)
|
||||
const dirtyContents = useStore(s => s.dirtyContents)
|
||||
const dirtyMetadataIds = useStore(s => s.dirtyMetadataIds)
|
||||
const fileMetadata = useStore(s => s.fileMetadata)
|
||||
const storeApi = useWorkflowStore()
|
||||
const { data: nodeMap } = useSkillAssetNodeMap()
|
||||
|
||||
@ -65,6 +67,21 @@ const SkillDocEditor: FC = () => {
|
||||
return fileContent?.content ?? ''
|
||||
}, [activeTabId, dirtyContents, fileContent?.content])
|
||||
|
||||
const currentMetadata = useMemo(() => {
|
||||
if (!activeTabId)
|
||||
return undefined
|
||||
return fileMetadata.get(activeTabId)
|
||||
}, [activeTabId, fileMetadata])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !fileContent)
|
||||
return
|
||||
if (dirtyMetadataIds.has(activeTabId))
|
||||
return
|
||||
storeApi.getState().setFileMetadata(activeTabId, fileContent.metadata ?? {})
|
||||
storeApi.getState().clearDraftMetadata(activeTabId)
|
||||
}, [activeTabId, dirtyMetadataIds, fileContent, storeApi])
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
if (!activeTabId || !isEditable)
|
||||
return
|
||||
@ -77,16 +94,21 @@ const SkillDocEditor: FC = () => {
|
||||
return
|
||||
|
||||
const content = dirtyContents.get(activeTabId)
|
||||
if (content === undefined)
|
||||
const hasDirtyMetadata = dirtyMetadataIds.has(activeTabId)
|
||||
if (content === undefined && !hasDirtyMetadata)
|
||||
return
|
||||
|
||||
try {
|
||||
await updateContent.mutateAsync({
|
||||
appId,
|
||||
nodeId: activeTabId,
|
||||
payload: { content },
|
||||
payload: {
|
||||
content: content ?? fileContent?.content ?? '',
|
||||
...(currentMetadata ? { metadata: currentMetadata } : {}),
|
||||
},
|
||||
})
|
||||
storeApi.getState().clearDraftContent(activeTabId)
|
||||
storeApi.getState().clearDraftMetadata(activeTabId)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('api.saved', { ns: 'common' }),
|
||||
@ -98,7 +120,7 @@ const SkillDocEditor: FC = () => {
|
||||
message: String(error),
|
||||
})
|
||||
}
|
||||
}, [activeTabId, appId, dirtyContents, isEditable, storeApi, t, updateContent])
|
||||
}, [activeTabId, appId, currentMetadata, dirtyContents, dirtyMetadataIds, fileContent?.content, isEditable, storeApi, t, updateContent])
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
|
||||
@ -190,6 +190,67 @@ const createDirtySlice: StateCreator<DirtySliceShape> = (set, get) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export type MetadataSliceShape = {
|
||||
fileMetadata: Map<string, Record<string, any>>
|
||||
dirtyMetadataIds: Set<string>
|
||||
setFileMetadata: (fileId: string, metadata: Record<string, any>) => void
|
||||
setDraftMetadata: (fileId: string, metadata: Record<string, any>) => void
|
||||
clearDraftMetadata: (fileId: string) => void
|
||||
clearFileMetadata: (fileId: string) => void
|
||||
isMetadataDirty: (fileId: string) => boolean
|
||||
getFileMetadata: (fileId: string) => Record<string, any> | undefined
|
||||
}
|
||||
|
||||
const createMetadataSlice: StateCreator<MetadataSliceShape> = (set, get) => ({
|
||||
fileMetadata: new Map<string, Record<string, any>>(),
|
||||
dirtyMetadataIds: new Set<string>(),
|
||||
|
||||
setFileMetadata: (fileId: string, metadata: Record<string, any>) => {
|
||||
const { fileMetadata } = get()
|
||||
const nextMap = new Map(fileMetadata)
|
||||
if (metadata)
|
||||
nextMap.set(fileId, metadata)
|
||||
else
|
||||
nextMap.delete(fileId)
|
||||
set({ fileMetadata: nextMap })
|
||||
},
|
||||
|
||||
setDraftMetadata: (fileId: string, metadata: Record<string, any>) => {
|
||||
const { fileMetadata, dirtyMetadataIds } = get()
|
||||
const nextMap = new Map(fileMetadata)
|
||||
nextMap.set(fileId, metadata || {})
|
||||
const nextDirty = new Set(dirtyMetadataIds)
|
||||
nextDirty.add(fileId)
|
||||
set({ fileMetadata: nextMap, dirtyMetadataIds: nextDirty })
|
||||
},
|
||||
|
||||
clearDraftMetadata: (fileId: string) => {
|
||||
const { dirtyMetadataIds } = get()
|
||||
if (!dirtyMetadataIds.has(fileId))
|
||||
return
|
||||
const nextDirty = new Set(dirtyMetadataIds)
|
||||
nextDirty.delete(fileId)
|
||||
set({ dirtyMetadataIds: nextDirty })
|
||||
},
|
||||
|
||||
clearFileMetadata: (fileId: string) => {
|
||||
const { fileMetadata, dirtyMetadataIds } = get()
|
||||
const nextMap = new Map(fileMetadata)
|
||||
nextMap.delete(fileId)
|
||||
const nextDirty = new Set(dirtyMetadataIds)
|
||||
nextDirty.delete(fileId)
|
||||
set({ fileMetadata: nextMap, dirtyMetadataIds: nextDirty })
|
||||
},
|
||||
|
||||
isMetadataDirty: (fileId: string) => {
|
||||
return get().dirtyMetadataIds.has(fileId)
|
||||
},
|
||||
|
||||
getFileMetadata: (fileId: string) => {
|
||||
return get().fileMetadata.get(fileId)
|
||||
},
|
||||
})
|
||||
|
||||
export type FileOperationsMenuSliceShape = {
|
||||
contextMenu: {
|
||||
top: number
|
||||
@ -211,6 +272,7 @@ export type SkillEditorSliceShape
|
||||
= TabSliceShape
|
||||
& FileTreeSliceShape
|
||||
& DirtySliceShape
|
||||
& MetadataSliceShape
|
||||
& FileOperationsMenuSliceShape
|
||||
& {
|
||||
resetSkillEditor: () => void
|
||||
@ -222,12 +284,14 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (set,
|
||||
const tabArgs = [set, get, store] as unknown as Parameters<StateCreator<TabSliceShape>>
|
||||
const fileTreeArgs = [set, get, store] as unknown as Parameters<StateCreator<FileTreeSliceShape>>
|
||||
const dirtyArgs = [set, get, store] as unknown as Parameters<StateCreator<DirtySliceShape>>
|
||||
const metadataArgs = [set, get, store] as unknown as Parameters<StateCreator<MetadataSliceShape>>
|
||||
const menuArgs = [set, get, store] as unknown as Parameters<StateCreator<FileOperationsMenuSliceShape>>
|
||||
|
||||
return {
|
||||
...createTabSlice(...tabArgs),
|
||||
...createFileTreeSlice(...fileTreeArgs),
|
||||
...createDirtySlice(...dirtyArgs),
|
||||
...createMetadataSlice(...metadataArgs),
|
||||
...createFileOperationsMenuSlice(...menuArgs),
|
||||
|
||||
resetSkillEditor: () => {
|
||||
@ -237,6 +301,8 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (set,
|
||||
previewTabId: null,
|
||||
expandedFolderIds: new Set<string>(),
|
||||
dirtyContents: new Map<string, string>(),
|
||||
fileMetadata: new Map<string, Record<string, any>>(),
|
||||
dirtyMetadataIds: new Set<string>(),
|
||||
contextMenu: null,
|
||||
})
|
||||
},
|
||||
|
||||
@ -68,6 +68,7 @@ export type AppAssetTreeResponse = {
|
||||
*/
|
||||
export type AppAssetFileContentResponse = {
|
||||
content: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -130,6 +131,8 @@ export type CreateFilePayload = {
|
||||
export type UpdateFileContentPayload = {
|
||||
/** New file content (UTF-8) */
|
||||
content: string
|
||||
/** Optional metadata associated with the file */
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user