diff --git a/NOTICE.txt b/NOTICE.txt index 590e5370ec6e..ceda2dbe9dd4 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -115,8 +115,4 @@ Zod (Open Source - MIT License) Copyright (c) 2025 Colin McDonnell https://github.com/colinhacks/zod/blob/main/LICENSE -zod-to-json-schema (Open Source - ISC License) -Copyright (c) 2020, Stefan Terdell -https://github.com/StefanTerdell/zod-to-json-schema/blob/master/LICENSE - The open source libraries included in this product are done so pursuant to each individual open source library license and subject to the disclaimers and limitations on liability set forth in each open source library license. diff --git a/apps/demos/package.json b/apps/demos/package.json index 049685dd55ac..fa18406c1f43 100644 --- a/apps/demos/package.json +++ b/apps/demos/package.json @@ -82,7 +82,6 @@ "whatwg-fetch": "2.0.4", "yargs": "17.7.2", "zod": "catalog:", - "zod-to-json-schema": "catalog:", "zone.js": "0.16.2", "express-rate-limit": "^8.3.1" }, diff --git a/e2e/wrappers/package.json b/e2e/wrappers/package.json index 34a6113952cd..8fa098c5c9df 100644 --- a/e2e/wrappers/package.json +++ b/e2e/wrappers/package.json @@ -50,7 +50,6 @@ "vue": "^3.5.13", "vue-router": "^4.3.0", "zod": "catalog:", - "zod-to-json-schema": "catalog:", "zone.js": "~0.15.0" }, "devDependencies": { diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts new file mode 100644 index 000000000000..1b5719fc3740 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/response_schema.test.ts @@ -0,0 +1,586 @@ +import { describe, expect, it } from '@jest/globals'; +import { coreCommands } from '@ts/grids/grid_core/ai_assistant/commands/index'; +import { GridCommands } from '@ts/grids/grid_core/ai_assistant/grid_commands'; +import type { GridCommand, JsonSchema, ResponseSchemaBranch } from '@ts/grids/grid_core/ai_assistant/types'; +import type { InternalGrid } from '@ts/grids/grid_core/types'; + +import { dataGridCommands } from '../commands/index'; + +const expectedFilterScalarValue: JsonSchema = { + anyOf: [ + { + type: 'string', + description: 'A plain string value. Date values should be in "YYYY-MM-DDTHH:mm:ss" format (e.g. "2024-05-10T00:00:00", "2024-05-10T14:30:00"). The time part is always required. The "Z" suffix or timezone offset should not be appended unless the user explicitly requests it.', + }, + { + type: 'number', + description: 'A numeric filter value.', + }, + { + type: 'boolean', + description: 'A boolean filter value.', + }, + { + type: 'null', + description: 'A null filter value.', + }, + ], +}; + +const expectedSummaryItemBaseProperties: JsonSchema = { + column: { + type: 'string', + }, + summaryType: { + type: 'string', + enum: ['sum', 'min', 'max', 'avg', 'count'], + }, + showInColumn: { + anyOf: [ + { + type: 'string', + }, + { + type: 'null', + }, + ], + }, + displayFormat: { + anyOf: [ + { + type: 'string', + }, + { + type: 'null', + }, + ], + }, +}; + +const expectedArgsByCommand: Record = { + columnsPinning: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + fixed: { + type: 'boolean', + }, + fixedPosition: { + anyOf: [ + { + type: 'string', + enum: ['left', 'right'], + }, + { + type: 'null', + }, + ], + }, + }, + required: ['dataField', 'fixed', 'fixedPosition'], + additionalProperties: false, + }, + columnsReorder: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + visibleIndex: { + type: 'integer', + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }, + }, + required: ['dataField', 'visibleIndex'], + additionalProperties: false, + }, + columnsResize: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + width: { + anyOf: [ + { + type: 'number', + }, + { + type: 'string', + }, + ], + }, + }, + required: ['dataField', 'width'], + additionalProperties: false, + }, + columnsVisibility: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + visible: { + type: 'boolean', + }, + }, + required: ['dataField', 'visible'], + additionalProperties: false, + }, + clearFilter: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + filterValue: { + type: 'object', + properties: { + expression: { + anyOf: [ + { + type: 'object', + properties: { + rootId: { + type: 'string', + }, + nodes: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + }, + expr: { + anyOf: [ + { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['basic'], + }, + field: { + type: 'string', + }, + operator: { + type: 'string', + enum: ['=', '<>', '<', '<=', '>', '>=', 'contains', 'notcontains', 'startswith', 'endswith'], + }, + value: expectedFilterScalarValue, + }, + required: ['type', 'field', 'operator', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['basic'], + }, + field: { + type: 'string', + }, + operator: { + type: 'string', + enum: ['anyof', 'noneof'], + }, + value: { + type: 'array', + items: expectedFilterScalarValue, + }, + }, + required: ['type', 'field', 'operator', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['combined'], + }, + combiner: { + type: 'string', + enum: ['and', 'or'], + }, + leftId: { + type: 'string', + }, + rightId: { + type: 'string', + }, + }, + required: ['type', 'combiner', 'leftId', 'rightId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['negated'], + }, + expressionId: { + type: 'string', + }, + }, + required: ['type', 'expressionId'], + additionalProperties: false, + }, + ], + }, + }, + required: ['id', 'expr'], + additionalProperties: false, + }, + minItems: 1, + }, + }, + required: ['rootId', 'nodes'], + additionalProperties: false, + }, + { + type: 'null', + }, + ], + }, + }, + required: ['expression'], + additionalProperties: false, + }, + focusRowByIndex: { + type: 'object', + properties: { + index: { + type: 'integer', + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }, + }, + required: ['index'], + additionalProperties: false, + }, + focusRowByKey: { + type: 'object', + properties: { + key: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'array', + items: { + type: 'object', + properties: { + field: { + type: 'string', + }, + value: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + }, + }, + required: ['field', 'value'], + additionalProperties: false, + }, + }, + ], + }, + }, + required: ['key'], + additionalProperties: false, + }, + pageIndex: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }, + }, + required: ['pageIndex'], + additionalProperties: false, + }, + pageSize: { + type: 'object', + properties: { + pageSize: { + type: 'integer', + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }, + }, + required: ['pageSize'], + additionalProperties: false, + }, + paging: { + type: 'object', + properties: { + enabled: { + type: 'boolean', + }, + }, + required: ['enabled'], + additionalProperties: false, + }, + searching: { + type: 'object', + properties: { + text: { + type: 'string', + }, + }, + required: ['text'], + additionalProperties: false, + }, + clearSelection: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + deselectAll: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + selectAll: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + selectionByIndexes: { + type: 'object', + properties: { + indexes: { + type: 'array', + items: { + type: 'integer', + minimum: 1, + maximum: Number.MAX_SAFE_INTEGER, + }, + minItems: 1, + }, + mode: { + type: 'string', + enum: ['select', 'deselect'], + }, + scope: { + type: 'string', + enum: ['allPages', 'page'], + }, + }, + required: ['indexes', 'mode', 'scope'], + additionalProperties: false, + }, + selectByKeys: { + type: 'object', + properties: { + keys: { + type: 'array', + items: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'array', + items: { + type: 'object', + properties: { + field: { + type: 'string', + }, + value: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + }, + }, + required: ['field', 'value'], + additionalProperties: false, + }, + }, + ], + }, + }, + preserve: { + type: 'boolean', + }, + }, + required: ['keys', 'preserve'], + additionalProperties: false, + }, + clearSorting: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + sorting: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + sortOrder: { + type: 'string', + enum: ['asc', 'desc', 'none'], + }, + }, + required: ['dataField', 'sortOrder'], + additionalProperties: false, + }, + grouping: { + type: 'object', + properties: { + dataField: { + type: 'string', + }, + groupIndex: { + anyOf: [ + { + type: 'integer', + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }, + { + type: 'null', + }, + ], + }, + }, + required: ['dataField', 'groupIndex'], + additionalProperties: false, + }, + clearGrouping: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + summary: { + type: 'object', + properties: { + totalItems: { + type: 'array', + items: { + type: 'object', + properties: expectedSummaryItemBaseProperties, + required: ['column', 'summaryType', 'showInColumn', 'displayFormat'], + additionalProperties: false, + }, + }, + groupItems: { + type: 'array', + items: { + type: 'object', + properties: { + ...expectedSummaryItemBaseProperties, + showInGroupFooter: { + anyOf: [ + { + type: 'boolean', + }, + { + type: 'null', + }, + ], + }, + alignByColumn: { + anyOf: [ + { + type: 'boolean', + }, + { + type: 'null', + }, + ], + }, + }, + required: ['column', 'summaryType', 'showInColumn', 'displayFormat', 'showInGroupFooter', 'alignByColumn'], + additionalProperties: false, + }, + }, + }, + required: ['totalItems', 'groupItems'], + additionalProperties: false, + }, + clearSummary: { + type: 'object', + properties: {}, + additionalProperties: false, + }, +}; + +const buildResponseSchema = (commands: GridCommand[]): JsonSchema => { + const gridCommands = new GridCommands({} as InternalGrid, commands); + + return gridCommands.buildResponseSchema(); +}; + +const getArgsByCommand = (schema: JsonSchema): Record => { + const { actions } = schema.properties as { + actions: { items: { anyOf: ResponseSchemaBranch['branch'][] } }; + }; + + return Object.fromEntries(actions.items.anyOf.map( + (branch) => [branch.properties.name.enum[0], branch.properties.args], + )); +}; + +const dataGridCommandNames = Object.keys(expectedArgsByCommand); +const treeListCommandNames = coreCommands.map(({ name }) => name); + +describe('Response schema', () => { + describe('DataGrid commands', () => { + const schema = buildResponseSchema([...coreCommands, ...dataGridCommands]); + const argsByCommand = getArgsByCommand(schema); + + it.each(dataGridCommandNames)('should build args schema for the "%s" command', (commandName) => { + expect(argsByCommand[commandName]).toEqual(expectedArgsByCommand[commandName]); + }); + + it('should build args for all commands', () => { + expect(Object.keys(argsByCommand)).toEqual(dataGridCommandNames); + }); + + it('should not build root $defs', () => { + expect(schema.$defs).toBeUndefined(); + }); + }); + + describe('TreeList commands', () => { + const schema = buildResponseSchema(coreCommands); + const argsByCommand = getArgsByCommand(schema); + + it.each(treeListCommandNames)('should build args schema for the "%s" command', (commandName) => { + expect(argsByCommand[commandName]).toEqual(expectedArgsByCommand[commandName]); + }); + + it('should not build root $defs', () => { + expect(schema.$defs).toBeUndefined(); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts index 9aa2dde21f45..a1b1264ad53a 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts @@ -1,6 +1,6 @@ import { defineGridCommand } from '@ts/grids/grid_core/ai_assistant/commands/defineGridCommand'; import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; -import { z } from 'zod'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { Column } from './types'; diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts index 8e85c2d481ec..b0d435b367a3 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts @@ -3,7 +3,7 @@ import { defineGridCommand } from '@ts/grids/grid_core/ai_assistant/commands/def // eslint-disable-next-line spellcheck/spell-checker import { optionalNullish } from '@ts/grids/grid_core/ai_assistant/commands/utils'; import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; -import { z } from 'zod'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { Column } from './types'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts index 5042659dd6ef..9a79ee92ffa8 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts @@ -334,6 +334,27 @@ describe('AI Assistant error handling', () => { }), ]); }); + + it('should fail when buildResponseSchema throws', async () => { + buildResponseSchemaSpy.mockImplementation(() => { + throw new Error('Unsupported schema type'); + }); + + const { model } = await createDataGridWithAiAndPopup(); + + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); + await flushAsync(); + + const messages = await model.loadMessages(); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'An unexpected error occurred. Please try again.', + }), + ]); + }); }); describe('request abort', () => { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/grid_commands.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/grid_commands.test.ts index 3d8d65d6ee97..fc8261582205 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/grid_commands.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/grid_commands.test.ts @@ -323,7 +323,6 @@ describe('GridCommands', () => { const schema = gridCommands.buildResponseSchema() as unknown as SchemaShape; const args = schema.properties.actions.items.anyOf[0].properties.args as JsonSchema; - // openai target makes all fields required (optional becomes nullable) expect(args.required).toEqual(['field1', 'field2']); }); @@ -393,7 +392,7 @@ describe('GridCommands', () => { op: filterOps, value: z.union([z.string(), z.number(), z.null()]), }).strict(); - const exprSchema: z.ZodType = z.lazy(() => z.union([ + const exprSchema: z.ZodType = z.lazy(() => z.union([ basicExpr, z.object({ type: z.literal('combined'), diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts index 3242421e49ff..78bb3afe6cb1 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts @@ -8,6 +8,7 @@ import { import type { Message } from '@js/ui/chat'; import { custom } from '@js/ui/dialog'; import * as themes from '@js/ui/themes'; +import type { z } from 'zod'; import { AI_ASSISTANT_AUTHOR_ID, @@ -28,6 +29,7 @@ import { isPopupOptions, isTitleOption, isUserMessage, + makeOpenAICompatible, } from '../utils'; jest.mock('@js/ui/dialog', () => ({ @@ -456,6 +458,68 @@ describe('expandTypeArraysToAnyOf', () => { }); }); +describe('makeOpenAICompatible', () => { + it('lists every object property in required', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { + type: 'object', + properties: { a: { type: 'string' }, b: { type: 'number' } }, + required: ['a'], + additionalProperties: false, + }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema.required).toEqual(['a', 'b']); + }); + + it('does not add required to an object without properties', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { + type: 'object', + properties: {}, + additionalProperties: false, + }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema).toEqual({ + type: 'object', + properties: {}, + additionalProperties: false, + }); + }); + + it('adds additionalProperties false when it is missing', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { + type: 'object', + properties: { a: { type: 'string' } }, + }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema.additionalProperties).toBe(false); + }); + + it('keeps existing additionalProperties', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: { type: 'string' }, + }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema.additionalProperties).toEqual({ type: 'string' }); + }); + + it('leaves other schemas unchanged', () => { + const jsonSchema: z.core.JSONSchema.BaseSchema = { type: 'string' }; + + makeOpenAICompatible({ jsonSchema }); + + expect(jsonSchema).toEqual({ type: 'string' }); + }); +}); + describe('hoistSchemaRefs', () => { it('returns empty object when no schemas have $ref', () => { const schema: JsonSchema = { @@ -605,102 +669,6 @@ describe('hoistSchemaRefs', () => { }); }); - describe('inline-path refs', () => { - it('hoists an inline $ref to root-level $defs', () => { - const schema: JsonSchema = { - type: 'object', - properties: { - expr: { - anyOf: [ - { type: 'string' }, - { - type: 'object', - properties: { - child: { $ref: '#/properties/expr' }, - }, - }, - ], - }, - }, - }; - - const result = hoistSchemaRefs([ - { prefix: 'test', schema }, - ]); - - expect(result.test_properties_expr).toBeDefined(); - - const props = schema.properties as JsonSchema; - const exprNode = props.expr as JsonSchema; - const branches = exprNode.anyOf as JsonSchema[]; - expect(branches[1].properties).toEqual({ - child: { $ref: '#/$defs/test_properties_expr' }, - }); - }); - - it('handles recursive inline $ref', () => { - const schema: JsonSchema = { - type: 'object', - properties: { - expression: { - anyOf: [ - { - anyOf: [ - { - type: 'object', - properties: { - type: { type: 'string', const: 'basic' }, - field: { type: 'string' }, - }, - additionalProperties: false, - }, - { - type: 'object', - properties: { - type: { type: 'string', const: 'combined' }, - left: { $ref: '#/properties/expression/anyOf/0' }, - right: { $ref: '#/properties/expression/anyOf/0' }, - }, - additionalProperties: false, - }, - ], - }, - { type: 'null' }, - ], - }, - }, - }; - - const result = hoistSchemaRefs([ - { prefix: 'filter', schema }, - ]); - - const defName = 'filter_properties_expression_anyOf_0'; - expect(result[defName]).toBeDefined(); - - const props = schema.properties as JsonSchema; - const exprNode = props.expression as JsonSchema; - const exprAnyOf = exprNode.anyOf as JsonSchema[]; - const innerAnyOf = exprAnyOf[0].anyOf as JsonSchema[]; - expect(innerAnyOf[1].properties).toEqual( - expect.objectContaining({ - left: { $ref: `#/$defs/${defName}` }, - right: { $ref: `#/$defs/${defName}` }, - }), - ); - - const hoistedDef = result[defName] as JsonSchema; - const hoistedInner = hoistedDef.anyOf as JsonSchema[]; - const hoistedProps = hoistedInner[1].properties as JsonSchema; - expect(hoistedProps.left).toEqual({ - $ref: `#/$defs/${defName}`, - }); - expect(hoistedProps.right).toEqual({ - $ref: `#/$defs/${defName}`, - }); - }); - }); - it('skips schemas without $ref', () => { const schemaWithRefs: JsonSchema = { type: 'object', diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts new file mode 100644 index 000000000000..48c39c0f2f61 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/zod.test.ts @@ -0,0 +1,19 @@ +import { + afterEach, describe, expect, it, jest, +} from '@jest/globals'; + +import { z } from '../zod'; + +describe('zod', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should not use eval when a schema is created and parsed', () => { + const functionSpy = jest.spyOn(globalThis, 'Function'); + + z.object({ value: z.string() }).strict().safeParse({ value: 'test' }); + + expect(functionSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts index ce912b658506..bbd179007b7e 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts @@ -9,6 +9,7 @@ import Guid from '@js/core/guid'; import { captionize } from '@js/core/utils/inflector'; import { isFunction, isString } from '@js/core/utils/type'; import type { Message } from '@js/ui/chat'; +import { logger } from '@ts/core/utils/m_console'; import { fromPromise } from '@ts/core/utils/m_deferred'; import { Controller } from '../modules/modules'; @@ -20,6 +21,7 @@ import type { AIMessage, CommandResult, GridCommand, + JsonSchema, } from './types'; import { getMessageStatus, isAIMessage } from './utils'; @@ -176,9 +178,18 @@ export class AIAssistantController extends Controller { }); } + private buildResponseSchema(): JsonSchema | undefined { + try { + return this.gridCommands?.buildResponseSchema(); + } catch (error: unknown) { + logger.error('Error building the AI Assistant response schema:', error); + return undefined; + } + } + private sendRequestToAICore(aiMessage: AIMessage): Promise { return this.withProcessing(new Promise((resolve, reject) => { - const responseSchema = this.gridCommands?.buildResponseSchema(); + const responseSchema = this.buildResponseSchema(); if (!responseSchema) { const localizedErrorMsg = messageLocalization.format('dxDataGrid-aiAssistantUnexpectedErrorMessage'); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts index fbce06c02290..cd9dd3f55bfe 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts @@ -1,6 +1,6 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; -import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; // eslint-disable-next-line spellcheck/spell-checker diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts index 886cbfe2ff79..2d1383b10db1 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts @@ -5,8 +5,8 @@ import type { import { when } from '@js/core/utils/deferred'; import { isDefined } from '@js/core/utils/type'; import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { InternalGrid } from '@ts/grids/grid_core/types'; -import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; import { isMultiValueExpr, resolveFilterValue } from './utils'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts index 8ba752fd6073..7c433729a667 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts @@ -1,6 +1,6 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { InternalGrid } from '@ts/grids/grid_core/types'; -import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; import { compositeKeyPairSchema, isKeyShapeValid, normalizeKey } from './utils'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts index 395fde83ec86..df9d6c6712b2 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts @@ -1,5 +1,5 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; -import { z } from 'zod'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import { defineGridCommand } from './defineGridCommand'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/searching.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/searching.ts index 957d0b566f3d..ee9fc712121d 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/searching.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/searching.ts @@ -1,5 +1,5 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; -import { z } from 'zod'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import { defineGridCommand } from './defineGridCommand'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts index a2abff569a5b..d36091f2823c 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts @@ -1,7 +1,7 @@ import type { DataSource } from '@js/common/data'; import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { InternalGrid, RowKey } from '@ts/grids/grid_core/types'; -import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; import { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts index 2acf3ae5b50a..b9ffae36d516 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts @@ -1,6 +1,6 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; -import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts index ec55f1260a00..5fe51a26aeb6 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts @@ -4,8 +4,8 @@ import type { } from '@js/common/grids'; import { isString } from '@js/core/utils/type'; import { dateUtilsTs } from '@ts/core/utils/date'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import { isDateType } from '@ts/grids/grid_core/m_utils'; -import { z } from 'zod'; type RowKey = string | number | Record; @@ -37,18 +37,21 @@ export const normalizeKey = ( }; /* eslint-disable spellcheck/spell-checker */ -type OptionalNullishSchema = z.ZodEffects< - z.ZodOptional>, - z.output | undefined, - z.input | null | undefined +type OptionalNullishSchema = z.ZodOptional< + z.ZodPipe< + z.ZodOptional>, + z.ZodTransform< + z.output | undefined, + z.output | null | undefined + > + > >; // Treats `null` as "absent" for optional schema fields -export function optionalNullish( +export function optionalNullish( schema: T, ): OptionalNullishSchema { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return schema.nullish().transform((value) => value ?? undefined); + return schema.nullish().transform((value) => value ?? undefined).optional(); } /* eslint-enable spellcheck/spell-checker */ diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts index 39d9916155de..ef29a39a916a 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts @@ -8,7 +8,7 @@ import { DEFAULT_SUCCESS_MESSAGE, EXECUTION_ABORT_MESSAGE, } from '@ts/grids/grid_core/ai_assistant/const'; -import { zodToJsonSchema } from 'zod-to-json-schema'; +import { z } from '@ts/grids/grid_core/ai_assistant/zod'; import type { InternalGrid } from '../types'; import type { @@ -19,7 +19,7 @@ import type { JsonSchema, ResponseSchemaBranch, } from './types'; -import { expandTypeArraysToAnyOf, hoistSchemaRefs } from './utils'; +import { expandTypeArraysToAnyOf, hoistSchemaRefs, makeOpenAICompatible } from './utils'; export class GridCommands { private readonly component: InternalGrid; @@ -85,7 +85,10 @@ export class GridCommands { const commands = [...this.commands.values()]; return commands.map((command) => { - const argsSchema = zodToJsonSchema(command.schema, { target: 'openAi' }) as JsonSchema; + const argsSchema = z.toJSONSchema(command.schema, { + io: 'input', + override: makeOpenAICompatible, + }) as JsonSchema; // Remove $schema from nested schemas since it's only necessary at root delete argsSchema.$schema; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts index f4e80e053b5f..6d3b5c4ead1a 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts @@ -44,7 +44,7 @@ export type CommandExecutor = ( // Empty schemas (no keys) collapse args to `undefined` so the executor // signature becomes `() => Promise` for no-arg commands. -type CommandArgs> = keyof z.infer extends never +type CommandArgs> = keyof TSchema['shape'] extends never ? undefined : z.infer; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts index 66d4595dd08d..b5e531ee738d 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts @@ -6,6 +6,7 @@ import type { Message } from '@js/ui/chat'; import { custom } from '@js/ui/dialog'; import { current, isCompact, isFluent } from '@js/ui/themes'; import type { BaseDialog, DialogParams } from '@ts/ui/dialog'; +import type { z } from 'zod'; import { AI_ASSISTANT_AUTHOR_ID, @@ -51,6 +52,20 @@ export const getMessageStatus = (commands: CommandResult[]): ResponseStatus => { return 'success'; }; +export const makeOpenAICompatible = ( + { jsonSchema }: { jsonSchema: z.core.JSONSchema.BaseSchema }, +): void => { + if (jsonSchema.type === 'object' && jsonSchema.properties) { + const propertyNames = Object.keys(jsonSchema.properties); + + if (propertyNames.length > 0) { + jsonSchema.required = propertyNames; + } + + jsonSchema.additionalProperties ??= false; + } +}; + /** * Some structured-output APIs do not support array-style `type` fields * and require explicit `anyOf` instead. @@ -109,29 +124,6 @@ export const expandTypeArraysToAnyOf = ( return result; }; -/** Resolves a JSON Pointer (e.g. `#/properties/expr/anyOf/0`) within a schema. */ -const resolveJsonPointer = ( - schema: JsonSchema, - pointer: string, -): unknown => { - if (!pointer.startsWith('#/')) { - return undefined; - } - - const segments = pointer.slice(2).split('/'); - let currentNode: unknown = schema; - - for (const segment of segments) { - if (!currentNode || typeof currentNode !== 'object') { - return undefined; - } - - currentNode = (currentNode as Record)[segment]; - } - - return currentNode; -}; - /** Recursively collects all `$ref` string values from a schema node. */ const collectAllRefs = (node: unknown, refs: Set): void => { if (!node || typeof node !== 'object') { @@ -187,29 +179,11 @@ const rewriteRefs = ( } }; -/** - * Derives a definition name from a `$ref` path. - * - * `#/$defs/MyType` → `"MyType"`, - * `#/properties/expression/anyOf/0` → `"properties_expression_anyOf_0"` - */ -const defNameFromRef = (ref: string): string => { - if (ref.startsWith('#/$defs/')) { - return ref.slice(8); - } - - return ref.slice(2).replace(/\//g, '_'); -}; - /** * Extracts all `$ref` targets from each sub-schema, moves them into a * merged top-level `$defs` map, and rewrites every `$ref` to point to * `#/$defs/_`. * - * Handles both `$defs`-based (`$ref: "#/$defs/Foo"`) and inline-path - * (`$ref: "#/properties/expression/anyOf/0"`) references that - * `zodToJsonSchema` may produce. - * * OpenAI Structured Outputs requires every `$ref` to resolve against * `$defs` at the **root** of the schema. This utility rewrites local * references so the combined schema stays valid. @@ -233,15 +207,13 @@ export const hoistSchemaRefs = ( const refMap = new Map(); for (const ref of refs) { - const baseName = defNameFromRef(ref); + const baseName = ref.slice('#/$defs/'.length); const prefixedName = `${prefix}_${baseName}`; const newRef = `#/$defs/${prefixedName}`; refMap.set(ref, newRef); - const resolved = ref.startsWith('#/$defs/') - ? (schema.$defs as JsonSchema | undefined)?.[baseName] - : resolveJsonPointer(schema, ref); + const resolved = (schema.$defs as JsonSchema | undefined)?.[baseName]; if (resolved && typeof resolved === 'object') { mergedDefs[prefixedName] = JSON.parse( diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/zod.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/zod.ts new file mode 100644 index 000000000000..ef4a1531266d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/zod.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +// eslint-disable-next-line spellcheck/spell-checker +z.config({ jitless: true }); + +export { z }; diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 24ab8eaedb4b..9bfb99fb244b 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -63,8 +63,7 @@ "inferno-create-element": "catalog:", "jszip": "^3.10.1", "rrule": "^2.7.1", - "zod": "catalog:", - "zod-to-json-schema": "catalog:" + "zod": "catalog:" }, "devDependencies": { "@babel/core": "7.29.7", diff --git a/packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js b/packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js deleted file mode 100644 index c1ce5b58b1a2..000000000000 --- a/packages/devextreme/testing/helpers/esm-shims/zod-to-json-schema.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Minimal zod-to-json-schema stub for QUnit ESM / import-map loader. - */ -export function zodToJsonSchema() { - return { type: 'object' }; -} - -export default zodToJsonSchema; diff --git a/packages/devextreme/testing/helpers/esm-shims/zod.js b/packages/devextreme/testing/helpers/esm-shims/zod.js index 489d0bbcf5ba..1aef9e725efe 100644 --- a/packages/devextreme/testing/helpers/esm-shims/zod.js +++ b/packages/devextreme/testing/helpers/esm-shims/zod.js @@ -29,6 +29,8 @@ const z = { transform() { return z; }, describe() { return z; }, safeParse() { return { success: true, data: {} }; }, + toJSONSchema() { return { type: 'object' }; }, + config() { return {}; }, }; export { z }; diff --git a/packages/devextreme/testing/runner/lib/importMap.ts b/packages/devextreme/testing/runner/lib/importMap.ts index 84d2d0e175dd..ab39dcd72125 100644 --- a/packages/devextreme/testing/runner/lib/importMap.ts +++ b/packages/devextreme/testing/runner/lib/importMap.ts @@ -352,7 +352,6 @@ export function buildQunitImportMap({ // Stubs zod: `${SHIMS}/zod.js`, - 'zod-to-json-schema': `${SHIMS}/zod-to-json-schema.js`, // Suite-specific missing-dependency overrides ...collectSuiteImportOverrides(suiteFilePath), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b1de37ab436..e4406f7eeaf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,11 +149,8 @@ catalogs: specifier: 10.4.1 version: 10.4.1 zod: - specifier: 3.24.4 - version: 3.24.4 - zod-to-json-schema: - specifier: 3.24.6 - version: 3.24.6 + specifier: 4.6.5 + version: 4.6.5 tools: '@types/node': specifier: 20.19.37 @@ -496,7 +493,7 @@ importers: version: 4.2.0 openai: specifier: 7.5.0 - version: 7.5.0(ws@8.21.1)(zod@3.24.4) + version: 7.5.0(ws@8.21.1)(zod@4.6.5) preact: specifier: 10.12.1 version: 10.12.1 @@ -565,10 +562,7 @@ importers: version: 17.7.2 zod: specifier: 'catalog:' - version: 3.24.4 - zod-to-json-schema: - specifier: 'catalog:' - version: 3.24.6(zod@3.24.4) + version: 4.6.5 zone.js: specifier: 0.16.2 version: 0.16.2 @@ -1241,10 +1235,7 @@ importers: version: 4.6.4(vue@3.5.40(typescript@5.8.3)) zod: specifier: 'catalog:' - version: 3.24.4 - zod-to-json-schema: - specifier: 'catalog:' - version: 3.24.6(zod@3.24.4) + version: 4.6.5 zone.js: specifier: ~0.15.0 version: 0.15.1 @@ -1350,10 +1341,7 @@ importers: version: 2.8.1 zod: specifier: 'catalog:' - version: 3.24.4 - zod-to-json-schema: - specifier: 'catalog:' - version: 3.24.6(zod@3.24.4) + version: 4.6.5 devDependencies: '@babel/core': specifier: 7.29.7 @@ -16332,11 +16320,6 @@ packages: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} engines: {node: '>=18'} - zod-to-json-schema@3.24.6: - resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} - peerDependencies: - zod: ^3.24.1 - zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -16348,9 +16331,6 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@3.24.4: - resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -16360,6 +16340,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zone.js@0.15.1: resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} @@ -17233,7 +17216,7 @@ snapshots: '@standard-schema/spec': 1.1.0 rxjs: 7.8.2 tslib: 2.8.1 - zod: 4.4.3 + zod: 4.6.5 '@angular/platform-browser-dynamic@20.3.33(@angular/common@20.3.33(@angular/core@20.3.33(@angular/compiler@20.3.33)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@20.3.33)(@angular/core@20.3.33(@angular/compiler@20.3.33)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.33(@angular/common@20.3.33(@angular/core@20.3.33(@angular/compiler@20.3.33)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.33(@angular/compiler@20.3.33)(rxjs@7.8.2)(zone.js@0.15.1)))': dependencies: @@ -26843,8 +26826,8 @@ snapshots: '@babel/parser': 7.29.8 eslint: 9.39.4(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + zod: 4.6.5 + zod-validation-error: 4.0.2(zod@4.6.5) transitivePeerDependencies: - supports-color @@ -31449,10 +31432,10 @@ snapshots: transitivePeerDependencies: - encoding - openai@7.5.0(ws@8.21.1)(zod@3.24.4): + openai@7.5.0(ws@8.21.1)(zod@4.6.5): optionalDependencies: ws: 8.21.1 - zod: 3.24.4 + zod: 4.6.5 opencollective-postinstall@2.0.3: {} @@ -35864,10 +35847,6 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 - zod-to-json-schema@3.24.6(zod@3.24.4): - dependencies: - zod: 3.24.4 - zod-to-json-schema@3.25.2(zod@4.1.13): dependencies: zod: 4.1.13 @@ -35876,11 +35855,9 @@ snapshots: dependencies: zod: 4.4.3 - zod-validation-error@4.0.2(zod@4.4.3): + zod-validation-error@4.0.2(zod@4.6.5): dependencies: - zod: 4.4.3 - - zod@3.24.4: {} + zod: 4.6.5 zod@3.25.76: {} @@ -35888,6 +35865,8 @@ snapshots: zod@4.4.3: {} + zod@4.6.5: {} + zone.js@0.15.1: {} zone.js@0.16.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 055ae9bbb03a..35b2d4a0ba69 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -76,8 +76,7 @@ catalog: jquery: 4.0.0 stylelint: 16.22.0 testcafe: 3.7.5 - zod: 3.24.4 - zod-to-json-schema: 3.24.6 + zod: 4.6.5 "@babel/eslint-parser": 7.29.7 "@eslint-stylistic/metadata": ^2.13.0 "@eslint/eslintrc": 3.3.5