feat: support save settings

This commit is contained in:
Joel
2026-01-16 17:44:08 +08:00
parent ee7a9a34e0
commit 0b33381efb
5 changed files with 225 additions and 10 deletions

View File

@ -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 (

View File

@ -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) => {

View File

@ -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 {

View File

@ -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,
})
},

View File

@ -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>
}
/**