(core) Draft version of AI assistant

Summary:
The feature is behind a flag GRIST_FORMULA_ASSISTANT (must be "true"). But can be enabled in the
developer console by invoking GRIST_FORMULA_ASSISTANT.set(true).

Keys can be overriden in the document settings page.

Test Plan: For now just a stub test that checks if this feature is disabled by default.

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D3815
pull/472/head
Jarosław Sadziński 1 year ago
parent 33c08057ad
commit d29770511c

@ -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();

@ -175,6 +175,9 @@ export class GristDoc extends DisposableWithEvents {
public externalSectionId: Computed<number|null>;
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;

@ -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<BaseFormatter>;
cells: ko.Computed<KoArray<CellRec>>;
/**
* Current history of chat. This is a temporary array used only in the ui.
*/
chatHistory: ko.PureComputed<Observable<ChatMessage[]>>;
// Helper which adds/removes/updates column's displayCol to match the formula.
saveDisplayFormula(formula: string): Promise<void>|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;
}

@ -10,3 +10,12 @@ export function COMMENTS(): Observable<boolean> {
}
return G.window.COMMENTS;
}
export function GRIST_FORMULA_ASSISTANT(): Observable<boolean> {
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;
}

@ -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;
}
`);

@ -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<DocTutorialSlide[] | null> = 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};

@ -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');

@ -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),

@ -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};
}
`);

@ -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(
'Grists 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', 'Grists 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, whats 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<Suggestion> {
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;
`);

@ -62,7 +62,7 @@ const cssSizer = styled('div', `
enum Status { NORMAL, EDITING, SAVING }
type SaveFunc = (value: string) => Promise<void>;
type SaveFunc = (value: string) => void|PromiseLike<void>;
export interface EditableLabelOptions {
save: SaveFunc;

@ -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<void>;
getAssistance(userPrompt: Prompt): Promise<Suggestion>;
/**
* Fetch content at a url.

@ -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;

@ -11,10 +11,13 @@ export const DEPS = { fetch };
export async function sendForCompletion(prompt: string): Promise<string> {
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<string> {
}
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"],
}),
},

@ -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<GristLoadCo
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
activation: getActivation(req as RequestWithLogin | undefined),
enableCustomCss: process.env.APP_STATIC_INCLUDE_CUSTOM_CSS === 'true',
enableCustomCss: isAffirmative(process.env.APP_STATIC_INCLUDE_CUSTOM_CSS),
supportedLngs: readLoadedLngs(req?.i18n),
namespaces: readLoadedNamespaces(req?.i18n),
featureComments: process.env.COMMENTS === "true",
featureComments: isAffirmative(process.env.COMMENTS),
featureFormulaAssistant: isAffirmative(process.env.GRIST_FORMULA_ASSISTANT),
supportEmail: SUPPORT_EMAIL,
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
...extra,

@ -55,7 +55,7 @@ describe('DocTutorial', function () {
it('shows a popup containing slides generated from the GristDocTutorial table', async function() {
assert.isTrue(await driver.findWait('.test-doc-tutorial-popup', 2000).isDisplayed());
assert.equal(await driver.find('.test-doc-tutorial-popup-title').getText(), 'DocTutorial');
assert.equal(await driver.find('.test-floating-popup-header').getText(), 'DocTutorial');
assert.equal(
await driver.findWait('.test-doc-tutorial-popup h1', 2000).getText(),
'Intro'
@ -172,12 +172,12 @@ describe('DocTutorial', function () {
});
it('can be minimized and maximized', async function() {
await driver.find('.test-doc-tutorial-popup-minimize-maximize').click();
await driver.find('.test-floating-popup-minimize-maximize').click();
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-body').isPresent());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-footer').isPresent());
await driver.find('.test-doc-tutorial-popup-minimize-maximize').click();
await driver.find('.test-floating-popup-minimize-maximize').click();
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
assert.isTrue(await driver.find('.test-doc-tutorial-popup-body').isDisplayed());
assert.isTrue(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
@ -262,7 +262,7 @@ describe('DocTutorial', function () {
assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Zane Rails']);
// Check that changes made to the tutorial since the last fork are included.
assert.equal(await driver.find('.test-doc-tutorial-popup-title').getText(),
assert.equal(await driver.find('.test-doc-tutorial-popup-header').getText(),
'DocTutorial V2');
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'NewTable']);
});

Loading…
Cancel
Save