From 2ff5e559564b90c716c21d6f771f335d3de65656 Mon Sep 17 00:00:00 2001
From: Diana Gromova <16adianay@gmail.com>
Date: Tue, 15 Sep 2026 18:45:14 +0400
Subject: [PATCH 1/8] Add Chat - Custom AI Assistant demo Angular
---
.../Angular/app/app.component.css | 4 +
.../Angular/app/app.component.html | 7 +
.../Angular/app/app.component.ts | 85 ++++++
.../Angular/app/commands/form-commands.ts | 109 +++++++
.../Angular/app/commands/grid-commands.ts | 283 ++++++++++++++++++
.../ai-assistant/ai-assistant.component.css | 42 +++
.../ai-assistant/ai-assistant.component.html | 65 ++++
.../ai-assistant/ai-assistant.component.ts | 142 +++++++++
.../employee-form/employee-form.component.css | 12 +
.../employee-form.component.html | 32 ++
.../employee-form/employee-form.component.ts | 58 ++++
.../task-grid/task-grid.component.css | 11 +
.../task-grid/task-grid.component.html | 43 +++
.../task-grid/task-grid.component.ts | 45 +++
.../Angular/app/data/data.ts | 183 +++++++++++
.../Angular/app/routing/chat-router.ts | 276 +++++++++++++++++
.../Angular/app/services/ai-service.ts | 94 ++++++
.../Angular/app/types/types.ts | 122 ++++++++
.../Chat/CustomAIAssistant/Angular/index.html | 26 ++
19 files changed, 1639 insertions(+)
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.css
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.html
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.ts
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/form-commands.ts
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.css
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.html
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.html
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.css
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.html
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.ts
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/data/data.ts
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/routing/chat-router.ts
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/index.html
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.css b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.css
new file mode 100644
index 000000000000..6be4a3660ee8
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.css
@@ -0,0 +1,4 @@
+.demo-container {
+ margin: 20px;
+ height: 556px;
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.html b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.html
new file mode 100644
index 000000000000..d9615f07be1b
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.html
@@ -0,0 +1,7 @@
+
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.ts
new file mode 100644
index 000000000000..185c3c166b53
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.ts
@@ -0,0 +1,85 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import {
+ Component, ViewChild, enableProdMode, provideZoneChangeDetection,
+} from '@angular/core';
+import config from 'devextreme/core/config';
+import { loadMessages } from 'devextreme-angular/common/core/localization';
+import type { AIIntegration } from 'devextreme-angular/common/ai-integration';
+import type { DxChatTypes } from 'devextreme-angular/ui/chat';
+import { AiAssistantComponent } from './components/ai-assistant/ai-assistant.component';
+import { EmployeeFormComponent } from './components/employee-form/employee-form.component';
+import { TaskGridComponent } from './components/task-grid/task-grid.component';
+import { createAiIntegration } from './services/ai-service';
+import { routeMessage } from './routing/chat-router';
+
+if (!/localhost/.test(document.location.host)) {
+ enableProdMode();
+}
+
+let modulePrefix = '';
+// @ts-ignore
+if (window && window.config?.packageConfigPaths) {
+ modulePrefix = '/app';
+}
+
+config({
+ editorStylingMode: 'filled',
+});
+
+config({
+ floatingActionButtonConfig: {
+ position: {
+ my: 'right bottom',
+ at: 'right bottom',
+ of: '#grid-container',
+ offset: '-16 -16',
+ },
+ },
+});
+
+loadMessages({
+ en: {
+ 'dxChat-textareaPlaceholder': 'Enter a prompt...',
+ },
+});
+
+@Component({
+ selector: 'demo-app',
+ templateUrl: `.${modulePrefix}/app.component.html`,
+ styleUrls: [`.${modulePrefix}/app.component.css`],
+ imports: [
+ AiAssistantComponent,
+ EmployeeFormComponent,
+ TaskGridComponent,
+ ],
+})
+export class AppComponent {
+ @ViewChild(EmployeeFormComponent) private employeeForm!: EmployeeFormComponent;
+
+ @ViewChild(TaskGridComponent) private taskGrid!: TaskGridComponent;
+
+ @ViewChild(AiAssistantComponent) private aiAssistant!: AiAssistantComponent;
+
+ readonly aiIntegration: AIIntegration = createAiIntegration();
+
+ async onMessageSubmitted(message: DxChatTypes.TextMessage): Promise {
+ this.aiAssistant.setDisabled(true);
+
+ try {
+ await routeMessage(message.text ?? '', {
+ form: this.employeeForm.instance,
+ gridInstance: this.taskGrid.instance,
+ aiIntegration: this.aiIntegration,
+ pushMessage: (message) => this.aiAssistant.pushMessage(message),
+ });
+ } finally {
+ this.aiAssistant.setDisabled(false);
+ }
+ }
+}
+
+bootstrapApplication(AppComponent, {
+ providers: [
+ provideZoneChangeDetection({ eventCoalescing: true }),
+ ],
+});
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/form-commands.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/form-commands.ts
new file mode 100644
index 000000000000..19009228110a
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/form-commands.ts
@@ -0,0 +1,109 @@
+import type { DxFormComponent, DxFormTypes } from 'devextreme-angular/ui/form';
+import type {
+ AIResult, CommandResult, FormAction, FormFieldOption,
+} from '../types/types';
+import { SMART_PASTE_TIMEOUT_MS } from '../data/data';
+
+export function getFormFieldOptions(form: DxFormComponent): FormFieldOption[] {
+ return ((form.instance.option('items') as { dataField?: string; label?: { text?: string } }[]) ?? [])
+ .filter((item) => item.dataField)
+ .map((item) => ({
+ dataField: item.dataField ?? '',
+ label: item.label?.text ?? item.dataField ?? '',
+ }));
+}
+
+export function applyFormClearAction(
+ form: DxFormComponent,
+ formAction: FormAction | null,
+): CommandResult | null {
+ if (!formAction || formAction.type === 'smart_paste') return null;
+
+ if (formAction.type === 'clear_all') {
+ try {
+ form.instance.clear();
+ return { status: 'success', message: 'Cleared all Form fields.' };
+ } catch {
+ return {
+ status: 'failure',
+ message: "I couldn't clear the form.",
+ };
+ }
+ }
+
+ if (formAction.type === 'clear_field') {
+ const isKnownField = getFormFieldOptions(form).some((f) => f.dataField === formAction.field);
+
+ if (!isKnownField) {
+ return {
+ status: 'failure',
+ message: `I couldn't find a field named '${formAction.field}' to clear.`,
+ };
+ }
+
+ form.instance.updateData(formAction.field ?? '', null);
+
+ return { status: 'success', message: `Cleared ${formAction.field}.` };
+ }
+
+ return null;
+}
+
+export function formatAiResultDetails(form: DxFormComponent, aiResult: AIResult): string {
+ const labelByField = new Map(getFormFieldOptions(form).map((f) => [f.dataField, f.label]));
+
+ return Object.keys(aiResult)
+ .map((field) => labelByField.get(field) ?? field)
+ .join(', ');
+}
+
+export function applyFormSmartPaste(form: DxFormComponent, text: string): Promise {
+ return new Promise((resolve) => {
+ let settled = false;
+ let timedOut = false;
+
+ const finish = (result: CommandResult): void => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeoutId);
+ form.instance.off('smartPasted', handleSmartPasted);
+ resolve(result);
+ };
+
+ const handleSmartPasted = (e: DxFormTypes.SmartPastedEvent): void => {
+ if (timedOut) {
+ return;
+ }
+
+ const aiResult = e.aiResult ?? {};
+ const fieldCount = Object.keys(aiResult).length;
+ finish(
+ fieldCount > 0
+ ? { status: 'success', message: `Updated the form (${formatAiResultDetails(form, aiResult)}).` }
+ : {
+ status: 'failure',
+ message: "I couldn't find any Form fields matching the request.",
+ },
+ );
+ };
+
+ const timeoutId = setTimeout(() => {
+ timedOut = true;
+ finish({
+ status: 'failure',
+ message: "I couldn't process your request. Please try rephrasing it.",
+ });
+ }, SMART_PASTE_TIMEOUT_MS);
+
+ form.instance.on('smartPasted', handleSmartPasted);
+
+ try {
+ form.instance.smartPaste(text);
+ } catch {
+ finish({
+ status: 'failure',
+ message: "I couldn't process your request. Please try rephrasing it.",
+ });
+ }
+ });
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts
new file mode 100644
index 000000000000..3bfa9806779f
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts
@@ -0,0 +1,283 @@
+import type { DxDataGridComponent } from 'devextreme-angular/ui/data-grid';
+import type {
+ ColumnLookup,
+ CommandResult,
+ ExecuteGridAssistantAction,
+ FilterCondition,
+ GridCommand,
+ GridCommandArgs,
+ GridFilterValue,
+} from '../types/types';
+
+export function getFilterConditions(filterValue: unknown): FilterCondition[] {
+ if (!Array.isArray(filterValue)) return [];
+
+ return Array.isArray(filterValue[0])
+ ? (filterValue as unknown[]).filter((item): item is FilterCondition => Array.isArray(item))
+ : [filterValue as FilterCondition];
+}
+
+export function combineFilterConditions(
+ existingFilterValue: unknown,
+ newCondition: FilterCondition,
+): GridFilterValue {
+ const [newColumn, newOperator] = newCondition;
+ const conditions = getFilterConditions(existingFilterValue).filter(
+ ([column, operator]) => column !== newColumn || operator !== newOperator,
+ );
+ conditions.push(newCondition);
+
+ return conditions.length === 1
+ ? conditions[0]
+ : conditions.flatMap((condition, index) => (index === 0 ? [condition] : ['and' as const, condition]));
+}
+
+export function getColumnOrFail(grid: DxDataGridComponent, columnName: string | undefined): ColumnLookup {
+ const column = grid.instance.columnOption(columnName ?? '');
+
+ if (!column) {
+ return {
+ column: null,
+ failure: {
+ status: 'failure',
+ message: `I couldn't find a DataGrid column named '${columnName}'.`,
+ },
+ };
+ }
+
+ return { column, failure: null };
+}
+
+export const gridCommands: Record = {
+ filterValue: {
+ description: `Apply a filter to a single column. Pass column (dataField), operator, and value.
+Supported operators: "=", "<>", "<", "<=", ">", ">=", "contains", "notcontains", "startswith", "endswith", "anyof".
+Date values must be in "YYYY-MM-DDTHH:mm:ss" format (e.g. "2024-05-10T00:00:00").
+The "Completion" column is a boolean (task completed or not): use operator "=" with value true for completed tasks, or value false for tasks that are not completed.
+To filter a date column by a year and/or month (the same thing the grid's own header filter does when you pick a year then a month), use operator "anyof" with value as an array of one or more strings in "YYYY" (whole year, e.g. "2023") or "YYYY/M" (whole month, month is 1-12 with no leading zero, e.g. "2023/5" for May 2023) format, e.g. {"column": "DueDate", "operator": "anyof", "value": ["2023/5"]} for "May 2023". Only use "anyof" when the year is known; if the year is missing and cannot be inferred from elsewhere in the request (e.g. plain "in May" with no year anywhere), do not guess it - omit this action entirely instead of adding it with a made-up year.`,
+ schema: {
+ type: 'object',
+ properties: {
+ column: { type: 'string' },
+ operator: {
+ type: 'string',
+ enum: ['=', '<>', '<', '<=', '>', '>=', 'contains', 'notcontains', 'startswith', 'endswith', 'anyof'],
+ },
+ value: {
+ anyOf: [{ type: ['string', 'number', 'boolean'] }, { type: 'array', items: { type: 'string' } }],
+ },
+ },
+ required: ['column', 'operator', 'value'],
+ },
+ execute(grid: DxDataGridComponent, args: GridCommandArgs, rawText?: string): CommandResult {
+ const { column, failure } = getColumnOrFail(grid, args.column);
+ if (failure) return failure;
+
+ const caption = column.caption ?? args.column ?? '';
+ const columnName = column.dataField ?? column.name ?? '';
+ let { value } = args;
+
+ if (columnName === 'Completion' && typeof value !== 'boolean') {
+ const normalized = String(value).trim().toLowerCase();
+ value = value === 100 || ['true', 'completed', 'yes', '100'].includes(normalized);
+ }
+
+ const isDateColumn = column.dataType === 'date' || column.dataType === 'datetime';
+
+ if (isDateColumn && typeof value === 'string') {
+ const parsedDate = new Date(value);
+ if (!Number.isNaN(parsedDate.getTime())) {
+ value = parsedDate;
+ }
+ }
+
+ if (args.operator === 'anyof' && isDateColumn && Array.isArray(value)) {
+ const mentionedYears = new Set(String(rawText ?? '').match(/\b\d{4}\b/g));
+ const hasUnrecognizedYear = value.some(
+ (entry) => !mentionedYears.has(String(entry).split('/')[0]),
+ );
+
+ if (hasUnrecognizedYear) {
+ return {
+ status: 'failure',
+ message: 'No field or column exists with such a name, or the entered value is invalid.',
+ };
+ }
+ }
+
+ try {
+ const newCondition: FilterCondition = [columnName, args.operator ?? '=', value ?? ''];
+ grid.instance.option('filterValue', combineFilterConditions(grid.instance.option('filterValue'), newCondition));
+ return {
+ status: 'success',
+ message: `Filtered by '${caption}'.`,
+ };
+ } catch {
+ return {
+ status: 'failure',
+ message: `I couldn't apply that filter to '${caption}'. Check that the value matches the column's type.`,
+ };
+ }
+ },
+ },
+
+ clearFilter: {
+ description: 'Clear all filters on the grid.',
+ schema: { type: 'object', properties: {} },
+ execute(grid: DxDataGridComponent): CommandResult {
+ try {
+ grid.instance.clearFilter();
+ return { status: 'success', message: 'Filter cleared.' };
+ } catch {
+ return {
+ status: 'failure',
+ message: "I couldn't clear the DataGrid's filters.",
+ };
+ }
+ },
+ },
+
+ sorting: {
+ description: 'Sort a column ascending or descending. Pass sortOrder "none" to remove sorting from this column only.',
+ schema: {
+ type: 'object',
+ properties: {
+ column: { type: 'string' },
+ sortOrder: { type: 'string', enum: ['asc', 'desc', 'none'] },
+ },
+ required: ['column', 'sortOrder'],
+ },
+ execute(grid: DxDataGridComponent, args: GridCommandArgs): CommandResult {
+ const { column, failure } = getColumnOrFail(grid, args.column);
+ if (failure) return failure;
+
+ const caption = column.caption ?? args.column ?? '';
+
+ try {
+ grid.instance.columnOption(
+ args.column ?? '',
+ 'sortOrder',
+ args.sortOrder === 'none' ? undefined : args.sortOrder,
+ );
+
+ const message = args.sortOrder === 'none'
+ ? `Cleared sorting on '${caption}'.`
+ : `Sorted by '${caption}' (${args.sortOrder === 'asc' ? 'ascending' : 'descending'}).`;
+
+ return { status: 'success', message };
+ } catch {
+ return {
+ status: 'failure',
+ message: `I couldn't sort by '${caption}'.`,
+ };
+ }
+ },
+ },
+
+ clearSorting: {
+ description: 'Remove sorting from all columns.',
+ schema: { type: 'object', properties: {} },
+ execute(grid: DxDataGridComponent): CommandResult {
+ try {
+ grid.instance.clearSorting();
+ return { status: 'success', message: 'Sorting cleared.' };
+ } catch {
+ return {
+ status: 'failure',
+ message: "I couldn't clear the DataGrid's sorting.",
+ };
+ }
+ },
+ },
+
+ columnsVisibility: {
+ description: 'Show or hide a column.',
+ schema: {
+ type: 'object',
+ properties: {
+ column: { type: 'string' },
+ visible: { type: 'boolean' },
+ },
+ required: ['column', 'visible'],
+ },
+ execute(grid: DxDataGridComponent, args: GridCommandArgs): CommandResult {
+ const { column, failure } = getColumnOrFail(grid, args.column);
+ if (failure) return failure;
+
+ const caption = column.caption ?? args.column ?? '';
+
+ try {
+ grid.instance.columnOption(args.column ?? '', 'visible', args.visible);
+ return {
+ status: 'success',
+ message: args.visible ? `Showed column '${caption}'.` : `Hid column '${caption}'.`,
+ };
+ } catch {
+ return {
+ status: 'failure',
+ message: `I couldn't change the visibility of '${caption}'.`,
+ };
+ }
+ },
+ },
+};
+
+export function buildGridResponseSchema(): Record {
+ const branches = Object.entries(gridCommands).map(([name, command]) => ({
+ type: 'object',
+ properties: {
+ name: { type: 'string', enum: [name] },
+ args: command.schema,
+ },
+ required: ['name', 'args'],
+ }));
+
+ return {
+ type: 'object',
+ properties: {
+ actions: {
+ type: 'array',
+ description: 'List of grid commands to execute, in order.',
+ items: { anyOf: branches },
+ },
+ },
+ required: ['actions'],
+ };
+}
+
+export function buildGridPromptSection(columnNames: string[]): string {
+ const commandDescriptions = Object.entries(gridCommands)
+ .map(([name, command]) => `- '${name}': ${command.description}`)
+ .join('\n');
+
+ return `GRID: translate any part of the request that affects the task grid into one or more grid commands (the "actions" array).
+Available columns (dataField): ${columnNames.join(', ')}.
+CRITICAL RULE: a column mentioned in the request must clearly correspond to one of the available columns above (matching by meaning is fine, e.g. "due date" -> "DueDate"). If it does not - even if it superficially looks like it could be a column name - you must NOT invent or substitute the closest-sounding available column. Instead, still emit the action using the column name exactly as written in the request, so the app can report that the column wasn't found - never replace it with a different, existing column just to make the action valid.
+Example: request "filter the ZXQ column by foo" - ZXQ matches no available column, so emit {"column": "ZXQ", ...} as-is (it will correctly fail as "column not found") - do NOT emit an action for 'Subject' or any other real column instead.
+The "Completion" column is a boolean: true means the task is completed, false means it is not. To filter for 'completed' tasks, use {'column': 'Completion', 'operator': '=', 'value': true}. To filter for 'not completed' tasks, use {'column': 'Completion', 'operator': '=', 'value': false}.
+Available grid commands:
+${commandDescriptions}`;
+}
+
+export function getGridColumnNames(gridInstance: DxDataGridComponent): string[] {
+ return (gridInstance.instance.option('columns') ?? []).map((col) => col.dataField ?? '');
+}
+
+export function applyGridActions(
+ grid: DxDataGridComponent,
+ actions: ExecuteGridAssistantAction[],
+ rawText?: string,
+): CommandResult[] {
+ return actions.map((action) => {
+ const command = gridCommands[action.name];
+
+ if (!command) {
+ return {
+ status: 'failure',
+ message: `I don't know how to do '${action.name}'.`,
+ };
+ }
+
+ return command.execute(grid, action.args ?? {}, rawText);
+ });
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.css b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.css
new file mode 100644
index 000000000000..cbe14763fcfd
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.css
@@ -0,0 +1,42 @@
+::ng-deep .dx-chat {
+ max-width: 900px;
+ border-radius: 0;
+ min-height: 20em;
+}
+
+::ng-deep .ai-chat-content {
+ height: 100%;
+}
+
+::ng-deep .ai-chat-empty-message {
+ font-size: 18px;
+ font-weight: 400;
+ text-align: center;
+ color: var(--dx-color-text);
+ margin-bottom: 8px;
+}
+
+::ng-deep .ai-chat-empty-prompt {
+ font-size: 14px;
+ color: #888;
+ text-align: center;
+ padding: 0 24px;
+ white-space: pre-line;
+}
+
+::ng-deep .ai-chat-clear-button {
+ margin-right: 8px;
+}
+
+::ng-deep .chat-popup .dx-popup-content {
+ padding: 0;
+}
+
+::ng-deep .dx-chat-messagebox {
+ border-top-color: transparent;
+}
+
+::ng-deep .dx-chat-messagelist-empty-view,
+::ng-deep .dx-chat-messagebox {
+ padding-top: 0;
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.html b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.html
new file mode 100644
index 000000000000..8534128feb67
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
{{ emptyViewMessage }}
+
+
+
+
+
+
+
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
new file mode 100644
index 000000000000..cb8d5b3c232a
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
@@ -0,0 +1,142 @@
+import {
+ Component, EventEmitter, Input, Output, ViewChild,
+} from '@angular/core';
+import { DxPopupModule, DxPopupComponent } from 'devextreme-angular/ui/popup';
+import { DxChatModule, DxChatComponent } from 'devextreme-angular/ui/chat';
+import type { DxChatTypes } from 'devextreme-angular/ui/chat';
+import { DxSpeedDialActionModule } from 'devextreme-angular/ui/speed-dial-action';
+import { ArrayStore, DataSource } from 'devextreme-angular/common/data';
+import {
+ CLASSES, chatSuggestions, EMPTY_VIEW_MESSAGE, EMPTY_VIEW_PROMPT,
+} from '../../data/data';
+
+let modulePrefix = '';
+// @ts-ignore
+if (window && window.config?.packageConfigPaths) {
+ modulePrefix = '/app';
+}
+
+@Component({
+ selector: 'app-ai-assistant',
+ standalone: true,
+ imports: [
+ DxPopupModule,
+ DxChatModule,
+ DxSpeedDialActionModule,
+ ],
+ templateUrl: `.${modulePrefix}/components/ai-assistant/ai-assistant.component.html`,
+ styleUrls: [`.${modulePrefix}/components/ai-assistant/ai-assistant.component.css`],
+})
+export class AiAssistantComponent {
+ @Input() disabled = false;
+
+ @Output() messageSubmitted = new EventEmitter();
+
+ @ViewChild(DxPopupComponent) private dxPopup!: DxPopupComponent;
+
+ @ViewChild(DxChatComponent) private dxChat!: DxChatComponent;
+
+ readonly CLASSES = CLASSES;
+
+ readonly emptyViewMessage = EMPTY_VIEW_MESSAGE;
+
+ readonly emptyViewPromptHtml = EMPTY_VIEW_PROMPT;
+
+ readonly suggestions = chatSuggestions;
+
+ readonly popupPosition = {
+ my: 'right top',
+ at: 'right top',
+ of: '.demo-container',
+ offset: '-20 20',
+ };
+
+ popupVisible = false;
+
+ fabVisible = true;
+
+ isClearDisabled = true;
+
+ private readonly store = new ArrayStore({ key: 'id' });
+
+ readonly dataSource = new DataSource({
+ store: this.store,
+ paginate: false,
+ });
+
+ private clearButtonInstance?: { option: (name: string, value: unknown) => void };
+
+ get clearButtonOptions(): Record {
+ return {
+ icon: 'clearhistory',
+ disabled: this.isClearDisabled,
+ hint: 'Clear chat',
+ onClick: () => this.clearChat(),
+ onInitialized: (e: { component: { option: (name: string, value: unknown) => void } }) => {
+ this.clearButtonInstance = e.component;
+ },
+ };
+ }
+
+ toggle(): void {
+ this.popupVisible = !this.popupVisible;
+ }
+
+ setDisabled(value: boolean): void {
+ this.disabled = value;
+ this.dxChat.instance.option('disabled', value);
+ if (!value) {
+ this.updateClearButtonState();
+ }
+ }
+
+ onPopupShowing(): void {
+ this.fabVisible = false;
+ }
+
+ onPopupHiding(): void {
+ this.fabVisible = true;
+ }
+
+ pushMessage(message: Partial): void {
+ this.dataSource.store().push([
+ {
+ type: 'insert',
+ data: {
+ id: Date.now() + Math.random(),
+ timestamp: new Date(),
+ ...message,
+ },
+ },
+ ]);
+ }
+
+ clearChat(): void {
+ this.store.clear();
+ this.dataSource.reload();
+ this.updateClearButtonState();
+ }
+
+ onSuggestionClick = ({ itemData }: { itemData?: { prompt: string } }): void => {
+ const { prompt } = itemData ?? {};
+
+ const message: DxChatTypes.TextMessage = {
+ id: Date.now() + Math.random(),
+ timestamp: new Date(),
+ author: { id: 'user' },
+ text: prompt,
+ };
+
+ this.pushMessage(message);
+ this.messageSubmitted.emit(message);
+ };
+
+ onMessageEntered(e: DxChatTypes.MessageEnteredEvent): void {
+ this.messageSubmitted.emit(e.message);
+ }
+
+ private updateClearButtonState(): void {
+ this.isClearDisabled = this.dataSource.items().length === 0;
+ this.clearButtonInstance?.option('disabled', this.isClearDisabled);
+ }
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css
new file mode 100644
index 000000000000..a1cf435a76cc
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css
@@ -0,0 +1,12 @@
+::ng-deep #form-container {
+ border: 1px solid #e0e0e0;
+ padding: 16px;
+}
+
+::ng-deep .dx-color-scheme-blue-light #form-container {
+ background-color: #fff;
+}
+
+::ng-deep .dx-layout-manager .dx-field-item.save-button {
+ padding-bottom: 0;
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.html b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.html
new file mode 100644
index 000000000000..7cd05170fe17
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
new file mode 100644
index 000000000000..2e29c9563f73
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
@@ -0,0 +1,58 @@
+import { CommonModule } from '@angular/common';
+import { Component, Input, ViewChild } from '@angular/core';
+import { DxFormModule, DxFormComponent } from 'devextreme-angular/ui/form';
+import { DxToastModule, DxToastComponent } from 'devextreme-angular/ui/toast';
+import type { AIIntegration } from 'devextreme-angular/common/ai-integration';
+import { employee, formFieldsConfig } from '../../data/data';
+import type { Employee } from '../../types/types';
+
+let modulePrefix = '';
+// @ts-ignore
+if (window && window.config?.packageConfigPaths) {
+ modulePrefix = '/app';
+}
+
+@Component({
+ selector: 'app-employee-form',
+ standalone: true,
+ imports: [CommonModule, DxFormModule, DxToastModule],
+ templateUrl: `.${modulePrefix}/components/employee-form/employee-form.component.html`,
+ styleUrls: [`.${modulePrefix}/components/employee-form/employee-form.component.css`],
+})
+export class EmployeeFormComponent {
+ @Input({ required: true }) aiIntegration!: AIIntegration;
+
+ @ViewChild(DxFormComponent) private dxForm!: DxFormComponent;
+
+ @ViewChild(DxToastComponent) private dxToast!: DxToastComponent;
+
+ formData: Employee = { ...employee };
+
+ readonly toastPosition = {
+ of: '#form-container',
+ at: 'bottom center',
+ my: 'bottom center',
+ offset: '0 -20',
+ };
+
+ readonly formFields = formFieldsConfig;
+
+ readonly saveButtonOptions = {
+ text: 'Save',
+ type: 'default',
+ disabled: true,
+ useSubmitBehavior: true,
+ width: '120px',
+ onClick: () => this.dxToast.instance.show(),
+ };
+
+ get instance(): DxFormComponent {
+ return this.dxForm;
+ }
+
+ onOptionChanged(e: { name: string; value: unknown }): void {
+ if (e.name === 'isDirty') {
+ this.dxForm.instance.getButton('Save')?.option('disabled', !e.value);
+ }
+ }
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.css b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.css
new file mode 100644
index 000000000000..cf539b135feb
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.css
@@ -0,0 +1,11 @@
+::ng-deep #grid-container {
+ margin-top: 20px;
+ min-height: 360px;
+}
+
+::ng-deep .priority-badge {
+ border-radius: 24px;
+ padding: 2px 8px;
+ display: inline-block;
+ text-align: center;
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.html b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.html
new file mode 100644
index 000000000000..f7f955e42c05
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.ts
new file mode 100644
index 000000000000..4bd29606f1dc
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.ts
@@ -0,0 +1,45 @@
+import { Component, ViewChild } from '@angular/core';
+import { DxDataGridModule, DxDataGridComponent } from 'devextreme-angular/ui/data-grid';
+import { colors, tasks } from '../../data/data';
+import type { ColumnFilterExpression, Task } from '../../types/types';
+
+let modulePrefix = '';
+// @ts-ignore
+if (window && window.config?.packageConfigPaths) {
+ modulePrefix = '/app';
+}
+
+@Component({
+ selector: 'app-task-grid',
+ standalone: true,
+ imports: [DxDataGridModule],
+ templateUrl: `.${modulePrefix}/components/task-grid/task-grid.component.html`,
+ styleUrls: [`.${modulePrefix}/components/task-grid/task-grid.component.css`],
+})
+export class TaskGridComponent {
+ @ViewChild(DxDataGridComponent) private dxDataGrid!: DxDataGridComponent;
+
+ readonly tasks = tasks;
+
+ readonly colors = colors;
+
+ readonly completionEditorOptions = {
+ elementAttr: { 'aria-label': 'Completed' },
+ };
+
+ get instance(): DxDataGridComponent {
+ return this.dxDataGrid;
+ }
+
+ calculateCompletionCellValue = (rowData: Task): boolean => rowData.Completion === 100;
+
+ calculateCompletionFilterExpression = (
+ filterValue: unknown,
+ selectedFilterOperation: string | null,
+ ): ColumnFilterExpression => {
+ const wantsCompleted = selectedFilterOperation === '<>' ? !filterValue : !!filterValue;
+ const rawCompletion = (rowData: Task): number => rowData.Completion;
+
+ return wantsCompleted ? [rawCompletion, '=', 100] : [rawCompletion, '<', 100];
+ };
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/data/data.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/data/data.ts
new file mode 100644
index 000000000000..4523b12f7033
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/data/data.ts
@@ -0,0 +1,183 @@
+import type {
+ Employee, FormActionType, RouterTarget, Task, TaskPriority,
+} from '../types/types';
+
+export const CLASSES = {
+ clearChatButton: 'ai-chat-clear-button',
+};
+
+export const AI_SERVICE_CONFIG = {
+ deployment: 'demo-mini',
+ apiVersion: '2024-02-01',
+ endpoint: 'https://public-api.devexpress.com/demo-openai',
+ apiKey: 'DEMO',
+};
+
+export const EMPTY_VIEW_MESSAGE = 'How can I help with this page?';
+
+export const EMPTY_VIEW_PROMPT = 'Update employee Form fields.\nFilter or sort tasks, display or hide DataGrid columns, or clear all filters and sorting.';
+
+export const SMART_PASTE_TIMEOUT_MS = 30000;
+export const MAX_USER_MESSAGE_LENGTH = 2000;
+
+export const FIELD_OR_VALUE_NOT_FOUND_MESSAGE = '❌ No field or column exists with such a name, or the entered value is invalid. Please check the name and value and try again.';
+
+export const ROUTER_TARGETS = new Set(['form', 'grid', 'mixed', 'none']);
+export const FORM_ACTION_TYPES = new Set(['clear_field', 'clear_all', 'smart_paste']);
+
+export const titles = ['Mr.', 'Mrs.', 'Ms.'];
+
+export const colors: Record = {
+ High: '#F1BBBC',
+ Normal: '#F9E2AE',
+ Low: '#9FD89F',
+};
+
+export const states = ['California', 'New York', 'Texas'];
+
+export const positions = [
+ 'CEO',
+ 'Sales Assistant',
+ 'CMO',
+ 'Manager',
+ 'Designer',
+ 'Developer',
+];
+
+export const employee: Employee = {
+ ID: 1,
+ Prefix: 'Mr.',
+ FirstName: 'John',
+ LastName: 'Heart',
+ Position: 'CEO',
+ State: 'California',
+ BirthDate: '1964/03/16',
+};
+
+export const tasks: Task[] = [
+ {
+ ID: 5,
+ Subject: 'Choose between PPO and HMO Health Plan',
+ StartDate: '2026/02/15',
+ DueDate: '2026/04/15',
+ Status: 'In Progress',
+ Priority: 'Low',
+ Completion: 75,
+ EmployeeID: 1,
+ },
+ {
+ ID: 6,
+ Subject: 'Google AdWords Strategy',
+ StartDate: '2026/02/16',
+ DueDate: '2026/02/28',
+ Status: 'Completed',
+ Priority: 'High',
+ Completion: 100,
+ EmployeeID: 1,
+ },
+ {
+ ID: 7,
+ Subject: 'New Brochures',
+ StartDate: '2026/02/17',
+ DueDate: '2026/02/24',
+ Status: 'Completed',
+ Priority: 'Normal',
+ Completion: 100,
+ EmployeeID: 1,
+ },
+ {
+ ID: 22,
+ Subject: 'Update NDA Agreement',
+ StartDate: '2026/03/14',
+ DueDate: '2026/03/16',
+ Status: 'Completed',
+ Priority: 'High',
+ Completion: 100,
+ EmployeeID: 1,
+ },
+ {
+ ID: 52,
+ Subject: 'Review Product Recall Report by Engineering Team',
+ StartDate: '2026/05/17',
+ DueDate: '2026/05/20',
+ Status: 'Completed',
+ Priority: 'High',
+ Completion: 100,
+ EmployeeID: 1,
+ },
+];
+
+export const chatSuggestions = [
+ { text: 'Show Completed Tasks', prompt: 'Show Completed Tasks' },
+ { text: 'Change State to Texas', prompt: 'Change State to Texas' },
+];
+
+export interface FormFieldDescriptor {
+ dataField: string;
+ label: { text: string };
+ editorType?: string;
+ editorOptions?: Record;
+ aiOptions: { instruction: string };
+}
+
+export const formFieldsConfig: FormFieldDescriptor[] = [
+ {
+ dataField: 'Prefix',
+ label: { text: 'Title' },
+ editorType: 'dxSelectBox',
+ editorOptions: { items: titles, searchEnabled: true },
+ aiOptions: {
+ instruction: 'Only fill this field with one of the allowed values (Mr., Mrs., Ms.) if a '
+ + 'title is explicitly mentioned in the text. Never use this field for any part '
+ + "of a person's name.",
+ },
+ },
+ {
+ dataField: 'FirstName',
+ label: { text: 'First Name' },
+ aiOptions: {
+ instruction: "Only fill this field if the text clearly refers to a person's given name. "
+ + 'Never use grid/task-related words like Subject, Priority, Status, Due Date, '
+ + 'Completion, or generic verbs like sort/filter/show as a name.',
+ },
+ },
+ {
+ dataField: 'LastName',
+ label: { text: 'Last Name' },
+ aiOptions: {
+ instruction: "If the text gives a full person name (e.g. 'customer name', 'employee name') "
+ + 'without separately labeled first/last names, use only the first word as First '
+ + 'Name and the rest of the name as Last Name.',
+ },
+ },
+ {
+ dataField: 'Position',
+ label: { text: 'Position' },
+ editorType: 'dxSelectBox',
+ editorOptions: { items: positions, searchEnabled: true },
+ aiOptions: {
+ instruction: 'Only fill this field with one of the allowed job position values if the text '
+ + "explicitly refers to the employee's own job title/role.",
+ },
+ },
+ {
+ dataField: 'State',
+ label: { text: 'State' },
+ editorType: 'dxSelectBox',
+ editorOptions: { items: states, searchEnabled: true },
+ aiOptions: {
+ instruction: 'Only fill this field with one of the allowed US state values if the text '
+ + "explicitly refers to the employee's home/office state",
+ },
+ },
+ {
+ dataField: 'BirthDate',
+ label: { text: 'Birth Date' },
+ editorType: 'dxDateBox',
+ editorOptions: { displayFormat: 'M/d/yyyy' },
+ aiOptions: {
+ instruction: "Only fill this field if the text explicitly refers to the employee's own birth "
+ + 'date or date of birth.',
+ },
+ },
+];
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/routing/chat-router.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/routing/chat-router.ts
new file mode 100644
index 000000000000..db2002757b14
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/routing/chat-router.ts
@@ -0,0 +1,276 @@
+import type { AIIntegration } from 'devextreme-angular/common/ai-integration';
+import type { DxDataGridComponent } from 'devextreme-angular/ui/data-grid';
+import type { DxFormComponent } from 'devextreme-angular/ui/form';
+import type {
+ ClassificationResult,
+ CommandResult,
+ ExecuteGridAssistantAction,
+ FormAction,
+ OperationOutcome,
+ PushMessage,
+ RouteMessageContext,
+ RouterContext,
+} from '../types/types';
+import { ChatCommandError } from '../types/types';
+import {
+ FIELD_OR_VALUE_NOT_FOUND_MESSAGE, FORM_ACTION_TYPES, MAX_USER_MESSAGE_LENGTH, ROUTER_TARGETS,
+} from '../data/data';
+import { applyFormClearAction, applyFormSmartPaste, getFormFieldOptions } from '../commands/form-commands';
+import {
+ applyGridActions, buildGridPromptSection, buildGridResponseSchema, getGridColumnNames,
+} from '../commands/grid-commands';
+
+export function extractJson(text: string): unknown {
+ const match = text.match(/\{[\s\S]*\}/);
+
+ try {
+ return JSON.parse((match ?? [''])[0]);
+ } catch {
+ throw new ChatCommandError(
+ '❌ I received an unexpected response from the AI service. Please rephrase your request and try again.',
+ );
+ }
+}
+
+export function executeAiCommand(text: string, aiIntegration: AIIntegration): Promise {
+ return new Promise((resolve, reject) => {
+ aiIntegration.execute(
+ { text },
+ {
+ onComplete: (finalResponse: string) => {
+ try {
+ resolve(extractJson(finalResponse));
+ } catch (error) {
+ reject(error);
+ }
+ },
+ onError: reject,
+ },
+ );
+ });
+}
+
+function buildGridSystemPrompt(columnNames: string[]): string {
+ return `You control a task DataGrid on this page.
+This page ALSO has a separate employee/customer profile form (fields like name, title/prefix, position, state, birth date) that is handled elsewhere - it is NOT part of this grid.
+Figure out what the user's request is about and translate ONLY the part that is clearly about the task grid into the matching commands described below.
+Do NOT create a grid action just because a value could technically fit a text column (e.g. 'Subject'). If the request is about the profile form (e.g. mentions a person's name, title, job position, state, or birth date), leave that part out of 'actions' entirely - even if no other part of the request is grid-related.
+
+${buildGridPromptSection(columnNames)}
+
+Respond with STRICT JSON only, no code fences, no explanations, matching this schema:
+${JSON.stringify(buildGridResponseSchema())}
+
+If the request has nothing to do with the grid, respond with 'actions': [].`;
+}
+
+function buildFormActionPromptSection(form: DxFormComponent): string {
+ const fieldList = getFormFieldOptions(form)
+ .map((f) => `${f.dataField} (${f.label})`)
+ .join(', ');
+
+ return `Form fields (dataField and label): ${fieldList}.
+If the request is about the form, also set \`formAction\` to one of:
+- \`{type: 'clear_field', field: ''}\` to clear one specific field.
+- \`{type: 'clear_all'}\` to clear/reset the whole form.
+- \`{type: 'smart_paste'}\` to fill in form data from the request text.
+Set \`formAction\` to \`null\` if the request is not about the form.`;
+}
+
+export async function classifyRequest(
+ text: string,
+ aiIntegration: AIIntegration,
+ form: DxFormComponent,
+): Promise {
+ const prompt = [
+ `Decide which UI area should handle the user's request.
+Return STRICT JSON only, without markdown fences.
+Format: {'target': 'form' | 'grid' | 'mixed' | 'none', 'formAction': | null, 'reason': 'short explanation' }
+Rules:
+- Use \`form\` for profile/customer form updates, field clearing, or smart-paste style data entry.
+- Use \`grid\` for sorting, filtering, showing/hiding columns, or other \`DataGrid\` tasks.
+- Use \`mixed\` when the request clearly asks for both a form change and a grid change together.
+- Use \`none\` when the request is unrelated to both areas.
+If you are not confident, return mixed.
+
+${buildFormActionPromptSection(form)}
+
+User request: '${text}'`,
+ ].join('\n');
+
+ try {
+ const parsed = await executeAiCommand(prompt, aiIntegration) as {
+ target?: string;
+ formAction?: FormAction | null;
+ };
+ const target = String(parsed?.target ?? 'mixed').trim().toLowerCase() as ClassificationResult['target'];
+ const rawFormAction = parsed?.formAction;
+ const formAction = rawFormAction && FORM_ACTION_TYPES.has(rawFormAction.type)
+ ? rawFormAction
+ : null;
+
+ return {
+ target: ROUTER_TARGETS.has(target) ? target : 'mixed',
+ formAction,
+ };
+ } catch {
+ return { target: 'mixed', formAction: null };
+ }
+}
+
+function buildGridResultsPromise(
+ gridInstance: DxDataGridComponent,
+ aiIntegration: AIIntegration,
+ text: string,
+): Promise {
+ const columnNames = getGridColumnNames(gridInstance);
+ const prompt = `${buildGridSystemPrompt(columnNames)}\n\nUser request: '${text}'`;
+
+ return executeAiCommand(prompt, aiIntegration)
+ .then((parsed) => {
+ const actions = Array.isArray((parsed as { actions?: ExecuteGridAssistantAction[] })?.actions)
+ ? (parsed as { actions: ExecuteGridAssistantAction[] }).actions
+ : [];
+
+ if (actions.length === 0) {
+ gridInstance?.instance.endCustomLoading();
+ return { results: [], error: null };
+ }
+
+ try {
+ return {
+ results: applyGridActions(gridInstance, actions, text),
+ error: null,
+ };
+ } finally {
+ gridInstance?.instance.endCustomLoading();
+ }
+ })
+ .catch((error) => {
+ gridInstance?.instance.endCustomLoading();
+ return { results: [], error };
+ });
+}
+
+function buildFormResultsPromise(
+ form: DxFormComponent,
+ formAction: FormAction | null,
+ text: string,
+): Promise {
+ const clearResult = applyFormClearAction(form, formAction);
+ if (clearResult) {
+ return Promise.resolve({ results: [clearResult], error: null });
+ }
+
+ return applyFormSmartPaste(form, text)
+ .then((result) => ({ results: [result], error: null }))
+ .catch((error) => ({ results: [], error }));
+}
+
+function formatFailures(failed: string[]): string {
+ return failed.map((message) => `❌ ${message}`).join('\n');
+}
+
+function formatSucceeded(succeeded: string[]): string {
+ return succeeded.map((message) => `✅ Done. ${message}`).join('\n');
+}
+
+export function joinSucceededOrThrow(results: CommandResult[], fallbackError: Error | null): string {
+ const succeeded = results.filter((r) => r.status === 'success').map((r) => r.message);
+ const failed = results.filter((r) => r.status === 'failure').map((r) => r.message);
+
+ if (succeeded.length === 0) {
+ throw failed.length > 0
+ ? new ChatCommandError(formatFailures(failed))
+ : (fallbackError ?? new ChatCommandError(FIELD_OR_VALUE_NOT_FOUND_MESSAGE));
+ }
+
+ return failed.length > 0
+ ? `${formatSucceeded(succeeded)}\n${formatFailures(failed)}`
+ : formatSucceeded(succeeded);
+}
+
+export async function runCommand(
+ text: string,
+ { form, gridInstance, aiIntegration }: RouterContext,
+): Promise {
+ if (text.length > MAX_USER_MESSAGE_LENGTH) {
+ return Promise.reject(
+ new ChatCommandError(
+ '❌ This message is too long for me to process. Please shorten it and try again.',
+ ),
+ );
+ }
+
+ const { target, formAction } = await classifyRequest(text, aiIntegration, form);
+
+ if (target === 'none') {
+ throw new ChatCommandError(
+ "❌ This request doesn't appear to be related to Form or DataGrid. Please try rephrasing it.",
+ );
+ }
+
+ if (target === 'form') {
+ const { results: formResults, error: formError } = await buildFormResultsPromise(form, formAction, text);
+
+ return joinSucceededOrThrow(formResults, formError);
+ }
+
+ if (target === 'grid') {
+ gridInstance?.instance.beginCustomLoading();
+
+ const { results: gridResults, error: gridError } = await buildGridResultsPromise(gridInstance, aiIntegration, text);
+
+ return joinSucceededOrThrow(gridResults, gridError);
+ }
+
+ const [
+ { results: formResults, error: formError },
+ { results: gridResults, error: gridError },
+ ] = await Promise.all([
+ buildFormResultsPromise(form, formAction, text),
+ buildGridResultsPromise(gridInstance, aiIntegration, text),
+ ]);
+
+ if (gridError) {
+ console.warn('DataGrid AI request failed, but the Form request may have succeeded:', gridError);
+ }
+
+ if (formError) {
+ console.warn('Form AI request failed, but the DataGrid request may have succeeded:', formError);
+ }
+
+ return joinSucceededOrThrow([...formResults, ...gridResults], gridError ?? formError);
+}
+
+export function reportAiResult(promise: Promise, pushMessage: PushMessage): Promise {
+ return promise
+ .then((message) => {
+ pushMessage({
+ author: { id: 'ai', name: 'AI Assistant' },
+ text: message,
+ });
+ })
+ .catch((error) => {
+ const text = error instanceof ChatCommandError
+ ? error.message
+ : "❌ I couldn't reach the AI service. Please check your connection and try again.";
+
+ pushMessage({
+ author: { id: 'ai', name: 'AI Assistant' },
+ text,
+ });
+ });
+}
+
+export function routeMessage(
+ text: string,
+ {
+ form, gridInstance, aiIntegration, pushMessage,
+ }: RouteMessageContext,
+): Promise {
+ return reportAiResult(
+ runCommand(text, { form, gridInstance, aiIntegration }),
+ pushMessage,
+ );
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts
new file mode 100644
index 000000000000..b1cc14838c86
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts
@@ -0,0 +1,94 @@
+import { AzureOpenAI } from 'openai';
+import type { OpenAI } from 'openai';
+import notify from 'devextreme/ui/notify';
+import { AIIntegration } from 'devextreme-angular/common/ai-integration';
+import type { RequestParams, AIResponse } from 'devextreme-angular/common/ai-integration';
+import { AI_SERVICE_CONFIG } from '../data/data';
+import { ChatCommandError } from '../types/types';
+
+async function getAIResponse(
+ aiService: AzureOpenAI,
+ messages: OpenAI.ChatCompletionMessageParam[],
+ signal: AbortSignal,
+): Promise {
+ const params = {
+ messages,
+ model: AI_SERVICE_CONFIG.deployment,
+ max_completion_tokens: 1000,
+ temperature: 0,
+ };
+
+ const response = await aiService.chat.completions.create(params, { signal });
+
+ return response.choices[0].message?.content ?? '';
+}
+
+function getAIResponseRecursive(
+ aiService: AzureOpenAI,
+ messages: OpenAI.ChatCompletionMessageParam[],
+ signal: AbortSignal,
+): Promise {
+ return getAIResponse(aiService, messages, signal).catch(async (error: Error) => {
+ if (!error.message.includes('Connection error')) {
+ throw error;
+ }
+
+ notify({
+ message: 'Our demo AI service reached a temporary request limit. Retrying in 30 seconds.',
+ width: 'auto',
+ type: 'error',
+ displayTime: 5000,
+ });
+
+ await new Promise((resolve) => { setTimeout(resolve, 30000); });
+
+ return getAIResponseRecursive(aiService, messages, signal);
+ });
+}
+
+export function createAiIntegration(): AIIntegration {
+ const aiService = new AzureOpenAI({
+ dangerouslyAllowBrowser: true,
+ deployment: AI_SERVICE_CONFIG.deployment,
+ endpoint: AI_SERVICE_CONFIG.endpoint,
+ apiVersion: AI_SERVICE_CONFIG.apiVersion,
+ apiKey: AI_SERVICE_CONFIG.apiKey,
+ });
+
+ return new AIIntegration({
+ sendRequest(params: RequestParams) {
+ const { prompt, data } = params;
+ const isValidRequest = JSON.stringify(prompt.user).length < 20000;
+
+ if (!isValidRequest) {
+ return {
+ promise: Promise.reject(
+ new ChatCommandError('❌ This message is too long for me to process. Please shorten it and try again.'),
+ ),
+ abort: () => {},
+ };
+ }
+
+ const controller = new AbortController();
+ const { signal } = controller;
+
+ const isSmartPasteRequest = Array.isArray((data as { fields?: unknown[] } | undefined)?.fields);
+ const system = isSmartPasteRequest
+ ? `${prompt.system ?? ''} IMPORTANT: reply on a SINGLE line with no line breaks of any kind - use ';;;' as the only separator between fields.`
+ : prompt.system ?? '';
+
+ const aiPrompt: OpenAI.ChatCompletionMessageParam[] = [
+ { role: 'system', content: system },
+ { role: 'user', content: prompt.user },
+ ];
+ const promise = getAIResponseRecursive(aiService, aiPrompt, signal);
+
+ return {
+ promise,
+ abort: () => {
+ controller.abort();
+ },
+ };
+ },
+ });
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts
new file mode 100644
index 000000000000..02e73fb34a43
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts
@@ -0,0 +1,122 @@
+import type { Column } from 'devextreme/ui/data_grid';
+import type { AIIntegration } from 'devextreme-angular/common/ai-integration';
+import type { DxChatTypes } from 'devextreme-angular/ui/chat';
+import type { DxDataGridComponent } from 'devextreme-angular/ui/data-grid';
+import type { DxFormComponent, DxFormTypes } from 'devextreme-angular/ui/form';
+
+export type TaskPriority = 'High' | 'Normal' | 'Low';
+export type RouterTarget = 'form' | 'grid' | 'mixed' | 'none';
+export type FormActionType = 'clear_field' | 'clear_all' | 'smart_paste';
+
+export interface Task {
+ ID: number;
+ Subject: string;
+ StartDate: string;
+ DueDate: string;
+ Status: string;
+ Priority: TaskPriority;
+ Completion: number;
+ EmployeeID: number;
+}
+
+export interface Employee {
+ ID: number;
+ Prefix: string;
+ FirstName: string;
+ LastName: string;
+ Position: string;
+ State: string;
+ BirthDate: string;
+}
+
+export interface CommandResult {
+ status: 'success' | 'failure';
+ message: string;
+}
+
+export type FilterOperation =
+ | '='
+ | '<>'
+ | '<'
+ | '<='
+ | '>'
+ | '>='
+ | 'contains'
+ | 'notcontains'
+ | 'startswith'
+ | 'endswith'
+ | 'anyof';
+
+export type SortOrder = 'asc' | 'desc';
+
+export type ScalarFilterValue = string | number | boolean | Date;
+
+export interface GridCommandArgs {
+ column?: string;
+ operator?: FilterOperation;
+ value?: ScalarFilterValue | string[];
+ sortOrder?: SortOrder | 'none';
+ visible?: boolean;
+}
+
+export interface GridCommand {
+ description: string;
+ schema: Record;
+ execute: (grid: DxDataGridComponent, args: GridCommandArgs, rawText?: string) => CommandResult;
+}
+
+export interface ExecuteGridAssistantAction {
+ name: string;
+ args?: GridCommandArgs;
+}
+
+export type FilterCondition = [string, FilterOperation, ScalarFilterValue | string[]];
+export type GridFilterValue = FilterCondition | (FilterCondition | 'and')[];
+
+export type ColumnFilterExpression = [(rowData: Task) => number, FilterOperation, number];
+
+export type ColumnLookup =
+ | { column: Column; failure: null }
+ | { column: null; failure: CommandResult };
+
+export interface FormFieldOption {
+ dataField: string;
+ label: string;
+}
+
+export interface FormAction {
+ type: FormActionType;
+ field?: string;
+}
+
+export interface ClassificationResult {
+ target: RouterTarget;
+ formAction: FormAction | null;
+}
+
+export interface OperationOutcome {
+ results: CommandResult[];
+ error: Error | null;
+}
+
+export type AIResult = DxFormTypes.SmartPastedEvent['aiResult'];
+
+export type PushMessage = (message: DxChatTypes.TextMessage) => void;
+
+export interface RouterContext {
+ form: DxFormComponent;
+ gridInstance: DxDataGridComponent;
+ aiIntegration: AIIntegration;
+}
+
+export interface RouteMessageContext extends RouterContext {
+ pushMessage: PushMessage;
+}
+
+export class ChatCommandError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'ChatCommandError';
+ Object.setPrototypeOf(this, ChatCommandError.prototype);
+ }
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/index.html b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/index.html
new file mode 100644
index 000000000000..1ab1fb54a1df
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/index.html
@@ -0,0 +1,26 @@
+
+
+
+ DevExtreme Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading...
+
+
+
From f889c0bc19b96e323c63dac81b847b05f0424ca5 Mon Sep 17 00:00:00 2001
From: Diana Gromova <16adianay@gmail.com>
Date: Wed, 16 Sep 2026 15:45:00 +0400
Subject: [PATCH 2/8] Fix: unused vars, color variables, CDR
- Removed unused vars
- Added variables to change a form background color
- Add ChangeDetectorRef to force change change detection for chat disabling
---
.../ai-assistant/ai-assistant.component.ts | 14 ++++++--------
.../employee-form/employee-form.component.css | 13 +++++++++----
2 files changed, 15 insertions(+), 12 deletions(-)
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
index cb8d5b3c232a..0ec11076dff4 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
@@ -1,8 +1,8 @@
import {
- Component, EventEmitter, Input, Output, ViewChild,
+ ChangeDetectorRef, Component, EventEmitter, Input, Output,
} from '@angular/core';
-import { DxPopupModule, DxPopupComponent } from 'devextreme-angular/ui/popup';
-import { DxChatModule, DxChatComponent } from 'devextreme-angular/ui/chat';
+import { DxPopupModule } from 'devextreme-angular/ui/popup';
+import { DxChatModule } from 'devextreme-angular/ui/chat';
import type { DxChatTypes } from 'devextreme-angular/ui/chat';
import { DxSpeedDialActionModule } from 'devextreme-angular/ui/speed-dial-action';
import { ArrayStore, DataSource } from 'devextreme-angular/common/data';
@@ -32,10 +32,6 @@ export class AiAssistantComponent {
@Output() messageSubmitted = new EventEmitter();
- @ViewChild(DxPopupComponent) private dxPopup!: DxPopupComponent;
-
- @ViewChild(DxChatComponent) private dxChat!: DxChatComponent;
-
readonly CLASSES = CLASSES;
readonly emptyViewMessage = EMPTY_VIEW_MESSAGE;
@@ -57,6 +53,8 @@ export class AiAssistantComponent {
isClearDisabled = true;
+ constructor(private readonly cdr: ChangeDetectorRef) {}
+
private readonly store = new ArrayStore({ key: 'id' });
readonly dataSource = new DataSource({
@@ -84,7 +82,7 @@ export class AiAssistantComponent {
setDisabled(value: boolean): void {
this.disabled = value;
- this.dxChat.instance.option('disabled', value);
+ this.cdr.detectChanges();
if (!value) {
this.updateClearButtonState();
}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css
index a1cf435a76cc..8e533e9e982e 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css
@@ -1,10 +1,15 @@
+:host-context(.dx-color-scheme-blue-light) {
+ --form-background-color: #fff;
+}
+
+:host-context(.dx-color-scheme-dark) {
+ --form-background-color: var(--dx-component-color-bg);
+}
+
::ng-deep #form-container {
border: 1px solid #e0e0e0;
padding: 16px;
-}
-
-::ng-deep .dx-color-scheme-blue-light #form-container {
- background-color: #fff;
+ background-color: var(--form-background-color);
}
::ng-deep .dx-layout-manager .dx-field-item.save-button {
From dc6428833c095b227142a3efbbc2b9c44791af61 Mon Sep 17 00:00:00 2001
From: Diana Gromova <16adianay@gmail.com>
Date: Wed, 16 Sep 2026 16:13:11 +0400
Subject: [PATCH 3/8] Update ai-assistant.component.ts
---
.../app/components/ai-assistant/ai-assistant.component.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
index 0ec11076dff4..eeeea2108f2e 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
@@ -53,7 +53,7 @@ export class AiAssistantComponent {
isClearDisabled = true;
- constructor(private readonly cdr: ChangeDetectorRef) {}
+ constructor(private readonly changeDetectorRef: ChangeDetectorRef) {}
private readonly store = new ArrayStore({ key: 'id' });
@@ -82,7 +82,7 @@ export class AiAssistantComponent {
setDisabled(value: boolean): void {
this.disabled = value;
- this.cdr.detectChanges();
+ this.changeDetectorRef.detectChanges();
if (!value) {
this.updateClearButtonState();
}
From 989e1742b63d2cda2cb1a17c15a861570e0fdce5 Mon Sep 17 00:00:00 2001
From: Diana Gromova <16adianay@gmail.com>
Date: Wed, 16 Sep 2026 17:08:52 +0400
Subject: [PATCH 4/8] Update grid-commands.ts
---
.../CustomAIAssistant/Angular/app/commands/grid-commands.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts
index 3bfa9806779f..99b3e5e9a981 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts
@@ -1,4 +1,4 @@
-import type { DxDataGridComponent } from 'devextreme-angular/ui/data-grid';
+import type { DxDataGridComponent, DxDataGridTypes } from 'devextreme-angular/ui/data-grid';
import type {
ColumnLookup,
CommandResult,
@@ -260,7 +260,7 @@ ${commandDescriptions}`;
}
export function getGridColumnNames(gridInstance: DxDataGridComponent): string[] {
- return (gridInstance.instance.option('columns') ?? []).map((col) => col.dataField ?? '');
+ return (gridInstance.instance.option('columns') as DxDataGridTypes.Column[]).map((column) => String(column.dataField));
}
export function applyGridActions(
From 48964826c5f973de5bf7f41e7cf365ea4be81579 Mon Sep 17 00:00:00 2001
From: Diana Gromova <16adianay@gmail.com>
Date: Wed, 16 Sep 2026 18:20:51 +0400
Subject: [PATCH 5/8] Update types
---
.../app/components/ai-assistant/ai-assistant.component.ts | 5 +++--
.../app/components/employee-form/employee-form.component.ts | 4 ++--
.../CustomAIAssistant/Angular/app/services/ai-service.ts | 5 ++---
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
index eeeea2108f2e..2cc16b7acc7e 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
@@ -3,6 +3,7 @@ import {
} from '@angular/core';
import { DxPopupModule } from 'devextreme-angular/ui/popup';
import { DxChatModule } from 'devextreme-angular/ui/chat';
+import { DxButtonComponent, type DxButtonTypes } from 'devextreme-angular/ui/button';
import type { DxChatTypes } from 'devextreme-angular/ui/chat';
import { DxSpeedDialActionModule } from 'devextreme-angular/ui/speed-dial-action';
import { ArrayStore, DataSource } from 'devextreme-angular/common/data';
@@ -62,7 +63,7 @@ export class AiAssistantComponent {
paginate: false,
});
- private clearButtonInstance?: { option: (name: string, value: unknown) => void };
+ private clearButtonInstance?: DxButtonTypes.InitializedEvent['component'];
get clearButtonOptions(): Record {
return {
@@ -70,7 +71,7 @@ export class AiAssistantComponent {
disabled: this.isClearDisabled,
hint: 'Clear chat',
onClick: () => this.clearChat(),
- onInitialized: (e: { component: { option: (name: string, value: unknown) => void } }) => {
+ onInitialized: (e: DxButtonTypes.InitializedEvent) => {
this.clearButtonInstance = e.component;
},
};
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
index 2e29c9563f73..0ef31f128977 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
@@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { Component, Input, ViewChild } from '@angular/core';
-import { DxFormModule, DxFormComponent } from 'devextreme-angular/ui/form';
+import { DxFormModule, DxFormComponent, DxFormTypes } from 'devextreme-angular/ui/form';
import { DxToastModule, DxToastComponent } from 'devextreme-angular/ui/toast';
import type { AIIntegration } from 'devextreme-angular/common/ai-integration';
import { employee, formFieldsConfig } from '../../data/data';
@@ -50,7 +50,7 @@ export class EmployeeFormComponent {
return this.dxForm;
}
- onOptionChanged(e: { name: string; value: unknown }): void {
+ onOptionChanged(e: DxFormTypes.OptionChangedEvent): void {
if (e.name === 'isDirty') {
this.dxForm.instance.getButton('Save')?.option('disabled', !e.value);
}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts
index b1cc14838c86..c442c443fc4f 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts
@@ -1,5 +1,4 @@
-import { AzureOpenAI } from 'openai';
-import type { OpenAI } from 'openai';
+import { AzureOpenAI, type OpenAI } from 'openai';
import notify from 'devextreme/ui/notify';
import { AIIntegration } from 'devextreme-angular/common/ai-integration';
import type { RequestParams, AIResponse } from 'devextreme-angular/common/ai-integration';
@@ -79,7 +78,7 @@ export function createAiIntegration(): AIIntegration {
const aiPrompt: OpenAI.ChatCompletionMessageParam[] = [
{ role: 'system', content: system },
- { role: 'user', content: prompt.user },
+ { role: 'user', content: prompt.user ?? '' },
];
const promise = getAIResponseRecursive(aiService, aiPrompt, signal);
From 6ae6a9cc8800c8461fa71df365ed0b9759a04c9f Mon Sep 17 00:00:00 2001
From: Diana Gromova <16adianay@gmail.com>
Date: Wed, 16 Sep 2026 19:24:09 +0400
Subject: [PATCH 6/8] Update ai-assistant.component.ts
---
.../app/components/ai-assistant/ai-assistant.component.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
index 2cc16b7acc7e..939cd9095081 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
@@ -3,7 +3,7 @@ import {
} from '@angular/core';
import { DxPopupModule } from 'devextreme-angular/ui/popup';
import { DxChatModule } from 'devextreme-angular/ui/chat';
-import { DxButtonComponent, type DxButtonTypes } from 'devextreme-angular/ui/button';
+import { type DxButtonTypes } from 'devextreme-angular/ui/button';
import type { DxChatTypes } from 'devextreme-angular/ui/chat';
import { DxSpeedDialActionModule } from 'devextreme-angular/ui/speed-dial-action';
import { ArrayStore, DataSource } from 'devextreme-angular/common/data';
From c27923a9e75a2ff7af8267a34b955376917d5004 Mon Sep 17 00:00:00 2001
From: Diana Gromova <16adianay@gmail.com>
Date: Thu, 17 Sep 2026 16:33:41 +0400
Subject: [PATCH 7/8] Fixed styles, ChatCommandError, onOptionsChanged
---
.../app/components/employee-form/employee-form.component.css | 2 +-
.../app/components/employee-form/employee-form.component.ts | 2 +-
.../Angular/app/components/task-grid/task-grid.component.css | 2 +-
.../Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts | 1 -
4 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css
index 8e533e9e982e..292dd5032198 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.css
@@ -6,7 +6,7 @@
--form-background-color: var(--dx-component-color-bg);
}
-::ng-deep #form-container {
+#form-container {
border: 1px solid #e0e0e0;
padding: 16px;
background-color: var(--form-background-color);
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
index 0ef31f128977..082853e64da0 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
@@ -52,7 +52,7 @@ export class EmployeeFormComponent {
onOptionChanged(e: DxFormTypes.OptionChangedEvent): void {
if (e.name === 'isDirty') {
- this.dxForm.instance.getButton('Save')?.option('disabled', !e.value);
+ e.component.getButton('Save')?.option('disabled', !e.value);
}
}
}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.css b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.css
index cf539b135feb..325bc76a4130 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.css
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.css
@@ -1,4 +1,4 @@
-::ng-deep #grid-container {
+#grid-container {
margin-top: 20px;
min-height: 360px;
}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts
index 02e73fb34a43..0eb4d1e979bd 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts
@@ -117,6 +117,5 @@ export class ChatCommandError extends Error {
constructor(message: string) {
super(message);
this.name = 'ChatCommandError';
- Object.setPrototypeOf(this, ChatCommandError.prototype);
}
}
From 2f6eeb0c0ad1366836cf1c9a74ac2a9537032361 Mon Sep 17 00:00:00 2001
From: Diana Gromova <16adianay@gmail.com>
Date: Fri, 18 Sep 2026 18:00:08 +0400
Subject: [PATCH 8/8] Fix comments
- Replace export function createAiIntegration() with @Injectable() class AiService { getAiIntegration() {...} }
- Remove standalone
- Change clearButtonOptions to a static variable
- Rename the instance getter
- Merge types and data
---
.../Angular/app/ai/ai.service.ts | 98 ++++++++++++++
.../Angular/app/app.component.ts | 15 ++-
.../chat-router.ts => app.service.ts} | 26 ++--
.../Angular/app/commands/form-commands.ts | 8 +-
.../Angular/app/commands/grid-commands.ts | 2 +-
.../ai-assistant/ai-assistant.component.ts | 32 +++--
.../employee-form/employee-form.component.ts | 6 +-
.../task-grid/task-grid.component.ts | 8 +-
.../Angular/app/{data => }/data.ts | 124 +++++++++++++++++-
.../Angular/app/services/ai-service.ts | 93 -------------
.../Angular/app/types/types.ts | 121 -----------------
.../Chat/CustomAIAssistant/Angular/index.html | 2 +-
12 files changed, 268 insertions(+), 267 deletions(-)
create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/ai/ai.service.ts
rename apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/{routing/chat-router.ts => app.service.ts} (96%)
rename apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/{data => }/data.ts (63%)
delete mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts
delete mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/ai/ai.service.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/ai/ai.service.ts
new file mode 100644
index 000000000000..7fe0dd54af00
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/ai/ai.service.ts
@@ -0,0 +1,98 @@
+import { AzureOpenAI, type OpenAI } from 'openai';
+import { Injectable } from '@angular/core';
+import notify from 'devextreme/ui/notify';
+import { AIIntegration } from 'devextreme-angular/common/ai-integration';
+import type { RequestParams, AIResponse } from 'devextreme-angular/common/ai-integration';
+import { AI_SERVICE_CONFIG, ChatCommandError } from '../data';
+
+async function getAIResponse(
+ aiService: AzureOpenAI,
+ messages: OpenAI.ChatCompletionMessageParam[],
+ signal: AbortSignal,
+): Promise {
+ const params = {
+ messages,
+ model: AI_SERVICE_CONFIG.deployment,
+ max_completion_tokens: 1000,
+ temperature: 0,
+ };
+
+ const response = await aiService.chat.completions.create(params, { signal });
+
+ return response.choices[0].message?.content ?? '';
+}
+
+function getAIResponseRecursive(
+ aiService: AzureOpenAI,
+ messages: OpenAI.ChatCompletionMessageParam[],
+ signal: AbortSignal,
+): Promise {
+ return getAIResponse(aiService, messages, signal).catch(async (error: Error) => {
+ if (!error.message.includes('Connection error')) {
+ throw error;
+ }
+
+ notify({
+ message: 'Our demo AI service reached a temporary request limit. Retrying in 30 seconds.',
+ width: 'auto',
+ type: 'error',
+ displayTime: 5000,
+ });
+
+ await new Promise((resolve) => { setTimeout(resolve, 30000); });
+
+ return getAIResponseRecursive(aiService, messages, signal);
+ });
+}
+
+const aiService = new AzureOpenAI({
+ dangerouslyAllowBrowser: true,
+ deployment: AI_SERVICE_CONFIG.deployment,
+ endpoint: AI_SERVICE_CONFIG.endpoint,
+ apiVersion: AI_SERVICE_CONFIG.apiVersion,
+ apiKey: AI_SERVICE_CONFIG.apiKey,
+});
+
+const aiIntegration = new AIIntegration({
+ sendRequest(params: RequestParams) {
+ const { prompt, data } = params;
+ const isValidRequest = JSON.stringify(prompt.user).length < 20000;
+
+ if (!isValidRequest) {
+ return {
+ promise: Promise.reject(
+ new ChatCommandError('❌ This message is too long for me to process. Please shorten it and try again.'),
+ ),
+ abort: () => {},
+ };
+ }
+
+ const controller = new AbortController();
+ const { signal } = controller;
+
+ const isSmartPasteRequest = Array.isArray((data as { fields?: unknown[] } | undefined)?.fields);
+ const system = isSmartPasteRequest
+ ? `${prompt.system ?? ''} IMPORTANT: reply on a SINGLE line with no line breaks of any kind - use ';;;' as the only separator between fields.`
+ : prompt.system ?? '';
+
+ const aiPrompt: OpenAI.ChatCompletionMessageParam[] = [
+ { role: 'system', content: system },
+ { role: 'user', content: prompt.user ?? '' },
+ ];
+ const promise = getAIResponseRecursive(aiService, aiPrompt, signal);
+
+ return {
+ promise,
+ abort: () => {
+ controller.abort();
+ },
+ };
+ },
+});
+
+@Injectable()
+export class AiService {
+ getAiIntegration(): AIIntegration {
+ return aiIntegration;
+ }
+}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.ts
index 185c3c166b53..5504bcf69be0 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.component.ts
@@ -9,8 +9,8 @@ import type { DxChatTypes } from 'devextreme-angular/ui/chat';
import { AiAssistantComponent } from './components/ai-assistant/ai-assistant.component';
import { EmployeeFormComponent } from './components/employee-form/employee-form.component';
import { TaskGridComponent } from './components/task-grid/task-grid.component';
-import { createAiIntegration } from './services/ai-service';
-import { routeMessage } from './routing/chat-router';
+import { AiService } from './ai/ai.service';
+import { routeMessage } from './app.service';
if (!/localhost/.test(document.location.host)) {
enableProdMode();
@@ -47,6 +47,7 @@ loadMessages({
selector: 'demo-app',
templateUrl: `.${modulePrefix}/app.component.html`,
styleUrls: [`.${modulePrefix}/app.component.css`],
+ providers: [AiService],
imports: [
AiAssistantComponent,
EmployeeFormComponent,
@@ -60,15 +61,19 @@ export class AppComponent {
@ViewChild(AiAssistantComponent) private aiAssistant!: AiAssistantComponent;
- readonly aiIntegration: AIIntegration = createAiIntegration();
+ readonly aiIntegration: AIIntegration;
+
+ constructor(aiService: AiService) {
+ this.aiIntegration = aiService.getAiIntegration();
+ }
async onMessageSubmitted(message: DxChatTypes.TextMessage): Promise {
this.aiAssistant.setDisabled(true);
try {
await routeMessage(message.text ?? '', {
- form: this.employeeForm.instance,
- gridInstance: this.taskGrid.instance,
+ form: this.employeeForm.formComponent,
+ gridInstance: this.taskGrid.gridComponent,
aiIntegration: this.aiIntegration,
pushMessage: (message) => this.aiAssistant.pushMessage(message),
});
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/routing/chat-router.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.service.ts
similarity index 96%
rename from apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/routing/chat-router.ts
rename to apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.service.ts
index db2002757b14..57e184d5e16f 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/routing/chat-router.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/app.service.ts
@@ -1,24 +1,22 @@
import type { AIIntegration } from 'devextreme-angular/common/ai-integration';
import type { DxDataGridComponent } from 'devextreme-angular/ui/data-grid';
import type { DxFormComponent } from 'devextreme-angular/ui/form';
-import type {
- ClassificationResult,
- CommandResult,
- ExecuteGridAssistantAction,
- FormAction,
- OperationOutcome,
- PushMessage,
- RouteMessageContext,
- RouterContext,
-} from '../types/types';
-import { ChatCommandError } from '../types/types';
import {
+ ChatCommandError,
FIELD_OR_VALUE_NOT_FOUND_MESSAGE, FORM_ACTION_TYPES, MAX_USER_MESSAGE_LENGTH, ROUTER_TARGETS,
-} from '../data/data';
-import { applyFormClearAction, applyFormSmartPaste, getFormFieldOptions } from '../commands/form-commands';
+ type ClassificationResult,
+ type CommandResult,
+ type ExecuteGridAssistantAction,
+ type FormAction,
+ type OperationOutcome,
+ type PushMessage,
+ type RouteMessageContext,
+ type RouterContext,
+} from './data';
+import { applyFormClearAction, applyFormSmartPaste, getFormFieldOptions } from './commands/form-commands';
import {
applyGridActions, buildGridPromptSection, buildGridResponseSchema, getGridColumnNames,
-} from '../commands/grid-commands';
+} from './commands/grid-commands';
export function extractJson(text: string): unknown {
const match = text.match(/\{[\s\S]*\}/);
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/form-commands.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/form-commands.ts
index 19009228110a..e543601d49d2 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/form-commands.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/form-commands.ts
@@ -1,8 +1,8 @@
import type { DxFormComponent, DxFormTypes } from 'devextreme-angular/ui/form';
-import type {
- AIResult, CommandResult, FormAction, FormFieldOption,
-} from '../types/types';
-import { SMART_PASTE_TIMEOUT_MS } from '../data/data';
+import {
+ SMART_PASTE_TIMEOUT_MS,
+ type AIResult, type CommandResult, type FormAction, type FormFieldOption,
+} from '../data';
export function getFormFieldOptions(form: DxFormComponent): FormFieldOption[] {
return ((form.instance.option('items') as { dataField?: string; label?: { text?: string } }[]) ?? [])
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts
index 99b3e5e9a981..d68521907a7e 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/commands/grid-commands.ts
@@ -7,7 +7,7 @@ import type {
GridCommand,
GridCommandArgs,
GridFilterValue,
-} from '../types/types';
+} from '../data';
export function getFilterConditions(filterValue: unknown): FilterCondition[] {
if (!Array.isArray(filterValue)) return [];
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
index 939cd9095081..21db1e742850 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/ai-assistant/ai-assistant.component.ts
@@ -9,7 +9,7 @@ import { DxSpeedDialActionModule } from 'devextreme-angular/ui/speed-dial-action
import { ArrayStore, DataSource } from 'devextreme-angular/common/data';
import {
CLASSES, chatSuggestions, EMPTY_VIEW_MESSAGE, EMPTY_VIEW_PROMPT,
-} from '../../data/data';
+} from '../../data';
let modulePrefix = '';
// @ts-ignore
@@ -19,7 +19,6 @@ if (window && window.config?.packageConfigPaths) {
@Component({
selector: 'app-ai-assistant',
- standalone: true,
imports: [
DxPopupModule,
DxChatModule,
@@ -52,8 +51,6 @@ export class AiAssistantComponent {
fabVisible = true;
- isClearDisabled = true;
-
constructor(private readonly changeDetectorRef: ChangeDetectorRef) {}
private readonly store = new ArrayStore({ key: 'id' });
@@ -65,22 +62,24 @@ export class AiAssistantComponent {
private clearButtonInstance?: DxButtonTypes.InitializedEvent['component'];
- get clearButtonOptions(): Record {
- return {
- icon: 'clearhistory',
- disabled: this.isClearDisabled,
- hint: 'Clear chat',
- onClick: () => this.clearChat(),
- onInitialized: (e: DxButtonTypes.InitializedEvent) => {
- this.clearButtonInstance = e.component;
- },
- };
- }
+ readonly clearButtonOptions: DxButtonTypes.Properties = {
+ icon: 'clearhistory',
+ disabled: true,
+ hint: 'Clear chat',
+ onClick: () => this.clearChat(),
+ onInitialized: (e: DxButtonTypes.InitializedEvent) => {
+ this.clearButtonInstance = e.component;
+ },
+ };
toggle(): void {
this.popupVisible = !this.popupVisible;
}
+ // Tried a plain [disabled] input bound to `AppComponent.isAssistantBusy` instead of this imperative
+ // setter, but change detection didn't reliably pick up the reset once a chat-driven request touched
+ // the DataGrid (repro: click a suggestion that runs a grid action) - the chat stayed disabled.
+ // detectChanges() here is what makes the reset trigger consistently.
setDisabled(value: boolean): void {
this.disabled = value;
this.changeDetectorRef.detectChanges();
@@ -135,7 +134,6 @@ export class AiAssistantComponent {
}
private updateClearButtonState(): void {
- this.isClearDisabled = this.dataSource.items().length === 0;
- this.clearButtonInstance?.option('disabled', this.isClearDisabled);
+ this.clearButtonInstance?.option('disabled', this.dataSource.items().length === 0);
}
}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
index 082853e64da0..41a4a6e3a604 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/employee-form/employee-form.component.ts
@@ -3,8 +3,7 @@ import { Component, Input, ViewChild } from '@angular/core';
import { DxFormModule, DxFormComponent, DxFormTypes } from 'devextreme-angular/ui/form';
import { DxToastModule, DxToastComponent } from 'devextreme-angular/ui/toast';
import type { AIIntegration } from 'devextreme-angular/common/ai-integration';
-import { employee, formFieldsConfig } from '../../data/data';
-import type { Employee } from '../../types/types';
+import { employee, formFieldsConfig, type Employee } from '../../data';
let modulePrefix = '';
// @ts-ignore
@@ -14,7 +13,6 @@ if (window && window.config?.packageConfigPaths) {
@Component({
selector: 'app-employee-form',
- standalone: true,
imports: [CommonModule, DxFormModule, DxToastModule],
templateUrl: `.${modulePrefix}/components/employee-form/employee-form.component.html`,
styleUrls: [`.${modulePrefix}/components/employee-form/employee-form.component.css`],
@@ -46,7 +44,7 @@ export class EmployeeFormComponent {
onClick: () => this.dxToast.instance.show(),
};
- get instance(): DxFormComponent {
+ get formComponent(): DxFormComponent {
return this.dxForm;
}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.ts
index 4bd29606f1dc..74ec9745ba7d 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/components/task-grid/task-grid.component.ts
@@ -1,7 +1,8 @@
import { Component, ViewChild } from '@angular/core';
import { DxDataGridModule, DxDataGridComponent } from 'devextreme-angular/ui/data-grid';
-import { colors, tasks } from '../../data/data';
-import type { ColumnFilterExpression, Task } from '../../types/types';
+import {
+ colors, tasks, type ColumnFilterExpression, type Task,
+} from '../../data';
let modulePrefix = '';
// @ts-ignore
@@ -11,7 +12,6 @@ if (window && window.config?.packageConfigPaths) {
@Component({
selector: 'app-task-grid',
- standalone: true,
imports: [DxDataGridModule],
templateUrl: `.${modulePrefix}/components/task-grid/task-grid.component.html`,
styleUrls: [`.${modulePrefix}/components/task-grid/task-grid.component.css`],
@@ -27,7 +27,7 @@ export class TaskGridComponent {
elementAttr: { 'aria-label': 'Completed' },
};
- get instance(): DxDataGridComponent {
+ get gridComponent(): DxDataGridComponent {
return this.dxDataGrid;
}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/data/data.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/data.ts
similarity index 63%
rename from apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/data/data.ts
rename to apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/data.ts
index 4523b12f7033..c32db4ca11c5 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/data/data.ts
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/data.ts
@@ -1,6 +1,124 @@
-import type {
- Employee, FormActionType, RouterTarget, Task, TaskPriority,
-} from '../types/types';
+import type { Column } from 'devextreme/ui/data_grid';
+import type { AIIntegration } from 'devextreme-angular/common/ai-integration';
+import type { DxChatTypes } from 'devextreme-angular/ui/chat';
+import type { DxDataGridComponent } from 'devextreme-angular/ui/data-grid';
+import type { DxFormComponent, DxFormTypes } from 'devextreme-angular/ui/form';
+
+export type TaskPriority = 'High' | 'Normal' | 'Low';
+export type RouterTarget = 'form' | 'grid' | 'mixed' | 'none';
+export type FormActionType = 'clear_field' | 'clear_all' | 'smart_paste';
+
+export interface Task {
+ ID: number;
+ Subject: string;
+ StartDate: string;
+ DueDate: string;
+ Status: string;
+ Priority: TaskPriority;
+ Completion: number;
+ EmployeeID: number;
+}
+
+export interface Employee {
+ ID: number;
+ Prefix: string;
+ FirstName: string;
+ LastName: string;
+ Position: string;
+ State: string;
+ BirthDate: string;
+}
+
+export interface CommandResult {
+ status: 'success' | 'failure';
+ message: string;
+}
+
+export type FilterOperation =
+ | '='
+ | '<>'
+ | '<'
+ | '<='
+ | '>'
+ | '>='
+ | 'contains'
+ | 'notcontains'
+ | 'startswith'
+ | 'endswith'
+ | 'anyof';
+
+export type SortOrder = 'asc' | 'desc';
+
+export type ScalarFilterValue = string | number | boolean | Date;
+
+export interface GridCommandArgs {
+ column?: string;
+ operator?: FilterOperation;
+ value?: ScalarFilterValue | string[];
+ sortOrder?: SortOrder | 'none';
+ visible?: boolean;
+}
+
+export interface GridCommand {
+ description: string;
+ schema: Record;
+ execute: (grid: DxDataGridComponent, args: GridCommandArgs, rawText?: string) => CommandResult;
+}
+
+export interface ExecuteGridAssistantAction {
+ name: string;
+ args?: GridCommandArgs;
+}
+
+export type FilterCondition = [string, FilterOperation, ScalarFilterValue | string[]];
+export type GridFilterValue = FilterCondition | (FilterCondition | 'and')[];
+
+export type ColumnFilterExpression = [(rowData: Task) => number, FilterOperation, number];
+
+export type ColumnLookup =
+ | { column: Column; failure: null }
+ | { column: null; failure: CommandResult };
+
+export interface FormFieldOption {
+ dataField: string;
+ label: string;
+}
+
+export interface FormAction {
+ type: FormActionType;
+ field?: string;
+}
+
+export interface ClassificationResult {
+ target: RouterTarget;
+ formAction: FormAction | null;
+}
+
+export interface OperationOutcome {
+ results: CommandResult[];
+ error: Error | null;
+}
+
+export type AIResult = DxFormTypes.SmartPastedEvent['aiResult'];
+
+export type PushMessage = (message: DxChatTypes.TextMessage) => void;
+
+export interface RouterContext {
+ form: DxFormComponent;
+ gridInstance: DxDataGridComponent;
+ aiIntegration: AIIntegration;
+}
+
+export interface RouteMessageContext extends RouterContext {
+ pushMessage: PushMessage;
+}
+
+export class ChatCommandError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'ChatCommandError';
+ }
+}
export const CLASSES = {
clearChatButton: 'ai-chat-clear-button',
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts
deleted file mode 100644
index c442c443fc4f..000000000000
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/services/ai-service.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { AzureOpenAI, type OpenAI } from 'openai';
-import notify from 'devextreme/ui/notify';
-import { AIIntegration } from 'devextreme-angular/common/ai-integration';
-import type { RequestParams, AIResponse } from 'devextreme-angular/common/ai-integration';
-import { AI_SERVICE_CONFIG } from '../data/data';
-import { ChatCommandError } from '../types/types';
-
-async function getAIResponse(
- aiService: AzureOpenAI,
- messages: OpenAI.ChatCompletionMessageParam[],
- signal: AbortSignal,
-): Promise {
- const params = {
- messages,
- model: AI_SERVICE_CONFIG.deployment,
- max_completion_tokens: 1000,
- temperature: 0,
- };
-
- const response = await aiService.chat.completions.create(params, { signal });
-
- return response.choices[0].message?.content ?? '';
-}
-
-function getAIResponseRecursive(
- aiService: AzureOpenAI,
- messages: OpenAI.ChatCompletionMessageParam[],
- signal: AbortSignal,
-): Promise {
- return getAIResponse(aiService, messages, signal).catch(async (error: Error) => {
- if (!error.message.includes('Connection error')) {
- throw error;
- }
-
- notify({
- message: 'Our demo AI service reached a temporary request limit. Retrying in 30 seconds.',
- width: 'auto',
- type: 'error',
- displayTime: 5000,
- });
-
- await new Promise((resolve) => { setTimeout(resolve, 30000); });
-
- return getAIResponseRecursive(aiService, messages, signal);
- });
-}
-
-export function createAiIntegration(): AIIntegration {
- const aiService = new AzureOpenAI({
- dangerouslyAllowBrowser: true,
- deployment: AI_SERVICE_CONFIG.deployment,
- endpoint: AI_SERVICE_CONFIG.endpoint,
- apiVersion: AI_SERVICE_CONFIG.apiVersion,
- apiKey: AI_SERVICE_CONFIG.apiKey,
- });
-
- return new AIIntegration({
- sendRequest(params: RequestParams) {
- const { prompt, data } = params;
- const isValidRequest = JSON.stringify(prompt.user).length < 20000;
-
- if (!isValidRequest) {
- return {
- promise: Promise.reject(
- new ChatCommandError('❌ This message is too long for me to process. Please shorten it and try again.'),
- ),
- abort: () => {},
- };
- }
-
- const controller = new AbortController();
- const { signal } = controller;
-
- const isSmartPasteRequest = Array.isArray((data as { fields?: unknown[] } | undefined)?.fields);
- const system = isSmartPasteRequest
- ? `${prompt.system ?? ''} IMPORTANT: reply on a SINGLE line with no line breaks of any kind - use ';;;' as the only separator between fields.`
- : prompt.system ?? '';
-
- const aiPrompt: OpenAI.ChatCompletionMessageParam[] = [
- { role: 'system', content: system },
- { role: 'user', content: prompt.user ?? '' },
- ];
- const promise = getAIResponseRecursive(aiService, aiPrompt, signal);
-
- return {
- promise,
- abort: () => {
- controller.abort();
- },
- };
- },
- });
-}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts
deleted file mode 100644
index 0eb4d1e979bd..000000000000
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/app/types/types.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import type { Column } from 'devextreme/ui/data_grid';
-import type { AIIntegration } from 'devextreme-angular/common/ai-integration';
-import type { DxChatTypes } from 'devextreme-angular/ui/chat';
-import type { DxDataGridComponent } from 'devextreme-angular/ui/data-grid';
-import type { DxFormComponent, DxFormTypes } from 'devextreme-angular/ui/form';
-
-export type TaskPriority = 'High' | 'Normal' | 'Low';
-export type RouterTarget = 'form' | 'grid' | 'mixed' | 'none';
-export type FormActionType = 'clear_field' | 'clear_all' | 'smart_paste';
-
-export interface Task {
- ID: number;
- Subject: string;
- StartDate: string;
- DueDate: string;
- Status: string;
- Priority: TaskPriority;
- Completion: number;
- EmployeeID: number;
-}
-
-export interface Employee {
- ID: number;
- Prefix: string;
- FirstName: string;
- LastName: string;
- Position: string;
- State: string;
- BirthDate: string;
-}
-
-export interface CommandResult {
- status: 'success' | 'failure';
- message: string;
-}
-
-export type FilterOperation =
- | '='
- | '<>'
- | '<'
- | '<='
- | '>'
- | '>='
- | 'contains'
- | 'notcontains'
- | 'startswith'
- | 'endswith'
- | 'anyof';
-
-export type SortOrder = 'asc' | 'desc';
-
-export type ScalarFilterValue = string | number | boolean | Date;
-
-export interface GridCommandArgs {
- column?: string;
- operator?: FilterOperation;
- value?: ScalarFilterValue | string[];
- sortOrder?: SortOrder | 'none';
- visible?: boolean;
-}
-
-export interface GridCommand {
- description: string;
- schema: Record;
- execute: (grid: DxDataGridComponent, args: GridCommandArgs, rawText?: string) => CommandResult;
-}
-
-export interface ExecuteGridAssistantAction {
- name: string;
- args?: GridCommandArgs;
-}
-
-export type FilterCondition = [string, FilterOperation, ScalarFilterValue | string[]];
-export type GridFilterValue = FilterCondition | (FilterCondition | 'and')[];
-
-export type ColumnFilterExpression = [(rowData: Task) => number, FilterOperation, number];
-
-export type ColumnLookup =
- | { column: Column; failure: null }
- | { column: null; failure: CommandResult };
-
-export interface FormFieldOption {
- dataField: string;
- label: string;
-}
-
-export interface FormAction {
- type: FormActionType;
- field?: string;
-}
-
-export interface ClassificationResult {
- target: RouterTarget;
- formAction: FormAction | null;
-}
-
-export interface OperationOutcome {
- results: CommandResult[];
- error: Error | null;
-}
-
-export type AIResult = DxFormTypes.SmartPastedEvent['aiResult'];
-
-export type PushMessage = (message: DxChatTypes.TextMessage) => void;
-
-export interface RouterContext {
- form: DxFormComponent;
- gridInstance: DxDataGridComponent;
- aiIntegration: AIIntegration;
-}
-
-export interface RouteMessageContext extends RouterContext {
- pushMessage: PushMessage;
-}
-
-export class ChatCommandError extends Error {
- constructor(message: string) {
- super(message);
- this.name = 'ChatCommandError';
- }
-}
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/index.html b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/index.html
index 1ab1fb54a1df..e3ca0a265a44 100644
--- a/apps/demos/Demos/Chat/CustomAIAssistant/Angular/index.html
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Angular/index.html
@@ -19,7 +19,7 @@
-