From 4a87d33375af40b54d26fc791f9107316d18d1cd Mon Sep 17 00:00:00 2001 From: Diana Gromova <16adianay@gmail.com> Date: Thu, 17 Sep 2026 19:13:35 +0400 Subject: [PATCH 1/4] Open Add Chat - Custom AI Assistant demo Vue --- .../CustomAIAssistant/Vue/AiAssistant.vue | 217 +++++++++++++ .../Demos/Chat/CustomAIAssistant/Vue/App.vue | 76 +++++ .../CustomAIAssistant/Vue/EmployeeForm.vue | 102 +++++++ .../Chat/CustomAIAssistant/Vue/TaskGrid.vue | 97 ++++++ .../Chat/CustomAIAssistant/Vue/aiService.ts | 93 ++++++ .../Chat/CustomAIAssistant/Vue/chatRouter.ts | 276 +++++++++++++++++ .../Demos/Chat/CustomAIAssistant/Vue/data.ts | 184 +++++++++++ .../CustomAIAssistant/Vue/formCommands.ts | 109 +++++++ .../CustomAIAssistant/Vue/gridCommands.ts | 285 ++++++++++++++++++ .../Chat/CustomAIAssistant/Vue/index.html | 28 ++ .../Demos/Chat/CustomAIAssistant/Vue/index.ts | 4 + .../Demos/Chat/CustomAIAssistant/Vue/types.ts | 125 ++++++++ 12 files changed, 1596 insertions(+) create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/App.vue create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/EmployeeForm.vue create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/TaskGrid.vue create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/aiService.ts create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/chatRouter.ts create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/data.ts create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/formCommands.ts create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/gridCommands.ts create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/index.html create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/index.ts create mode 100644 apps/demos/Demos/Chat/CustomAIAssistant/Vue/types.ts 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..8d13d6db3f28 --- /dev/null +++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue @@ -0,0 +1,217 @@ + + + + + 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 @@ + + + + + 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'; + } +} From 71280984e869a91afa2372a468a9c8fae4331fa2 Mon Sep 17 00:00:00 2001 From: Diana Gromova <16adianay@gmail.com> Date: Thu, 17 Sep 2026 19:40:44 +0400 Subject: [PATCH 2/4] Update AiAssistant.vue --- apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue index 8d13d6db3f28..4f9b9e08376a 100644 --- a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue +++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue @@ -73,7 +73,7 @@ import { } from './data.ts'; interface AiAssistantProps { disabled: boolean } -interface AiAssistantEmits { (e: 'message-submitted', message: DxChatTypes.TextMessage) } +interface AiAssistantEmits { (e: 'message-submitted', message: DxChatTypes.TextMessage): void } const props = defineProps(); const emit = defineEmits(); From 8d29c40a4cf75cfa9c6f2f7721cb78c4a53a69ec Mon Sep 17 00:00:00 2001 From: Diana Gromova <16adianay@gmail.com> Date: Fri, 18 Sep 2026 12:39:22 +0400 Subject: [PATCH 3/4] Update AiAssistant.vue --- apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue index 4f9b9e08376a..0557648d0c14 100644 --- a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue +++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue @@ -2,7 +2,7 @@ Date: Fri, 18 Sep 2026 18:20:18 +0400 Subject: [PATCH 4/4] fix: clear history disabled state Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Diana Gromova <72144169+16adianay@users.noreply.github.com> --- .../demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue index 0557648d0c14..6d9d12aa2493 100644 --- a/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue +++ b/apps/demos/Demos/Chat/CustomAIAssistant/Vue/AiAssistant.vue @@ -164,9 +164,12 @@ function onMessageEntered(e: DxChatTypes.MessageEnteredEvent) { } watch(() => props.disabled, (disabled) => { - if (!disabled) { - updateClearButtonState(); + if (disabled) { + clearButtonInstance.value?.option('disabled', true); + return; } + + updateClearButtonState(); }); defineExpose({ pushMessage });