fix(workflow): tool plugin output_schema array type not selectable in subsequent nodes (#29035)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chen Jiaju 2025-12-15 11:17:15 +08:00 committed by GitHub
parent 02122907e5
commit 63624dece1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 391 additions and 15 deletions

View File

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

View File

@ -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<ToolNodeType> = {
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,
})

View File

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