diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/ai/ai.service.ts b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/ai/ai.service.ts new file mode 100644 index 000000000000..e390181c1dd1 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/ai/ai.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@angular/core'; +import { AzureOpenAI, OpenAI } from 'openai'; + +export type AIMessage = OpenAI.ChatCompletionMessageParam; + +export type AIAnswer = OpenAI.ChatCompletionMessage; + +const AzureOpenAIConfig = { + dangerouslyAllowBrowser: true, + deployment: 'demo-mini', + apiVersion: '2024-02-01', + endpoint: 'https://public-api.devexpress.com/demo-openai', + apiKey: 'DEMO', +}; + +@Injectable() +export class AiService { + chatService: AzureOpenAI; + + constructor() { + this.chatService = new AzureOpenAI(AzureOpenAIConfig); + } + + async requestModel( + messages: AIMessage[], + tools: OpenAI.ChatCompletionTool[], + ): Promise { + const params = { + messages, + model: AzureOpenAIConfig.deployment, + tools, + tool_choice: 'auto' as const, + max_completion_tokens: 1000, + temperature: 0.7, + }; + + const response = await this.chatService.chat.completions.create(params, { maxRetries: 0 }); + + return response.choices[0].message; + } +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.component.css b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.component.css new file mode 100644 index 000000000000..4a7fa09a4ecb --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.component.css @@ -0,0 +1,124 @@ +.demo-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +::ng-deep .options { + padding: 20px; + display: flex; + flex-direction: column; + min-width: 280px; + background-color: var(--dx-color-options-panel-bg); + gap: 16px; + width: 100%; + max-width: 900px; + box-sizing: border-box; +} + +::ng-deep .option { + display: flex; + align-items: center; + gap: 8px; +} + +::ng-deep .caption { + font-size: var(--dx-font-size-sm); + font-weight: 500; +} + +::ng-deep .ai-chat-card { + position: relative; + width: 100%; + max-width: 900px; +} + +::ng-deep .ai-chat-card > .dx-toolbar { + position: absolute; + height: var(--dxds-spacing-480, 48px); + padding-inline: var(--dxds-spacing-240, 24px); + border-bottom: var(--dx-border-width) solid var(--dx-color-border); + background-color: transparent; +} + +::ng-deep .ai-chat-card > .dx-toolbar .dx-toolbar-items-container { + height: 100%; +} + +::ng-deep .ai-chat-title .dx-toolbar-item-content { + font-size: var(--dxds-font-size-title-md, 20px); + font-weight: var(--dxds-font-weight-title-default, 600); +} + +::ng-deep .ai-chat-card > .dx-chat { + padding-top: var(--dxds-spacing-480, 48px); +} + +::ng-deep .dx-chat-messagelist-empty-image { + display: none; +} + +::ng-deep .dx-chat-messagelist-empty-message { + font-size: var(--dx-font-size-heading-5); +} + +::ng-deep .dx-chat-messagebubble-content { + display: flex; + flex-direction: column; +} + +::ng-deep .dx-chat-messagebubble:has(> .dx-chat-tool-call-view) { + min-width: 344px; +} + +::ng-deep .dx-theme-fluent-typography .dx-chat-messagebubble:has(> .dx-chat-tool-call-view), +::ng-deep .dx-theme-fluent-next-typography .dx-chat-messagebubble:has(> .dx-chat-tool-call-view) { + min-width: 320px; +} + +::ng-deep .chat-suggestion-cards { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + margin-top: 32px; + width: 100%; +} + +::ng-deep .chat-suggestion-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 0 1 230px; + width: 230px; + max-width: 230px; + padding: 16px; + border: var(--dx-border-width) solid var(--dx-color-border); + border-radius: calc(var(--dx-border-radius) * 3); + background: color-mix(in srgb, var(--dx-color-text) 4%, var(--dx-component-color-bg)); + color: var(--dx-color-text); + font-family: inherit; + text-align: start; + cursor: pointer; + transition: 0.2s ease; +} + +::ng-deep .chat-suggestion-card:hover { + background: color-mix(in srgb, var(--dx-color-text) 10%, var(--dx-component-color-bg)); + box-shadow: var(--dxds-box-shadow-md, 0 4px 12px 0 rgba(0, 0, 0, 0.04), 0 4px 24px 0 rgba(0, 0, 0, 0.02)); +} + +::ng-deep .chat-suggestion-card-title { + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +::ng-deep .chat-suggestion-card-description { + color: color-mix(in srgb, var(--dx-color-text) 70%, var(--dx-component-color-bg)); + font-size: 12px; + font-weight: 400; + line-height: 16px; +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.component.html b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.component.html new file mode 100644 index 000000000000..47a187356f5b --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.component.html @@ -0,0 +1,70 @@ +
+
+ + + + + + + + + +
+
{{ + data.texts.message + }}
+
{{ + data.texts.prompt + }}
+
+ @for (card of suggestionCards; track card.title) { + + } +
+
+
+
+ +
+
Options
+ +
+ +
+
+
diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.component.ts b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.component.ts new file mode 100644 index 000000000000..efe13636f0c9 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.component.ts @@ -0,0 +1,119 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { + ChangeDetectorRef, Component, enableProdMode, provideZoneChangeDetection, +} from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { + DxChatModule, DxButtonModule, DxCheckBoxModule, DxToolbarModule, +} from 'devextreme-angular'; +import type { DxChatTypes } from 'devextreme-angular/ui/chat'; +import type { DxButtonTypes } from 'devextreme-angular/ui/button'; +import { Observable } from 'rxjs'; +import { loadMessages } from 'devextreme-angular/common/core/localization'; +import { DataSource } from 'devextreme-angular/common/data'; +import { AppService } from './app.service'; +import { AiService } from './ai/ai.service'; + +if (!/localhost/.test(document.location.host)) { + enableProdMode(); +} + +@Component({ + selector: 'demo-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'], + imports: [ + DxChatModule, + DxButtonModule, + DxCheckBoxModule, + DxToolbarModule, + AsyncPipe, + ], +}) +export class AppComponent { + dataSource: DataSource; + + user: DxChatTypes.User; + + typingUsers$: Observable; + + alerts$: Observable; + + suggestionCards: AppService['suggestionCards']; + + expandAll = false; + + isDisabled = false; + + clearButtonOptions: DxButtonTypes.Properties; + + constructor( + private readonly appService: AppService, + private readonly changeDetectorRef: ChangeDetectorRef, + ) { + loadMessages(this.appService.getDictionary()); + + this.dataSource = this.appService.dataSource; + this.user = this.appService.user; + this.alerts$ = this.appService.alerts$; + this.typingUsers$ = this.appService.typingUsers$; + this.suggestionCards = this.appService.suggestionCards; + this.clearButtonOptions = { + icon: 'clearhistory', + stylingMode: 'text', + hint: 'Clear chat', + onClick: () => this.appService.clearMessages(), + }; + } + + toggleDisabledState(disabled: boolean, event = undefined) { + this.isDisabled = disabled; + this.clearButtonOptions = { ...this.clearButtonOptions, disabled }; + + this.changeDetectorRef.detectChanges(); + + if (disabled) { + event?.target.blur(); + } else { + event?.target.focus(); + } + } + + async onMessageEntered(e: DxChatTypes.MessageEnteredEvent) { + if (this.appService.isProcessing) { + return; + } + + if (!this.appService.alerts.length) { + this.toggleDisabledState(true, e.event); + } + + try { + await this.appService.onMessageEntered(e); + } finally { + this.toggleDisabledState(false, e.event); + } + } + + async onSuggestionClick(prompt: string) { + if (this.appService.isProcessing) { + return; + } + + this.toggleDisabledState(true); + + try { + await this.appService.sendSuggestion(prompt); + } finally { + this.toggleDisabledState(false); + } + } +} + +bootstrapApplication(AppComponent, { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }), + AppService, + AiService, + ], +}); diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.service.ts b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.service.ts new file mode 100644 index 000000000000..a0171f379351 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/app/app.service.ts @@ -0,0 +1,386 @@ +import { Injectable } from '@angular/core'; +import { Observable, BehaviorSubject } from 'rxjs'; +import { type DxChatTypes } from 'devextreme-angular/ui/chat'; +import { DataSource, CustomStore } from 'devextreme-angular/common/data'; +import { AiService, type AIMessage } from './ai/ai.service'; + +const ALERT_TIMEOUT = 1000 * 60; +const MAX_TOOL_ROUNDS = 3; +const TOOL_DELAY = 700; +const TOOL_ERROR_TEXT = 'Failed to execute the tool'; +const SYSTEM_PROMPT = ` +You are a helpful assistant. +Use the tools to answer questions about the weather, the current time in offices and exchange rates. +Always call a tool instead of guessing. +Pass the country as a short name (for example UK or US), cities without the country, and currencies as ISO codes. +When several tools are needed, call all of them at once. +If a tool fails, say so briefly. +Keep the answers short. +`; +const assistantAvatarSvg = ''; + +interface SuggestionCard { + title: string; + description: string; + prompt: string; +} + +const tools = [ + { + type: 'function' as const, + function: { + name: 'getWeather', + description: 'Get the current weather for a city', + parameters: { + type: 'object', + properties: { + country: { type: 'string', description: 'The short country name, for example UK or US' }, + city: { type: 'string', description: 'The city name' }, + }, + required: ['country', 'city'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'getCurrentTime', + description: 'Get the current local time in the offices of one or several cities', + parameters: { + type: 'object', + properties: { + locations: { type: 'array', items: { type: 'string' }, description: 'City names without the country, for example Barcelona' }, + }, + required: ['locations'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'getExchangeRate', + description: 'Get the exchange rate between two currencies', + parameters: { + type: 'object', + properties: { + fromCurrency: { type: 'string', description: 'The ISO code of the source currency' }, + toCurrency: { type: 'string', description: 'The ISO code of the target currency' }, + }, + required: ['fromCurrency', 'toCurrency'], + }, + }, + }, +]; + +const weatherByCity: Record = { + 'london': '15°C (59°F)', + 'barcelona': '21°C (70°F)', + 'los angeles': '24°C (75°F)', + 'new york': '12°C (54°F)', +}; + +const timeByCity: Record = { + 'london': '09:18 (UTC+00:00) Greenwich Mean Time', + 'barcelona': '10:18 (UTC+01:00) Central European Time', + 'los angeles': '01:18 (UTC−08:00) Pacific Time (US & Canada)', + 'new york': '04:18 (UTC−05:00) Eastern Time (US & Canada)', +}; + +const dollarRates: Record = { + USD: 1, + EUR: 0.92, + GBP: 0.79, + JPY: 149.5, +}; + +const toolImplementations: Record string> = { + getWeather: ({ city }) => { + const weather = weatherByCity[String(city).toLowerCase()]; + + if (!weather) { + throw new Error(TOOL_ERROR_TEXT); + } + + return weather; + }, + getCurrentTime: ({ locations }) => (locations ?? []) + .map((location: string) => `${location}: ${timeByCity[String(location).toLowerCase()] ?? 'unknown time zone'}`) + .join('\n'), + getExchangeRate: ({ fromCurrency, toCurrency }) => { + const from = dollarRates[String(fromCurrency).toUpperCase()]; + const to = dollarRates[String(toCurrency).toUpperCase()]; + + if (!from || !to) { + throw new Error(TOOL_ERROR_TEXT); + } + + const rate = Math.round((to / from) * 10000) / 10000; + + return `1 ${String(fromCurrency).toUpperCase()} = ${rate} ${String(toCurrency).toUpperCase()}`; + }, +}; + +@Injectable({ + providedIn: 'root', +}) + +export class AppService { + aiService: AiService; + + user: DxChatTypes.User = { + id: 'user', + }; + + assistant: DxChatTypes.User = { + id: 'assistant', + name: 'AI Assistant', + avatarUrl: `data:image/svg+xml;utf8,${encodeURIComponent(assistantAvatarSvg)}`, + }; + + suggestionCards: SuggestionCard[] = [ + { + title: 'Single Tool', + description: 'Find out the current time in the Barcelona and Los Angeles offices.', + prompt: 'Check the current time in Barcelona, Spain and Los Angeles, US.', + }, + { + title: 'Multiple Tools', + description: 'Get the current weather and time in Barcelona and the USD to EUR rate.', + prompt: 'Check the current weather conditions in Barcelona, Spain, the current time in Barcelona, Spain and the exchange rate from USD to EUR.', + }, + { + title: 'Error State', + description: 'Check the weather in London and in Atlantis: one of the tools fails.', + prompt: 'Check the current weather conditions in London, UK and in Atlantis, Atlantis.', + }, + ]; + + store: DxChatTypes.Message[] = []; + + messages: AIMessage[] = [{ role: 'system', content: SYSTEM_PROMPT }]; + + alerts: DxChatTypes.Alert[] = []; + + customStore: CustomStore; + + dataSource: DataSource; + + isProcessing = false; + + typingUsersSubject: BehaviorSubject = new BehaviorSubject([]); + + alertsSubject: BehaviorSubject = new BehaviorSubject([]); + + constructor(aiService: AiService) { + this.aiService = aiService; + this.initDataSource(); + } + + get typingUsers$(): Observable { + return this.typingUsersSubject.asObservable(); + } + + get alerts$(): Observable { + return this.alertsSubject.asObservable(); + } + + getDictionary() { + return { + en: { + 'dxChat-emptyListMessage': 'Chat is Empty', + 'dxChat-emptyListPrompt': 'Click a tile below to call a function.', + 'dxChat-textareaPlaceholder': 'Ask AI Assistant...', + }, + }; + } + + initDataSource() { + this.customStore = new CustomStore({ + key: 'id', + load: () => new Promise((resolve) => { + setTimeout(() => { + resolve([...this.store]); + }, 0); + }), + insert: (message) => new Promise((resolve) => { + setTimeout(() => { + this.store.push(message); + resolve(message); + }); + }), + }); + + this.dataSource = new DataSource({ + store: this.customStore, + paginate: false, + }); + } + + static wait(delay: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, delay); + }); + } + + async runTool(call: { id: string; function: { name: string; arguments: string } }): Promise { + const { name } = call.function; + let args: Record = {}; + + try { + args = JSON.parse(call.function.arguments || '{}'); + } catch { + args = {}; + } + + await AppService.wait(TOOL_DELAY); + + try { + const implementation = toolImplementations[name]; + + if (!implementation) { + throw new Error(TOOL_ERROR_TEXT); + } + + return { + name, arguments: args, status: 'success', result: implementation(args), + }; + } catch (err: unknown) { + return { + name, arguments: args, status: 'error', result: this.getErrorMessage(err), + }; + } + } + + async getAIResponse( + messageList: AIMessage[], + toolCalls: DxChatTypes.ToolCall[] = [], + round = 0, + ): Promise<{ text: string; toolCalls: DxChatTypes.ToolCall[] }> { + const answer = await this.aiService.requestModel(messageList, tools); + const calls = (answer.tool_calls ?? []).filter(({ type }) => type === 'function') as any[]; + + if (!calls.length) { + return { text: answer.content ?? '', toolCalls }; + } + + if (round === MAX_TOOL_ROUNDS) { + throw new Error('The model requested too many tool calls. Try rephrasing your request.'); + } + + messageList.push({ + role: 'assistant', + content: answer.content, + tool_calls: calls, + }); + + const results = await Promise.all(calls.map((call) => this.runTool(call))); + + results.forEach((result, index) => { + messageList.push({ + role: 'tool', + tool_call_id: calls[index].id, + content: result.status === 'error' ? `Error: ${result.result}` : result.result as string, + }); + }); + + return this.getAIResponse(messageList, [...toolCalls, ...results], round + 1); + } + + async processMessageSending(message: DxChatTypes.TextMessage) { + this.isProcessing = true; + + const historyLength = this.messages.length; + + this.messages.push({ role: 'user', content: message.text ?? '' }); + this.typingUsersSubject.next([this.assistant]); + + try { + const { text, toolCalls } = await this.getAIResponse(this.messages); + + this.typingUsersSubject.next([]); + this.messages.push({ role: 'assistant', content: text }); + this.renderAssistantMessage(text, toolCalls); + } catch (err: unknown) { + this.typingUsersSubject.next([]); + this.messages.length = historyLength; + this.alertError(this.getErrorMessage(err)); + } finally { + this.isProcessing = false; + } + } + + getErrorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + return 'Unknown error'; + } + + renderAssistantMessage(text: string, toolCalls: DxChatTypes.ToolCall[]) { + const message: DxChatTypes.TextMessage = { + id: Date.now(), + timestamp: new Date(), + author: this.assistant, + text, + }; + + if (toolCalls.length) { + message.toolCalls = toolCalls; + } + + this.dataSource.store().push([{ type: 'insert', data: message }]); + } + + alertError(message: string) { + this.setAlerts([{ + message, + }]); + + setTimeout(() => { + this.setAlerts([]); + }, ALERT_TIMEOUT); + } + + setAlerts(alerts: DxChatTypes.Alert[]) { + this.alerts = alerts; + this.alertsSubject.next(alerts); + } + + async sendSuggestion(prompt: string) { + if (this.isProcessing) { + return; + } + + const message: DxChatTypes.TextMessage = { + id: Date.now(), + timestamp: new Date(), + author: this.user, + text: prompt, + }; + + this.dataSource.store().push([{ type: 'insert', data: message }]); + + if (!this.alerts.length) { + await this.processMessageSending(message); + } + } + + async onMessageEntered({ message }: DxChatTypes.MessageEnteredEvent) { + if (this.isProcessing) { + return; + } + + this.dataSource.store().push([{ type: 'insert', data: { id: Date.now(), ...message } }]); + + if (!this.alerts.length) { + await this.processMessageSending(message as DxChatTypes.TextMessage); + } + } + + clearMessages() { + this.store.length = 0; + this.messages.length = 1; + this.setAlerts([]); + this.typingUsersSubject.next([]); + this.dataSource.reload(); + } +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/Angular/index.html b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/index.html new file mode 100644 index 000000000000..7dd155b53981 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/Angular/index.html @@ -0,0 +1,17 @@ + + + + DevExtreme Demo + + + + + + +
+ Loading... +
+ + + + diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/React/App.tsx b/apps/demos/Demos/Chat/ToolCallVisualization/React/App.tsx new file mode 100644 index 000000000000..246d21edc56f --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/React/App.tsx @@ -0,0 +1,178 @@ +import React, { + useCallback, useMemo, useRef, useState, +} from 'react'; +import Chat from 'devextreme-react/chat'; +import type { ChatTypes } from 'devextreme-react/chat'; +import Toolbar, { Item } from 'devextreme-react/toolbar'; +import type { ButtonTypes } from 'devextreme-react/button'; +import CheckBox from 'devextreme-react/check-box'; +import type { CheckBoxTypes } from 'devextreme-react/check-box'; +import { loadMessages } from 'devextreme-react/common/core/localization'; +import { + user, + assistant, + suggestionCards, +} from './data.ts'; +import { dataSource, useApi } from './useApi.ts'; + +loadMessages({ + en: { + 'dxChat-emptyListMessage': 'Chat is Empty', + 'dxChat-emptyListPrompt': 'Click a tile below to call a function.', + 'dxChat-textareaPlaceholder': 'Ask AI Assistant...', + }, +}); + +function SuggestionCard({ card, onSelect }: { + card: typeof suggestionCards[number]; + onSelect: (prompt: string) => void; +}) { + const onClick = useCallback(() => onSelect(card.prompt), [card.prompt, onSelect]); + + return ( + + ); +} + +export default function App() { + const { + alerts, + insertMessage, + fetchAIResponse, + clearMessages, + } = useApi(); + + const [typingUsers, setTypingUsers] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [expandAll, setExpandAll] = useState(false); + const isProcessingRef = useRef(false); + + const processAIRequest = useCallback(async (message: ChatTypes.Message): Promise => { + isProcessingRef.current = true; + setIsProcessing(true); + setTypingUsers([assistant]); + + await fetchAIResponse(message); + + setTypingUsers([]); + setIsProcessing(false); + isProcessingRef.current = false; + }, [fetchAIResponse]); + + const onMessageEntered = useCallback(async ({ message, event }: ChatTypes.MessageEnteredEvent): Promise => { + if (isProcessingRef.current) { + return; + } + + insertMessage({ id: Date.now(), ...message }); + + if (!alerts.length) { + (event?.target as HTMLElement).blur(); + + await processAIRequest(message); + + setTimeout(() => (event?.target as HTMLElement).focus()); + } + }, [insertMessage, alerts.length, processAIRequest]); + + const sendSuggestion = useCallback((prompt: string): void => { + if (isProcessingRef.current) { + return; + } + + const message: ChatTypes.Message = { + id: Date.now(), + timestamp: new Date(), + author: user, + text: prompt, + }; + + insertMessage(message); + + if (!alerts.length) { + processAIRequest(message); + } + }, [insertMessage, alerts.length, processAIRequest]); + + const clearButtonOptions = useMemo((): ButtonTypes.Properties => ({ + icon: 'clearhistory', + stylingMode: 'text', + hint: 'Clear chat', + disabled: isProcessing, + onClick: () => { + setTypingUsers([]); + clearMessages(); + }, + }), [isProcessing, clearMessages]); + + const toolCallOptions = useMemo(() => ({ expandAll: !!expandAll }), [expandAll]); + + const emptyViewRender = useCallback(({ texts }: { texts: { message: string; prompt: string } }) => ( +
+
{texts.message}
+
{texts.prompt}
+
+ {suggestionCards.map((card) => ( + + ))} +
+
+ ), [sendSuggestion]); + + return ( + <> +
+ + + + + +
+ +
+
Options
+ +
+ +
+
+ + ); +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/React/data.ts b/apps/demos/Demos/Chat/ToolCallVisualization/React/data.ts new file mode 100644 index 000000000000..621a9ccfdc90 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/React/data.ts @@ -0,0 +1,36 @@ +import type { ChatTypes } from 'devextreme-react/chat'; + +export const ALERT_TIMEOUT = 1000 * 60; +export const MAX_TOOL_ROUNDS = 3; +export const TOOL_DELAY = 700; +export const TOOL_ERROR_TEXT = 'Failed to execute the tool'; + +const assistantAvatarSvg = ''; + +export const user: ChatTypes.User = { + id: 'user', +}; + +export const assistant: ChatTypes.User = { + id: 'assistant', + name: 'AI Assistant', + avatarUrl: `data:image/svg+xml;utf8,${encodeURIComponent(assistantAvatarSvg)}`, +}; + +export const suggestionCards = [ + { + title: 'Single Tool', + description: 'Find out the current time in the Barcelona and Los Angeles offices.', + prompt: 'Check the current time in Barcelona, Spain and Los Angeles, US.', + }, + { + title: 'Multiple Tools', + description: 'Get the current weather and time in Barcelona and the USD to EUR rate.', + prompt: 'Check the current weather conditions in Barcelona, Spain, the current time in Barcelona, Spain and the exchange rate from USD to EUR.', + }, + { + title: 'Error State', + description: 'Check the weather in London and in Atlantis: one of the tools fails.', + prompt: 'Check the current weather conditions in London, UK and in Atlantis, Atlantis.', + }, +]; diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/React/index.html b/apps/demos/Demos/Chat/ToolCallVisualization/React/index.html new file mode 100644 index 000000000000..b6ad2f9b1595 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/React/index.html @@ -0,0 +1,18 @@ + + + + DevExtreme Demo + + + + + + + +
+
+
+ + + + diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/React/index.tsx b/apps/demos/Demos/Chat/ToolCallVisualization/React/index.tsx new file mode 100644 index 000000000000..8acbec4b6179 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/React/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import App from './App.tsx'; + +ReactDOM.render( + , + document.getElementById('app'), +); diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/React/service.ts b/apps/demos/Demos/Chat/ToolCallVisualization/React/service.ts new file mode 100644 index 000000000000..59bb87e2a132 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/React/service.ts @@ -0,0 +1,212 @@ +import { AzureOpenAI, OpenAI } from 'openai'; +import type { ChatTypes } from 'devextreme-react/chat'; +import { MAX_TOOL_ROUNDS, TOOL_DELAY, TOOL_ERROR_TEXT } from './data.ts'; + +export type AIMessage = OpenAI.ChatCompletionMessageParam; + +type ToolArguments = Record; + +export interface AIResponse { + text: string; + toolCalls: ChatTypes.ToolCall[]; +} + +const AzureOpenAIConfig = { + dangerouslyAllowBrowser: true, + deployment: 'demo-mini', + apiVersion: '2024-02-01', + endpoint: 'https://public-api.devexpress.com/demo-openai', + apiKey: 'DEMO', +}; + +export const SYSTEM_PROMPT = ` +You are a helpful assistant. +Use the tools to answer questions about the weather, the current time in offices and exchange rates. +Always call a tool instead of guessing. +Pass the country as a short name (for example UK or US), cities without the country, and currencies as ISO codes. +When several tools are needed, call all of them at once. +If a tool fails, say so briefly. +Keep the answers short. +`; + +const tools: OpenAI.ChatCompletionTool[] = [ + { + type: 'function', + function: { + name: 'getWeather', + description: 'Get the current weather for a city', + parameters: { + type: 'object', + properties: { + country: { type: 'string', description: 'The short country name, for example UK or US' }, + city: { type: 'string', description: 'The city name' }, + }, + required: ['country', 'city'], + }, + }, + }, + { + type: 'function', + function: { + name: 'getCurrentTime', + description: 'Get the current local time in the offices of one or several cities', + parameters: { + type: 'object', + properties: { + locations: { type: 'array', items: { type: 'string' }, description: 'City names without the country, for example Barcelona' }, + }, + required: ['locations'], + }, + }, + }, + { + type: 'function', + function: { + name: 'getExchangeRate', + description: 'Get the exchange rate between two currencies', + parameters: { + type: 'object', + properties: { + fromCurrency: { type: 'string', description: 'The ISO code of the source currency' }, + toCurrency: { type: 'string', description: 'The ISO code of the target currency' }, + }, + required: ['fromCurrency', 'toCurrency'], + }, + }, + }, +]; + +const weatherByCity = new Map([ + ['london', '15°C (59°F)'], + ['barcelona', '21°C (70°F)'], + ['los angeles', '24°C (75°F)'], + ['new york', '12°C (54°F)'], +]); + +const timeByCity = new Map([ + ['london', '09:18 (UTC+00:00) Greenwich Mean Time'], + ['barcelona', '10:18 (UTC+01:00) Central European Time'], + ['los angeles', '01:18 (UTC−08:00) Pacific Time (US & Canada)'], + ['new york', '04:18 (UTC−05:00) Eastern Time (US & Canada)'], +]); + +const dollarRates: Record = { + USD: 1, + EUR: 0.92, + GBP: 0.79, + JPY: 149.5, +}; + +const toolImplementations: Record string> = { + getWeather: ({ city }) => { + const weather = weatherByCity.get(String(city).toLowerCase()); + + if (!weather) { + throw new Error(TOOL_ERROR_TEXT); + } + + return weather; + }, + getCurrentTime: ({ locations }) => ((locations ?? []) as string[]) + .map((location) => `${location}: ${timeByCity.get(String(location).toLowerCase()) ?? 'unknown time zone'}`) + .join('\n'), + getExchangeRate: ({ fromCurrency, toCurrency }) => { + const from = dollarRates[String(fromCurrency).toUpperCase()]; + const to = dollarRates[String(toCurrency).toUpperCase()]; + + if (!from || !to) { + throw new Error(TOOL_ERROR_TEXT); + } + + const rate = Math.round((to / from) * 10000) / 10000; + + return `1 ${String(fromCurrency).toUpperCase()} = ${rate} ${String(toCurrency).toUpperCase()}`; + }, +}; + +const chatService = new AzureOpenAI(AzureOpenAIConfig); + +const wait = (delay: number): Promise => new Promise((resolve): void => { + setTimeout(resolve, delay); +}); + +async function requestModel(messages: AIMessage[]): Promise { + const params = { + messages, + model: AzureOpenAIConfig.deployment, + tools, + tool_choice: 'auto' as const, + max_completion_tokens: 1000, + temperature: 0.7, + }; + + const response = await chatService.chat.completions.create(params, { maxRetries: 0 }); + + return response.choices[0].message; +} + +async function runTool(call: OpenAI.ChatCompletionMessageFunctionToolCall): Promise { + const { name } = call.function; + let args: ToolArguments = {}; + + try { + args = JSON.parse(call.function.arguments || '{}') as ToolArguments; + } catch { + args = {}; + } + + await wait(TOOL_DELAY); + + try { + const implementation = toolImplementations[name]; + + if (!implementation) { + throw new Error(TOOL_ERROR_TEXT); + } + + return { + name, arguments: args, status: 'success', result: implementation(args), + }; + } catch (err: unknown) { + return { + name, arguments: args, status: 'error', result: (err as Error).message, + }; + } +} + +export async function getAIResponse( + messages: AIMessage[], + toolCalls: ChatTypes.ToolCall[] = [], + round = 0, +): Promise { + const answer = await requestModel(messages); + const calls = (answer.tool_calls ?? []).filter( + (call): call is OpenAI.ChatCompletionMessageFunctionToolCall => call.type === 'function', + ); + + if (!calls.length) { + return { text: answer.content ?? '', toolCalls }; + } + + if (round === MAX_TOOL_ROUNDS) { + throw new Error('The model requested too many tool calls. Try rephrasing your request.'); + } + + messages.push({ + role: 'assistant', + content: answer.content, + tool_calls: calls, + }); + + const results = await Promise.all(calls.map(runTool)); + + results.forEach((result, index) => { + messages.push({ + role: 'tool', + tool_call_id: calls[index].id, + content: result.status === 'error' ? `Error: ${result.result}` : String(result.result), + }); + }); + + return getAIResponse(messages, [...toolCalls, ...results], round + 1); +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/React/styles.css b/apps/demos/Demos/Chat/ToolCallVisualization/React/styles.css new file mode 100644 index 000000000000..d01727509958 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/React/styles.css @@ -0,0 +1,124 @@ +#app { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +.options { + padding: 20px; + display: flex; + flex-direction: column; + min-width: 280px; + background-color: var(--dx-color-options-panel-bg); + gap: 16px; + width: 100%; + max-width: 900px; + box-sizing: border-box; +} + +.option { + display: flex; + align-items: center; + gap: 8px; +} + +.caption { + font-size: var(--dx-font-size-sm); + font-weight: 500; +} + +.ai-chat-card { + position: relative; + width: 100%; + max-width: 900px; +} + +.ai-chat-card > .dx-toolbar { + position: absolute; + height: var(--dxds-spacing-480, 48px); + padding-inline: var(--dxds-spacing-240, 24px); + border-bottom: var(--dx-border-width) solid var(--dx-color-border); + background-color: transparent; +} + +.ai-chat-card > .dx-toolbar .dx-toolbar-items-container { + height: 100%; +} + +.ai-chat-title .dx-toolbar-item-content { + font-size: var(--dxds-font-size-title-md, 20px); + font-weight: var(--dxds-font-weight-title-default, 600); +} + +.ai-chat-card > .dx-chat { + padding-top: var(--dxds-spacing-480, 48px); +} + +.dx-chat-messagelist-empty-image { + display: none; +} + +.dx-chat-messagelist-empty-message { + font-size: var(--dx-font-size-heading-5); +} + +.dx-chat-messagebubble-content { + display: flex; + flex-direction: column; +} + +.dx-chat-messagebubble:has(> .dx-chat-tool-call-view) { + min-width: 344px; +} + +.dx-theme-fluent-typography .dx-chat-messagebubble:has(> .dx-chat-tool-call-view), +.dx-theme-fluent-next-typography .dx-chat-messagebubble:has(> .dx-chat-tool-call-view) { + min-width: 320px; +} + +.chat-suggestion-cards { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + margin-top: 32px; + width: 100%; +} + +.chat-suggestion-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 0 1 230px; + width: 230px; + max-width: 230px; + padding: 16px; + border: var(--dx-border-width) solid var(--dx-color-border); + border-radius: calc(var(--dx-border-radius) * 3); + background: color-mix(in srgb, var(--dx-color-text) 4%, var(--dx-component-color-bg)); + color: var(--dx-color-text); + font-family: inherit; + text-align: start; + cursor: pointer; + transition: 0.2s ease; +} + +.chat-suggestion-card:hover { + background: color-mix(in srgb, var(--dx-color-text) 10%, var(--dx-component-color-bg)); + box-shadow: var(--dxds-box-shadow-md, 0 4px 12px 0 rgba(0, 0, 0, 0.04), 0 4px 24px 0 rgba(0, 0, 0, 0.02)); +} + +.chat-suggestion-card-title { + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +.chat-suggestion-card-description { + color: color-mix(in srgb, var(--dx-color-text) 70%, var(--dx-component-color-bg)); + font-size: 12px; + font-weight: 400; + line-height: 16px; +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/React/useApi.ts b/apps/demos/Demos/Chat/ToolCallVisualization/React/useApi.ts new file mode 100644 index 000000000000..8d807a23c252 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/React/useApi.ts @@ -0,0 +1,90 @@ +import { useCallback, useState } from 'react'; +import type { ChatTypes } from 'devextreme-react/chat'; +import { CustomStore, DataSource } from 'devextreme-react/common/data'; +import { ALERT_TIMEOUT, assistant } from './data.ts'; +import { getAIResponse, SYSTEM_PROMPT } from './service.ts'; +import type { AIMessage } from './service.ts'; + +const store: ChatTypes.Message[] = []; +let history: AIMessage[] = [{ role: 'system', content: SYSTEM_PROMPT }]; + +const customStore = new CustomStore({ + key: 'id', + load: (): Promise => new Promise((resolve) => { + setTimeout(() => { + resolve([...store]); + }, 0); + }), + insert: (message: ChatTypes.Message): Promise => new Promise((resolve) => { + setTimeout(() => { + store.push(message); + resolve(message); + }); + }), +}); + +export const dataSource = new DataSource({ + store: customStore, + paginate: false, +}); + +const getErrorMessage = (err: unknown): string => { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + return 'Unknown error'; +}; + +export const useApi = () => { + const [alerts, setAlerts] = useState([]); + + const insertMessage = useCallback((data: ChatTypes.Message): void => { + dataSource.store().push([{ type: 'insert', data }]); + }, []); + + const alertError = useCallback((message: string): void => { + setAlerts([{ + message, + }]); + + setTimeout(() => { + setAlerts([]); + }, ALERT_TIMEOUT); + }, []); + + const fetchAIResponse = useCallback(async (message: ChatTypes.Message): Promise => { + const historyLength = history.length; + + history.push({ role: 'user', content: message.text ?? '' }); + + try { + const { text, toolCalls } = await getAIResponse(history); + + history.push({ role: 'assistant', content: text }); + + insertMessage({ + id: Date.now(), + timestamp: new Date(), + author: assistant, + text, + ...(toolCalls.length ? { toolCalls } : {}), + }); + } catch (err: unknown) { + history.length = historyLength; + alertError(getErrorMessage(err)); + } + }, [alertError, insertMessage]); + + const clearMessages = useCallback((): void => { + store.length = 0; + history = [{ role: 'system', content: SYSTEM_PROMPT }]; + setAlerts([]); + dataSource.reload(); + }, []); + + return { + alerts, + insertMessage, + fetchAIResponse, + clearMessages, + }; +}; diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/App.js b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/App.js new file mode 100644 index 000000000000..f00b0757aef0 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/App.js @@ -0,0 +1,160 @@ +import React, { + useCallback, useMemo, useRef, useState, +} from 'react'; +import Chat from 'devextreme-react/chat'; +import Toolbar, { Item } from 'devextreme-react/toolbar'; +import CheckBox from 'devextreme-react/check-box'; +import { loadMessages } from 'devextreme-react/common/core/localization'; +import { user, assistant, suggestionCards } from './data.js'; +import { dataSource, useApi } from './useApi.js'; + +loadMessages({ + en: { + 'dxChat-emptyListMessage': 'Chat is Empty', + 'dxChat-emptyListPrompt': 'Click a tile below to call a function.', + 'dxChat-textareaPlaceholder': 'Ask AI Assistant...', + }, +}); +function SuggestionCard({ card, onSelect }) { + const onClick = useCallback(() => onSelect(card.prompt), [card.prompt, onSelect]); + return ( + + ); +} +export default function App() { + const { + alerts, insertMessage, fetchAIResponse, clearMessages, + } = useApi(); + const [typingUsers, setTypingUsers] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [expandAll, setExpandAll] = useState(false); + const isProcessingRef = useRef(false); + const processAIRequest = useCallback( + async (message) => { + isProcessingRef.current = true; + setIsProcessing(true); + setTypingUsers([assistant]); + await fetchAIResponse(message); + setTypingUsers([]); + setIsProcessing(false); + isProcessingRef.current = false; + }, + [fetchAIResponse], + ); + const onMessageEntered = useCallback( + async ({ message, event }) => { + if (isProcessingRef.current) { + return; + } + insertMessage({ id: Date.now(), ...message }); + if (!alerts.length) { + (event?.target).blur(); + await processAIRequest(message); + setTimeout(() => (event?.target).focus()); + } + }, + [insertMessage, alerts.length, processAIRequest], + ); + const sendSuggestion = useCallback( + (prompt) => { + if (isProcessingRef.current) { + return; + } + const message = { + id: Date.now(), + timestamp: new Date(), + author: user, + text: prompt, + }; + insertMessage(message); + if (!alerts.length) { + processAIRequest(message); + } + }, + [insertMessage, alerts.length, processAIRequest], + ); + const clearButtonOptions = useMemo( + () => ({ + icon: 'clearhistory', + stylingMode: 'text', + hint: 'Clear chat', + disabled: isProcessing, + onClick: () => { + setTypingUsers([]); + clearMessages(); + }, + }), + [isProcessing, clearMessages], + ); + const toolCallOptions = useMemo(() => ({ expandAll: !!expandAll }), [expandAll]); + const emptyViewRender = useCallback( + ({ texts }) => ( +
+
{texts.message}
+
{texts.prompt}
+
+ {suggestionCards.map((card) => ( + + ))} +
+
+ ), + [sendSuggestion], + ); + return ( + <> +
+ + + + + +
+ +
+
Options
+ +
+ +
+
+ + ); +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/data.js b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/data.js new file mode 100644 index 000000000000..57f24000ccc2 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/data.js @@ -0,0 +1,32 @@ +export const ALERT_TIMEOUT = 1000 * 60; +export const MAX_TOOL_ROUNDS = 3; +export const TOOL_DELAY = 700; +export const TOOL_ERROR_TEXT = 'Failed to execute the tool'; +const assistantAvatarSvg = + ''; +export const user = { + id: 'user', +}; +export const assistant = { + id: 'assistant', + name: 'AI Assistant', + avatarUrl: `data:image/svg+xml;utf8,${encodeURIComponent(assistantAvatarSvg)}`, +}; +export const suggestionCards = [ + { + title: 'Single Tool', + description: 'Find out the current time in the Barcelona and Los Angeles offices.', + prompt: 'Check the current time in Barcelona, Spain and Los Angeles, US.', + }, + { + title: 'Multiple Tools', + description: 'Get the current weather and time in Barcelona and the USD to EUR rate.', + prompt: + 'Check the current weather conditions in Barcelona, Spain, the current time in Barcelona, Spain and the exchange rate from USD to EUR.', + }, + { + title: 'Error State', + description: 'Check the weather in London and in Atlantis: one of the tools fails.', + prompt: 'Check the current weather conditions in London, UK and in Atlantis, Atlantis.', + }, +]; diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/index.html b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/index.html new file mode 100644 index 000000000000..b6ad2f9b1595 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/index.html @@ -0,0 +1,18 @@ + + + + DevExtreme Demo + + + + + + + +
+
+
+ + + + diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/index.js b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/index.js new file mode 100644 index 000000000000..b853e0be8242 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/index.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App.js'; + +ReactDOM.render(, document.getElementById('app')); diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/service.js b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/service.js new file mode 100644 index 000000000000..10453b49e69e --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/service.js @@ -0,0 +1,182 @@ +import { AzureOpenAI } from 'openai'; +import { MAX_TOOL_ROUNDS, TOOL_DELAY, TOOL_ERROR_TEXT } from './data.js'; + +const AzureOpenAIConfig = { + dangerouslyAllowBrowser: true, + deployment: 'demo-mini', + apiVersion: '2024-02-01', + endpoint: 'https://public-api.devexpress.com/demo-openai', + apiKey: 'DEMO', +}; +export const SYSTEM_PROMPT = ` +You are a helpful assistant. +Use the tools to answer questions about the weather, the current time in offices and exchange rates. +Always call a tool instead of guessing. +Pass the country as a short name (for example UK or US), cities without the country, and currencies as ISO codes. +When several tools are needed, call all of them at once. +If a tool fails, say so briefly. +Keep the answers short. +`; +const tools = [ + { + type: 'function', + function: { + name: 'getWeather', + description: 'Get the current weather for a city', + parameters: { + type: 'object', + properties: { + country: { type: 'string', description: 'The short country name, for example UK or US' }, + city: { type: 'string', description: 'The city name' }, + }, + required: ['country', 'city'], + }, + }, + }, + { + type: 'function', + function: { + name: 'getCurrentTime', + description: 'Get the current local time in the offices of one or several cities', + parameters: { + type: 'object', + properties: { + locations: { + type: 'array', + items: { type: 'string' }, + description: 'City names without the country, for example Barcelona', + }, + }, + required: ['locations'], + }, + }, + }, + { + type: 'function', + function: { + name: 'getExchangeRate', + description: 'Get the exchange rate between two currencies', + parameters: { + type: 'object', + properties: { + fromCurrency: { type: 'string', description: 'The ISO code of the source currency' }, + toCurrency: { type: 'string', description: 'The ISO code of the target currency' }, + }, + required: ['fromCurrency', 'toCurrency'], + }, + }, + }, +]; +const weatherByCity = new Map([ + ['london', '15°C (59°F)'], + ['barcelona', '21°C (70°F)'], + ['los angeles', '24°C (75°F)'], + ['new york', '12°C (54°F)'], +]); +const timeByCity = new Map([ + ['london', '09:18 (UTC+00:00) Greenwich Mean Time'], + ['barcelona', '10:18 (UTC+01:00) Central European Time'], + ['los angeles', '01:18 (UTC−08:00) Pacific Time (US & Canada)'], + ['new york', '04:18 (UTC−05:00) Eastern Time (US & Canada)'], +]); +const dollarRates = { + USD: 1, + EUR: 0.92, + GBP: 0.79, + JPY: 149.5, +}; +const toolImplementations = { + getWeather: ({ city }) => { + const weather = weatherByCity.get(String(city).toLowerCase()); + if (!weather) { + throw new Error(TOOL_ERROR_TEXT); + } + return weather; + }, + getCurrentTime: ({ locations }) => + (locations ?? []) + .map( + (location) => + `${location}: ${timeByCity.get(String(location).toLowerCase()) ?? 'unknown time zone'}`, + ) + .join('\n'), + getExchangeRate: ({ fromCurrency, toCurrency }) => { + const from = dollarRates[String(fromCurrency).toUpperCase()]; + const to = dollarRates[String(toCurrency).toUpperCase()]; + if (!from || !to) { + throw new Error(TOOL_ERROR_TEXT); + } + const rate = Math.round((to / from) * 10000) / 10000; + return `1 ${String(fromCurrency).toUpperCase()} = ${rate} ${String(toCurrency).toUpperCase()}`; + }, +}; +const chatService = new AzureOpenAI(AzureOpenAIConfig); +const wait = (delay) => + new Promise((resolve) => { + setTimeout(resolve, delay); + }); +async function requestModel(messages) { + const params = { + messages, + model: AzureOpenAIConfig.deployment, + tools, + tool_choice: 'auto', + max_completion_tokens: 1000, + temperature: 0.7, + }; + const response = await chatService.chat.completions.create(params, { maxRetries: 0 }); + return response.choices[0].message; +} +async function runTool(call) { + const { name } = call.function; + let args = {}; + try { + args = JSON.parse(call.function.arguments || '{}'); + } catch { + args = {}; + } + await wait(TOOL_DELAY); + try { + const implementation = toolImplementations[name]; + if (!implementation) { + throw new Error(TOOL_ERROR_TEXT); + } + return { + name, + arguments: args, + status: 'success', + result: implementation(args), + }; + } catch (err) { + return { + name, + arguments: args, + status: 'error', + result: err.message, + }; + } +} +export async function getAIResponse(messages, toolCalls = [], round = 0) { + const answer = await requestModel(messages); + const calls = (answer.tool_calls ?? []).filter((call) => call.type === 'function'); + if (!calls.length) { + return { text: answer.content ?? '', toolCalls }; + } + if (round === MAX_TOOL_ROUNDS) { + throw new Error('The model requested too many tool calls. Try rephrasing your request.'); + } + messages.push({ + role: 'assistant', + content: answer.content, + tool_calls: calls, + }); + const results = await Promise.all(calls.map(runTool)); + results.forEach((result, index) => { + messages.push({ + role: 'tool', + tool_call_id: calls[index].id, + content: result.status === 'error' ? `Error: ${result.result}` : String(result.result), + }); + }); + return getAIResponse(messages, [...toolCalls, ...results], round + 1); +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/styles.css b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/styles.css new file mode 100644 index 000000000000..d01727509958 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/styles.css @@ -0,0 +1,124 @@ +#app { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +.options { + padding: 20px; + display: flex; + flex-direction: column; + min-width: 280px; + background-color: var(--dx-color-options-panel-bg); + gap: 16px; + width: 100%; + max-width: 900px; + box-sizing: border-box; +} + +.option { + display: flex; + align-items: center; + gap: 8px; +} + +.caption { + font-size: var(--dx-font-size-sm); + font-weight: 500; +} + +.ai-chat-card { + position: relative; + width: 100%; + max-width: 900px; +} + +.ai-chat-card > .dx-toolbar { + position: absolute; + height: var(--dxds-spacing-480, 48px); + padding-inline: var(--dxds-spacing-240, 24px); + border-bottom: var(--dx-border-width) solid var(--dx-color-border); + background-color: transparent; +} + +.ai-chat-card > .dx-toolbar .dx-toolbar-items-container { + height: 100%; +} + +.ai-chat-title .dx-toolbar-item-content { + font-size: var(--dxds-font-size-title-md, 20px); + font-weight: var(--dxds-font-weight-title-default, 600); +} + +.ai-chat-card > .dx-chat { + padding-top: var(--dxds-spacing-480, 48px); +} + +.dx-chat-messagelist-empty-image { + display: none; +} + +.dx-chat-messagelist-empty-message { + font-size: var(--dx-font-size-heading-5); +} + +.dx-chat-messagebubble-content { + display: flex; + flex-direction: column; +} + +.dx-chat-messagebubble:has(> .dx-chat-tool-call-view) { + min-width: 344px; +} + +.dx-theme-fluent-typography .dx-chat-messagebubble:has(> .dx-chat-tool-call-view), +.dx-theme-fluent-next-typography .dx-chat-messagebubble:has(> .dx-chat-tool-call-view) { + min-width: 320px; +} + +.chat-suggestion-cards { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + margin-top: 32px; + width: 100%; +} + +.chat-suggestion-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 0 1 230px; + width: 230px; + max-width: 230px; + padding: 16px; + border: var(--dx-border-width) solid var(--dx-color-border); + border-radius: calc(var(--dx-border-radius) * 3); + background: color-mix(in srgb, var(--dx-color-text) 4%, var(--dx-component-color-bg)); + color: var(--dx-color-text); + font-family: inherit; + text-align: start; + cursor: pointer; + transition: 0.2s ease; +} + +.chat-suggestion-card:hover { + background: color-mix(in srgb, var(--dx-color-text) 10%, var(--dx-component-color-bg)); + box-shadow: var(--dxds-box-shadow-md, 0 4px 12px 0 rgba(0, 0, 0, 0.04), 0 4px 24px 0 rgba(0, 0, 0, 0.02)); +} + +.chat-suggestion-card-title { + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +.chat-suggestion-card-description { + color: color-mix(in srgb, var(--dx-color-text) 70%, var(--dx-component-color-bg)); + font-size: 12px; + font-weight: 400; + line-height: 16px; +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/useApi.js b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/useApi.js new file mode 100644 index 000000000000..060584ac8310 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/ReactJs/useApi.js @@ -0,0 +1,81 @@ +import { useCallback, useState } from 'react'; +import { CustomStore, DataSource } from 'devextreme-react/common/data'; +import { ALERT_TIMEOUT, assistant } from './data.js'; +import { getAIResponse, SYSTEM_PROMPT } from './service.js'; + +const store = []; +let history = [{ role: 'system', content: SYSTEM_PROMPT }]; +const customStore = new CustomStore({ + key: 'id', + load: () => + new Promise((resolve) => { + setTimeout(() => { + resolve([...store]); + }, 0); + }), + insert: (message) => + new Promise((resolve) => { + setTimeout(() => { + store.push(message); + resolve(message); + }); + }), +}); +export const dataSource = new DataSource({ + store: customStore, + paginate: false, +}); +const getErrorMessage = (err) => { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + return 'Unknown error'; +}; +export const useApi = () => { + const [alerts, setAlerts] = useState([]); + const insertMessage = useCallback((data) => { + dataSource.store().push([{ type: 'insert', data }]); + }, []); + const alertError = useCallback((message) => { + setAlerts([ + { + message, + }, + ]); + setTimeout(() => { + setAlerts([]); + }, ALERT_TIMEOUT); + }, []); + const fetchAIResponse = useCallback( + async (message) => { + const historyLength = history.length; + history.push({ role: 'user', content: message.text ?? '' }); + try { + const { text, toolCalls } = await getAIResponse(history); + history.push({ role: 'assistant', content: text }); + insertMessage({ + id: Date.now(), + timestamp: new Date(), + author: assistant, + text, + ...(toolCalls.length ? { toolCalls } : {}), + }); + } catch (err) { + history.length = historyLength; + alertError(getErrorMessage(err)); + } + }, + [alertError, insertMessage], + ); + const clearMessages = useCallback(() => { + store.length = 0; + history = [{ role: 'system', content: SYSTEM_PROMPT }]; + setAlerts([]); + dataSource.reload(); + }, []); + return { + alerts, + insertMessage, + fetchAIResponse, + clearMessages, + }; +}; diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/Vue/App.vue b/apps/demos/Demos/Chat/ToolCallVisualization/Vue/App.vue new file mode 100644 index 000000000000..a61e4cf7d342 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/Vue/App.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/Vue/data.ts b/apps/demos/Demos/Chat/ToolCallVisualization/Vue/data.ts new file mode 100644 index 000000000000..95f6cc35eba9 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/Vue/data.ts @@ -0,0 +1,184 @@ +import { CustomStore, DataSource } from 'devextreme-vue/common/data'; +import { type DxChatTypes } from 'devextreme-vue/chat'; + +export interface ToolImplementationArgs { + city?: string; + locations?: string[]; + fromCurrency?: string; + toCurrency?: string; +} + +export const dictionary = { + en: { + 'dxChat-emptyListMessage': 'Chat is Empty', + 'dxChat-emptyListPrompt': 'Click a tile below to call a function.', + 'dxChat-textareaPlaceholder': 'Ask AI Assistant...', + }, +}; + +export const ALERT_TIMEOUT = 1000 * 60; +export const MAX_TOOL_ROUNDS = 3; +export const TOOL_DELAY = 700; +export const TOOL_ERROR_TEXT = 'Failed to execute the tool'; + +export const user: DxChatTypes.User = { + id: 'user', +}; + +const assistantAvatarSvg = ''; + +export const assistant: DxChatTypes.User = { + id: 'assistant', + name: 'AI Assistant', + avatarUrl: `data:image/svg+xml;utf8,${encodeURIComponent(assistantAvatarSvg)}`, +}; + +export const suggestionCards = [ + { + title: 'Single Tool', + description: 'Find out the current time in the Barcelona and Los Angeles offices.', + prompt: 'Check the current time in Barcelona, Spain and Los Angeles, US.', + }, + { + title: 'Multiple Tools', + description: 'Get the current weather and time in Barcelona and the USD to EUR rate.', + prompt: 'Check the current weather conditions in Barcelona, Spain, the current time in Barcelona, Spain and the exchange rate from USD to EUR.', + }, + { + title: 'Error State', + description: 'Check the weather in London and in Atlantis: one of the tools fails.', + prompt: 'Check the current weather conditions in London, UK and in Atlantis, Atlantis.', + }, +]; + +export const SYSTEM_PROMPT = ` +You are a helpful assistant. +Use the tools to answer questions about the weather, the current time in offices and exchange rates. +Always call a tool instead of guessing. +Pass the country as a short name (for example UK or US), cities without the country, and currencies as ISO codes. +When several tools are needed, call all of them at once. +If a tool fails, say so briefly. +Keep the answers short. +`; + +export const tools = [ + { + type: 'function', + function: { + name: 'getWeather', + description: 'Get the current weather for a city', + parameters: { + type: 'object', + properties: { + country: { type: 'string', description: 'The short country name, for example UK or US' }, + city: { type: 'string', description: 'The city name' }, + }, + required: ['country', 'city'], + }, + }, + }, + { + type: 'function', + function: { + name: 'getCurrentTime', + description: 'Get the current local time in the offices of one or several cities', + parameters: { + type: 'object', + properties: { + locations: { type: 'array', items: { type: 'string' }, description: 'City names without the country, for example Barcelona' }, + }, + required: ['locations'], + }, + }, + }, + { + type: 'function', + function: { + name: 'getExchangeRate', + description: 'Get the exchange rate between two currencies', + parameters: { + type: 'object', + properties: { + fromCurrency: { type: 'string', description: 'The ISO code of the source currency' }, + toCurrency: { type: 'string', description: 'The ISO code of the target currency' }, + }, + required: ['fromCurrency', 'toCurrency'], + }, + }, + }, +] as const; + +const weatherByCity: Record = { + 'london': '15°C (59°F)', + 'barcelona': '21°C (70°F)', + 'los angeles': '24°C (75°F)', + 'new york': '12°C (54°F)', +}; + +const timeByCity: Record = { + 'london': '09:18 (UTC+00:00) Greenwich Mean Time', + 'barcelona': '10:18 (UTC+01:00) Central European Time', + 'los angeles': '01:18 (UTC−08:00) Pacific Time (US & Canada)', + 'new york': '04:18 (UTC−05:00) Eastern Time (US & Canada)', +}; + +const dollarRates: Record = { + USD: 1, + EUR: 0.92, + GBP: 0.79, + JPY: 149.5, +}; + +export const toolImplementations: Record string> = { + getWeather: ({ city }) => { + const weather = weatherByCity[String(city).toLowerCase()]; + + if (!weather) { + throw new Error(TOOL_ERROR_TEXT); + } + + return weather; + }, + getCurrentTime: ({ locations }) => (locations ?? []) + .map((location) => `${location}: ${timeByCity[String(location).toLowerCase()] ?? 'unknown time zone'}`) + .join('\n'), + getExchangeRate: ({ fromCurrency, toCurrency }) => { + const from = dollarRates[String(fromCurrency).toUpperCase()]; + const to = dollarRates[String(toCurrency).toUpperCase()]; + + if (!from || !to) { + throw new Error(TOOL_ERROR_TEXT); + } + + const rate = Math.round((to / from) * 10000) / 10000; + + return `1 ${String(fromCurrency).toUpperCase()} = ${rate} ${String(toCurrency).toUpperCase()}`; + }, +}; + +const store: DxChatTypes.Message[] = []; + +const customStore = new CustomStore({ + key: 'id', + load: () => new Promise((resolve) => { + setTimeout(() => { + resolve([...store]); + }, 0); + }), + insert: (message: DxChatTypes.Message) => new Promise((resolve) => { + setTimeout(() => { + store.push(message); + resolve(message); + }); + }), +}); + +export const dataSource = new DataSource({ + store: customStore, + paginate: false, +}); + +export function clearStore() { + store.length = 0; + dataSource.reload(); +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/Vue/index.html b/apps/demos/Demos/Chat/ToolCallVisualization/Vue/index.html new file mode 100644 index 000000000000..e0b2856fbaa6 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/Vue/index.html @@ -0,0 +1,18 @@ + + + + DevExtreme Demo + + + + + + + +
+
+
+ + + + diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/Vue/index.ts b/apps/demos/Demos/Chat/ToolCallVisualization/Vue/index.ts new file mode 100644 index 000000000000..684d04215d72 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/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/ToolCallVisualization/Vue/service.ts b/apps/demos/Demos/Chat/ToolCallVisualization/Vue/service.ts new file mode 100644 index 000000000000..2b0a59ecde04 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/Vue/service.ts @@ -0,0 +1,32 @@ +import { AzureOpenAI } from 'openai'; +import type { OpenAI } from 'openai'; +import { tools } from './data.ts'; + +export type AIMessage = OpenAI.ChatCompletionMessageParam; + +export type AIAnswer = OpenAI.ChatCompletionMessage; + +const AzureOpenAIConfig = { + dangerouslyAllowBrowser: true, + deployment: 'demo-mini', + apiVersion: '2024-02-01', + endpoint: 'https://public-api.devexpress.com/demo-openai', + apiKey: 'DEMO', +}; + +const chatService = new AzureOpenAI(AzureOpenAIConfig); + +export async function requestModel(messages: AIMessage[]): Promise { + const params: Record = { + messages, + model: AzureOpenAIConfig.deployment, + tools, + tool_choice: 'auto', + max_completion_tokens: 1000, + temperature: 0.7, + }; + + const response = await chatService.chat.completions.create(params as any, { maxRetries: 0 }); + + return response.choices[0].message; +} diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/jQuery/data.js b/apps/demos/Demos/Chat/ToolCallVisualization/jQuery/data.js new file mode 100644 index 000000000000..4d4b6db79591 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/jQuery/data.js @@ -0,0 +1,133 @@ +const deployment = 'demo-mini'; +const apiVersion = '2024-02-01'; +const endpoint = 'https://public-api.devexpress.com/demo-openai'; +const apiKey = 'DEMO'; +const ALERT_TIMEOUT = 1000 * 60; +const MAX_TOOL_ROUNDS = 3; +const TOOL_DELAY = 700; +const TOOL_ERROR_TEXT = 'Failed to execute the tool'; +const user = { + id: 'user', +}; +const assistantAvatarSvg = ''; +const assistant = { + id: 'assistant', + name: 'AI Assistant', + avatarUrl: `data:image/svg+xml;utf8,${encodeURIComponent(assistantAvatarSvg)}`, +}; +const suggestionCards = [ + { + title: 'Single Tool', + description: 'Find out the current time in the Barcelona and Los Angeles offices.', + prompt: 'Check the current time in Barcelona, Spain and Los Angeles, US.', + }, + { + title: 'Multiple Tools', + description: 'Get the current weather and time in Barcelona and the USD to EUR rate.', + prompt: 'Check the current weather conditions in Barcelona, Spain, the current time in Barcelona, Spain and the exchange rate from USD to EUR.', + }, + { + title: 'Error State', + description: 'Check the weather in London and in Atlantis: one of the tools fails.', + prompt: 'Check the current weather conditions in London, UK and in Atlantis, Atlantis.', + }, +]; +const SYSTEM_PROMPT = ` +You are a helpful assistant. +Use the tools to answer questions about the weather, the current time in offices and exchange rates. +Always call a tool instead of guessing. +Pass the country as a short name (for example UK or US), cities without the country, and currencies as ISO codes. +When several tools are needed, call all of them at once. +If a tool fails, say so briefly. +Keep the answers short. +`; +const tools = [ + { + type: 'function', + function: { + name: 'getWeather', + description: 'Get the current weather for a city', + parameters: { + type: 'object', + properties: { + country: { type: 'string', description: 'The short country name, for example UK or US' }, + city: { type: 'string', description: 'The city name' }, + }, + required: ['country', 'city'], + }, + }, + }, + { + type: 'function', + function: { + name: 'getCurrentTime', + description: 'Get the current local time in the offices of one or several cities', + parameters: { + type: 'object', + properties: { + locations: { type: 'array', items: { type: 'string' }, description: 'City names without the country, for example Barcelona' }, + }, + required: ['locations'], + }, + }, + }, + { + type: 'function', + function: { + name: 'getExchangeRate', + description: 'Get the exchange rate between two currencies', + parameters: { + type: 'object', + properties: { + fromCurrency: { type: 'string', description: 'The ISO code of the source currency' }, + toCurrency: { type: 'string', description: 'The ISO code of the target currency' }, + }, + required: ['fromCurrency', 'toCurrency'], + }, + }, + }, +]; +const weatherByCity = { + 'london': '15°C (59°F)', + 'barcelona': '21°C (70°F)', + 'los angeles': '24°C (75°F)', + 'new york': '12°C (54°F)', +}; +const timeByCity = { + 'london': '09:18 (UTC+00:00) Greenwich Mean Time', + 'barcelona': '10:18 (UTC+01:00) Central European Time', + 'los angeles': '01:18 (UTC−08:00) Pacific Time (US & Canada)', + 'new york': '04:18 (UTC−05:00) Eastern Time (US & Canada)', +}; +const dollarRates = { + USD: 1, + EUR: 0.92, + GBP: 0.79, + JPY: 149.5, +}; +const toolImplementations = { + getWeather: ({ city }) => { + const weather = weatherByCity[String(city).toLowerCase()]; + + if (!weather) { + throw new Error(TOOL_ERROR_TEXT); + } + + return weather; + }, + getCurrentTime: ({ locations }) => (locations ?? []) + .map((location) => `${location}: ${timeByCity[String(location).toLowerCase()] ?? 'unknown time zone'}`) + .join('\n'), + getExchangeRate: ({ fromCurrency, toCurrency }) => { + const from = dollarRates[String(fromCurrency).toUpperCase()]; + const to = dollarRates[String(toCurrency).toUpperCase()]; + + if (!from || !to) { + throw new Error(TOOL_ERROR_TEXT); + } + + const rate = Math.round((to / from) * 10000) / 10000; + + return `1 ${String(fromCurrency).toUpperCase()} = ${rate} ${String(toCurrency).toUpperCase()}`; + }, +}; diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/jQuery/index.html b/apps/demos/Demos/Chat/ToolCallVisualization/jQuery/index.html new file mode 100644 index 000000000000..3f9254a3a897 --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/jQuery/index.html @@ -0,0 +1,39 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
Options
+ +
+
+
+
+
+ + diff --git a/apps/demos/Demos/Chat/ToolCallVisualization/jQuery/index.js b/apps/demos/Demos/Chat/ToolCallVisualization/jQuery/index.js new file mode 100644 index 000000000000..e2698d9e31cc --- /dev/null +++ b/apps/demos/Demos/Chat/ToolCallVisualization/jQuery/index.js @@ -0,0 +1,306 @@ +$(() => { + const store = []; + const messages = [ + { role: 'system', content: SYSTEM_PROMPT }, + ]; + let isProcessing = false; + + DevExpress.localization.loadMessages({ + en: { + 'dxChat-emptyListMessage': 'Chat is Empty', + 'dxChat-emptyListPrompt': 'Click a tile below to call a function.', + 'dxChat-textareaPlaceholder': 'Ask AI Assistant...', + }, + }); + + const chatService = new AzureOpenAI({ + dangerouslyAllowBrowser: true, + deployment, + endpoint, + apiVersion, + apiKey, + }); + + function wait(delay) { + return new Promise((resolve) => { + setTimeout(resolve, delay); + }); + } + + async function requestModel(messageList) { + const params = { + messages: messageList, + model: deployment, + tools, + tool_choice: 'auto', + max_completion_tokens: 1000, + temperature: 0.7, + }; + + const response = await chatService.chat.completions.create(params, { maxRetries: 0 }); + + return response.choices[0].message; + } + + async function runTool(call) { + const { name } = call.function; + let args = {}; + + try { + args = JSON.parse(call.function.arguments || '{}'); + } catch { + args = {}; + } + + await wait(TOOL_DELAY); + + try { + const implementation = toolImplementations[name]; + + if (!implementation) { + throw new Error(TOOL_ERROR_TEXT); + } + + const value = implementation(args); + const result = typeof value === 'string' ? value : JSON.stringify(value, null, 2); + + return { + name, arguments: args, status: 'success', result, + }; + } catch (err) { + return { + name, arguments: args, status: 'error', result: err.message, + }; + } + } + + async function getAIResponse(messageList, toolCalls = [], round = 0) { + const answer = await requestModel(messageList); + const calls = (answer.tool_calls ?? []).filter(({ type }) => type === 'function'); + + if (!calls.length) { + return { text: answer.content ?? '', toolCalls }; + } + + if (round === MAX_TOOL_ROUNDS) { + throw new Error('The model requested too many tool calls. Try rephrasing your request.'); + } + + messageList.push({ + role: 'assistant', + content: answer.content, + tool_calls: calls, + }); + + const results = await Promise.all(calls.map(runTool)); + + results.forEach((result, index) => { + messageList.push({ + role: 'tool', + tool_call_id: calls[index].id, + content: result.status === 'error' ? `Error: ${result.result}` : result.result, + }); + }); + + return getAIResponse(messageList, [...toolCalls, ...results], round + 1); + } + + function getErrorMessage(err) { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + return 'Unknown error'; + } + + function alertError(message) { + instance.option({ + alerts: [{ + message, + }], + }); + + setTimeout(() => { + instance.option({ alerts: [] }); + }, ALERT_TIMEOUT); + } + + function toggleDisabledState(disabled, event) { + instance.option('disabled', disabled); + toolbar.option('items[1].options.disabled', disabled); + + if (disabled) { + event?.target.blur(); + } else { + event?.target.focus(); + } + } + + async function processMessageSending(message, event) { + isProcessing = true; + toggleDisabledState(true, event); + + const historyLength = messages.length; + + messages.push({ role: 'user', content: message.text }); + instance.option({ typingUsers: [assistant] }); + + try { + const { text, toolCalls } = await getAIResponse(messages); + + instance.option({ typingUsers: [] }); + + messages.push({ role: 'assistant', content: text }); + + renderAssistantMessage(text, toolCalls); + } catch (err) { + instance.option({ typingUsers: [] }); + messages.length = historyLength; + alertError(getErrorMessage(err)); + } finally { + isProcessing = false; + toggleDisabledState(false, event); + } + } + + function renderAssistantMessage(text, toolCalls) { + const message = { + id: Date.now(), + timestamp: new Date(), + author: assistant, + text, + }; + + if (toolCalls.length) { + message.toolCalls = toolCalls; + } + + dataSource.store().push([{ type: 'insert', data: message }]); + } + + function onClearButtonClick() { + store.length = 0; + messages.length = 1; + instance.option({ alerts: [], typingUsers: [] }); + dataSource.reload(); + } + + function sendSuggestion(prompt) { + if (isProcessing) { + return; + } + + const message = { + id: Date.now(), + timestamp: new Date(), + author: user, + text: prompt, + }; + + dataSource.store().push([{ type: 'insert', data: message }]); + + if (!instance.option('alerts').length) { + processMessageSending(message); + } + } + + function createSuggestionCard(card) { + return $('