From ab6993b6e784b37947a054f25eb4f285edeb8bc8 Mon Sep 17 00:00:00 2001 From: yyh Date: Wed, 25 Mar 2026 18:35:51 +0800 Subject: [PATCH] fix(workflow): tighten tsgo types in workflow editor --- .../hooks/__tests__/use-config-vision.spec.ts | 2 +- .../workflow/hooks/use-nodes-interactions.ts | 2 +- .../components/form-input-item.helpers.ts | 22 +++-- .../_base/components/form-input-item.tsx | 3 +- .../var-reference-picker.trigger.spec.tsx | 1 + .../variable/var-reference-vars.tsx | 6 +- .../use-single-run-form-params.spec.ts | 2 +- .../components/variable-modal.helpers.ts | 69 +++++++++++---- .../workflow/update-dsl-modal.helpers.ts | 88 +++++++++++-------- .../variable-inspect/value-content.helpers.ts | 22 ++++- 10 files changed, 150 insertions(+), 67 deletions(-) diff --git a/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts index 5811f14a60..e56a72d98f 100644 --- a/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts @@ -20,7 +20,7 @@ const createModel = (overrides: Partial = {}): ModelConfig => ({ provider: 'openai', name: 'gpt-4o', mode: 'chat', - completion_params: [], + completion_params: {}, ...overrides, }) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index b10ddfb720..c0457918db 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1963,7 +1963,7 @@ export const useNodesInteractions = () => { if (selectedNode) { // Keep this list aligned with availableBlocksFilter(inContainer) // in use-available-blocks.ts. - const commonNestedDisallowPasteNodes = [ + const commonNestedDisallowPasteNodes: BlockEnum[] = [ BlockEnum.End, BlockEnum.Iteration, BlockEnum.Loop, diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts b/web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts index f53c4779f2..fb57cf1d11 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts @@ -158,16 +158,28 @@ export const getTargetVarType = (state: FormInputState) => { export const getFilterVar = (state: FormInputState) => { if (state.isNumber) return (varPayload: Var) => varPayload.type === VarType.number - if (state.isString) - return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + if (state.isString) { + return (varPayload: Var) => ( + varPayload.type === VarType.string + || varPayload.type === VarType.number + || varPayload.type === VarType.secret + ) + } if (state.isFile) - return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type) + return (varPayload: Var) => varPayload.type === VarType.file || varPayload.type === VarType.arrayFile if (state.isBoolean) return (varPayload: Var) => varPayload.type === VarType.boolean if (state.isObject) return (varPayload: Var) => varPayload.type === VarType.object - if (state.isArray) - return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayMessage].includes(varPayload.type) + if (state.isArray) { + return (varPayload: Var) => ( + varPayload.type === VarType.array + || varPayload.type === VarType.arrayString + || varPayload.type === VarType.arrayNumber + || varPayload.type === VarType.arrayObject + || varPayload.type === VarType.arrayMessage + ) + } return undefined } diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx index 74d362b8fe..79614371a6 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -308,6 +308,7 @@ const FormInputItem: FC = ({ const resolvedType = isAssembleValue ? VarKindType.nested_node : newType ?? (varInput?.type === VarKindType.nested_node ? VarKindType.nested_node : getVarKindType(formState)) + const nextVarKindType = resolvedType ?? varInput?.type ?? VarKindType.constant const resolvedNestedNodeConfig = resolvedType === VarKindType.nested_node ? (nestedNodeConfig ?? varInput?.nested_node_config ?? { extractor_node_id: nodeId && variable ? `${nodeId}_ext_${variable}` : '', @@ -321,7 +322,7 @@ const FormInputItem: FC = ({ ...value, [variable]: { ...varInput, - type: resolvedType, + type: nextVarKindType, value: normalizedValue, nested_node_config: resolvedNestedNodeConfig, }, diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx index 60b7e34f29..a3917ce113 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx @@ -19,6 +19,7 @@ const createProps = ( isLoading: false, isShowAPart: false, isShowNodeName: true, + isValidVar: true, maxNodeNameWidth: 80, maxTypeWidth: 60, maxVarNameWidth: 80, diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index db97c7cd20..3878933a02 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -144,7 +144,11 @@ const Item: FC = ({ }) => { const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties const isFile = itemData.type === VarType.file && !isStructureOutput - const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0) + const isObj = ( + (itemData.type === VarType.object || itemData.type === VarType.file) + && itemData.children + && (itemData.children as Var[]).length > 0 + ) const isSys = itemData.variable.startsWith('sys.') const isEnv = itemData.variable.startsWith('env.') const isChatVar = itemData.variable.startsWith('conversation.') diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts index 7313b6945e..d3972a1533 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts @@ -37,7 +37,7 @@ const createInputVar = (variable: string): InputVar => ({ required: false, }) -const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({ +const createNode = (id: string, title: string, type: BlockEnum = BlockEnum.Tool): Node => ({ id, position: { x: 0, y: 0 }, data: { diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts index 944b197e19..7cedb44a26 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import type { ChatVarType } from '../type' -import type { ConversationVariable } from '@/app/components/workflow/types' +import type { ConversationVariable, JsonValue } from '@/app/components/workflow/types' import { checkKeys } from '@/utils/var' import { ChatVarType as ChatVarTypeEnum } from '../type' import { @@ -29,6 +29,31 @@ export type ToastPayload = { customComponent?: ReactNode } +const isJsonValue = (value: unknown): value is JsonValue => { + if (value === null) + return true + + const valueType = typeof value + if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') + return true + + if (Array.isArray(value)) + return value.every(item => isJsonValue(item)) + + if (valueType === 'object') { + if (!value) + return false + + return Object.values(value).every(item => isJsonValue(item)) + } + + return false +} + +const isJsonRecord = (value: JsonValue): value is Record => { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + export const typeList = [ ChatVarTypeEnum.String, ChatVarTypeEnum.Number, @@ -56,23 +81,27 @@ export const getPlaceholderByType = (type: ChatVarType) => { } export const buildObjectValueItems = (chatVar?: ConversationVariable): ObjectValueItem[] => { - if (!chatVar || !chatVar.value || Object.keys(chatVar.value).length === 0) + if (!chatVar) return [DEFAULT_OBJECT_VALUE] - return Object.keys(chatVar.value).map((key) => { - const itemValue = chatVar.value[key] + const value = chatVar.value + if (!isJsonRecord(value) || Object.keys(value).length === 0) + return [DEFAULT_OBJECT_VALUE] + + return Object.keys(value).map((key) => { + const itemValue = value[key] return { key, type: typeof itemValue === 'string' ? ChatVarTypeEnum.String : ChatVarTypeEnum.Number, - value: itemValue, + value: typeof itemValue === 'string' || typeof itemValue === 'number' ? itemValue : undefined, } }) } export const formatObjectValueFromList = (list: ObjectValueItem[]) => { - return list.reduce>((acc, curr) => { + return list.reduce>((acc, curr) => { if (curr.key) - acc[curr.key] = curr.value || null + acc[curr.key] = curr.value === undefined || curr.value === '' ? null : curr.value return acc }, {}) } @@ -87,22 +116,22 @@ export const formatChatVariableValue = ({ objectValue: ObjectValueItem[] type: ChatVarType value: unknown -}) => { +}): JsonValue => { switch (type) { case ChatVarTypeEnum.String: - return value || '' + return typeof value === 'string' ? value : '' case ChatVarTypeEnum.Number: - return value || 0 + return typeof value === 'number' && !Number.isNaN(value) ? value : 0 case ChatVarTypeEnum.Boolean: - return value === undefined ? true : value + return typeof value === 'boolean' ? value : false case ChatVarTypeEnum.Object: - return editInJSON ? value : formatObjectValueFromList(objectValue) + return editInJSON && isJsonValue(value) ? value : formatObjectValueFromList(objectValue) case ChatVarTypeEnum.ArrayString: case ChatVarTypeEnum.ArrayNumber: case ChatVarTypeEnum.ArrayObject: - return Array.isArray(value) ? value.filter(Boolean) : [] + return Array.isArray(value) ? value.filter((item): item is JsonValue => item !== undefined) : [] case ChatVarTypeEnum.ArrayBoolean: - return value || [] + return Array.isArray(value) ? value.filter((item): item is JsonValue => item !== undefined) : [] } } @@ -146,10 +175,16 @@ export const parseEditorContent = ({ }: { content: string type: ChatVarType -}) => { - const parsed = JSON.parse(content) - if (type !== ChatVarTypeEnum.ArrayBoolean) +}): JsonValue => { + const parsed: unknown = JSON.parse(content) + if (type !== ChatVarTypeEnum.ArrayBoolean) { + if (!isJsonValue(parsed)) + throw new Error('Invalid JSON') return parsed + } + + if (!Array.isArray(parsed)) + throw new Error('Invalid JSON array') return parsed .map((item: string | boolean) => { diff --git a/web/app/components/workflow/update-dsl-modal.helpers.ts b/web/app/components/workflow/update-dsl-modal.helpers.ts index a86be7266f..e149948ab0 100644 --- a/web/app/components/workflow/update-dsl-modal.helpers.ts +++ b/web/app/components/workflow/update-dsl-modal.helpers.ts @@ -1,8 +1,20 @@ import type { CommonNodeType, Node } from './types' +import type { + AnnotationReplyConfig, + FileUpload, + OpeningStatement, + RetrieverResource, + Runtime, + SensitiveWordAvoidance, + SpeechToText, + SuggestedQuestionsAfterAnswer, + TextToSpeech, +} from '@/app/components/base/features/types' +import type { WorkflowDraftFeatures } from '@/types/workflow' import { load as yamlLoad } from 'js-yaml' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { DSLImportStatus } from '@/models/app' -import { AppModeEnum } from '@/types/app' +import { AppModeEnum, TransferMethod } from '@/types/app' import { BlockEnum, SupportUploadFileTypes } from './types' type ParsedDSL = { @@ -13,47 +25,49 @@ type ParsedDSL = { } } -type WorkflowFileUploadFeatures = { - enabled?: boolean - allowed_file_types?: SupportUploadFileTypes[] - allowed_file_extensions?: string[] - allowed_file_upload_methods?: string[] - number_limits?: number - image?: { - enabled?: boolean - number_limits?: number - transfer_methods?: string[] - } -} - -type WorkflowFeatures = { - file_upload?: WorkflowFileUploadFeatures - opening_statement?: string - suggested_questions?: string[] - suggested_questions_after_answer?: { enabled: boolean } - speech_to_text?: { enabled: boolean } - text_to_speech?: { enabled: boolean } - retriever_resource?: { enabled: boolean } - sensitive_word_avoidance?: { enabled: boolean } -} - type ImportNotificationPayload = { type: 'success' | 'warning' message: string children?: string } +type NormalizedWorkflowFeatures = { + file: FileUpload + opening: OpeningStatement + suggested: SuggestedQuestionsAfterAnswer + speech2text: SpeechToText + text2speech: TextToSpeech + citation: RetrieverResource + moderation: SensitiveWordAvoidance + annotationReply: AnnotationReplyConfig + sandbox: Runtime +} + +const DEFAULT_TRANSFER_METHODS: TransferMethod[] = [TransferMethod.local_file, TransferMethod.remote_url] + +const normalizeEnabledFeature = (feature?: boolean | Record) => { + if (typeof feature === 'boolean') + return { enabled: feature } + + if (feature && typeof feature === 'object') + return { enabled: typeof feature.enabled === 'boolean' ? feature.enabled : false } + + return { enabled: false } +} + export const getInvalidNodeTypes = (mode?: AppModeEnum) => { if (mode === AppModeEnum.ADVANCED_CHAT) { - return [ + const invalidNodeTypes: BlockEnum[] = [ BlockEnum.End, BlockEnum.TriggerWebhook, BlockEnum.TriggerSchedule, BlockEnum.TriggerPlugin, ] + return invalidNodeTypes } - return [BlockEnum.Answer] + const invalidNodeTypes: BlockEnum[] = [BlockEnum.Answer] + return invalidNodeTypes } export const validateDSLContent = (content: string, mode?: AppModeEnum) => { @@ -61,7 +75,7 @@ export const validateDSLContent = (content: string, mode?: AppModeEnum) => { const data = yamlLoad(content) as ParsedDSL const nodes = data?.workflow?.graph?.nodes ?? [] const invalidNodes = getInvalidNodeTypes(mode) - return !nodes.some((node: Node) => invalidNodes.includes(node?.data?.type)) + return !nodes.some((node: Node) => node?.data?.type ? invalidNodes.includes(node.data.type) : false) } catch { return false @@ -82,19 +96,19 @@ export const getImportNotificationPayload = (status: DSLImportStatus, t: (key: s } } -export const normalizeWorkflowFeatures = (features?: WorkflowFeatures) => { +export const normalizeWorkflowFeatures = (features?: WorkflowDraftFeatures): NormalizedWorkflowFeatures => { const resolvedFeatures = features ?? {} return { file: { image: { enabled: !!resolvedFeatures.file_upload?.image?.enabled, number_limits: resolvedFeatures.file_upload?.image?.number_limits || 3, - transfer_methods: resolvedFeatures.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + transfer_methods: resolvedFeatures.file_upload?.image?.transfer_methods || DEFAULT_TRANSFER_METHODS, }, enabled: !!(resolvedFeatures.file_upload?.enabled || resolvedFeatures.file_upload?.image?.enabled), allowed_file_types: resolvedFeatures.file_upload?.allowed_file_types || [SupportUploadFileTypes.image], allowed_file_extensions: resolvedFeatures.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`), - allowed_file_upload_methods: resolvedFeatures.file_upload?.allowed_file_upload_methods || resolvedFeatures.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + allowed_file_upload_methods: resolvedFeatures.file_upload?.allowed_file_upload_methods || resolvedFeatures.file_upload?.image?.transfer_methods || DEFAULT_TRANSFER_METHODS, number_limits: resolvedFeatures.file_upload?.number_limits || resolvedFeatures.file_upload?.image?.number_limits || 3, }, opening: { @@ -102,10 +116,12 @@ export const normalizeWorkflowFeatures = (features?: WorkflowFeatures) => { opening_statement: resolvedFeatures.opening_statement, suggested_questions: resolvedFeatures.suggested_questions, }, - suggested: resolvedFeatures.suggested_questions_after_answer || { enabled: false }, - speech2text: resolvedFeatures.speech_to_text || { enabled: false }, - text2speech: resolvedFeatures.text_to_speech || { enabled: false }, - citation: resolvedFeatures.retriever_resource || { enabled: false }, - moderation: resolvedFeatures.sensitive_word_avoidance || { enabled: false }, + suggested: normalizeEnabledFeature(resolvedFeatures.suggested_questions_after_answer), + speech2text: normalizeEnabledFeature(resolvedFeatures.speech_to_text), + text2speech: normalizeEnabledFeature(resolvedFeatures.text_to_speech), + citation: normalizeEnabledFeature(resolvedFeatures.retriever_resource), + moderation: normalizeEnabledFeature(resolvedFeatures.sensitive_word_avoidance), + annotationReply: normalizeEnabledFeature(resolvedFeatures.annotation_reply), + sandbox: normalizeEnabledFeature(resolvedFeatures.sandbox), } } diff --git a/web/app/components/workflow/variable-inspect/value-content.helpers.ts b/web/app/components/workflow/variable-inspect/value-content.helpers.ts index 3095efdec2..72b8083c50 100644 --- a/web/app/components/workflow/variable-inspect/value-content.helpers.ts +++ b/web/app/components/workflow/variable-inspect/value-content.helpers.ts @@ -1,4 +1,4 @@ -import type { VarInInspect } from '@/types/workflow' +import type { FileResponse, VarInInspect } from '@/types/workflow' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { checkJsonSchemaDepth, @@ -14,6 +14,14 @@ type UploadedFileLike = { upload_file_id?: string } +const isFileResponse = (value: unknown): value is FileResponse => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return false + + return ['related_id', 'filename', 'mime_type', 'transfer_method', 'type', 'url', 'upload_file_id', 'remote_url'] + .every(key => key in value) +} + export const getValueEditorState = (currentVar: VarInInspect) => { const showTextEditor = currentVar.value_type === 'secret' || currentVar.value_type === 'string' || currentVar.value_type === 'number' const showBoolEditor = typeof currentVar.value === 'boolean' @@ -40,9 +48,15 @@ export const getValueEditorState = (currentVar: VarInInspect) => { export const formatInspectFileValue = (currentVar: VarInInspect) => { if (currentVar.value_type === 'file') - return currentVar.value ? getProcessedFilesFromResponse([currentVar.value]) : [] - if (currentVar.value_type === 'array[file]' || (currentVar.type === VarInInspectType.system && currentVar.name === 'files')) - return currentVar.value && currentVar.value.length > 0 ? getProcessedFilesFromResponse(currentVar.value) : [] + return isFileResponse(currentVar.value) ? getProcessedFilesFromResponse([currentVar.value]) : [] + + if (currentVar.value_type === 'array[file]' || (currentVar.type === VarInInspectType.system && currentVar.name === 'files')) { + if (!Array.isArray(currentVar.value) || !currentVar.value.every(isFileResponse)) + return [] + + return currentVar.value.length > 0 ? getProcessedFilesFromResponse(currentVar.value) : [] + } + return [] }