diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue
new file mode 100644
index 000000000000..6d9d12aa2493
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
+
+ {{ emptyViewMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/App.vue b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/App.vue
new file mode 100644
index 000000000000..cc0750b1f3e5
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/App.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/EmployeeForm.vue b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/EmployeeForm.vue
new file mode 100644
index 000000000000..0042126b2ee5
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/EmployeeForm.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/TaskGrid.vue b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/TaskGrid.vue
new file mode 100644
index 000000000000..309d1f93a5ac
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/TaskGrid.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+ {{ data.value }}
+
+
+
+
+
+
+
+
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/aiService.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/aiService.ts
new file mode 100644
index 000000000000..7d68a6197ecf
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/aiService.ts
@@ -0,0 +1,93 @@
+import { AzureOpenAI, type OpenAI } from 'openai';
+import notify from 'devextreme/ui/notify';
+import { AIIntegration } from 'devextreme-vue/common/ai-integration';
+import type { RequestParams, AIResponse } from 'devextreme-vue/common/ai-integration';
+import { AI_SERVICE_CONFIG } from './data.ts';
+import { ChatCommandError } from './types.ts';
+
+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/Vue/chatRouter.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/chatRouter.ts
new file mode 100644
index 000000000000..bda0d64cd07c
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/chatRouter.ts
@@ -0,0 +1,276 @@
+import type { AIIntegration } from 'devextreme-vue/common/ai-integration';
+import type {
+ ClassificationResult,
+ CommandResult,
+ EmployeeForm,
+ ExecuteGridAssistantAction,
+ FormAction,
+ OperationOutcome,
+ PushMessage,
+ RouteMessageContext,
+ RouterContext,
+ TaskGrid,
+} from './types';
+import { ChatCommandError } from './types.ts';
+import {
+ FIELD_OR_VALUE_NOT_FOUND_MESSAGE, FORM_ACTION_TYPES, MAX_USER_MESSAGE_LENGTH, ROUTER_TARGETS,
+} from './data.ts';
+import { applyFormClearAction, applyFormSmartPaste, getFormFieldOptions } from './formCommands.ts';
+import {
+ applyGridActions, buildGridPromptSection, buildGridResponseSchema, getGridColumnNames,
+} from './gridCommands.ts';
+
+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: EmployeeForm): 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: EmployeeForm,
+): 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: TaskGrid,
+ 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?.endCustomLoading();
+ return { results: [], error: null };
+ }
+
+ try {
+ return {
+ results: applyGridActions(gridInstance, actions, text),
+ error: null,
+ };
+ } finally {
+ gridInstance?.endCustomLoading();
+ }
+ })
+ .catch((error) => {
+ gridInstance?.endCustomLoading();
+ return { results: [], error };
+ });
+}
+
+function buildFormResultsPromise(
+ form: EmployeeForm,
+ 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?.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/Vue/data.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/data.ts
new file mode 100644
index 000000000000..1f5fd02d6677
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/data.ts
@@ -0,0 +1,184 @@
+import type { FormItemComponent } from 'devextreme/ui/form';
+import type {
+ Employee, FormActionType, RouterTarget, Task, TaskPriority,
+} from './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?: FormItemComponent;
+ 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/Vue/formCommands.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/formCommands.ts
new file mode 100644
index 000000000000..40982ba6d928
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/formCommands.ts
@@ -0,0 +1,109 @@
+import type { SmartPastedEvent } from 'devextreme/ui/form';
+import type {
+ AIResult, CommandResult, EmployeeForm, FormAction, FormFieldOption,
+} from './types';
+import { SMART_PASTE_TIMEOUT_MS } from './data.ts';
+
+export function getFormFieldOptions(form: EmployeeForm): FormFieldOption[] {
+ return ((form.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: EmployeeForm,
+ formAction: FormAction | null,
+): CommandResult | null {
+ if (!formAction || formAction.type === 'smart_paste') return null;
+
+ if (formAction.type === 'clear_all') {
+ try {
+ form.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.updateData(formAction.field ?? '', null);
+
+ return { status: 'success', message: `Cleared ${formAction.field}.` };
+ }
+
+ return null;
+}
+
+export function formatAiResultDetails(form: EmployeeForm, 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: EmployeeForm, text: string): Promise {
+ return new Promise((resolve) => {
+ let settled = false;
+ let timedOut = false;
+
+ const finish = (result: CommandResult) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeoutId);
+ form.off('smartPasted', handleSmartPasted);
+ resolve(result);
+ };
+
+ const handleSmartPasted = (e: SmartPastedEvent) => {
+ 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.on('smartPasted', handleSmartPasted);
+
+ try {
+ form.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/Vue/gridCommands.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/gridCommands.ts
new file mode 100644
index 000000000000..39845a9ba8e1
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/gridCommands.ts
@@ -0,0 +1,285 @@
+import type { Column } from 'devextreme/ui/data_grid';
+import type {
+ ColumnLookup,
+ CommandResult,
+ ExecuteGridAssistantAction,
+ FilterCondition,
+ GridCommand,
+ GridCommandArgs,
+ GridFilterValue,
+ Task,
+ TaskGrid,
+} from './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: TaskGrid, columnName: string | undefined): ColumnLookup {
+ const column = grid.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: TaskGrid, 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.option('filterValue', combineFilterConditions(grid.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: TaskGrid): CommandResult {
+ try {
+ grid.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: TaskGrid, args: GridCommandArgs): CommandResult {
+ const { column, failure } = getColumnOrFail(grid, args.column);
+ if (failure) return failure;
+
+ const caption = column.caption ?? args.column ?? '';
+
+ try {
+ grid.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: TaskGrid): CommandResult {
+ try {
+ grid.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: TaskGrid, args: GridCommandArgs): CommandResult {
+ const { column, failure } = getColumnOrFail(grid, args.column);
+ if (failure) return failure;
+
+ const caption = column.caption ?? args.column ?? '';
+
+ try {
+ grid.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: TaskGrid): string[] {
+ return (gridInstance.option('columns') as Column[]).map((column) => String(column.dataField));
+}
+
+export function applyGridActions(
+ grid: TaskGrid,
+ 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/Vue/index.html b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/index.html
new file mode 100644
index 000000000000..61d7a89488fe
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/index.html
@@ -0,0 +1,28 @@
+
+
+
+ DevExtreme Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/index.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/index.ts
new file mode 100644
index 000000000000..684d04215d72
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/index.ts
@@ -0,0 +1,4 @@
+import { createApp } from 'vue';
+import App from './App.vue';
+
+createApp(App).mount('#app');
diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/types.ts b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/types.ts
new file mode 100644
index 000000000000..dd62cbbd81a7
--- /dev/null
+++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/types.ts
@@ -0,0 +1,125 @@
+import type { Column } from 'devextreme/ui/data_grid';
+import type dxDataGrid from 'devextreme/ui/data_grid';
+import type dxForm from 'devextreme/ui/form';
+import type { AIResult } from 'devextreme/ui/form';
+import type { AIIntegration } from 'devextreme-vue/common/ai-integration';
+import type { DxChatTypes } from 'devextreme-vue/chat';
+
+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 type EmployeeForm = dxForm;
+export type TaskGrid = dxDataGrid;
+
+export interface GridCommandArgs {
+ column?: string;
+ operator?: FilterOperation;
+ value?: ScalarFilterValue | string[];
+ sortOrder?: SortOrder | 'none';
+ visible?: boolean;
+}
+
+export interface GridCommand {
+ description: string;
+ schema: Record;
+ execute: (grid: TaskGrid, 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 };
+
+export type PushMessage = (message: DxChatTypes.TextMessage) => void;
+
+export interface RouterContext {
+ form: EmployeeForm;
+ gridInstance: TaskGrid;
+ aiIntegration: AIIntegration;
+}
+
+export interface RouteMessageContext extends RouterContext {
+ pushMessage: PushMessage;
+}
+
+export class ChatCommandError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'ChatCommandError';
+ }
+}