From 63624dece1ce29e61ac9b0c6d7f1e444430ac6c8 Mon Sep 17 00:00:00 2001 From: Chen Jiaju <619507631@qq.com> Date: Mon, 15 Dec 2025 11:17:15 +0800 Subject: [PATCH] fix(workflow): tool plugin output_schema array type not selectable in subsequent nodes (#29035) Co-authored-by: Claude --- .../__tests__/output-schema-utils.test.ts | 280 ++++++++++++++++++ .../components/workflow/nodes/tool/default.ts | 25 +- .../nodes/tool/output-schema-utils.ts | 101 +++++++ 3 files changed, 391 insertions(+), 15 deletions(-) create mode 100644 web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts create mode 100644 web/app/components/workflow/nodes/tool/output-schema-utils.ts diff --git a/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts b/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts new file mode 100644 index 0000000000..54f3205e81 --- /dev/null +++ b/web/app/components/workflow/nodes/tool/__tests__/output-schema-utils.test.ts @@ -0,0 +1,280 @@ +import { VarType } from '@/app/components/workflow/types' +import { + normalizeJsonSchemaType, + pickItemSchema, + resolveVarType, +} from '../output-schema-utils' + +// Mock the getMatchedSchemaType dependency +jest.mock('../../_base/components/variable/use-match-schema-type', () => ({ + getMatchedSchemaType: (schema: any) => { + // Return schema_type or schemaType if present + return schema?.schema_type || schema?.schemaType || undefined + }, +})) + +describe('output-schema-utils', () => { + describe('normalizeJsonSchemaType', () => { + it('should return undefined for null or undefined schema', () => { + expect(normalizeJsonSchemaType(null)).toBeUndefined() + expect(normalizeJsonSchemaType(undefined)).toBeUndefined() + }) + + it('should return the type directly for simple string type', () => { + expect(normalizeJsonSchemaType({ type: 'string' })).toBe('string') + expect(normalizeJsonSchemaType({ type: 'number' })).toBe('number') + expect(normalizeJsonSchemaType({ type: 'boolean' })).toBe('boolean') + expect(normalizeJsonSchemaType({ type: 'object' })).toBe('object') + expect(normalizeJsonSchemaType({ type: 'array' })).toBe('array') + expect(normalizeJsonSchemaType({ type: 'integer' })).toBe('integer') + }) + + it('should handle array type with nullable (e.g., ["string", "null"])', () => { + expect(normalizeJsonSchemaType({ type: ['string', 'null'] })).toBe('string') + expect(normalizeJsonSchemaType({ type: ['null', 'number'] })).toBe('number') + expect(normalizeJsonSchemaType({ type: ['object', 'null'] })).toBe('object') + }) + + it('should handle oneOf schema', () => { + expect(normalizeJsonSchemaType({ + oneOf: [ + { type: 'string' }, + { type: 'null' }, + ], + })).toBe('string') + }) + + it('should handle anyOf schema', () => { + expect(normalizeJsonSchemaType({ + anyOf: [ + { type: 'number' }, + { type: 'null' }, + ], + })).toBe('number') + }) + + it('should handle allOf schema', () => { + expect(normalizeJsonSchemaType({ + allOf: [ + { type: 'object' }, + ], + })).toBe('object') + }) + + it('should infer object type from properties', () => { + expect(normalizeJsonSchemaType({ + properties: { + name: { type: 'string' }, + }, + })).toBe('object') + }) + + it('should infer array type from items', () => { + expect(normalizeJsonSchemaType({ + items: { type: 'string' }, + })).toBe('array') + }) + + it('should return undefined for empty schema', () => { + expect(normalizeJsonSchemaType({})).toBeUndefined() + }) + }) + + describe('pickItemSchema', () => { + it('should return undefined for null or undefined schema', () => { + expect(pickItemSchema(null)).toBeUndefined() + expect(pickItemSchema(undefined)).toBeUndefined() + }) + + it('should return undefined if no items property', () => { + expect(pickItemSchema({ type: 'array' })).toBeUndefined() + expect(pickItemSchema({})).toBeUndefined() + }) + + it('should return items directly if items is an object', () => { + const itemSchema = { type: 'string' } + expect(pickItemSchema({ items: itemSchema })).toBe(itemSchema) + }) + + it('should return first item if items is an array (tuple schema)', () => { + const firstItem = { type: 'string' } + const secondItem = { type: 'number' } + expect(pickItemSchema({ items: [firstItem, secondItem] })).toBe(firstItem) + }) + }) + + describe('resolveVarType', () => { + describe('primitive types', () => { + it('should resolve string type', () => { + const result = resolveVarType({ type: 'string' }) + expect(result.type).toBe(VarType.string) + }) + + it('should resolve number type', () => { + const result = resolveVarType({ type: 'number' }) + expect(result.type).toBe(VarType.number) + }) + + it('should resolve integer type', () => { + const result = resolveVarType({ type: 'integer' }) + expect(result.type).toBe(VarType.integer) + }) + + it('should resolve boolean type', () => { + const result = resolveVarType({ type: 'boolean' }) + expect(result.type).toBe(VarType.boolean) + }) + + it('should resolve object type', () => { + const result = resolveVarType({ type: 'object' }) + expect(result.type).toBe(VarType.object) + }) + }) + + describe('array types', () => { + it('should resolve array of strings to arrayString', () => { + const result = resolveVarType({ + type: 'array', + items: { type: 'string' }, + }) + expect(result.type).toBe(VarType.arrayString) + }) + + it('should resolve array of numbers to arrayNumber', () => { + const result = resolveVarType({ + type: 'array', + items: { type: 'number' }, + }) + expect(result.type).toBe(VarType.arrayNumber) + }) + + it('should resolve array of integers to arrayNumber', () => { + const result = resolveVarType({ + type: 'array', + items: { type: 'integer' }, + }) + expect(result.type).toBe(VarType.arrayNumber) + }) + + it('should resolve array of booleans to arrayBoolean', () => { + const result = resolveVarType({ + type: 'array', + items: { type: 'boolean' }, + }) + expect(result.type).toBe(VarType.arrayBoolean) + }) + + it('should resolve array of objects to arrayObject', () => { + const result = resolveVarType({ + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + }, + }) + expect(result.type).toBe(VarType.arrayObject) + }) + + it('should resolve array without items to generic array', () => { + const result = resolveVarType({ type: 'array' }) + expect(result.type).toBe(VarType.array) + }) + }) + + describe('complex schema - user scenario (tags field)', () => { + it('should correctly resolve tags array with object items', () => { + // This is the exact schema from the user's issue + const tagsSchema = { + type: 'array', + description: '标签数组', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: '标签ID', + }, + k: { + type: 'number', + description: '标签类型', + }, + group: { + type: 'number', + description: '标签分组', + }, + }, + }, + } + + const result = resolveVarType(tagsSchema) + expect(result.type).toBe(VarType.arrayObject) + }) + }) + + describe('nullable types', () => { + it('should handle nullable string type', () => { + const result = resolveVarType({ type: ['string', 'null'] }) + expect(result.type).toBe(VarType.string) + }) + + it('should handle nullable array type', () => { + const result = resolveVarType({ + type: ['array', 'null'], + items: { type: 'string' }, + }) + expect(result.type).toBe(VarType.arrayString) + }) + }) + + describe('unknown types', () => { + it('should resolve unknown type to any', () => { + const result = resolveVarType({ type: 'unknown_type' }) + expect(result.type).toBe(VarType.any) + }) + + it('should resolve empty schema to any', () => { + const result = resolveVarType({}) + expect(result.type).toBe(VarType.any) + }) + }) + + describe('file types via schemaType', () => { + it('should resolve object with file schemaType to file', () => { + const result = resolveVarType({ + type: 'object', + schema_type: 'file', + }) + expect(result.type).toBe(VarType.file) + expect(result.schemaType).toBe('file') + }) + + it('should resolve array of files to arrayFile', () => { + const result = resolveVarType({ + type: 'array', + items: { + type: 'object', + schema_type: 'file', + }, + }) + expect(result.type).toBe(VarType.arrayFile) + }) + }) + + describe('nested arrays', () => { + it('should handle array of arrays as generic array', () => { + const result = resolveVarType({ + type: 'array', + items: { + type: 'array', + items: { type: 'string' }, + }, + }) + // Nested arrays fall back to generic array type + expect(result.type).toBe(VarType.array) + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/tool/default.ts b/web/app/components/workflow/nodes/tool/default.ts index 5625a6c336..0fcd321a29 100644 --- a/web/app/components/workflow/nodes/tool/default.ts +++ b/web/app/components/workflow/nodes/tool/default.ts @@ -1,12 +1,13 @@ import { genNodeMetaData } from '@/app/components/workflow/utils' -import { BlockEnum, VarType } from '@/app/components/workflow/types' -import type { NodeDefault, ToolWithProvider } from '../../types' +import { BlockEnum } from '@/app/components/workflow/types' +import type { NodeDefault, ToolWithProvider, Var } from '../../types' import type { ToolNodeType } from './types' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import { TOOL_OUTPUT_STRUCT } from '../../constants' import { CollectionType } from '@/app/components/tools/types' import { canFindTool } from '@/utils' -import { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type' +import { Type } from '../llm/types' +import { resolveVarType } from './output-schema-utils' const i18nPrefix = 'workflow.errorMsg' @@ -88,32 +89,26 @@ const nodeDefault: NodeDefault = { const currCollection = currentTools.find(item => canFindTool(item.id, provider_id)) const currTool = currCollection?.tools.find(tool => tool.name === payload.tool_name) const output_schema = currTool?.output_schema - let res: any[] = [] + let res: Var[] = [] if (!output_schema || !output_schema.properties) { res = TOOL_OUTPUT_STRUCT } else { - const outputSchema: any[] = [] + const outputSchema: Var[] = [] Object.keys(output_schema.properties).forEach((outputKey) => { const output = output_schema.properties[outputKey] - const dataType = output.type - const schemaType = getMatchedSchemaType(output, schemaTypeDefinitions) - let type = dataType === 'array' - ? `Array[${output.items?.type ? output.items.type.slice(0, 1).toLocaleLowerCase() + output.items.type.slice(1) : 'Unknown'}]` - : `${output.type ? output.type.slice(0, 1).toLocaleLowerCase() + output.type.slice(1) : 'Unknown'}` - - if (type === VarType.object && schemaType === 'file') - type = VarType.file + const { type, schemaType } = resolveVarType(output, schemaTypeDefinitions) outputSchema.push({ variable: outputKey, type, - description: output.description, + des: output.description, schemaType, children: output.type === 'object' ? { schema: { - type: 'object', + type: Type.object, properties: output.properties, + additionalProperties: false, }, } : undefined, }) diff --git a/web/app/components/workflow/nodes/tool/output-schema-utils.ts b/web/app/components/workflow/nodes/tool/output-schema-utils.ts new file mode 100644 index 0000000000..684ff0b29f --- /dev/null +++ b/web/app/components/workflow/nodes/tool/output-schema-utils.ts @@ -0,0 +1,101 @@ +import { VarType } from '@/app/components/workflow/types' +import { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type' +import type { SchemaTypeDefinition } from '@/service/use-common' + +/** + * Normalizes a JSON Schema type to a simple string type. + * Handles complex schemas with oneOf, anyOf, allOf. + */ +export const normalizeJsonSchemaType = (schema: any): string | undefined => { + if (!schema) return undefined + const { type, properties, items, oneOf, anyOf, allOf } = schema + + if (Array.isArray(type)) + return type.find((item: string | null) => item && item !== 'null') || type[0] + + if (typeof type === 'string') + return type + + const compositeCandidates = [oneOf, anyOf, allOf] + .filter((entry): entry is any[] => Array.isArray(entry)) + .flat() + + for (const candidate of compositeCandidates) { + const normalized = normalizeJsonSchemaType(candidate) + if (normalized) + return normalized + } + + if (properties) + return 'object' + + if (items) + return 'array' + + return undefined +} + +/** + * Extracts the items schema from an array schema. + */ +export const pickItemSchema = (schema: any) => { + if (!schema || !schema.items) + return undefined + return Array.isArray(schema.items) ? schema.items[0] : schema.items +} + +/** + * Resolves a JSON Schema to a VarType enum value. + * Properly handles array types by inspecting item types. + */ +export const resolveVarType = ( + schema: any, + schemaTypeDefinitions?: SchemaTypeDefinition[], +): { type: VarType; schemaType?: string } => { + const schemaType = getMatchedSchemaType(schema, schemaTypeDefinitions) + const normalizedType = normalizeJsonSchemaType(schema) + + switch (normalizedType) { + case 'string': + return { type: VarType.string, schemaType } + case 'number': + return { type: VarType.number, schemaType } + case 'integer': + return { type: VarType.integer, schemaType } + case 'boolean': + return { type: VarType.boolean, schemaType } + case 'object': + if (schemaType === 'file') + return { type: VarType.file, schemaType } + return { type: VarType.object, schemaType } + case 'array': { + const itemSchema = pickItemSchema(schema) + if (!itemSchema) + return { type: VarType.array, schemaType } + + const { type: itemType, schemaType: itemSchemaType } = resolveVarType(itemSchema, schemaTypeDefinitions) + const resolvedSchemaType = schemaType || itemSchemaType + + if (itemSchemaType === 'file') + return { type: VarType.arrayFile, schemaType: resolvedSchemaType } + + switch (itemType) { + case VarType.string: + return { type: VarType.arrayString, schemaType: resolvedSchemaType } + case VarType.number: + case VarType.integer: + return { type: VarType.arrayNumber, schemaType: resolvedSchemaType } + case VarType.boolean: + return { type: VarType.arrayBoolean, schemaType: resolvedSchemaType } + case VarType.object: + return { type: VarType.arrayObject, schemaType: resolvedSchemaType } + case VarType.file: + return { type: VarType.arrayFile, schemaType: resolvedSchemaType } + default: + return { type: VarType.array, schemaType: resolvedSchemaType } + } + } + default: + return { type: VarType.any, schemaType } + } +}