import {Banner, buildBannerMessage, cssBannerLink} from 'app/client/components/Banner'; import * as commands from 'app/client/components/commands'; import {GristDoc} from 'app/client/components/GristDoc'; import {makeT} from 'app/client/lib/localization'; import {localStorageBoolObs, sessionStorageBoolObs} from 'app/client/lib/localStorageObs'; import {movable} from 'app/client/lib/popupUtils'; import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel'; import {ChatMessage} from 'app/client/models/entities/ColumnRec'; import {HAS_FORMULA_ASSISTANT, WHICH_FORMULA_ASSISTANT} from 'app/client/models/features'; import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {buildHighlightedCode} from 'app/client/ui/CodeHighlight'; import {autoGrow} from 'app/client/ui/forms'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; import {createUserImage} from 'app/client/ui/UserImage'; import {basicButton, bigPrimaryButtonLink, primaryButton} from 'app/client/ui2018/buttons'; import {theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {loadingDots} from 'app/client/ui2018/loaders'; import {menu, menuCssClass, menuItem} from 'app/client/ui2018/menus'; import {FormulaEditor} from 'app/client/widgets/FormulaEditor'; import {ApiError} from 'app/common/ApiError'; import {AssistanceResponse, AssistanceState, FormulaAssistanceContext} from 'app/common/AssistancePrompts'; import {isFreePlan} from 'app/common/Features'; import {commonUrls} from 'app/common/gristUrls'; import {TelemetryEvent, TelemetryMetadata} from 'app/common/Telemetry'; import {getGristConfig} from 'app/common/urlUtils'; import {Computed, Disposable, dom, DomElementArg, makeTestId, MutableObsArray, obsArray, Observable, styled, subscribeElem} from 'grainjs'; import debounce from 'lodash/debounce'; import noop from 'lodash/noop'; import {marked} from 'marked'; import {v4 as uuidv4} from 'uuid'; const t = makeT('FormulaEditor'); const testId = makeTestId('test-formula-editor-'); const LOW_CREDITS_WARNING_BANNER_THRESHOLD = 10; /** * An extension or the FormulaEditor that provides assistance for writing formulas. * It renders itself in the detached FormulaEditor and adds some extra UI elements. * - Save button: a subscription for the Enter key that saves the formula and closes the assistant. * - Preview button: a new functionality that allows to preview the formula in a temporary column. * - Cancel button: a subscription for the Escape key that discards all changes and closes the assistant. * - A chat component: that allows to communicate with the assistant. */ export class FormulaAssistant extends Disposable { private _gristDoc = this._options.gristDoc; private _appModel = this._gristDoc.appModel; /** Chat component */ private _chat: ChatHistory; /** State of the user input */ private _userInput = Observable.create(this, ''); /** Dom element that holds the user input */ // TODO: move it to a separate component private _input: HTMLTextAreaElement; /** Is the formula assistant expanded */ private _assistantExpanded = this.autoDispose(localStorageBoolObs( `u:${this._appModel.currentUser?.id ?? 0};formulaAssistantExpanded`, true)); /** Is the request pending */ private _waiting = Observable.create(this, false); /** Is assistant features are enabled */ private _assistantEnabled: boolean; /** Preview column ref */ private _transformColRef: string; /** Preview column id */ private _transformColId: string; /** Method to invoke when we are closed, it saves or reverts */ private _triggerFinalize: (() => void) = noop; /** What action button was clicked, by default close without saving */ private _action: 'save' | 'cancel' | 'close' = 'close'; // Our dom element (used for resizing). private _domElement: HTMLElement; // Input wrapper element (used for resizing). private _inputWrapper: HTMLElement; /** Chat panel body element. */ private _chatPanelBody: HTMLElement; /** Client height of the chat panel body element. */ private _chatPanelBodyClientHeight = Observable.create(this, 0); /** Set to true the first time the panel has been expanded (including by default). */ private _hasExpandedOnce = false; /** * Last known height of the chat panel. * * This is like `_chatPanelBodyClientHeight`, but updated only for the purposes of * being able to collapse and expand the panel to a known height. */ private _lastChatPanelHeight: number|undefined; /** True if the chat panel is being resized via dragging. */ private _isResizing = Observable.create(this, false); /** Whether the low credit limit banner should be shown. */ private _showApproachingLimitBanner = this.autoDispose( sessionStorageBoolObs( `org:${this._appModel.currentOrg?.id ?? 0};formulaAssistantShowApproachingLimitBanner`, true )); /** Number of remaining credits. If null, assistant usage is unlimited. */ private _numRemainingCredits = Observable.create(this, null); /** * Debounced version of the method that will force parent editor to resize, we call it often * as we have an ability to resize the chat window. */ private _resizeEditor = debounce(() => { if (!this.isDisposed()) { this._options.editor.resize(); } }, 10); constructor(private _options: { column: ColumnRec, field?: ViewFieldRec, gristDoc: GristDoc, editor: FormulaEditor }) { super(); this._assistantEnabled = HAS_FORMULA_ASSISTANT(); if (!this._options.field) { // TODO: field is not passed only for rules (as there is no preview there available to the user yet) // this should be implemented but it requires creating a helper column to helper column and we don't // have infrastructure for that yet. throw new Error('Formula assistant requires a field to be passed.'); } this._chat = ChatHistory.create(this, { ...this._options, apply: this._apply.bind(this), logTelemetryEvent: this._logTelemetryEvent.bind(this), }); this.autoDispose(commands.createGroup({ activateAssistant: () => { this._expandChatPanel(); setTimeout(() => { this._focusChatInput(); }, 0); } }, this, true)); // Unfortunately we need to observe the size of the formula editor dom and resize it accordingly. const observer = new ResizeObserver(this._resizeEditor); observer.observe(this._options.editor.getDom()); this.onDispose(() => observer.disconnect()); // Start bundling all actions from this moment on and close the editor as soon, // as user tries to do something different. const bundleInfo = this._options.gristDoc.docData.startBundlingActions({ description: 'Formula Editor', prepare: () => this._preparePreview(), finalize: () => this._cleanupPreview(), shouldIncludeInBundle: (actions) => { if (actions.length !== 1) { return false; } const actionName = actions[0][0]; if (actionName === 'ModifyColumn') { const tableId = this._options.column.table.peek().tableId.peek(); return actions[0][1] === tableId && typeof actions[0][2] === 'string' && [this._transformColId, this._options.column.id.peek()].includes(actions[0][2]); } else if (actionName === 'UpdateRecord') { return actions[0][1] === '_grist_Tables_column' && actions[0][2] === this._transformColRef; } else { return false; } } }); this._triggerFinalize = bundleInfo.triggerFinalize; this.onDispose(() => { if (this._hasExpandedOnce) { const suggestionApplied = this._chat.conversationSuggestedFormulas.get() .includes(this._options.column.formula.peek()); if (suggestionApplied) { this._logTelemetryEvent('assistantApplySuggestion', false, { conversationLength: this._chat.conversationLength.get(), conversationHistoryLength: this._chat.conversationHistoryLength.get(), }); } this._logTelemetryEvent('assistantClose', false, { suggestionApplied, conversationLength: this._chat.conversationLength.get(), conversationHistoryLength: this._chat.conversationHistoryLength.get(), }); } // This will be noop if already called. this._triggerFinalize(); }); } // The main dom added to the editor and the bottom (3 buttons and chat window). public buildDom() { // When the tools are resized, resize the editor. const observer = new ResizeObserver(this._resizeEditor); this._domElement = cssTools( (el) => observer.observe(el), dom.onDispose(() => observer.disconnect()), cssButtons( basicButton(t('Cancel'), dom.on('click', () => { this._cancel(); }), testId('cancel-button')), basicButton(t('Preview'), dom.on('click', async () => { await this._preview(); }), testId('preview-button')), primaryButton(t('Save'), dom.on('click', () => { this._saveOrClose(); }), testId('save-button')), ), this._buildChatPanel(), ); if (this._assistantEnabled) { if (!this._assistantExpanded.get()) { this._chatPanelBody.style.setProperty('height', '0px'); } else { // The actual height doesn't matter too much here, so we just pick // a value that guarantees the assistant will fill as much of the // available space as possible. this._chatPanelBody.style.setProperty('height', '999px'); } } if (this._assistantEnabled && this._assistantExpanded.get()) { this._logTelemetryEvent('assistantOpen', true); this._hasExpandedOnce = true; } return this._domElement; } private _buildChatPanel() { return dom.maybe(this._assistantEnabled, () => { return cssChatPanel( cssChatPanelHeaderResizer( movable({ onStart: this._onResizeStart.bind(this), onMove: this._onResizeMove.bind(this), onEnd: this._onResizeEnd.bind(this), }), cssChatPanelHeaderResizer.cls('-collapsed', use => !use(this._assistantExpanded)), ), this._buildChatPanelHeader(), this._buildChatPanelBody(), ); }); } private _logTelemetryEvent(event: TelemetryEvent, includeContext = false, metadata: TelemetryMetadata = {}) { logTelemetryEvent(event, { full: { docIdDigest: this._gristDoc.docId, conversationId: this._chat.conversationId.get(), ...(!includeContext ? {} : {context: { type: 'formula', tableId: this._options.column.table.peek().tableId.peek(), colId: this._options.column.colId.peek(), } as FormulaAssistanceContext}), ...metadata, }, }); } private _buildChatPanelHeader() { return cssChatPanelHeader( cssChatPanelHeaderTitle( icon('Robot'), t('AI Assistant'), ), cssChatPanelHeaderButtons( cssChatPanelHeaderButton( dom.domComputed(this._assistantExpanded, isExpanded => isExpanded ? icon('Dropdown') : icon('DropdownUp')), dom.on('click', () => { if (this._assistantExpanded.get()) { this._collapseChatPanel(); } else { this._expandChatPanel(); } }), testId('ai-assistant-expand-collapse'), ), cssChatPanelHeaderButton( icon('Dots'), menu(() => [ menuItem( () => this._clear(), t('Clear Conversation'), testId('ai-assistant-options-clear-conversation'), ), ], {menuCssClass: menuCssClass + ' ' + cssChatOptionsMenu.className}), testId('ai-assistant-options'), ), ), ); } private _buildChatPanelBody() { setTimeout(() => { if (!this.isDisposed()) { // Scroll to the bottom of the chat right after it is rendered without the animation. this._chat.scrollDown(false); } this._options.editor.resize(); }, 0); const observer = new ResizeObserver(() => { // Keep track of changes to the chat panel body height; its children need to know it to adjust // their max heights accordingly. this._chatPanelBodyClientHeight.set(this._chatPanelBody.clientHeight); }); this._chatPanelBody = cssChatPanelBody( dom.onDispose(() => observer.disconnect()), testId('ai-assistant-chat-panel'), this._buildChatPanelBanner(), this._chat.buildDom(), this._appModel.currentValidUser ? this._buildChatInput() : this._buildSignupNudge(), cssChatPanelBody.cls('-resizing', this._isResizing), // Stop propagation of mousedown events, as the formula editor will still focus. dom.on('mousedown', (ev) => ev.stopPropagation()), ); observer.observe(this._chatPanelBody); return this._chatPanelBody; } private _buildChatPanelBanner() { return dom.domComputed(use => { const numCredits = use(this._numRemainingCredits); if ( numCredits === null || numCredits > LOW_CREDITS_WARNING_BANNER_THRESHOLD ) { return null; } else if (numCredits === 0) { return dom.create(Banner, { content: buildBannerMessage( t('You have used all available credits.'), ' ', this._buildBannerUpgradeMessage(), testId('ai-assistant-banner-message'), ), style: 'error', bannerCssClass: cssBanner.className, }); } else { const showBanner = use(this._showApproachingLimitBanner); if (!showBanner) { return null; } return dom.create(Banner, { content: buildBannerMessage( t('You have {{numCredits}} remaining credits.', {numCredits}), ' ', this._buildBannerUpgradeMessage(), testId('ai-assistant-banner-message'), ), style: 'warning', showCloseButton: true, onClose: () => { this._showApproachingLimitBanner.set(false); }, bannerCssClass: cssBanner.className, }); } }); } private _buildBannerUpgradeMessage() { const canUpgradeSite = this._appModel.isOwner() && Boolean(this._appModel.planName && isFreePlan(this._appModel.planName)); const isBillingManager = this._appModel.isBillingManager() || this._appModel.isSupport(); if (!canUpgradeSite && !isBillingManager) { return t('For higher limits, contact the site owner.'); } return t('For higher limits, {{upgradeNudge}}.', {upgradeNudge: cssBannerLink( canUpgradeSite ? t('upgrade to the Pro Team plan') : t('upgrade your plan'), dom.on('click', async () => { if (canUpgradeSite) { this._gristDoc.appModel.showUpgradeModal(); } else { await urlState().pushUrl({billing: 'billing'}); } })) }); } /** * Save button handler. We just store the action and wait for the bundler to finalize. */ private _saveOrClose() { if (this._hasExpandedOnce) { this._logTelemetryEvent('assistantSave', true, { oldFormula: this._options.column.formula.peek(), newFormula: this._options.editor.getTextValue(), }); } this._action = 'save'; this._triggerFinalize(); } /** * Cancel button handler. */ private _cancel() { if (this._hasExpandedOnce) { this._logTelemetryEvent('assistantCancel', true, { conversationLength: this._chat.conversationLength.get(), }); } this._action = 'cancel'; this._triggerFinalize(); } /** * Preview button handler. */ private async _preview() { const tableId = this._options.column.table.peek().tableId.peek(); const formula = this._options.editor.getCellValue(); const isFormula = true; await this._options.gristDoc.docData.sendAction( ['ModifyColumn', tableId, this._transformColId, {formula, isFormula} ]); if (!this.isDisposed()) { this._options.editor.focus(); } } private async _preparePreview() { const docData = this._options.gristDoc.docData; const tableId = this._options.column.table.peek().tableId.peek(); // Add a new column to the table, and set it as the transform column. const {colRef, colId} = await docData.sendAction(['AddColumn', tableId, 'gristHelper_Transform', { type: this._options.column.type.peek(), label: this._options.column.colId.peek(), isFormula: true, formula: this._options.column.formula.peek(), widgetOptions: JSON.stringify(this._options.field?.widgetOptionsJson()), }]); this._transformColRef = colRef; this._transformColId = colId; const rules = this._options.field?.rulesList(); if (rules) { await docData.sendAction(['UpdateRecord', '_grist_Tables_column', colRef, { rules: this._options.field?.rulesList(), }]); } this._options.field?.colRef(colRef); // Don't save, it is only in browser. // Update the transform column so that it points to the original column. const transformColumn = this._options.field?.column.peek(); if (transformColumn) { transformColumn.isTransforming(true); this._options.column.isTransforming(true); transformColumn.origColRef(this._options.column.getRowId()); // Don't save } } private async _cleanupPreview() { // Mark that we did finalize already. this._triggerFinalize = noop; const docData = this._options.gristDoc.docData; const tableId = this._options.column.table.peek().tableId.peek(); const column = this._options.column; try { if (this._action === 'save') { const formula = this._options.editor.getCellValue(); // Modify column right away, so that it looks smoother on the ui, when we // switch the column for the field. await docData.sendActions([ ['ModifyColumn', tableId, column.colId.peek(), { formula, isFormula: true}], ]); } // Switch the column for the field, this isn't sending any actions, we are just restoring it to what it is // in database. But now the column has already correct data as it was already calculated. this._options.field?.colRef(column.getRowId()); // Now trigger the action in our owner that should dispose us. The save // method will be no op if we saved anything. if (this._action === 'save') { commands.allCommands.fieldEditSaveHere.run(); } else if (this._action === 'cancel') { commands.allCommands.fieldEditCancel.run(); } else { if (this._action !== 'close') { throw new Error('Unexpected value for _action'); } if (!this.isDisposed()) { commands.allCommands.fieldEditCancel.run(); } } await docData.sendActions([ ['RemoveColumn', tableId, this._transformColId] ]); } finally { // Repeat the change, in case of an error. this._options.field?.colRef(column.getRowId()); column.isTransforming(false); } } private _collapseChatPanel() { if (!this._assistantExpanded.get()) { return; } this._assistantExpanded.set(false); // The panel's height and client height may differ; to ensure the collapse transition // appears linear, temporarily disable the transition and sync the height and client // height. this._chatPanelBody.style.setProperty('transition', 'none'); this._chatPanelBody.style.setProperty('height', `${this._chatPanelBody.clientHeight}px`); // eslint-disable-next-line no-unused-expressions this._chatPanelBody.offsetHeight; // Flush CSS changes. this._chatPanelBody.style.removeProperty('transition'); this._chatPanelBody.style.setProperty('height', '0px'); this._resizeEditor(); } private _expandChatPanel() { if (!this._hasExpandedOnce) { this._logTelemetryEvent('assistantOpen', true); this._hasExpandedOnce = true; } if (this._assistantExpanded.get()) { return; } this._assistantExpanded.set(true); const editor = this._options.editor.getDom(); let availableSpace = editor.clientHeight - MIN_FORMULA_EDITOR_HEIGHT_PX - FORMULA_EDITOR_BUTTONS_HEIGHT_PX - CHAT_PANEL_HEADER_HEIGHT_PX; if (editor.querySelector('.error_msg')) { availableSpace -= editor.querySelector('.error_msg')!.clientHeight; } if (editor.querySelector('.error_details')) { availableSpace -= editor.querySelector('.error_details')!.clientHeight; } if (this._lastChatPanelHeight) { const height = Math.min(Math.max(this._lastChatPanelHeight, 220), availableSpace); this._chatPanelBody.style.setProperty('height', `${height}px`); this._lastChatPanelHeight = height; } else { this._lastChatPanelHeight = availableSpace; this._chatPanelBody.style.setProperty('height', `${this._lastChatPanelHeight}px`); } this._resizeEditor(); } private _onResizeStart() { this._isResizing.set(true); const start = this._domElement?.clientHeight; const total = this._options.editor.getDom().clientHeight; return { start, total }; } /** * Resize handler for the chat window. */ private _onResizeMove(x: number, y: number, {start, total}: {start: number, total: number}): void { // The y axis includes the panel header and formula editor buttons; excluded them from the // new height of the panel body. const newChatPanelBodyHeight = start - y - CHAT_PANEL_HEADER_HEIGHT_PX - FORMULA_EDITOR_BUTTONS_HEIGHT_PX; // Toggle `_isResizing` whenever the new panel body height crosses the threshold for the minimum // height. As of now, the sole purpose of this observable is to control when the animation for // expanding and collapsing is shown. if (newChatPanelBodyHeight < MIN_CHAT_PANEL_BODY_HEIGHT_PX && this._isResizing.get()) { this._isResizing.set(false); } else if (newChatPanelBodyHeight >= MIN_CHAT_PANEL_BODY_HEIGHT_PX && !this._isResizing.get()) { this._isResizing.set(true); } const collapseThreshold = 78; if (newChatPanelBodyHeight < collapseThreshold) { this._collapseChatPanel(); } else { this._expandChatPanel(); const calculatedHeight = Math.max( MIN_CHAT_PANEL_BODY_HEIGHT_PX, Math.min(total - MIN_FORMULA_EDITOR_HEIGHT_PX, newChatPanelBodyHeight) ); this._chatPanelBody.style.height = `${calculatedHeight}px`; } } private _onResizeEnd() { this._isResizing.set(false); if (this._assistantExpanded.get()) { this._lastChatPanelHeight = this._chatPanelBody.clientHeight; } } /** * Builds the chat input at the bottom of the chat. */ private _buildChatInput() { // Make sure we dispose the previous input. if (this._input) { dom.domDispose(this._input); } // Input is created by hand, as we need a finer control of the user input than what is available // in generic textInput control. this._input = cssInput( dom.on('input', (ev: Event) => { this._userInput.set((ev.target as HTMLInputElement).value); }), autoGrow(this._userInput), dom.style('max-height', use => { // Set an upper bound on the height the input can grow to, so that when the chat panel // is resized, the input is automatically resized to fit and doesn't overflow. const panelHeight = use(this._chatPanelBodyClientHeight); // The available input height is computed by taking the the panel height, and subtracting // the heights of all the other elements (except for the input). const availableInputHeight = panelHeight - ((this._inputWrapper?.clientHeight ?? 0) - (this._input?.clientHeight ?? 0)) - MIN_CHAT_HISTORY_HEIGHT_PX; return `${Math.max(availableInputHeight, MIN_CHAT_INPUT_HEIGHT_PX)}px`; }), dom.onKeyDown({ Enter$: (ev) => this._handleChatEnterKeyDown(ev), Escape: () => this._cancel(), }), dom.autoDispose(this._userInput.addListener(value => this._input.value = value)), dom.prop('disabled', this._waiting), dom.prop('placeholder', use => { const lastFormula = use(this._chat.lastSuggestedFormula); if (lastFormula) { return t('Press Enter to apply suggested formula.'); } else { return t('What do you need help with?'); } }), dom.autoDispose(this._waiting.addListener(value => { if (!value) { setTimeout(() => this._focusChatInput(), 0); } })), ); return this._inputWrapper = cssHContainer( testId('ai-assistant-chat-input'), dom.cls(cssTopBorder.className), dom.cls(cssVSpace.className), cssInputWrapper( dom.cls(cssTypography.className), this._input, cssInputButtonsRow( cssSendMessageButton( icon('FieldAny'), dom.on('click', this._handleSendMessageClick.bind(this)), cssSendMessageButton.cls('-disabled', use => use(this._waiting) || use(this._userInput).length === 0 ), ), dom.on('click', (ev) => { ev.stopPropagation(); this._focusChatInput(); }), cssInputButtonsRow.cls('-disabled', this._waiting), ), cssInputWrapper.cls('-disabled', this._waiting), ), ); } /** * Builds the signup nudge shown to anonymous users at the bottom of the chat. */ private _buildSignupNudge() { const {deploymentType} = getGristConfig(); return deploymentType === 'saas' ? buildSignupNudge() : buildAnonNudge(); } private async _handleChatEnterKeyDown(ev: KeyboardEvent) { // If shift is pressed, we want to insert a new line. if (ev.shiftKey) { return; } ev.preventDefault(); const lastFormula = this._chat.lastSuggestedFormula.get(); if (this._input.value === '' && lastFormula) { this._apply(lastFormula).catch(reportError); } else { this._ask().catch(reportError); } } private async _handleSendMessageClick(ev: MouseEvent) { if (this._waiting.get() || this._input.value.length === 0) { return; } await this._ask(); } private async _apply(formula: string) { this._options.editor.setFormula(formula); this._resizeEditor(); await this._preview(); } private async _sendMessage(description: string): Promise { // Destruct options. const {column, gristDoc} = this._options; // Get the state of the chat from the column. const conversationId = this._chat.conversationId.get(); const prevState = column.chatHistory.peek().get().state; const {reply, suggestedActions, suggestedFormula, state, limit} = await askAI(gristDoc, { conversationId, column, description, state: prevState, }); if (limit && limit.limit >= 0) { this._numRemainingCredits.set(Math.max(limit.limit - limit.usage, 0)); } else { this._numRemainingCredits.set(null); } console.debug('received formula assistant response: ', {suggestedActions, suggestedFormula, reply, state}); // If back-end is capable of conversation, keep its state. const chatHistoryNew = column.chatHistory.peek(); const value = chatHistoryNew.get(); value.state = state; // If model has a conversational skills (and maintains a history), we might get actually // some markdown text back, so we need to parse it. const prettyMessage = state ? (reply || suggestedFormula || '') : (suggestedFormula || reply || ''); // Add it to the chat. return { message: prettyMessage, formula: suggestedFormula, action: suggestedActions[0], sender: 'ai', }; } private _focusChatInput() { if (!this._input) { return; } this._input.focus(); if (this._input.value.length > 0) { // Make sure focus moves to the last character. this._input.selectionStart = this._input.value.length; this._input.scrollTop = this._input.scrollHeight; } } private _clear() { this._chat.clear(); this._userInput.set(''); } private async _ask() { if (this._waiting.get()) { return; } const message = this._userInput.get(); if (!message) { return; } this._chat.addQuestion(message); this._userInput.set(''); await this._doAsk(message); } private async _doAsk(message: string) { this._chat.thinking(); this._waiting.set(true); try { const response = await this._sendMessage(message); this._chat.addResponse(response); } catch (err: unknown) { if (err instanceof ApiError && err.status === 429 && err.details?.limit) { const {projectedValue, maximum} = err.details.limit; if (projectedValue >= maximum) { this._numRemainingCredits.set(0); return; } } throw err; } finally { this._chat.thinking(false); this._waiting.set(false); } } } /** * A model for the chat panel. It is responsible for keeping the history of the chat and * sending messages to the AI. */ class ChatHistory extends Disposable { public conversationId: Observable; public conversation: MutableObsArray; public conversationHistory: MutableObsArray; public conversationLength: Computed; public conversationHistoryLength: Computed; public conversationSuggestedFormulas: Computed; public lastSuggestedFormula: Computed; private _element: HTMLElement; constructor(private _options: { column: ColumnRec, gristDoc: GristDoc, apply: (formula: string) => void, logTelemetryEvent: (event: TelemetryEvent, includeContext?: boolean, metadata?: TelemetryMetadata) => void, }) { super(); const column = this._options.column; let conversationId = column.chatHistory.peek().get().conversationId; if (!conversationId) { conversationId = uuidv4(); const chatHistory = column.chatHistory.peek(); chatHistory.set({...chatHistory.get(), conversationId}); } this.conversationId = Observable.create(this, conversationId); this.autoDispose(this.conversationId.addListener((newConversationId) => { // If a new conversation id was generated (e.g. on Clear Conversation), save it // to the column's history. const chatHistory = column.chatHistory.peek(); chatHistory.set({...chatHistory.get(), conversationId: newConversationId}); })); // Create observable array of messages that is connected to the column's chatHistory. this.conversationHistory = this.autoDispose(obsArray(column.chatHistory.peek().get().messages)); this.autoDispose(this.conversationHistory.addListener((cur) => { const chatHistory = column.chatHistory.peek(); chatHistory.set({...chatHistory.get(), messages: [...cur]}); })); this.conversation = this.autoDispose(obsArray()); this.conversationHistoryLength = Computed.create(this, use => use(this.conversationHistory).length); this.conversationLength = Computed.create(this, use => use(this.conversation).length); this.conversationSuggestedFormulas = Computed.create(this, use => { return use(this.conversation) .map(({formula}) => formula) .filter((formula): formula is string => Boolean(formula)); }); this.lastSuggestedFormula = Computed.create(this, use => { return [...use(this.conversationHistory)].reverse().find(({formula}) => formula)?.formula ?? null; }); } public thinking(on = true) { if (!on) { // Find all index of all thinking messages. const messages = [...this.conversationHistory.get()].filter(m => m.message === '...'); // Remove all thinking messages. for (const message of messages) { this.conversationHistory.splice(this.conversationHistory.get().indexOf(message), 1); } } else { this.conversationHistory.push({ message: '...', sender: 'ai', }); this.scrollDown(); } } public supportsMarkdown() { return this._options.column.chatHistory.peek().get().state !== undefined; } public addResponse(message: ChatMessage) { // Clear any thinking from messages. this.thinking(false); const entry: ChatMessage = {...message, sender: 'ai'}; this.conversationHistory.push(entry); this.conversation.push(entry); this.scrollDown(); } public addQuestion(message: string) { this.thinking(false); const entry: ChatMessage = {message, sender: 'user'}; this.conversationHistory.push(entry); this.conversation.push(entry); } public lastQuestion() { const list = this.conversationHistory.get(); if (list.length === 0) { return null; } const lastMessage = list[list.length - 1]; if (lastMessage?.sender === 'user') { return lastMessage.message; } throw new Error('No last question found'); } public removeLastResponse() { const lastMessage = this.conversationHistory.get()[this.conversationHistory.get().length - 1]; if (lastMessage?.sender === 'ai') { this.conversationHistory.pop(); } } public clear() { this._options.logTelemetryEvent('assistantClearConversation', true); this.conversationId.set(uuidv4()); this.conversationHistory.set([]); const {column} = this._options; // Get the state of the chat from the column. const prevState = column.chatHistory.peek().get(); prevState.state = undefined; } public scrollDown(smooth = true) { this._element.scroll({ top: 99999, behavior: smooth ? 'smooth' : 'auto' }); } public buildDom() { return this._element = cssHistory( this._buildIntroMessage(), dom.forEach(this.conversationHistory, entry => { if (entry.sender === 'user') { return cssMessage( dom('span', dom.text(entry.message), testId('ai-assistant-message-user'), testId('ai-assistant-message'), ), cssAvatar(buildAvatar(this._options.gristDoc)), ); } else { return dom('div', cssAiMessage( cssAvatar(cssAiImage()), entry.message === '...' ? cssLoadingDots() : this._render(entry.message, dom.cls('formula-assistant-message'), testId('ai-assistant-message-ai'), testId('ai-assistant-message'), ), ), cssAiMessageButtonsRow( cssAiMessageButtons( primaryButton(t('Apply'), dom.on('click', () => { this._options.apply(entry.formula!); })), ), dom.show(Boolean(entry.formula)), ), ); } }), ); } private _buildIntroMessage() { return cssAiIntroMessage( cssAvatar(cssAiImage()), dom('div', cssAiMessageParagraph(t(`Hi, I'm the Grist Formula AI Assistant.`)), cssAiMessageParagraph(t(`There are some things you should know when working with me:`)), cssAiMessageParagraph( cssAiMessageBullet( cssTickIcon('Tick'), t('I can only help with formulas. I cannot build tables, columns, and views, or write access rules.'), ), cssAiMessageBullet( cssTickIcon('Tick'), t( 'Talk to me like a person. No need to specify tables and column names. For example, you can ask ' + '"Please calculate the total invoice amount."' ), ), (WHICH_FORMULA_ASSISTANT() === 'OpenAI') ? cssAiMessageBullet( cssTickIcon('Tick'), dom('div', t( 'When you talk to me, your questions and your document structure (visible in {{codeView}}) ' + 'are sent to OpenAI. {{learnMore}}.', { codeView: cssLink(t('Code View'), urlState().setLinkUrl({docPage: 'code'})), learnMore: cssLink(t('Learn more'), {href: commonUrls.helpAssistantDataUse, target: '_blank'}), } ), ), ) : null, ), cssAiMessageParagraph( t( 'For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, ' + 'or visit our {{community}} for more help.', { functionList: cssLink(t('Function List'), {href: commonUrls.functions, target: '_blank'}), formulaCheatSheet: cssLink(t('Formula Cheat Sheet'), {href: commonUrls.formulaSheet, target: '_blank'}), community: cssLink(t('Community'), {href: commonUrls.community, target: '_blank'}), } ), ), ), testId('ai-assistant-message-intro'), ); } /** * Renders the message as markdown if possible, otherwise as a code block. */ private _render(message: string, ...args: DomElementArg[]) { const doc = this._options.gristDoc; if (this.supportsMarkdown()) { return dom('div', (el) => subscribeElem(el, doc.currentTheme, () => { const content = sanitizeHTML(marked(message, { highlight: (code) => { const codeBlock = buildHighlightedCode(code, { gristTheme: doc.currentTheme, maxLines: 60, }); return codeBlock.innerHTML; }, })); el.innerHTML = content; }), ...args ); } else { return buildHighlightedCode(message, { gristTheme: doc.currentTheme, maxLines: 100, }); } } } /** * Sends the message to the backend and returns the response. */ async function askAI(grist: GristDoc, options: { column: ColumnRec, description: string, conversationId: string, state?: AssistanceState }): Promise { const {column, description, conversationId, state} = options; const tableId = column.table.peek().tableId.peek(); const colId = column.colId.peek(); return await grist.docApi.getAssistance({ conversationId, context: {type: 'formula', tableId, colId}, text: description, state, }); } /** Builds avatar image for user or assistant. */ function buildAvatar(grist: GristDoc) { const user = grist.app.topAppModel.appObs.get()?.currentUser || null; if (user) { return (createUserImage(user, 'medium')); } else { // TODO: this will not happen, as this should be only for logged in users. return (dom('div', '')); } } function buildSignupNudge() { return cssSignupNudgeWrapper( cssSignupNudgeParagraph( t('Sign up for a free Grist account to start using the Formula AI Assistant.'), ), cssSignupNudgeButtonsRow( bigPrimaryButtonLink( t('Sign Up for Free'), {href: getLoginOrSignupUrl()}, testId('ai-assistant-sign-up'), ), ), ); } function buildAnonNudge() { return cssSignupNudgeWrapper( cssSignupNudgeWrapper.cls('-center'), cssSignupNudgeParagraph( t('Formula AI Assistant is only available for logged in users.'), ), ); } const MIN_FORMULA_EDITOR_HEIGHT_PX = 100; const FORMULA_EDITOR_BUTTONS_HEIGHT_PX = 42; const MIN_CHAT_HISTORY_HEIGHT_PX = 160; const MIN_CHAT_PANEL_BODY_HEIGHT_PX = 180; const CHAT_PANEL_HEADER_HEIGHT_PX = 30; const MIN_CHAT_INPUT_HEIGHT_PX = 42; const cssChatPanel = styled('div', ` position: relative; display: flex; flex-direction: column; overflow:hidden; flex-grow: 1; `); const cssChatPanelHeader = styled('div', ` display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; padding: 0px 8px 0px 8px; background-color: ${theme.formulaAssistantHeaderBg}; height: ${CHAT_PANEL_HEADER_HEIGHT_PX}px; border-top: 1px solid ${theme.formulaAssistantBorder}; border-bottom: 1px solid ${theme.formulaAssistantBorder}; `); const cssChatPanelHeaderTitle = styled('div', ` display: flex; align-items: center; color: ${theme.lightText}; --icon-color: ${theme.accentIcon}; column-gap: 8px; user-select: none; `); const cssChatPanelHeaderButtons = styled('div', ` display: flex; align-items: center; column-gap: 8px; `); const cssChatPanelHeaderButton = styled('div', ` --icon-color: ${theme.controlSecondaryFg}; border-radius: 3px; padding: 3px; cursor: pointer; user-select: none; &:hover, &.weasel-popup-open { background-color: ${theme.hover}; } `); const cssChatPanelHeaderResizer = styled('div', ` position: absolute; top: -3px; height: 7px; width: 100%; cursor: ns-resize; `); const cssChatPanelBody = styled('div', ` overflow: hidden; display: flex; flex-direction: column; flex-grow: 1; transition: height 0.4s; &-resizing { transition: unset; } `); const cssTopBorder = styled('div', ` border-top: 1px solid ${theme.formulaAssistantBorder}; `); const cssVSpace = styled('div', ` padding-top: 18px; padding-bottom: 18px; `); const cssHContainer = styled('div', ` margin-top: auto; padding-left: 18px; padding-right: 18px; display: flex; flex-shrink: 0; flex-direction: column; `); const cssTypography = styled('div', ` color: ${theme.inputFg}; `); const cssHistory = styled('div', ` overflow: auto; display: flex; flex-direction: column; color: ${theme.inputFg}; `); const cssInputWrapper = styled('div', ` display: flex; flex-direction: column; border: 1px solid ${theme.inputBorder}; border-radius: 3px; align-items: center; --icon-color: ${theme.controlSecondaryFg}; &-disabled { background-color: ${theme.inputDisabledBg}; } & > input { outline: none; padding: 0px; align-self: stretch; flex: 1; border: none; background-color: inherit; } `); const cssMessage = styled('div', ` display: grid; grid-template-columns: 1fr 60px; border-top: 1px solid ${theme.formulaAssistantBorder}; padding: 20px 0px 20px 20px; `); const cssAiMessage = styled('div', ` position: relative; display: grid; grid-template-columns: 60px 1fr; border-top: 1px solid ${theme.formulaAssistantBorder}; padding: 20px 20px 20px 0px; & pre { border: none; background: ${theme.formulaAssistantPreformattedTextBg}; font-size: 10px; } & pre .ace-chrome, & pre .ace-dracula { background: ${theme.formulaAssistantPreformattedTextBg} !important; } & p > code { background: #FFFFFF; border: 1px solid #E1E4E5; color: #333333; white-space: pre-wrap; word-wrap: break-word; } `); const cssAiIntroMessage = styled(cssAiMessage, ` border-top: unset; `); const cssAvatar = styled('div', ` display: flex; align-items: flex-start; justify-content: center; `); const cssAiImage = styled('div', ` flex: none; height: 32px; width: 32px; border-radius: 50%; background-color: white; background-image: var(--icon-GristLogo); background-size: 22px 22px; background-repeat: no-repeat; background-position: center; `); const cssButtons = styled('div', ` display: flex; justify-content: flex-end; gap: 8px; padding: 8px; `); const cssTools = styled('div._tools_container', ` display: flex; flex-direction: column; overflow: hidden; `); const cssInputButtonsRow = styled('div', ` padding-top: 8px; width: 100%; justify-content: flex-end; cursor: text; display: flex; &-disabled { cursor: default; } `); const cssSendMessageButton = styled('div', ` padding: 3px; border-radius: 4px; align-self: flex-end; margin-bottom: 6px; margin-right: 6px; &-disabled { --icon-color: ${theme.controlSecondaryFg}; } &:not(&-disabled) { cursor: pointer; --icon-color: ${theme.controlPrimaryFg}; color: ${theme.controlPrimaryFg}; background-color: ${theme.controlPrimaryBg}; } &:hover:not(&-disabled) { background-color: ${theme.controlPrimaryHoverBg}; } `); const cssInput = styled('textarea', ` border: 0px; flex-grow: 1; outline: none; width: 100%; padding: 4px 6px; padding-top: 6px; resize: none; min-height: ${MIN_CHAT_INPUT_HEIGHT_PX}px; background: transparent; &:disabled { background-color: ${theme.inputDisabledBg}; color: ${theme.inputDisabledFg}; } &::placeholder { color: ${theme.inputPlaceholderFg}; } `); const cssChatOptionsMenu = styled('div', ` z-index: ${vars.floatingPopupMenuZIndex}; `); const cssAiMessageButtonsRow = styled('div', ` display: flex; justify-content: flex-end; padding: 8px; `); const cssAiMessageButtons = styled('div', ` display: flex; column-gap: 8px; `); const cssAiMessageParagraph = styled('div', ` margin-bottom: 8px; `); const cssAiMessageBullet = styled('div', ` display: flex; align-items: flex-start; margin-bottom: 6px; `); const cssTickIcon = styled(icon, ` --icon-color: ${theme.accentIcon}; margin-right: 8px; flex-shrink: 0; `); const cssLoadingDots = styled(loadingDots, ` --dot-size: 5px; align-items: center; `); const cssSignupNudgeWrapper = styled('div', ` border-top: 1px solid ${theme.formulaAssistantBorder}; padding: 16px; margin-top: auto; display: flex; flex-shrink: 0; flex-direction: column; &-center { display: flex; justify-content: center; align-items: center; } `); const cssSignupNudgeParagraph = styled('div', ` font-size: ${vars.mediumFontSize}; font-weight: 500; margin-bottom: 12px; text-align: center; `); const cssSignupNudgeButtonsRow = styled('div', ` display: flex; justify-content: center; `); const cssBanner = styled('div', ` padding: 6px 8px 6px 8px; `);