diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index e25bcacb9b..2ada662067 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -1,6 +1,5 @@ 'use client' import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types' -import type { WorkflowToolModalPayload } from '@/app/components/tools/workflow-tool' import { RiCloseLine, } from '@remixicon/react' @@ -411,9 +410,9 @@ const ProviderDetail = ({ onRemove={onClickCustomToolDelete} /> )} - {isShowEditWorkflowToolModal && ( + {isShowEditWorkflowToolModal && customCollection && ( setIsShowEditWorkflowToolModal(false)} onRemove={onClickWorkflowToolDelete} onSave={updateWorkflowToolProvider} diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index e20061a899..b0b5cf7166 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -52,7 +52,7 @@ export type Collection = { icon_dark?: string | Emoji label: TypeWithI18N type: CollectionType | string - team_credentials: Record + team_credentials: Record is_team_authorization: boolean allow_delete: boolean labels: string[] @@ -124,6 +124,7 @@ export type Event = { description: TypeWithI18N parameters: TriggerParameter[] labels: string[] + // eslint-disable-next-line ts/no-explicit-any output_schema: Record } @@ -131,9 +132,10 @@ export type Tool = { name: string author: string label: TypeWithI18N - description: any + description: TypeWithI18N parameters: ToolParameter[] labels: string[] + // eslint-disable-next-line ts/no-explicit-any output_schema: Record } @@ -215,6 +217,7 @@ export type WorkflowToolProviderOutputSchema = { export type WorkflowToolProviderRequest = { name: string + label: string icon: Emoji description: string parameters: WorkflowToolProviderParameter[] diff --git a/web/app/components/tools/workflow-tool/components/tool-input-table.tsx b/web/app/components/tools/workflow-tool/components/tool-input-table.tsx new file mode 100644 index 0000000000..7c61f59a2f --- /dev/null +++ b/web/app/components/tools/workflow-tool/components/tool-input-table.tsx @@ -0,0 +1,105 @@ +'use client' +import type { FC } from 'react' +import type { WorkflowToolProviderParameter } from '@/app/components/tools/types' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/utils/classnames' +import MethodSelector from '../method-selector' + +type ToolInputTableProps = { + parameters: WorkflowToolProviderParameter[] + onParameterChange: (key: 'description' | 'form', value: string, index: number) => void +} + +type ParameterRowProps = { + item: WorkflowToolProviderParameter + index: number + onParameterChange: (key: 'description' | 'form', value: string, index: number) => void +} + +const ParameterRow: FC = ({ item, index, onParameterChange }) => { + const { t } = useTranslation() + const isImageParameter = item.name === '__image' + + return ( + + +
+
+ {item.name} + {item.required && ( + + {t('createTool.toolInput.required', { ns: 'tools' })} + + )} +
+
{item.type}
+
+ + + {isImageParameter + ? ( +
+
+ {t('createTool.toolInput.methodParameter', { ns: 'tools' })} +
+
+ ) + : ( + onParameterChange('form', value, index)} + /> + )} + + + onParameterChange('description', e.target.value, index)} + /> + + + ) +} + +const ToolInputTable: FC = ({ parameters, onParameterChange }) => { + const { t } = useTranslation() + + return ( +
+ + + + + + + + + + {parameters.map((item, index) => ( + + ))} + +
+ {t('createTool.toolInput.name', { ns: 'tools' })} + + {t('createTool.toolInput.method', { ns: 'tools' })} + + {t('createTool.toolInput.description', { ns: 'tools' })} +
+
+ ) +} + +export default React.memo(ToolInputTable) diff --git a/web/app/components/tools/workflow-tool/components/tool-output-table.tsx b/web/app/components/tools/workflow-tool/components/tool-output-table.tsx new file mode 100644 index 0000000000..b902fcae12 --- /dev/null +++ b/web/app/components/tools/workflow-tool/components/tool-output-table.tsx @@ -0,0 +1,88 @@ +'use client' +import type { FC } from 'react' +import type { WorkflowToolProviderOutputParameter } from '@/app/components/tools/types' +import { RiErrorWarningLine } from '@remixicon/react' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import Tooltip from '@/app/components/base/tooltip' + +type ToolOutputTableProps = { + parameters: WorkflowToolProviderOutputParameter[] + isReserved: (name: string) => boolean +} + +type OutputRowProps = { + item: WorkflowToolProviderOutputParameter + isReserved: (name: string) => boolean +} + +const OutputRow: FC = ({ item, isReserved }) => { + const { t } = useTranslation() + const showDuplicateWarning = !item.reserved && isReserved(item.name) + + return ( + + +
+
+ {item.name} + {item.reserved && ( + + {t('createTool.toolOutput.reserved', { ns: 'tools' })} + + )} + {showDuplicateWarning && ( + + {t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })} +
+ )} + > + + + )} +
+
{item.type}
+ + + + + {item.description} + + + + ) +} + +const ToolOutputTable: FC = ({ parameters, isReserved }) => { + const { t } = useTranslation() + + return ( +
+ + + + + + + + + {parameters.map(item => ( + + ))} + +
+ {t('createTool.name', { ns: 'tools' })} + + {t('createTool.toolOutput.description', { ns: 'tools' })} +
+
+ ) +} + +export default React.memo(ToolOutputTable) diff --git a/web/app/components/tools/workflow-tool/configure-button.tsx b/web/app/components/tools/workflow-tool/configure-button.tsx index 6526722b63..1dbba79045 100644 --- a/web/app/components/tools/workflow-tool/configure-button.tsx +++ b/web/app/components/tools/workflow-tool/configure-button.tsx @@ -1,22 +1,18 @@ 'use client' -import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types' +import type { Emoji } from '@/app/components/tools/types' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react' import { useRouter } from 'next/navigation' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' +import Divider from '@/app/components/base/divider' import Loading from '@/app/components/base/loading' -import Toast from '@/app/components/base/toast' import Indicator from '@/app/components/header/indicator' import WorkflowToolModal from '@/app/components/tools/workflow-tool' import { useAppContext } from '@/context/app-context' -import { createWorkflowToolProvider, fetchWorkflowToolDetailByAppID, saveWorkflowToolProvider } from '@/service/tools' -import { useInvalidateAllWorkflowTools } from '@/service/use-tools' import { cn } from '@/utils/classnames' -import Divider from '../../base/divider' +import { useConfigureButton } from './hooks/use-configure-button' type Props = { disabled: boolean @@ -33,10 +29,103 @@ type Props = { disabledReason?: string } +type UnpublishedCardProps = { + disabled: boolean + isManager: boolean + onConfigureClick: () => void +} + +const UnpublishedCard = ({ disabled, isManager, onConfigureClick }: UnpublishedCardProps) => { + const { t } = useTranslation() + + const handleClick = () => { + if (!disabled && isManager) + onConfigureClick() + } + + return ( +
+ +
+ {t('common.workflowAsTool', { ns: 'workflow' })} +
+ + {t('common.configureRequired', { ns: 'workflow' })} + +
+ ) +} + +type NonManagerCardProps = object + +const NonManagerCard = (_props: NonManagerCardProps) => { + const { t } = useTranslation() + + return ( +
+ +
+ {t('common.workflowAsTool', { ns: 'workflow' })} +
+
+ ) +} + +type PublishedActionsProps = { + disabled: boolean + isManager: boolean + outdated: boolean + onConfigureClick: () => void + onManageClick: () => void +} + +const PublishedActions = ({ disabled, isManager, outdated, onConfigureClick, onManageClick }: PublishedActionsProps) => { + const { t } = useTranslation() + + return ( +
+
+ + +
+ {outdated && ( +
+ {t('common.workflowAsToolTip', { ns: 'workflow' })} +
+ )} +
+ ) +} + const WorkflowToolConfigureButton = ({ disabled, published, - detailNeedUpdate, + detailNeedUpdate: _detailNeedUpdate, workflowAppId, icon, name, @@ -49,229 +138,95 @@ const WorkflowToolConfigureButton = ({ }: Props) => { const { t } = useTranslation() const router = useRouter() - const [showModal, setShowModal] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [detail, setDetail] = useState() const { isCurrentWorkspaceManager } = useAppContext() - const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools() - const outdated = useMemo(() => { - if (!detail) - return false - if (detail.tool.parameters.length !== inputs?.length) { - return true - } - else { - for (const item of inputs || []) { - const param = detail.tool.parameters.find(toolParam => toolParam.name === item.variable) - if (!param) { - return true - } - else if (param.required !== item.required) { - return true - } - else { - if (item.type === 'paragraph' && param.type !== 'string') - return true - if (item.type === 'text-input' && param.type !== 'string') - return true - } - } - } - return false - }, [detail, inputs]) + const { + showModal, + isLoading, + outdated, + payload, + openModal, + closeModal, + handleCreate, + handleUpdate, + } = useConfigureButton({ + published, + workflowAppId, + icon, + name, + description, + inputs, + outputs, + handlePublish, + onRefreshData, + }) - const payload = useMemo(() => { - let parameters: WorkflowToolProviderParameter[] = [] - let outputParameters: WorkflowToolProviderOutputParameter[] = [] + const handleUnpublishedClick = () => { + if (!disabled) + openModal() + } + + const handleManageClick = () => { + router.push('/tools?category=workflow') + } + + const cardClassName = cn( + 'group rounded-lg bg-background-section-burn transition-colors', + disabled || !isCurrentWorkspaceManager ? 'cursor-not-allowed opacity-60 shadow-xs' : 'cursor-pointer', + !disabled && !published && isCurrentWorkspaceManager && 'hover:bg-state-accent-hover', + ) + + const renderCardContent = () => { + if (!isCurrentWorkspaceManager) + return if (!published) { - parameters = (inputs || []).map((item) => { - return { - name: item.variable, - description: '', - form: 'llm', - required: item.required, - type: item.type, - } - }) - outputParameters = (outputs || []).map((item) => { - return { - name: item.variable, - description: '', - type: item.value_type, - } - }) + return ( + + ) } - else if (detail && detail.tool) { - parameters = (inputs || []).map((item) => { - return { - name: item.variable, - required: item.required, - type: item.type === 'paragraph' ? 'string' : item.type, - description: detail.tool.parameters.find(param => param.name === item.variable)?.llm_description || '', - form: detail.tool.parameters.find(param => param.name === item.variable)?.form || 'llm', - } - }) - outputParameters = (outputs || []).map((item) => { - const found = detail.tool.output_schema?.properties?.[item.variable] - return { - name: item.variable, - description: found ? found.description : '', - type: item.value_type, - } - }) - } - return { - icon: detail?.icon || icon, - label: detail?.label || name, - name: detail?.name || '', - description: detail?.description || description, - parameters, - outputParameters, - labels: detail?.tool?.labels || [], - privacy_policy: detail?.privacy_policy || '', - ...(published - ? { - workflow_tool_id: detail?.workflow_tool_id, - } - : { - workflow_app_id: workflowAppId, - }), - } - }, [detail, published, workflowAppId, icon, name, description, inputs]) - const getDetail = useCallback(async (workflowAppId: string) => { - setIsLoading(true) - const res = await fetchWorkflowToolDetailByAppID(workflowAppId) - setDetail(res) - setIsLoading(false) - }, []) - - useEffect(() => { - if (published) - getDetail(workflowAppId) - }, [getDetail, published, workflowAppId]) - - useEffect(() => { - if (detailNeedUpdate) - getDetail(workflowAppId) - }, [detailNeedUpdate, getDetail, workflowAppId]) - - const createHandle = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => { - try { - await createWorkflowToolProvider(data) - invalidateAllWorkflowTools() - onRefreshData?.() - getDetail(workflowAppId) - Toast.notify({ - type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), - }) - setShowModal(false) - } - catch (e) { - Toast.notify({ type: 'error', message: (e as Error).message }) - } + return ( +
+ +
+ {t('common.workflowAsTool', { ns: 'workflow' })} +
+
+ ) } - const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{ - workflow_app_id: string - workflow_tool_id: string - }>) => { - try { - await handlePublish() - await saveWorkflowToolProvider(data) - onRefreshData?.() - invalidateAllWorkflowTools() - getDetail(workflowAppId) - Toast.notify({ - type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), - }) - setShowModal(false) - } - catch (e) { - Toast.notify({ type: 'error', message: (e as Error).message }) - } - } + const showContent = !published || !isLoading return ( <> - {(!published || !isLoading) && ( -
- {isCurrentWorkspaceManager - ? ( -
!disabled && !published && setShowModal(true)} - > - -
- {t('common.workflowAsTool', { ns: 'workflow' })} -
- {!published && ( - - {t('common.configureRequired', { ns: 'workflow' })} - - )} -
- ) - : ( -
- -
- {t('common.workflowAsTool', { ns: 'workflow' })} -
-
- )} + {showContent && ( +
+ {renderCardContent()} {disabledReason && (
{disabledReason}
)} {published && ( -
-
- - -
- {outdated && ( -
- {t('common.workflowAsToolTip', { ns: 'workflow' })} -
- )} -
+ )}
)} @@ -280,12 +235,13 @@ const WorkflowToolConfigureButton = ({ setShowModal(false)} - onCreate={createHandle} - onSave={updateWorkflowToolProvider} + onHide={closeModal} + onCreate={handleCreate} + onSave={handleUpdate} /> )} ) } + export default WorkflowToolConfigureButton diff --git a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts new file mode 100644 index 0000000000..9e12438ae0 --- /dev/null +++ b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts @@ -0,0 +1,213 @@ +'use client' +import type { Emoji, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types' +import type { InputVar, Variable } from '@/app/components/workflow/types' +import type { PublishWorkflowParams } from '@/types/workflow' +import { useBoolean } from 'ahooks' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import Toast from '@/app/components/base/toast' +import { useInvalidateAllWorkflowTools } from '@/service/use-tools' +import { + useCreateWorkflowTool, + useInvalidateWorkflowToolDetail, + useUpdateWorkflowTool, + useWorkflowToolDetail, +} from './use-workflow-tool' + +export type ConfigureButtonProps = { + published: boolean + workflowAppId: string + icon: Emoji + name: string + description: string + inputs?: InputVar[] + outputs?: Variable[] + handlePublish: (params?: PublishWorkflowParams) => Promise + onRefreshData?: () => void +} + +// Type for parameter building context +type ParameterBuildContext = { + inputs: InputVar[] | undefined + outputs: Variable[] | undefined + detail: WorkflowToolProviderResponse | undefined + published: boolean +} + +/** + * Check if tool parameters are outdated compared to workflow inputs + */ +function checkOutdated(detail: WorkflowToolProviderResponse | undefined, inputs: InputVar[] | undefined): boolean { + if (!detail) + return false + + const toolParams = detail.tool.parameters + const inputList = inputs ?? [] + + if (toolParams.length !== inputList.length) + return true + + return inputList.some((item) => { + const param = toolParams.find(p => p.name === item.variable) + if (!param || param.required !== item.required) + return true + + const isTextType = item.type === 'paragraph' || item.type === 'text-input' + return isTextType && param.type !== 'string' + }) +} + +/** + * Build input parameters based on context + */ +function buildInputParameters(ctx: ParameterBuildContext): WorkflowToolProviderParameter[] { + const inputList = ctx.inputs ?? [] + + if (!ctx.published || !ctx.detail?.tool) { + return inputList.map(item => ({ + name: item.variable, + description: '', + form: 'llm', + required: item.required, + type: item.type, + })) + } + + const existingParams = ctx.detail.tool.parameters + return inputList.map((item) => { + const existing = existingParams.find(p => p.name === item.variable) + return { + name: item.variable, + required: item.required, + type: item.type === 'paragraph' ? 'string' : item.type, + description: existing?.llm_description ?? '', + form: existing?.form ?? 'llm', + } + }) +} + +/** + * Build output parameters + */ +function buildOutputParameters(outputs: Variable[] | undefined, detail?: WorkflowToolProviderResponse) { + return (outputs ?? []).map((item) => { + const found = detail?.tool.output_schema?.properties?.[item.variable] + return { + name: item.variable, + description: found?.description ?? '', + type: item.value_type, + } + }) +} + +/** + * Custom hook for managing configure button state and logic + */ +export const useConfigureButton = ({ + published, + workflowAppId, + icon, + name, + description, + inputs, + outputs, + handlePublish, + onRefreshData, +}: ConfigureButtonProps) => { + const { t } = useTranslation() + const [showModal, { setTrue: openModal, setFalse: closeModal }] = useBoolean(false) + + // Data fetching with React Query + const { + data: detail, + isLoading, + refetch: refetchDetail, + } = useWorkflowToolDetail(workflowAppId, published) + + // Mutations + const { mutateAsync: createTool } = useCreateWorkflowTool() + const { mutateAsync: updateTool } = useUpdateWorkflowTool() + const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools() + const invalidateDetail = useInvalidateWorkflowToolDetail() + + // Check if parameters are outdated + const outdated = useMemo( + () => checkOutdated(detail, inputs), + [detail, inputs], + ) + + // Build payload for modal + const payload = useMemo(() => { + const ctx: ParameterBuildContext = { inputs, outputs, detail, published } + const parameters = buildInputParameters(ctx) + const outputParameters = buildOutputParameters(outputs, detail) + + return { + icon: detail?.icon ?? icon, + label: detail?.label ?? name, + name: detail?.name ?? '', + description: detail?.description ?? description, + parameters, + outputParameters, + labels: detail?.tool?.labels ?? [], + privacy_policy: detail?.privacy_policy ?? '', + ...(published + ? { workflow_tool_id: detail?.workflow_tool_id } + : { workflow_app_id: workflowAppId }), + } + }, [detail, published, workflowAppId, icon, name, description, inputs, outputs]) + + // Common cache invalidation logic + const invalidateCaches = useCallback(() => { + invalidateAllWorkflowTools() + invalidateDetail(workflowAppId) + onRefreshData?.() + refetchDetail() + }, [invalidateAllWorkflowTools, invalidateDetail, workflowAppId, onRefreshData, refetchDetail]) + + // Common success handler + const handleSuccess = useCallback(() => { + Toast.notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) }) + closeModal() + }, [t, closeModal]) + + // Handler for creating new workflow tool + const handleCreate = useCallback(async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => { + try { + await createTool(data) + invalidateCaches() + handleSuccess() + } + catch (e) { + Toast.notify({ type: 'error', message: (e as Error).message }) + } + }, [createTool, invalidateCaches, handleSuccess]) + + // Handler for updating workflow tool + const handleUpdate = useCallback(async (data: WorkflowToolProviderRequest & Partial<{ + workflow_app_id: string + workflow_tool_id: string + }>) => { + try { + await handlePublish() + await updateTool(data) + invalidateCaches() + handleSuccess() + } + catch (e) { + Toast.notify({ type: 'error', message: (e as Error).message }) + } + }, [handlePublish, updateTool, invalidateCaches, handleSuccess]) + + return { + showModal, + isLoading, + detail, + outdated, + payload, + openModal, + closeModal, + handleCreate, + handleUpdate, + } +} diff --git a/web/app/components/tools/workflow-tool/hooks/use-modal-state.ts b/web/app/components/tools/workflow-tool/hooks/use-modal-state.ts new file mode 100644 index 0000000000..159d37ed67 --- /dev/null +++ b/web/app/components/tools/workflow-tool/hooks/use-modal-state.ts @@ -0,0 +1,40 @@ +'use client' +import { useBoolean } from 'ahooks' +import { useCallback } from 'react' + +export type ModalStateResult = { + isOpen: boolean + open: () => void + close: () => void + toggle: () => void +} + +/** + * Simple hook for managing modal open/close state + */ +export const useModalState = (initialState = false): ModalStateResult => { + const [isOpen, { setTrue: open, setFalse: close, toggle }] = useBoolean(initialState) + return { isOpen, open, close, toggle } +} + +/** + * Hook for managing multiple modal states + */ +export const useMultiModalState = (modalNames: T[]) => { + // Create individual modal states + const modals = modalNames.reduce((acc, name) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [isOpen, { setTrue: open, setFalse: close }] = useBoolean(false) + acc[name] = { isOpen, open, close } + return acc + }, {} as Record void, close: () => void }>) + + // Helper to close all modals + const closeAll = useCallback(() => { + modalNames.forEach((name) => { + modals[name].close() + }) + }, [modals, modalNames]) + + return { modals, closeAll } +} diff --git a/web/app/components/tools/workflow-tool/hooks/use-workflow-tool-form.ts b/web/app/components/tools/workflow-tool/hooks/use-workflow-tool-form.ts new file mode 100644 index 0000000000..496d8536bd --- /dev/null +++ b/web/app/components/tools/workflow-tool/hooks/use-workflow-tool-form.ts @@ -0,0 +1,240 @@ +'use client' +import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '@/app/components/tools/types' +import { produce } from 'immer' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Toast from '@/app/components/base/toast' +import { VarType } from '@/app/components/workflow/types' +import { buildWorkflowOutputParameters } from '../utils' + +export type WorkflowToolFormPayload = { + icon: Emoji + label: string + name: string + description: string + parameters: WorkflowToolProviderParameter[] + outputParameters?: WorkflowToolProviderOutputParameter[] | null + labels: string[] + privacy_policy: string + workflow_app_id?: string + workflow_tool_id?: string + tool?: { + output_schema?: WorkflowToolProviderOutputSchema | null + } +} + +export type UseWorkflowToolFormProps = { + payload: WorkflowToolFormPayload + isAdd?: boolean + onCreate?: (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => void + onSave?: (data: WorkflowToolProviderRequest & Partial<{ + workflow_app_id: string + workflow_tool_id: string + }>) => void +} + +type FormState = { + emoji: Emoji + label: string + name: string + description: string + parameters: WorkflowToolProviderParameter[] + labels: string[] + privacyPolicy: string +} + +/** + * Validate tool name format (alphanumeric and underscores only) + */ +const isNameValid = (name: string): boolean => { + if (name === '') + return true + return /^\w+$/.test(name) +} + +/** + * Custom hook for managing workflow tool form state and logic + */ +export const useWorkflowToolForm = ({ + payload, + isAdd, + onCreate, + onSave, +}: UseWorkflowToolFormProps) => { + const { t } = useTranslation() + + // Form state + const [formState, setFormState] = useState({ + emoji: payload.icon, + label: payload.label, + name: payload.name, + description: payload.description, + parameters: payload.parameters, + labels: payload.labels, + privacyPolicy: payload.privacy_policy, + }) + + // Computed output parameters (from payload.outputParameters or derived from tool.output_schema) + const outputParameters = useMemo( + () => buildWorkflowOutputParameters(payload.outputParameters ?? null, payload.tool?.output_schema ?? null), + [payload.outputParameters, payload.tool?.output_schema], + ) + + // Reserved output parameters (text, files, json) + const reservedOutputParameters = useMemo(() => [ + { + name: 'text', + description: t('nodes.tool.outputVars.text', { ns: 'workflow' }), + type: VarType.string, + reserved: true, + }, + { + name: 'files', + description: t('nodes.tool.outputVars.files.title', { ns: 'workflow' }), + type: VarType.arrayFile, + reserved: true, + }, + { + name: 'json', + description: t('nodes.tool.outputVars.json', { ns: 'workflow' }), + type: VarType.arrayObject, + reserved: true, + }, + ], [t]) + + // Check if output parameter name conflicts with reserved names + const isOutputParameterReserved = useCallback((name: string) => { + return reservedOutputParameters.some(p => p.name === name) + }, [reservedOutputParameters]) + + // State update handlers + const setEmoji = useCallback((emoji: Emoji) => { + setFormState(prev => ({ ...prev, emoji })) + }, []) + + const setLabel = useCallback((label: string) => { + setFormState(prev => ({ ...prev, label })) + }, []) + + const setName = useCallback((name: string) => { + setFormState(prev => ({ ...prev, name })) + }, []) + + const setDescription = useCallback((description: string) => { + setFormState(prev => ({ ...prev, description })) + }, []) + + const setLabels = useCallback((labels: string[]) => { + setFormState(prev => ({ ...prev, labels })) + }, []) + + const setPrivacyPolicy = useCallback((privacyPolicy: string) => { + setFormState(prev => ({ ...prev, privacyPolicy })) + }, []) + + // Handle parameter change (description or form/method) + const handleParameterChange = useCallback((key: 'description' | 'form', value: string, index: number) => { + setFormState((prev) => { + const newParameters = produce(prev.parameters, (draft) => { + if (key === 'description') + draft[index].description = value + else + draft[index].form = value + }) + return { ...prev, parameters: newParameters } + }) + }, []) + + // Validate form and show error toast if invalid + const validateForm = useCallback((): boolean => { + if (!formState.label) { + Toast.notify({ + type: 'error', + message: t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.name', { ns: 'tools' }) }), + }) + return false + } + + if (!formState.name) { + Toast.notify({ + type: 'error', + message: t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.nameForToolCall', { ns: 'tools' }) }), + }) + return false + } + + if (!isNameValid(formState.name)) { + Toast.notify({ + type: 'error', + message: t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' }), + }) + return false + } + + return true + }, [formState.label, formState.name, t]) + + // Build request params for API + const buildRequestParams = useCallback((): WorkflowToolProviderRequest => ({ + name: formState.name, + description: formState.description, + icon: formState.emoji, + label: formState.label, + parameters: formState.parameters.map(item => ({ + name: item.name, + description: item.description, + form: item.form, + })), + labels: formState.labels, + privacy_policy: formState.privacyPolicy, + }), [formState]) + + // Submit form + const onConfirm = useCallback(() => { + if (!validateForm()) + return + + const requestParams = buildRequestParams() + + if (isAdd) { + onCreate?.({ + ...requestParams, + workflow_app_id: payload.workflow_app_id!, + }) + } + else { + onSave?.({ + ...requestParams, + workflow_tool_id: payload.workflow_tool_id, + }) + } + }, [validateForm, buildRequestParams, isAdd, onCreate, onSave, payload.workflow_app_id, payload.workflow_tool_id]) + + return { + // Form state + emoji: formState.emoji, + label: formState.label, + name: formState.name, + description: formState.description, + parameters: formState.parameters, + labels: formState.labels, + privacyPolicy: formState.privacyPolicy, + + // Computed values + outputParameters, + reservedOutputParameters, + allOutputParameters: [...reservedOutputParameters, ...outputParameters], + isNameValid: isNameValid(formState.name), + + // Handlers + setEmoji, + setLabel, + setName, + setDescription, + setLabels, + setPrivacyPolicy, + handleParameterChange, + isOutputParameterReserved, + onConfirm, + } +} diff --git a/web/app/components/tools/workflow-tool/hooks/use-workflow-tool.ts b/web/app/components/tools/workflow-tool/hooks/use-workflow-tool.ts new file mode 100644 index 0000000000..3c22923b8a --- /dev/null +++ b/web/app/components/tools/workflow-tool/hooks/use-workflow-tool.ts @@ -0,0 +1,70 @@ +import type { WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types' +import { + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { get, post } from '@/service/base' + +const NAME_SPACE = 'workflow-tool' + +// Query key factory for workflow tool detail +const workflowToolDetailKey = (appId: string) => [NAME_SPACE, 'detail', appId] + +/** + * Fetch workflow tool detail by app ID + */ +export const useWorkflowToolDetail = (appId: string, enabled = true) => { + return useQuery({ + queryKey: workflowToolDetailKey(appId), + queryFn: () => get(`/workspaces/current/tool-provider/workflow/detail?workflow_app_id=${appId}`), + enabled: enabled && !!appId, + }) +} + +/** + * Invalidate workflow tool detail cache + */ +export const useInvalidateWorkflowToolDetail = () => { + const queryClient = useQueryClient() + return (appId: string) => { + queryClient.invalidateQueries({ + queryKey: workflowToolDetailKey(appId), + }) + } +} + +type CreateWorkflowToolPayload = WorkflowToolProviderRequest & { workflow_app_id: string } + +/** + * Create workflow tool provider mutation + */ +export const useCreateWorkflowTool = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'create'], + mutationFn: (payload: CreateWorkflowToolPayload) => { + return post('/workspaces/current/tool-provider/workflow/create', { + body: payload, + }) + }, + }) +} + +type UpdateWorkflowToolPayload = WorkflowToolProviderRequest & Partial<{ + workflow_app_id: string + workflow_tool_id: string +}> + +/** + * Update workflow tool provider mutation + */ +export const useUpdateWorkflowTool = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'update'], + mutationFn: (payload: UpdateWorkflowToolPayload) => { + return post('/workspaces/current/tool-provider/workflow/update', { + body: payload, + }) + }, + }) +} diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index 78375857ea..979e7fd786 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -1,10 +1,8 @@ 'use client' import type { FC } from 'react' import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types' -import { RiErrorWarningLine } from '@remixicon/react' -import { produce } from 'immer' +import type { WorkflowToolFormPayload } from './hooks/use-workflow-tool-form' import * as React from 'react' -import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Button from '@/app/components/base/button' @@ -12,14 +10,14 @@ import Drawer from '@/app/components/base/drawer-plus' import EmojiPicker from '@/app/components/base/emoji-picker' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' -import Toast from '@/app/components/base/toast' import Tooltip from '@/app/components/base/tooltip' import LabelSelector from '@/app/components/tools/labels/selector' import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal' -import MethodSelector from '@/app/components/tools/workflow-tool/method-selector' -import { VarType } from '@/app/components/workflow/types' import { cn } from '@/utils/classnames' -import { buildWorkflowOutputParameters } from './utils' +import ToolInputTable from './components/tool-input-table' +import ToolOutputTable from './components/tool-output-table' +import { useModalState } from './hooks/use-modal-state' +import { useWorkflowToolForm } from './hooks/use-workflow-tool-form' export type WorkflowToolModalPayload = { icon: Emoji @@ -39,7 +37,7 @@ export type WorkflowToolModalPayload = { type Props = { isAdd?: boolean - payload: WorkflowToolModalPayload + payload: WorkflowToolFormPayload onHide: () => void onRemove?: () => void onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void @@ -48,7 +46,64 @@ type Props = { workflow_tool_id: string }>) => void } -// Add and Edit + +// Form field wrapper component +type FormFieldProps = { + label: string + required?: boolean + tooltip?: string + children: React.ReactNode +} + +const FormField: FC = ({ label, required, tooltip, children }) => ( +
+
+ {label} + {required && *} + {tooltip && ( + {tooltip}
} /> + )} +
+ {children} +
+) + +// Footer actions component +type FooterActionsProps = { + isAdd?: boolean + onRemove?: () => void + onHide: () => void + onSaveClick: () => void +} + +const FooterActions: FC = ({ isAdd, onRemove, onHide, onSaveClick }) => { + const { t } = useTranslation() + const showDeleteButton = !isAdd && onRemove + + return ( +
+ {showDeleteButton && ( + + )} +
+ + +
+
+ ) +} + +// Main component const WorkflowToolAsModal: FC = ({ isAdd, payload, @@ -59,108 +114,24 @@ const WorkflowToolAsModal: FC = ({ }) => { const { t } = useTranslation() - const [showEmojiPicker, setShowEmojiPicker] = useState(false) - const [emoji, setEmoji] = useState(payload.icon) - const [label, setLabel] = useState(payload.label) - const [name, setName] = useState(payload.name) - const [description, setDescription] = useState(payload.description) - const [parameters, setParameters] = useState(payload.parameters) - const rawOutputParameters = payload.outputParameters - const outputSchema = payload.tool?.output_schema - const outputParameters = useMemo(() => buildWorkflowOutputParameters(rawOutputParameters, outputSchema), [rawOutputParameters, outputSchema]) - const reservedOutputParameters: WorkflowToolProviderOutputParameter[] = [ - { - name: 'text', - description: t('nodes.tool.outputVars.text', { ns: 'workflow' }), - type: VarType.string, - reserved: true, - }, - { - name: 'files', - description: t('nodes.tool.outputVars.files.title', { ns: 'workflow' }), - type: VarType.arrayFile, - reserved: true, - }, - { - name: 'json', - description: t('nodes.tool.outputVars.json', { ns: 'workflow' }), - type: VarType.arrayObject, - reserved: true, - }, - ] + // Modal states + const emojiPicker = useModalState(false) + const confirmModal = useModalState(false) - const handleParameterChange = (key: string, value: string, index: number) => { - const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => { - if (key === 'description') - draft[index].description = value - else - draft[index].form = value - }) - setParameters(newData) - } - const [labels, setLabels] = useState(payload.labels) - const handleLabelSelect = (value: string[]) => { - setLabels(value) - } - const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy) - const [showModal, setShowModal] = useState(false) + // Form state and logic + const form = useWorkflowToolForm({ + payload, + isAdd, + onCreate, + onSave, + }) - const isNameValid = (name: string) => { - // when the user has not input anything, no need for a warning - if (name === '') - return true - - return /^\w+$/.test(name) - } - - const isOutputParameterReserved = (name: string) => { - return reservedOutputParameters.find(p => p.name === name) - } - - const onConfirm = () => { - let errorMessage = '' - if (!label) - errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.name', { ns: 'tools' }) }) - - if (!name) - errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.nameForToolCall', { ns: 'tools' }) }) - - if (!isNameValid(name)) - errorMessage = t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' }) - - if (errorMessage) { - Toast.notify({ - type: 'error', - message: errorMessage, - }) - return - } - - const requestParams = { - name, - description, - icon: emoji, - label, - parameters: parameters.map(item => ({ - name: item.name, - description: item.description, - form: item.form, - })), - labels, - privacy_policy: privacyPolicy, - } - if (!isAdd) { - onSave?.({ - ...requestParams, - workflow_tool_id: payload.workflow_tool_id!, - }) - } - else { - onCreate?.({ - ...requestParams, - workflow_app_id: payload.workflow_app_id!, - }) - } + // Handle save button click + const handleSaveClick = () => { + if (isAdd) + form.onConfirm() + else + confirmModal.open() } return ( @@ -176,217 +147,119 @@ const WorkflowToolAsModal: FC = ({ body={(
- {/* name & icon */} -
-
- {t('createTool.name', { ns: 'tools' })} - {' '} - * -
+ {/* Name & Icon */} +
- { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} /> + setLabel(e.target.value)} - /> -
-
- {/* name for tool call */} -
-
- {t('createTool.nameForToolCall', { ns: 'tools' })} - {' '} - * - - {t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })} -
- )} + value={form.label} + onChange={e => form.setLabel(e.target.value)} />
+ + + {/* Name for Tool Call */} + setName(e.target.value)} + value={form.name} + onChange={e => form.setName(e.target.value)} /> - {!isNameValid(name) && ( -
{t('createTool.nameForToolCallTip', { ns: 'tools' })}
+ {!form.isNameValid && ( +
+ {t('createTool.nameForToolCallTip', { ns: 'tools' })} +
)} -
- {/* description */} -
-
{t('createTool.description', { ns: 'tools' })}
+ + + {/* Description */} +