diff --git a/app/client/components/AceEditor.js b/app/client/components/AceEditor.js index 44896b10..c17988be 100644 --- a/app/client/components/AceEditor.js +++ b/app/client/components/AceEditor.js @@ -187,6 +187,10 @@ AceEditor.prototype._setup = function() { if (this.gristDoc && this.column) { const getSuggestions = (prefix) => { const section = this.gristDoc.viewModel.activeSection(); + // If section is disposed or is pointing to an empty row, don't try to autocomplete. + if (!section?.getRowId()) { + return []; + } const tableId = section.table().tableId(); const columnId = this.column.colId(); const rowId = section.activeRowId(); diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 497bfdb9..8a11040c 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -175,6 +175,9 @@ export class GristDoc extends DisposableWithEvents { public externalSectionId: Computed; public viewLayout: ViewLayout|null = null; + // Holder for the popped up formula editor. + public readonly formulaPopup = Holder.create(this); + private _actionLog: ActionLog; private _undoStack: UndoStack; private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null; diff --git a/app/client/models/entities/ColumnRec.ts b/app/client/models/entities/ColumnRec.ts index 39cde785..5f5f9fcc 100644 --- a/app/client/models/entities/ColumnRec.ts +++ b/app/client/models/entities/ColumnRec.ts @@ -1,6 +1,8 @@ import {KoArray} from 'app/client/lib/koArray'; +import {localStorageJsonObs} from 'app/client/lib/localStorageObs'; import {CellRec, DocModel, IRowModel, recordSet, refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel'; +import {urlState} from 'app/client/models/gristUrlState'; import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil'; import * as gristTypes from 'app/common/gristTypes'; import {getReferencedTableId} from 'app/common/gristTypes'; @@ -11,6 +13,7 @@ import { FullFormatterArgs } from 'app/common/ValueFormatter'; import {createParser} from 'app/common/ValueParser'; +import {Observable} from 'grainjs'; import * as ko from 'knockout'; // Column behavior type, used primarily in the UI. @@ -77,6 +80,11 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> { formatter: ko.Computed; cells: ko.Computed>; + /** + * Current history of chat. This is a temporary array used only in the ui. + */ + chatHistory: ko.PureComputed>; + // Helper which adds/removes/updates column's displayCol to match the formula. saveDisplayFormula(formula: string): Promise|undefined; @@ -151,6 +159,12 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void { }; this.behavior = ko.pureComputed(() => this.isEmpty() ? 'empty' : this.isFormula() ? 'formula' : 'data'); + + this.chatHistory = this.autoDispose(ko.computed(() => { + const docId = urlState().state.get().doc ?? ''; + const key = `formula-assistant-history-${docId}-${this.table().tableId()}-${this.colId()}`; + return localStorageJsonObs(key, [] as ChatMessage[]); + })); } export function formatterForRec( @@ -168,3 +182,22 @@ export function formatterForRec( }; return func(args); } + +/** + * A chat message. Either send by the user or by the AI. + */ +export interface ChatMessage { + /** + * The message to display. It is a prompt typed by the user or a formula returned from the AI. + */ + message: string; + /** + * The sender of the message. Either the user or the AI. + */ + sender: 'user' | 'ai'; + /** + * The formula returned from the AI. It is only set when the sender is the AI. For now it is the same + * value as the message, but it might change in the future when we use more conversational AI. + */ + formula?: string; +} diff --git a/app/client/models/features.ts b/app/client/models/features.ts index 8617e75e..ce5095c3 100644 --- a/app/client/models/features.ts +++ b/app/client/models/features.ts @@ -10,3 +10,12 @@ export function COMMENTS(): Observable { } return G.window.COMMENTS; } + +export function GRIST_FORMULA_ASSISTANT(): Observable { + const G = getBrowserGlobals('document', 'window'); + if (!G.window.GRIST_FORMULA_ASSISTANT) { + G.window.GRIST_FORMULA_ASSISTANT = + localStorageBoolObs('GRIST_FORMULA_ASSISTANT', Boolean(getGristConfig().featureFormulaAssistant)); + } + return G.window.GRIST_FORMULA_ASSISTANT; +} diff --git a/app/client/ui/CodeHighlight.ts b/app/client/ui/CodeHighlight.ts index b48c1661..825ebb72 100644 --- a/app/client/ui/CodeHighlight.ts +++ b/app/client/ui/CodeHighlight.ts @@ -1,4 +1,4 @@ -import {colors, vars} from 'app/client/ui2018/cssVars'; +import {colors, theme, vars} from 'app/client/ui2018/cssVars'; import * as ace from 'brace'; import {BindableValue, dom, DomElementArg, styled, subscribeElem} from 'grainjs'; @@ -71,3 +71,18 @@ const cssHighlightedCode = styled(cssCodeBlock, ` text-overflow: ellipsis; } `); + +export const cssFieldFormula = styled(buildHighlightedCode, ` + flex: auto; + cursor: pointer; + margin-top: 4px; + padding-left: 24px; + --icon-color: ${theme.accentIcon}; + + &-disabled-icon.formula_field_sidepane::before { + --icon-color: ${theme.lightText}; + } + &-disabled { + pointer-events: none; + } +`); diff --git a/app/client/ui/DocTutorial.ts b/app/client/ui/DocTutorial.ts index 40fc554f..3693ee3f 100644 --- a/app/client/ui/DocTutorial.ts +++ b/app/client/ui/DocTutorial.ts @@ -1,22 +1,21 @@ import {GristDoc} from 'app/client/components/GristDoc'; import {urlState} from 'app/client/models/gristUrlState'; import {renderer} from 'app/client/ui/DocTutorialRenderer'; +import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; import {hoverTooltip} from 'app/client/ui/tooltips'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; -import {isNarrowScreen, isNarrowScreenObs, mediaXSmall, theme} from 'app/client/ui2018/cssVars'; +import {mediaXSmall, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {confirmModal, modal} from 'app/client/ui2018/modals'; import {parseUrlId} from 'app/common/gristUrls'; -import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs'; +import {dom, makeTestId, Observable, styled} from 'grainjs'; import {marked} from 'marked'; import debounce = require('lodash/debounce'); import range = require('lodash/range'); import sortBy = require('lodash/sortBy'); -const POPUP_PADDING_PX = 16; - interface DocTutorialSlide { slideContent: string; boxContent?: string; @@ -26,20 +25,16 @@ interface DocTutorialSlide { const testId = makeTestId('test-doc-tutorial-'); -export class DocTutorial extends Disposable { +export class DocTutorial extends FloatingPopup { private _appModel = this._gristDoc.docPageModel.appModel; private _currentDoc = this._gristDoc.docPageModel.currentDoc.get(); private _docComm = this._gristDoc.docComm; private _docData = this._gristDoc.docData; private _docId = this._gristDoc.docId(); - private _popupElement: HTMLElement | null = null; private _slides: Observable = Observable.create(this, null); private _currentSlideIndex = Observable.create(this, this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0); - private _isMinimized = Observable.create(this, false); - private _clientX: number; - private _clientY: number; private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, { // Save new position immediately if at least 1 second has passed since the last change. @@ -50,25 +45,105 @@ export class DocTutorial extends Disposable { constructor(private _gristDoc: GristDoc) { super(); + } - this._handleMouseDown = this._handleMouseDown.bind(this); - this._handleMouseMove = this._handleMouseMove.bind(this); - this._handleMouseUp = this._handleMouseUp.bind(this); - this._handleTouchStart = this._handleTouchStart.bind(this); - this._handleTouchMove = this._handleTouchMove.bind(this); - this._handleTouchEnd = this._handleTouchEnd.bind(this); - this._handleWindowResize = this._handleWindowResize.bind(this); + public async start() { + this.showPopup(); + await this._loadSlides(); + } - this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup())); + protected _buildTitle() { + return dom('span', dom.text(this._gristDoc.docPageModel.currentDocTitle), testId('popup-header')); + } - this.onDispose(() => { - this._closePopup(); - }); + protected _buildContent() { + return [ + dom.domComputed(use => { + const slides = use(this._slides); + const slideIndex = use(this._currentSlideIndex); + const slide = slides?.[slideIndex]; + return cssPopupBody( + !slide ? cssSpinner(loadingSpinner()) : [ + dom('div', elem => { + elem.innerHTML = slide.slideContent; + }), + !slide.boxContent ? null : cssTryItOutBox( + dom('div', elem => { elem.innerHTML = slide.boxContent!; }), + ), + dom.on('click', (ev) => { + if((ev.target as HTMLElement).tagName !== 'IMG') { + return; + } + + this._openLightbox((ev.target as HTMLImageElement).src); + }), + this._restartGIFs(), + ], + testId('popup-body'), + ); + }), + cssPopupFooter( + dom.domComputed(use => { + const slides = use(this._slides); + if (!slides) { return null; } + + const slideIndex = use(this._currentSlideIndex); + const numSlides = slides.length; + const isFirstSlide = slideIndex === 0; + const isLastSlide = slideIndex === numSlides - 1; + return [ + cssFooterButtonsLeft( + cssPopupFooterButton(icon('Undo'), + hoverTooltip('Restart Tutorial', {key: 'docTutorialTooltip'}), + dom.on('click', () => this._restartTutorial()), + testId('popup-restart'), + ), + ), + cssProgressBar( + range(slides.length).map((i) => cssProgressBarDot( + {title: slides[i].slideTitle}, + cssProgressBarDot.cls('-current', i === slideIndex), + i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)), + testId(`popup-slide-${i + 1}`), + )), + ), + cssFooterButtonsRight( + basicButton('Previous', + dom.on('click', async () => { + await this._previousSlide(); + }), + {style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`}, + testId('popup-previous'), + ), + primaryButton(isLastSlide ? 'Finish': 'Next', + isLastSlide + ? dom.on('click', async () => await this._finishTutorial()) + : dom.on('click', async () => await this._nextSlide()), + testId('popup-next'), + ), + ), + ]; + }), + testId('popup-footer'), + ), + ]; } - public async start() { - this._showPopup(); - await this._loadSlides(); + protected _buildArgs() { + return [ + dom.cls('doc-tutorial-popup'), + testId('popup'), + // Pre-fetch images from all slides and store them in a hidden div. + dom.maybe(this._slides, slides => + dom('div', + {style: 'display: none;'}, + dom.forEach(slides, slide => { + if (slide.imageUrls.length === 0) { return null; } + return dom('div', slide.imageUrls.map(src => dom('img', {src}))); + }), + ), + ), + ]; } private async _loadSlides() { @@ -134,103 +209,6 @@ export class DocTutorial extends Disposable { this._slides.set(slides); } - private _showPopup() { - this._popupElement = this._buildPopup(); - document.body.appendChild(this._popupElement); - - const topPaddingPx = getTopPopupPaddingPx(); - const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_PADDING_PX; - const initialTop = document.body.offsetHeight - this._popupElement.offsetHeight - topPaddingPx; - this._popupElement.style.left = `${initialLeft}px`; - this._popupElement.style.top = `${initialTop}px`; - } - - private _closePopup() { - if (!this._popupElement) { return; } - - document.body.removeChild(this._popupElement); - dom.domDispose(this._popupElement); - this._popupElement = null; - } - - private _handleMouseDown(ev: MouseEvent) { - this._clientX = ev.clientX; - this._clientY = ev.clientY; - document.addEventListener('mousemove', this._handleMouseMove); - document.addEventListener('mouseup', this._handleMouseUp); - } - - private _handleTouchStart(ev: TouchEvent) { - this._clientX = ev.touches[0].clientX; - this._clientY = ev.touches[0].clientY; - document.addEventListener('touchmove', this._handleTouchMove); - document.addEventListener('touchend', this._handleTouchEnd); - } - - private _handleMouseMove({clientX, clientY}: MouseEvent) { - this._handleMove(clientX, clientY); - } - - private _handleTouchMove({touches}: TouchEvent) { - this._handleMove(touches[0].clientX, touches[0].clientY); - } - - private _handleMove(clientX: number, clientY: number) { - const deltaX = clientX - this._clientX; - const deltaY = clientY - this._clientY; - let newLeft = this._popupElement!.offsetLeft + deltaX; - let newTop = this._popupElement!.offsetTop + deltaY; - - const topPaddingPx = getTopPopupPaddingPx(); - if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; } - if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; } - if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) { - newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX; - } - if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) { - newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx; - } - - this._popupElement!.style.left = `${newLeft}px`; - this._popupElement!.style.top = `${newTop}px`; - this._clientX = clientX; - this._clientY = clientY; - } - - private _handleMouseUp() { - document.removeEventListener('mousemove', this._handleMouseMove); - document.removeEventListener('mouseup', this._handleMouseUp); - document.body.removeEventListener('mouseleave', this._handleMouseUp); - } - - private _handleTouchEnd() { - document.removeEventListener('touchmove', this._handleTouchMove); - document.removeEventListener('touchend', this._handleTouchEnd); - document.body.removeEventListener('touchcancel', this._handleTouchEnd); - } - - private _handleWindowResize() { - this._repositionPopup(); - } - - private _repositionPopup() { - let newLeft = this._popupElement!.offsetLeft; - let newTop = this._popupElement!.offsetTop; - - const topPaddingPx = getTopPopupPaddingPx(); - if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; } - if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; } - if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) { - newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX; - } - if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) { - newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx; - } - - this._popupElement!.style.left = `${newLeft}px`; - this._popupElement!.style.top = `${newTop}px`; - } - private async _saveCurrentSlidePosition() { const currentOptions = this._currentDoc?.options ?? {}; await this._appModel.api.updateDoc(this._docId, { @@ -290,128 +268,6 @@ export class DocTutorial extends Disposable { }; } - private _buildPopup() { - return cssPopup( - {tabIndex: '-1'}, - cssPopupHeader( - dom.domComputed(this._isMinimized, isMinimized => { - return [ - cssPopupHeaderSpacer(), - cssPopupTitle( - cssPopupTitleText(dom.text(this._gristDoc.docPageModel.currentDocTitle)), - testId('popup-title'), - ), - cssPopupHeaderButton( - isMinimized ? icon('Maximize'): icon('Minimize'), - hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}), - dom.on('click', () => { - this._isMinimized.set(!this._isMinimized.get()); - this._repositionPopup(); - }), - testId('popup-minimize-maximize'), - ), - ]; - }), - dom.on('mousedown', this._handleMouseDown), - dom.on('touchstart', this._handleTouchStart), - testId('popup-header'), - ), - dom.maybe(use => !use(this._isMinimized), () => [ - dom.domComputed(use => { - const slides = use(this._slides); - const slideIndex = use(this._currentSlideIndex); - const slide = slides?.[slideIndex]; - return cssPopupBody( - !slide ? cssSpinner(loadingSpinner()) : [ - dom('div', elem => { - elem.innerHTML = slide.slideContent; - }), - !slide.boxContent ? null : cssTryItOutBox( - dom('div', elem => { elem.innerHTML = slide.boxContent!; }), - ), - dom.on('click', (ev) => { - if((ev.target as HTMLElement).tagName !== 'IMG') { - return; - } - - this._openLightbox((ev.target as HTMLImageElement).src); - }), - this._restartGIFs(), - ], - testId('popup-body'), - ); - }), - cssPopupFooter( - dom.domComputed(use => { - const slides = use(this._slides); - if (!slides) { return null; } - - const slideIndex = use(this._currentSlideIndex); - const numSlides = slides.length; - const isFirstSlide = slideIndex === 0; - const isLastSlide = slideIndex === numSlides - 1; - return [ - cssFooterButtonsLeft( - cssPopupFooterButton(icon('Undo'), - hoverTooltip('Restart Tutorial', {key: 'docTutorialTooltip'}), - dom.on('click', () => this._restartTutorial()), - testId('popup-restart'), - ), - ), - cssProgressBar( - range(slides.length).map((i) => cssProgressBarDot( - {title: slides[i].slideTitle}, - cssProgressBarDot.cls('-current', i === slideIndex), - i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)), - testId(`popup-slide-${i + 1}`), - )), - ), - cssFooterButtonsRight( - basicButton('Previous', - dom.on('click', async () => { - await this._previousSlide(); - }), - {style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`}, - testId('popup-previous'), - ), - primaryButton(isLastSlide ? 'Finish': 'Next', - isLastSlide - ? dom.on('click', async () => await this._finishTutorial()) - : dom.on('click', async () => await this._nextSlide()), - testId('popup-next'), - ), - ), - ]; - }), - testId('popup-footer'), - ), - ]), - // Pre-fetch images from all slides and store them in a hidden div. - dom.maybe(this._slides, slides => - dom('div', - {style: 'display: none;'}, - dom.forEach(slides, slide => { - if (slide.imageUrls.length === 0) { return null; } - - return dom('div', slide.imageUrls.map(src => dom('img', {src}))); - }), - ), - ), - () => { window.addEventListener('resize', this._handleWindowResize); }, - dom.onDispose(() => { - document.removeEventListener('mousemove', this._handleMouseMove); - document.removeEventListener('mouseup', this._handleMouseUp); - document.removeEventListener('touchmove', this._handleTouchMove); - document.removeEventListener('touchend', this._handleTouchEnd); - window.removeEventListener('resize', this._handleWindowResize); - }), - cssPopup.cls('-minimized', this._isMinimized), - cssPopup.cls('-mobile', isNarrowScreenObs()), - dom.cls('doc-tutorial-popup'), - testId('popup'), - ); - } - private _openLightbox(src: string) { modal((ctl) => { this.onDispose(ctl.close); @@ -429,85 +285,6 @@ export class DocTutorial extends Disposable { } } -function getTopPopupPaddingPx(): number { - // On mobile, we need additional padding to avoid blocking the top and bottom bars. - return POPUP_PADDING_PX + (isNarrowScreen() ? 50 : 0); -} - -const POPUP_HEIGHT = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`; -const POPUP_HEIGHT_MOBILE = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px) - (2 * 50px)))`; -const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`; - -const cssPopup = styled('div', ` - position: absolute; - display: flex; - flex-direction: column; - border: 2px solid ${theme.accentBorder}; - border-radius: 5px; - z-index: 999; - height: ${POPUP_HEIGHT}; - width: ${POPUP_WIDTH}; - background-color: ${theme.popupBg}; - box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow}; - outline: unset; - - &-mobile { - height: ${POPUP_HEIGHT_MOBILE}; - } - - &-minimized { - max-width: 225px; - height: unset; - } - - &-minimized:not(&-mobile) { - max-height: ${POPUP_HEIGHT}; - } - - &-minimized&-mobile { - max-height: ${POPUP_HEIGHT_MOBILE}; - } -`); - -const cssPopupHeader = styled('div', ` - display: flex; - color: ${theme.tutorialsPopupHeaderFg}; - --icon-color: ${theme.tutorialsPopupHeaderFg}; - background-color: ${theme.accentBorder}; - align-items: center; - justify-content: space-between; - flex-shrink: 0; - cursor: grab; - padding-left: 4px; - padding-right: 4px; - height: 30px; - user-select: none; - column-gap: 8px; - - &:active { - cursor: grabbing; - } -`); - -const cssPopupTitle = styled('div', ` - display: flex; - justify-content: center; - align-items: center; - font-weight: 600; - overflow: hidden; -`); - -const cssPopupTitleText = styled('div', ` - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -`); - -const cssPopupBody = styled('div', ` - flex-grow: 1; - padding: 24px; - overflow: auto; -`); const cssPopupFooter = styled('div', ` display: flex; @@ -526,20 +303,7 @@ const cssTryItOutBox = styled('div', ` background-color: ${theme.tutorialsPopupBoxBg}; `); -const cssPopupHeaderButton = styled('div', ` - padding: 4px; - border-radius: 4px; - cursor: pointer; - - &:hover { - background-color: ${theme.hover}; - } -`); -const cssPopupHeaderSpacer = styled('div', ` - width: 24px; - height: 24px; -`); const cssPopupFooterButton = styled('div', ` --icon-color: ${theme.controlSecondaryFg}; diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index 61cc247f..b1237fc6 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -2,26 +2,26 @@ * This module export a component for editing some document settings consisting of the timezone, * (new settings to be added here ...). */ -import {makeT} from 'app/client/lib/localization'; -import {Computed, Disposable, dom, fromKo, IDisposableOwner, styled} from 'grainjs'; -import {ACSelectItem, buildACSelect} from "app/client/lib/ACSelect"; +import {GristDoc} from 'app/client/components/GristDoc'; +import {ACIndexImpl} from 'app/client/lib/ACIndex'; +import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect'; import {copyToClipboard} from 'app/client/lib/copyToClipboard'; -import {ACIndexImpl} from "app/client/lib/ACIndex"; -import {docListHeader} from "app/client/ui/DocMenuCss"; +import {makeT} from 'app/client/lib/localization'; +import {reportError} from 'app/client/models/AppModel'; +import {KoSaveableObservable} from 'app/client/models/modelUtil'; +import {docListHeader} from 'app/client/ui/DocMenuCss'; import {showTransientTooltip} from 'app/client/ui/tooltips'; import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {select} from 'app/client/ui2018/menus'; +import {confirmModal} from 'app/client/ui2018/modals'; import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker'; import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete'; import {EngineCode} from 'app/common/DocumentSettings'; import {GristLoadConfig} from 'app/common/gristUrls'; -import {propertyCompare} from "app/common/gutil"; -import {getCurrency, locales} from "app/common/Locales"; -import {GristDoc} from 'app/client/components/GristDoc'; -import * as moment from "moment-timezone"; -import {KoSaveableObservable} from 'app/client/models/modelUtil'; -import {reportError} from 'app/client/models/AppModel'; -import {confirmModal} from 'app/client/ui2018/modals'; +import {propertyCompare} from 'app/common/gutil'; +import {getCurrency, locales} from 'app/common/Locales'; +import {Computed, Disposable, dom, fromKo, IDisposableOwner, styled} from 'grainjs'; +import * as moment from 'moment-timezone'; const t = makeT('DocumentSettings'); diff --git a/app/client/ui/FieldConfig.ts b/app/client/ui/FieldConfig.ts index 626ac49e..c128d583 100644 --- a/app/client/ui/FieldConfig.ts +++ b/app/client/ui/FieldConfig.ts @@ -3,6 +3,7 @@ import {CursorPos} from 'app/client/components/Cursor'; import {GristDoc} from 'app/client/components/GristDoc'; import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec'; import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight'; +import {buildAiButton} from 'app/client/ui/FormulaAssistance'; import {GristTooltips} from 'app/client/ui/GristTooltips'; import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; import {withInfoTooltip} from 'app/client/ui/tooltips'; @@ -12,6 +13,7 @@ import {testId, theme} from 'app/client/ui2018/cssVars'; import {textInput} from 'app/client/ui2018/editableLabel'; import {cssIconButton, icon} from 'app/client/ui2018/icons'; import {IconName} from 'app/client/ui2018/IconList'; +import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features'; import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus'; import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor'; import {sanitizeIdent} from 'app/common/gutil'; @@ -356,6 +358,7 @@ export function buildFormulaConfig( ]), formulaBuilder(onSaveConvertToFormula), cssEmptySeparator(), + dom.maybe(GRIST_FORMULA_ASSISTANT(), () => cssRow(buildAiButton(gristDoc, origColumn))), cssRow(textButton( t("Convert to trigger formula"), dom.on("click", convertFormulaToTrigger), diff --git a/app/client/ui/FloatingPopup.ts b/app/client/ui/FloatingPopup.ts new file mode 100644 index 00000000..15b85fc9 --- /dev/null +++ b/app/client/ui/FloatingPopup.ts @@ -0,0 +1,330 @@ +import {hoverTooltip} from 'app/client/ui/tooltips'; +import {isNarrowScreen, isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {Disposable, dom, DomArg, DomContents, makeTestId, Observable, styled} from 'grainjs'; + +const POPUP_PADDING_PX = 16; + +const testId = makeTestId('test-floating-popup-'); + +export interface PopupOptions { + title?: () => DomContents; + content?: () => DomContents; + onClose?: () => void; + closeButton?: boolean; + autoHeight?: boolean; +} + +export class FloatingPopup extends Disposable { + protected _isMinimized = Observable.create(this, false); + private _popupElement: HTMLElement | null = null; + + private _clientX: number; + private _clientY: number; + + constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) { + super(); + + this._handleMouseDown = this._handleMouseDown.bind(this); + this._handleMouseMove = this._handleMouseMove.bind(this); + this._handleMouseUp = this._handleMouseUp.bind(this); + this._handleTouchStart = this._handleTouchStart.bind(this); + this._handleTouchMove = this._handleTouchMove.bind(this); + this._handleTouchEnd = this._handleTouchEnd.bind(this); + this._handleWindowResize = this._handleWindowResize.bind(this); + + this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup())); + + this.onDispose(() => { + this._closePopup(); + }); + } + + public showPopup() { + this._popupElement = this._buildPopup(); + document.body.appendChild(this._popupElement); + const topPaddingPx = getTopPopupPaddingPx(); + const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_PADDING_PX; + const initialTop = document.body.offsetHeight - this._popupElement.offsetHeight - topPaddingPx; + this._popupElement.style.left = `${initialLeft}px`; + this._popupElement.style.top = `${initialTop}px`; + } + + protected _closePopup() { + if (!this._popupElement) { return; } + document.body.removeChild(this._popupElement); + dom.domDispose(this._popupElement); + this._popupElement = null; + } + + protected _buildTitle(): DomContents { + return this._options.title?.() ?? null; + } + + protected _buildContent(): DomContents { + return this._options.content?.() ?? null; + } + + protected _buildArgs(): any { + return this._args; + } + + private _handleMouseDown(ev: MouseEvent) { + if (ev.button !== 0) { return; } // Only handle left-click. + this._clientX = ev.clientX; + this._clientY = ev.clientY; + document.addEventListener('mousemove', this._handleMouseMove); + document.addEventListener('mouseup', this._handleMouseUp); + } + + private _handleTouchStart(ev: TouchEvent) { + this._clientX = ev.touches[0].clientX; + this._clientY = ev.touches[0].clientY; + document.addEventListener('touchmove', this._handleTouchMove); + document.addEventListener('touchend', this._handleTouchEnd); + } + + private _handleMouseMove({clientX, clientY}: MouseEvent) { + this._handleMove(clientX, clientY); + } + + private _handleTouchMove({touches}: TouchEvent) { + this._handleMove(touches[0].clientX, touches[0].clientY); + } + + private _handleMove(clientX: number, clientY: number) { + const deltaX = clientX - this._clientX; + const deltaY = clientY - this._clientY; + let newLeft = this._popupElement!.offsetLeft + deltaX; + let newTop = this._popupElement!.offsetTop + deltaY; + + const topPaddingPx = getTopPopupPaddingPx(); + if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; } + if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; } + if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) { + newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX; + } + if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) { + newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx; + } + + this._popupElement!.style.left = `${newLeft}px`; + this._popupElement!.style.top = `${newTop}px`; + this._clientX = clientX; + this._clientY = clientY; + } + + private _handleMouseUp() { + document.removeEventListener('mousemove', this._handleMouseMove); + document.removeEventListener('mouseup', this._handleMouseUp); + document.body.removeEventListener('mouseleave', this._handleMouseUp); + } + + private _handleTouchEnd() { + document.removeEventListener('touchmove', this._handleTouchMove); + document.removeEventListener('touchend', this._handleTouchEnd); + document.body.removeEventListener('touchcancel', this._handleTouchEnd); + } + + private _handleWindowResize() { + this._repositionPopup(); + } + + private _repositionPopup() { + let newLeft = this._popupElement!.offsetLeft; + let newTop = this._popupElement!.offsetTop; + + const topPaddingPx = getTopPopupPaddingPx(); + if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; } + if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; } + if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) { + newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX; + } + if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) { + newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx; + } + + this._popupElement!.style.left = `${newLeft}px`; + this._popupElement!.style.top = `${newTop}px`; + } + + private _buildPopup() { + const body = cssPopup( + {tabIndex: '-1'}, + cssPopup.cls('-auto', this._options.autoHeight ?? false), + cssPopupHeader( + dom.domComputed(this._isMinimized, isMinimized => { + return [ + // Copy buttons on the left side of the header, to automatically + // center the title. + cssPopupButtons( + !this._options.closeButton ? null : cssPopupHeaderButton( + icon('CrossSmall'), + ), + cssPopupHeaderButton( + icon('Maximize') + ), + dom.style('visibility', 'hidden'), + ), + cssPopupTitle( + cssPopupTitleText(this._buildTitle()), + testId('title'), + ), + cssPopupButtons( + !this._options.closeButton ? null : cssPopupHeaderButton( + icon('CrossSmall'), + dom.on('click', () => { + this._options.onClose?.() ?? this._closePopup(); + }), + testId('close'), + ), + cssPopupHeaderButton( + isMinimized ? icon('Maximize'): icon('Minimize'), + hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}), + dom.on('click', () => { + this._isMinimized.set(!this._isMinimized.get()); + this._repositionPopup(); + }), + testId('minimize-maximize'), + ), + ) + ]; + }), + dom.on('mousedown', this._handleMouseDown), + dom.on('touchstart', this._handleTouchStart), + testId('header'), + ), + dom.maybe(use => !use(this._isMinimized), () => this._buildContent()), + () => { window.addEventListener('resize', this._handleWindowResize); }, + dom.onDispose(() => { + document.removeEventListener('mousemove', this._handleMouseMove); + document.removeEventListener('mouseup', this._handleMouseUp); + document.removeEventListener('touchmove', this._handleTouchMove); + document.removeEventListener('touchend', this._handleTouchEnd); + window.removeEventListener('resize', this._handleWindowResize); + }), + cssPopup.cls('-minimized', this._isMinimized), + cssPopup.cls('-mobile', isNarrowScreenObs()), + testId('window'), + this._buildArgs() + ); + + // For auto-height popups, we need to reposition the popup when the content changes. + // It is important for auto-grow and to prevent popup from going off-screen. + if (this._options.autoHeight) { + const observer = new MutationObserver(() => { + this._repositionPopup(); + }); + observer.observe(body, {childList: true, subtree: true}); + dom.update(body, + dom.onDispose(() => observer.disconnect()) + ); + } + + return body; + } +} + +function getTopPopupPaddingPx(): number { + // On mobile, we need additional padding to avoid blocking the top and bottom bars. + return POPUP_PADDING_PX + (isNarrowScreen() ? 50 : 0); +} + +const POPUP_HEIGHT = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`; +const POPUP_HEIGHT_MOBILE = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px) - (2 * 50px)))`; +const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`; + +const cssPopup = styled('div', ` + position: absolute; + display: flex; + flex-direction: column; + border: 2px solid ${theme.accentBorder}; + border-radius: 5px; + z-index: 999; + height: ${POPUP_HEIGHT}; + width: ${POPUP_WIDTH}; + background-color: ${theme.popupBg}; + box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow}; + outline: unset; + + &-mobile { + height: ${POPUP_HEIGHT_MOBILE}; + } + + &-minimized { + max-width: 225px; + height: unset; + } + + &-minimized:not(&-mobile) { + max-height: ${POPUP_HEIGHT}; + } + + &-minimized&-mobile { + max-height: ${POPUP_HEIGHT_MOBILE}; + } + + &-auto { + height: auto; + max-height: ${POPUP_HEIGHT}; + } + + &-auto&-mobile { + max-height: ${POPUP_HEIGHT_MOBILE}; + } +`); + +const cssPopupHeader = styled('div', ` + color: ${theme.tutorialsPopupHeaderFg}; + --icon-color: ${theme.tutorialsPopupHeaderFg}; + background-color: ${theme.accentBorder}; + align-items: center; + flex-shrink: 0; + cursor: grab; + padding-left: 4px; + padding-right: 4px; + height: 30px; + user-select: none; + display: flex; + justify-content: space-between; + position: relative; + &:active { + cursor: grabbing; + } +`); + +const cssPopupButtons = styled('div', ` + display: flex; + column-gap: 8px; + align-items: center; +`); + +const cssPopupTitle = styled('div', ` + display: flex; + justify-content: center; + align-items: center; + font-weight: 600; + overflow: hidden; +`); + +const cssPopupTitleText = styled('div', ` + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`); + +export const cssPopupBody = styled('div', ` + flex-grow: 1; + padding: 24px; + overflow: auto; +`); + +const cssPopupHeaderButton = styled('div', ` + padding: 4px; + border-radius: 4px; + cursor: pointer; + + &:hover { + background-color: ${theme.hover}; + } +`); diff --git a/app/client/ui/FormulaAssistance.ts b/app/client/ui/FormulaAssistance.ts new file mode 100644 index 00000000..aa1de920 --- /dev/null +++ b/app/client/ui/FormulaAssistance.ts @@ -0,0 +1,578 @@ +import * as AceEditor from 'app/client/components/AceEditor'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {ColumnRec} from 'app/client/models/entities/ColumnRec'; +import {buildHighlightedCode} from 'app/client/ui/CodeHighlight'; +import {FloatingPopup} from 'app/client/ui/FloatingPopup'; +import {createUserImage} from 'app/client/ui/UserImage'; +import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'; +import {theme} from 'app/client/ui2018/cssVars'; +import {cssTextInput, rawTextInput} from 'app/client/ui2018/editableLabel'; +import {icon} from 'app/client/ui2018/icons'; +import {Suggestion} from 'app/common/AssistancePrompts'; +import {Disposable, dom, makeTestId, MultiHolder, obsArray, Observable, styled} from 'grainjs'; +import noop from 'lodash/noop'; + +const testId = makeTestId('test-assistant-'); + +export function buildAiButton(grist: GristDoc, column: ColumnRec) { + column = grist.docModel.columns.createFloatingRowModel(column.origColRef); + return textButton( + dom.autoDispose(column), + 'Open AI assistant', + testId('open-button'), + dom.on('click', () => openAIAssistant(grist, column)) + ); +} + +interface Context { + grist: GristDoc; + column: ColumnRec; +} + +function buildFormula(owner: MultiHolder, props: Context) { + const { grist, column } = props; + const formula = Observable.create(owner, column.formula.peek()); + const calcSize = (fullDom: HTMLElement, size: any) => { + return { + width: fullDom.clientWidth, + height: size.height, + }; + }; + const editor = AceEditor.create({ + column, + gristDoc: grist, + calcSize, + editorState: formula, + }); + owner.autoDispose(editor); + const buildDom = () => { + return cssFormulaWrapper( + dom.cls('formula_field_sidepane'), + editor.buildDom((aceObj: any) => { + aceObj.setFontSize(11); + aceObj.setHighlightActiveLine(false); + aceObj.getSession().setUseWrapMode(false); + aceObj.renderer.setPadding(0); + setTimeout(() => editor.editor.focus()); + editor.setValue(formula.get()); + }), + ); + }; + return { + buildDom, + set(value: string) { + editor.setValue(value); + }, + get() { + return editor.getValue(); + }, + }; +} + +function buildControls( + owner: MultiHolder, + props: Context & { + currentFormula: () => string; + savedClicked: () => void, + robotClicked: () => void, + } +) { + + const hasHistory = props.column.chatHistory.peek().get().length > 0; + + // State variables, to show various parts of the UI. + const saveButtonVisible = Observable.create(owner, true); + const previewButtonVisible = Observable.create(owner, true); + const robotWellVisible = Observable.create(owner, !hasHistory); + const robotButtonVisible = Observable.create(owner, !hasHistory && !robotWellVisible.get()); + const helpWellVisible = Observable.create(owner, !hasHistory); + + + + // Click handlers + const saveClicked = async () => { + await preview(); + props.savedClicked(); + }; + const previewClicked = async () => await preview(); + const robotClicked = () => props.robotClicked(); + + // Public API + const preview = async () => { + // Currently we don't have a preview, so just save. + const formula = props.currentFormula(); + const tableId = props.column.table.peek().tableId.peek(); + const colId = props.column.colId.peek(); + props.grist.docData.sendAction([ + 'ModifyColumn', + tableId, + colId, + { formula, isFormula: true }, + ]).catch(reportError); + }; + + const buildWells = () => { + return cssContainer( + cssWell( + 'Grist’s AI Formula Assistance. Need help? Our AI assistant can help. ', + textButton('Ask the bot.', dom.on('click', robotClicked)), + dom.show(robotWellVisible) + ), + cssWell( + 'Formula Help. See our Function List and Formula Cheat Sheet, or visit our Community for more help.', + dom.show(helpWellVisible) + ), + dom.show(use => use(robotWellVisible) || use(helpWellVisible)) + ); + }; + const buildDom = () => { + return [ + cssButtonsWrapper( + cssButtons( + primaryButton('Save', dom.show(saveButtonVisible), dom.on('click', saveClicked)), + basicButton('Preview', dom.show(previewButtonVisible), dom.on('click', previewClicked)), + textButton('🤖', dom.show(robotButtonVisible), dom.on('click', robotClicked)), + dom.show( + use => use(previewButtonVisible) || use(saveButtonVisible) || use(robotButtonVisible) + ) + ) + ), + buildWells(), + ]; + }; + return { + buildDom, + preview, + hideHelp() { + robotWellVisible.set(false); + helpWellVisible.set(false); + }, + hideRobot() { + robotButtonVisible.set(false); + } + }; +} + +function buildChat(owner: Disposable, context: Context & { formulaClicked: (formula: string) => void }) { + const { grist, column } = context; + + const history = owner.autoDispose(obsArray(column.chatHistory.peek().get())); + const hasHistory = history.get().length > 0; + const enabled = Observable.create(owner, hasHistory); + const introVisible = Observable.create(owner, !hasHistory); + owner.autoDispose(history.addListener((cur) => { + column.chatHistory.peek().set([...cur]); + })); + + const submit = async () => { + // Ask about suggestion, and send the whole history. Currently the chat is implemented by just sending + // all previous user prompts back to the AI. This is subject to change (and probably should be done in the backend). + const prompt = history.get().filter(x => x.sender === 'user') + .map(entry => entry.message) + .filter(Boolean) + .join("\n"); + console.debug('prompt', prompt); + const { suggestedActions } = await askAI(grist, column, prompt); + console.debug('suggestedActions', suggestedActions); + const firstAction = suggestedActions[0] as any; + // Add the formula to the history. + const formula = firstAction[3].formula as string; + // Add to history + history.push({ + message: formula, + sender: 'ai', + formula + }); + return formula; + }; + + const chatEnterClicked = async (val: string) => { + if (!val) { return; } + // Hide intro. + introVisible.set(false); + // Add question to the history. + history.push({ + message: val, + sender: 'user', + }); + // Submit all questions to the AI. + context.formulaClicked(await submit()); + }; + + const regenerateClick = async () => { + // Remove the last AI response from the history. + history.pop(); + // And submit again. + context.formulaClicked(await submit()); + }; + + const newChat = () => { + // Clear the history. + history.set([]); + // Show intro. + introVisible.set(true); + }; + + const userPrompt = Observable.create(owner, ''); + + const userImage = () => { + 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', '')); + } + }; + + const buildHistory = () => { + return cssVBox( + dom.forEach(history, entry => { + if (entry.sender === 'user') { + return cssMessage( + cssAvatar(userImage()), + dom.text(entry.message), + ); + } else { + return cssAiMessage( + cssAvatar(cssAiImage()), + buildHighlightedCode(entry.message, { maxLines: 10 }, cssCodeStyles.cls('')), + cssCopyIconWrapper( + icon('Copy', dom.on('click', () => context.formulaClicked(entry.message))), + ) + ); + } + }) + ); + }; + + const buildIntro = () => { + return cssVBox( + dom.cls(cssTopGreenBorder.className), + dom.cls(cssTypography.className), + dom.style('flex-grow', '1'), + dom.style('min-height', '0'), + dom.style('overflow-y', 'auto'), + dom.maybe(introVisible, () => + cssHContainer( + dom.style('margin-bottom', '10px'), + cssVBox( + dom('h4', 'Grist’s AI Assistance'), + dom('h5', 'Tips'), + cssWell( + '“Example prompt” Some instructions for how to draft a prompt. A link to even more examples in support.' + ), + cssWell( + 'Example Values. Instruction about entering example values in the column, maybe with an image?' + ), + dom('h5', 'Capabilities'), + cssWell( + 'Formula Assistance Only. Python code. Spreadsheet functions? May sometimes get it wrong. ' + ), + cssWell('Conversational. Remembers what was said and allows follow-up corrections.'), + dom('h5', 'Data'), + cssWell( + 'Data Usage. Something about how we can see prompts to improve the feature and product, but cannot see doc.' + ), + cssWell( + 'Data Sharing. Something about OpenAI, what’s being transmitted. How does it expose doc data?' + ), + textButton('Learn more', dom.style('align-self', 'flex-start')) + ) + ) + ), + dom.maybe( + use => !use(introVisible), + () => buildHistory() + ), + ); + }; + + const buildButtons = () => { + // We will show buttons only if we have a history. + return dom.maybe(use => use(history).length > 0, () => cssVContainer( + cssHBox( + cssPlainButton(icon('Script'), 'New Chat', dom.on('click', newChat)), + cssPlainButton(icon('Revert'), 'Regenerate', dom.on('click', regenerateClick), dom.style('margin-left', '8px')), + ), + dom.style('padding-bottom', '0'), + dom.style('padding-top', '12px'), + )); + }; + + const buildInput = () => { + return cssHContainer( + dom.cls(cssTopBorder.className), + dom.cls(cssVSpace.className), + cssInputWrapper( + dom.cls(cssTextInput.className), + dom.cls(cssTypography.className), + rawTextInput(userPrompt, chatEnterClicked, noop), + icon('FieldAny') + ), + buildButtons() + ); + }; + + const buildDom = () => { + return dom.maybe(enabled, () => cssVFullBox( + buildIntro(), + cssSpacer(), + buildInput(), + dom.style('overflow', 'hidden'), + dom.style('flex-grow', '1') + )); + }; + + return { + buildDom, + show() { + enabled.set(true); + introVisible.set(true); + } + }; +} + +/** + * Builds and opens up a Formula Popup with an AI assistant. + */ +function openAIAssistant(grist: GristDoc, column: ColumnRec) { + const owner = MultiHolder.create(null); + const props: Context = { grist, column }; + + // Build up all components, and wire up them to each other. + + // First is the formula editor displayed in the upper part of the popup. + const formulaEditor = buildFormula(owner, props); + + // Next are the buttons in the middle. It has a Save, Preview, and Robot button, and probably some wells + // with tips or other buttons. + const controls = buildControls(owner, { + ...props, + // Pass a formula accessor, it is used to get the current formula and apply or preview it. + currentFormula: () => formulaEditor.get(), + // Event or saving, we listen to it to close the popup. + savedClicked() { + grist.formulaPopup.clear(); + }, + // Handler for robot icon click. We hide the robot icon and the help, and show the chat area. + robotClicked() { + chat.show(); + controls.hideHelp(); + controls.hideRobot(); + } + }); + + // Now, the chat area. It has a history of previous questions, and a prompt for the user to ask a new + // question. + const chat = buildChat(owner, {...props, + // When a formula is clicked (or just was returned from the AI), we set it in the formula editor and hit + // the preview button. + formulaClicked: (formula: string) => { + formulaEditor.set(formula); + controls.preview().catch(reportError); + }, + }); + + const header = `${column.table.peek().tableNameDef.peek()}.${column.label.peek()}`; + const popup = FloatingPopup.create(null, { + title: () => header, + content: () => [ + formulaEditor.buildDom(), + controls.buildDom(), + chat.buildDom(), + ], + onClose: () => grist.formulaPopup.clear(), + closeButton: true, + autoHeight: true, + }); + + popup.autoDispose(owner); + popup.showPopup(); + + // Add this popup to the main holder (and dispose the previous one). + grist.formulaPopup.autoDispose(popup); +} + +async function askAI(grist: GristDoc, column: ColumnRec, description: string): Promise { + const tableId = column.table.peek().tableId.peek(); + const colId = column.colId.peek(); + try { + const result = await grist.docComm.getAssistance({tableId, colId, description}); + return result; + } catch (error) { + reportError(error); + throw error; + } +} + +const cssVBox = styled('div', ` + display: flex; + flex-direction: column; +`); + +const cssFormulaWrapper = styled('div.formula_field_edit.formula_editor', ` + position: relative; + padding: 5px 0 5px 24px; + flex: auto; + overflow-y: auto; +`); + +const cssVFullBox = styled(cssVBox, ` + flex-grow: 1; +`); + +const cssHBox = styled('div', ` + display: flex; +`); + +const cssVSpace = styled('div', ` + padding-top: 18px; + padding-bottom: 18px; +`); + +const cssHContainer = styled('div', ` + padding-left: 18px; + padding-right: 18px; + display: flex; + flex-direction: column; +`); + +const cssButtonsWrapper = styled(cssHContainer, ` + padding-top: 0; + background-color: ${theme.formulaEditorBg}; +`); + +const cssVContainer = styled('div', ` + padding-top: 18px; + padding-bottom: 18px; + display: flex; + flex-direction: column; +`); + +const cssContainer = styled('div', ` + padding: 18px; + display: flex; + flex-direction: column; +`); + +const cssSpacer = styled('div', ` + margin-top: auto; + display: flex; +`); + +const cssButtons = styled('div', ` + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 8px 0px; +`); + +const cssWell = styled('div', ` + padding: 8px; + color: ${theme.inputFg}; + border-radius: 4px; + background-color: ${theme.rightPanelBg}; + & + & { + margin-top: 8px; + } +`); + +const cssMessage = styled('div', ` + display: grid; + grid-template-columns: 60px 1fr; + padding-right: 54px; + padding-top: 12px; + padding-bottom: 12px; +`); + +const cssAiMessage = styled('div', ` + display: grid; + grid-template-columns: 60px 1fr 54px; + padding-top: 20px; + padding-bottom: 20px; + background: #D9D9D94f; + +`); + +const cssCodeStyles = styled('div', ` + background: #E3E3E3; + border: none; + & .ace-chrome { + background: #E3E3E3; + border: none; + } +`); + +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 cssTopGreenBorder = styled('div', ` + border-top: 1px solid ${theme.accentBorder}; +`); + +const cssTypography = styled('div', ` + color: ${theme.inputFg}; +`); + +const cssTopBorder = styled('div', ` + border-top: 1px solid ${theme.inputBorder}; +`); + +const cssCopyIconWrapper = styled('div', ` + display: none; + align-items: center; + justify-content: center; + flex: none; + cursor: pointer; + .${cssAiMessage.className}:hover & { + display: flex; + } +`); + +const cssInputWrapper = styled('div', ` + display: flex; + background-color: ${theme.mainPanelBg}; + align-items: center; + gap: 8px; + padding-right: 8px !important; + --icon-color: ${theme.controlSecondaryFg}; + &:hover, &:focus-within { + --icon-color: ${theme.accentIcon}; + } + & > input { + outline: none; + padding: 0px; + align-self: stretch; + flex: 1; + border: none; + background-color: inherit; + } +`); + +const cssPlainButton = styled(basicButton, ` + border-color: ${theme.inputBorder}; + color: ${theme.controlSecondaryFg}; + --icon-color: ${theme.controlSecondaryFg}; + display: inline-flex; + gap: 10px; + align-items: flex-end; + border-radius: 3px; + padding: 5px 7px; + padding-right: 13px; +`); diff --git a/app/client/ui2018/editableLabel.ts b/app/client/ui2018/editableLabel.ts index 52ff835b..95507ad0 100644 --- a/app/client/ui2018/editableLabel.ts +++ b/app/client/ui2018/editableLabel.ts @@ -62,7 +62,7 @@ const cssSizer = styled('div', ` enum Status { NORMAL, EDITING, SAVING } -type SaveFunc = (value: string) => Promise; +type SaveFunc = (value: string) => void|PromiseLike; export interface EditableLabelOptions { save: SaveFunc; diff --git a/app/common/ActiveDocAPI.ts b/app/common/ActiveDocAPI.ts index 87add975..d2f88ee8 100644 --- a/app/common/ActiveDocAPI.ts +++ b/app/common/ActiveDocAPI.ts @@ -1,4 +1,5 @@ import {ActionGroup} from 'app/common/ActionGroup'; +import {Prompt, Suggestion} from 'app/common/AssistancePrompts'; import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions'; import {FormulaProperties} from 'app/common/GranularAccessClause'; import {UIRowId} from 'app/common/UIRowId'; @@ -322,7 +323,7 @@ export interface ActiveDocAPI { /** * Generates a formula code based on the AI suggestions, it also modifies the column and sets it type to a formula. */ - getAssistance(tableId: string, colId: string, description: string): Promise; + getAssistance(userPrompt: Prompt): Promise; /** * Fetch content at a url. diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 948312fb..0e9f683e 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -583,6 +583,9 @@ export interface GristLoadConfig { // TODO: remove when comments will be released. featureComments?: boolean; + // TODO: remove once released. + featureFormulaAssistant?: boolean; + // Email address of the support user. supportEmail?: string; diff --git a/app/server/lib/Assistance.ts b/app/server/lib/Assistance.ts index 804506e7..6a3e3af7 100644 --- a/app/server/lib/Assistance.ts +++ b/app/server/lib/Assistance.ts @@ -11,10 +11,13 @@ export const DEPS = { fetch }; export async function sendForCompletion(prompt: string): Promise { let completion: string|null = null; let retries: number = 0; + const openApiKey = process.env.OPENAI_API_KEY; + const model = process.env.COMPLETION_MODEL || "text-davinci-002"; + while(retries++ < 3) { try { - if (process.env.OPENAI_API_KEY) { - completion = await sendForCompletionOpenAI(prompt); + if (openApiKey) { + completion = await sendForCompletionOpenAI(prompt, openApiKey, model); } if (process.env.HUGGINGFACE_API_KEY) { completion = await sendForCompletionHuggingFace(prompt); @@ -33,8 +36,7 @@ export async function sendForCompletion(prompt: string): Promise { } -async function sendForCompletionOpenAI(prompt: string) { - const apiKey = process.env.OPENAI_API_KEY; +async function sendForCompletionOpenAI(prompt: string, apiKey: string, model = "text-davinci-002") { if (!apiKey) { throw new Error("OPENAI_API_KEY not set"); } @@ -51,7 +53,7 @@ async function sendForCompletionOpenAI(prompt: string) { max_tokens: 150, temperature: 0, // COMPLETION_MODEL of `code-davinci-002` may be better if you have access to it. - model: process.env.COMPLETION_MODEL || "text-davinci-002", + model, stop: ["\n\n"], }), }, diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index accdae3f..68c90683 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -1,4 +1,5 @@ import {getPageTitleSuffix, GristLoadConfig, HideableUiElements, IHideableUiElement} from 'app/common/gristUrls'; +import {isAffirmative} from 'app/common/gutil'; import {getTagManagerSnippet} from 'app/common/tagManager'; import {Document} from 'app/common/UserAPI'; import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager'; @@ -60,10 +61,11 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial