fix(workflow): tighten tsgo types in workflow editor

This commit is contained in:
yyh
2026-03-25 18:35:51 +08:00
parent 3571bee55f
commit ab6993b6e7
10 changed files with 150 additions and 67 deletions

View File

@ -20,7 +20,7 @@ const createModel = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({
provider: 'openai',
name: 'gpt-4o',
mode: 'chat',
completion_params: [],
completion_params: {},
...overrides,
})

View File

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

View File

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

View File

@ -308,6 +308,7 @@ const FormInputItem: FC<Props> = ({
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<Props> = ({
...value,
[variable]: {
...varInput,
type: resolvedType,
type: nextVarKindType,
value: normalizedValue,
nested_node_config: resolvedNestedNodeConfig,
},

View File

@ -19,6 +19,7 @@ const createProps = (
isLoading: false,
isShowAPart: false,
isShowNodeName: true,
isValidVar: true,
maxNodeNameWidth: 80,
maxTypeWidth: 60,
maxVarNameWidth: 80,

View File

@ -144,7 +144,11 @@ const Item: FC<ItemProps> = ({
}) => {
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.')

View File

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

View File

@ -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<string, JsonValue> => {
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<Record<string, string | number | null>>((acc, curr) => {
return list.reduce<Record<string, JsonValue>>((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) => {

View File

@ -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<string, unknown>) => {
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<CommonNodeType>) => invalidNodes.includes(node?.data?.type))
return !nodes.some((node: Node<CommonNodeType>) => 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),
}
}

View File

@ -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 []
}