From 0b33381efbbc791eff705d539e59cc723b58d4dd Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 16 Jan 2026 17:44:08 +0800 Subject: [PATCH] feat: support save settings --- .../components/workflow/skill/editor-tabs.tsx | 8 +- .../plugins/tool-block/component.tsx | 130 +++++++++++++++++- .../workflow/skill/skill-doc-editor.tsx | 28 +++- .../store/workflow/skill-editor-slice.ts | 66 +++++++++ web/types/app-asset.ts | 3 + 5 files changed, 225 insertions(+), 10 deletions(-) diff --git a/web/app/components/workflow/skill/editor-tabs.tsx b/web/app/components/workflow/skill/editor-tabs.tsx index 37e036ae05..8f5ea5896f 100644 --- a/web/app/components/workflow/skill/editor-tabs.tsx +++ b/web/app/components/workflow/skill/editor-tabs.tsx @@ -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 ( diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx index 41d865e449..08f841efeb 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx @@ -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 +} + +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 = ({ nodeKey, provider, @@ -59,6 +90,9 @@ const ToolBlockComponent: FC = ({ const [isSettingOpen, setIsSettingOpen] = useState(false) const [toolValue, setToolValue] = useState(null) const [portalContainer, setPortalContainer] = useState(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 = ({ } }, [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 = ({ } 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 = {} + 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 = ({ 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 | 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) => { diff --git a/web/app/components/workflow/skill/skill-doc-editor.tsx b/web/app/components/workflow/skill/skill-doc-editor.tsx index dae6799131..3e248637ff 100644 --- a/web/app/components/workflow/skill/skill-doc-editor.tsx +++ b/web/app/components/workflow/skill/skill-doc-editor.tsx @@ -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 { diff --git a/web/app/components/workflow/store/workflow/skill-editor-slice.ts b/web/app/components/workflow/store/workflow/skill-editor-slice.ts index f998130493..2f1dce53f5 100644 --- a/web/app/components/workflow/store/workflow/skill-editor-slice.ts +++ b/web/app/components/workflow/store/workflow/skill-editor-slice.ts @@ -190,6 +190,67 @@ const createDirtySlice: StateCreator = (set, get) => ({ }, }) +export type MetadataSliceShape = { + fileMetadata: Map> + dirtyMetadataIds: Set + setFileMetadata: (fileId: string, metadata: Record) => void + setDraftMetadata: (fileId: string, metadata: Record) => void + clearDraftMetadata: (fileId: string) => void + clearFileMetadata: (fileId: string) => void + isMetadataDirty: (fileId: string) => boolean + getFileMetadata: (fileId: string) => Record | undefined +} + +const createMetadataSlice: StateCreator = (set, get) => ({ + fileMetadata: new Map>(), + dirtyMetadataIds: new Set(), + + setFileMetadata: (fileId: string, metadata: Record) => { + 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) => { + 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 = (set, const tabArgs = [set, get, store] as unknown as Parameters> const fileTreeArgs = [set, get, store] as unknown as Parameters> const dirtyArgs = [set, get, store] as unknown as Parameters> + const metadataArgs = [set, get, store] as unknown as Parameters> const menuArgs = [set, get, store] as unknown as Parameters> return { ...createTabSlice(...tabArgs), ...createFileTreeSlice(...fileTreeArgs), ...createDirtySlice(...dirtyArgs), + ...createMetadataSlice(...metadataArgs), ...createFileOperationsMenuSlice(...menuArgs), resetSkillEditor: () => { @@ -237,6 +301,8 @@ export const createSkillEditorSlice: StateCreator = (set, previewTabId: null, expandedFolderIds: new Set(), dirtyContents: new Map(), + fileMetadata: new Map>(), + dirtyMetadataIds: new Set(), contextMenu: null, }) }, diff --git a/web/types/app-asset.ts b/web/types/app-asset.ts index 8bd60ae37f..9a63a5dfd6 100644 --- a/web/types/app-asset.ts +++ b/web/types/app-asset.ts @@ -68,6 +68,7 @@ export type AppAssetTreeResponse = { */ export type AppAssetFileContentResponse = { content: string + metadata?: Record } /** @@ -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 } /**