(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
This commit is contained in:
Jarosław Sadziński 2023-03-23 19:22:28 +01:00
parent 33c08057ad
commit d29770511c
16 changed files with 1108 additions and 361 deletions

View File

@ -187,6 +187,10 @@ AceEditor.prototype._setup = function() {
if (this.gristDoc && this.column) { if (this.gristDoc && this.column) {
const getSuggestions = (prefix) => { const getSuggestions = (prefix) => {
const section = this.gristDoc.viewModel.activeSection(); 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 tableId = section.table().tableId();
const columnId = this.column.colId(); const columnId = this.column.colId();
const rowId = section.activeRowId(); const rowId = section.activeRowId();

View File

@ -175,6 +175,9 @@ export class GristDoc extends DisposableWithEvents {
public externalSectionId: Computed<number|null>; public externalSectionId: Computed<number|null>;
public viewLayout: ViewLayout|null = null; public viewLayout: ViewLayout|null = null;
// Holder for the popped up formula editor.
public readonly formulaPopup = Holder.create(this);
private _actionLog: ActionLog; private _actionLog: ActionLog;
private _undoStack: UndoStack; private _undoStack: UndoStack;
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null; private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;

View File

@ -1,6 +1,8 @@
import {KoArray} from 'app/client/lib/koArray'; import {KoArray} from 'app/client/lib/koArray';
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
import {CellRec, DocModel, IRowModel, recordSet, import {CellRec, DocModel, IRowModel, recordSet,
refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel'; refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel';
import {urlState} from 'app/client/models/gristUrlState';
import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil'; import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil';
import * as gristTypes from 'app/common/gristTypes'; import * as gristTypes from 'app/common/gristTypes';
import {getReferencedTableId} from 'app/common/gristTypes'; import {getReferencedTableId} from 'app/common/gristTypes';
@ -11,6 +13,7 @@ import {
FullFormatterArgs FullFormatterArgs
} from 'app/common/ValueFormatter'; } from 'app/common/ValueFormatter';
import {createParser} from 'app/common/ValueParser'; import {createParser} from 'app/common/ValueParser';
import {Observable} from 'grainjs';
import * as ko from 'knockout'; import * as ko from 'knockout';
// Column behavior type, used primarily in the UI. // Column behavior type, used primarily in the UI.
@ -77,6 +80,11 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
formatter: ko.Computed<BaseFormatter>; formatter: ko.Computed<BaseFormatter>;
cells: ko.Computed<KoArray<CellRec>>; 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. // Helper which adds/removes/updates column's displayCol to match the formula.
saveDisplayFormula(formula: string): Promise<void>|undefined; 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.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( export function formatterForRec(
@ -168,3 +182,22 @@ export function formatterForRec(
}; };
return func(args); 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;
}

View File

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

View File

@ -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 * as ace from 'brace';
import {BindableValue, dom, DomElementArg, styled, subscribeElem} from 'grainjs'; import {BindableValue, dom, DomElementArg, styled, subscribeElem} from 'grainjs';
@ -71,3 +71,18 @@ const cssHighlightedCode = styled(cssCodeBlock, `
text-overflow: ellipsis; 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;
}
`);

View File

@ -1,22 +1,21 @@
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {renderer} from 'app/client/ui/DocTutorialRenderer'; import {renderer} from 'app/client/ui/DocTutorialRenderer';
import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {hoverTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip} from 'app/client/ui/tooltips';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; 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 {icon} from 'app/client/ui2018/icons';
import {loadingSpinner} from 'app/client/ui2018/loaders'; import {loadingSpinner} from 'app/client/ui2018/loaders';
import {confirmModal, modal} from 'app/client/ui2018/modals'; import {confirmModal, modal} from 'app/client/ui2018/modals';
import {parseUrlId} from 'app/common/gristUrls'; 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 {marked} from 'marked';
import debounce = require('lodash/debounce'); import debounce = require('lodash/debounce');
import range = require('lodash/range'); import range = require('lodash/range');
import sortBy = require('lodash/sortBy'); import sortBy = require('lodash/sortBy');
const POPUP_PADDING_PX = 16;
interface DocTutorialSlide { interface DocTutorialSlide {
slideContent: string; slideContent: string;
boxContent?: string; boxContent?: string;
@ -26,20 +25,16 @@ interface DocTutorialSlide {
const testId = makeTestId('test-doc-tutorial-'); const testId = makeTestId('test-doc-tutorial-');
export class DocTutorial extends Disposable { export class DocTutorial extends FloatingPopup {
private _appModel = this._gristDoc.docPageModel.appModel; private _appModel = this._gristDoc.docPageModel.appModel;
private _currentDoc = this._gristDoc.docPageModel.currentDoc.get(); private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();
private _docComm = this._gristDoc.docComm; private _docComm = this._gristDoc.docComm;
private _docData = this._gristDoc.docData; private _docData = this._gristDoc.docData;
private _docId = this._gristDoc.docId(); private _docId = this._gristDoc.docId();
private _popupElement: HTMLElement | null = null;
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null); private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
private _currentSlideIndex = Observable.create(this, private _currentSlideIndex = Observable.create(this,
this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0); 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, { private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
// Save new position immediately if at least 1 second has passed since the last change. // Save new position immediately if at least 1 second has passed since the last change.
@ -50,273 +45,19 @@ export class DocTutorial extends Disposable {
constructor(private _gristDoc: GristDoc) { constructor(private _gristDoc: GristDoc) {
super(); 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 async start() { public async start() {
this._showPopup(); this.showPopup();
await this._loadSlides(); await this._loadSlides();
} }
private async _loadSlides() { protected _buildTitle() {
const tableId = 'GristDocTutorial'; return dom('span', dom.text(this._gristDoc.docPageModel.currentDocTitle), testId('popup-header'));
if (!this._docData.getTable(tableId)) {
throw new Error('DocTutorial failed to find table GristDocTutorial');
} }
await this._docComm.waitForInitialization(); protected _buildContent() {
if (this.isDisposed()) { return; }
await this._docData.fetchTable(tableId);
if (this.isDisposed()) { return; }
const tableData = this._docData.getTable(tableId)!;
const slides = (await Promise.all(
sortBy(tableData.getRowIds(), tableData.getRowPropFunc('manualSort') as any)
.map(async rowId => {
let slideTitle: string | undefined;
const imageUrls: string[] = [];
const getValue = (colId: string): string | undefined => {
const value = tableData.getValue(rowId, colId);
return value ? String(value) : undefined;
};
const walkTokens = (token: marked.Token) => {
if (token.type === 'image') {
imageUrls.push(token.href);
}
if (!slideTitle && token.type === 'heading' && token.depth === 1) {
slideTitle = token.text;
}
};
let slideContent = getValue('slide_content');
if (!slideContent) { return null; }
slideContent = sanitizeHTML(await marked.parse(slideContent, {
async: true, renderer, walkTokens
}));
let boxContent = getValue('box_content');
if (boxContent) {
boxContent = sanitizeHTML(await marked.parse(boxContent, {
async: true, renderer, walkTokens
}));
}
return {
slideContent,
boxContent,
slideTitle,
imageUrls,
};
})
)).filter(slide => slide !== null) as DocTutorialSlide[];
if (this.isDisposed()) { return; }
if (slides.length === 0) {
throw new Error('DocTutorial failed to find slides in table GristDocTutorial');
}
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, {
options: {
...currentOptions,
tutorial: {
lastSlideIndex: this._currentSlideIndex.get(),
}
}
});
}
private async _changeSlide(slideIndex: number) {
this._currentSlideIndex.set(slideIndex);
await this._saveCurrentSlidePositionDebounced();
}
private async _previousSlide() {
await this._changeSlide(this._currentSlideIndex.get() - 1);
}
private async _nextSlide() {
await this._changeSlide(this._currentSlideIndex.get() + 1);
}
private async _finishTutorial() {
this._saveCurrentSlidePositionDebounced.cancel();
await this._saveCurrentSlidePosition();
await urlState().pushUrl({});
}
private async _restartTutorial() {
const doRestart = async () => {
const urlId = this._currentDoc!.id;
const {trunkId} = parseUrlId(urlId);
const docApi = this._appModel.api.getDocAPI(urlId);
await docApi.replace({sourceDocId: trunkId, resetTutorialMetadata: true});
};
confirmModal(
'Do you want to restart the tutorial? All progress will be lost.',
'Restart',
doRestart
);
}
private _restartGIFs() {
return (element: HTMLElement) => {
setTimeout(() => {
const imgs = element.querySelectorAll('img');
for (const img of imgs) {
// Re-assigning src to itself is a neat way to restart a GIF.
// eslint-disable-next-line no-self-assign
img.src = img.src;
}
}, 0);
};
}
private _buildPopup() {
return cssPopup(
{tabIndex: '-1'},
cssPopupHeader(
dom.domComputed(this._isMinimized, isMinimized => {
return [ 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 => { dom.domComputed(use => {
const slides = use(this._slides); const slides = use(this._slides);
const slideIndex = use(this._currentSlideIndex); const slideIndex = use(this._currentSlideIndex);
@ -385,33 +126,148 @@ export class DocTutorial extends Disposable {
}), }),
testId('popup-footer'), testId('popup-footer'),
), ),
]), ];
}
protected _buildArgs() {
return [
dom.cls('doc-tutorial-popup'),
testId('popup'),
// Pre-fetch images from all slides and store them in a hidden div. // Pre-fetch images from all slides and store them in a hidden div.
dom.maybe(this._slides, slides => dom.maybe(this._slides, slides =>
dom('div', dom('div',
{style: 'display: none;'}, {style: 'display: none;'},
dom.forEach(slides, slide => { dom.forEach(slides, slide => {
if (slide.imageUrls.length === 0) { return null; } if (slide.imageUrls.length === 0) { return null; }
return dom('div', slide.imageUrls.map(src => dom('img', {src}))); 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); private async _loadSlides() {
document.removeEventListener('touchmove', this._handleTouchMove); const tableId = 'GristDocTutorial';
document.removeEventListener('touchend', this._handleTouchEnd); if (!this._docData.getTable(tableId)) {
window.removeEventListener('resize', this._handleWindowResize); throw new Error('DocTutorial failed to find table GristDocTutorial');
}), }
cssPopup.cls('-minimized', this._isMinimized),
cssPopup.cls('-mobile', isNarrowScreenObs()), await this._docComm.waitForInitialization();
dom.cls('doc-tutorial-popup'), if (this.isDisposed()) { return; }
testId('popup'),
await this._docData.fetchTable(tableId);
if (this.isDisposed()) { return; }
const tableData = this._docData.getTable(tableId)!;
const slides = (await Promise.all(
sortBy(tableData.getRowIds(), tableData.getRowPropFunc('manualSort') as any)
.map(async rowId => {
let slideTitle: string | undefined;
const imageUrls: string[] = [];
const getValue = (colId: string): string | undefined => {
const value = tableData.getValue(rowId, colId);
return value ? String(value) : undefined;
};
const walkTokens = (token: marked.Token) => {
if (token.type === 'image') {
imageUrls.push(token.href);
}
if (!slideTitle && token.type === 'heading' && token.depth === 1) {
slideTitle = token.text;
}
};
let slideContent = getValue('slide_content');
if (!slideContent) { return null; }
slideContent = sanitizeHTML(await marked.parse(slideContent, {
async: true, renderer, walkTokens
}));
let boxContent = getValue('box_content');
if (boxContent) {
boxContent = sanitizeHTML(await marked.parse(boxContent, {
async: true, renderer, walkTokens
}));
}
return {
slideContent,
boxContent,
slideTitle,
imageUrls,
};
})
)).filter(slide => slide !== null) as DocTutorialSlide[];
if (this.isDisposed()) { return; }
if (slides.length === 0) {
throw new Error('DocTutorial failed to find slides in table GristDocTutorial');
}
this._slides.set(slides);
}
private async _saveCurrentSlidePosition() {
const currentOptions = this._currentDoc?.options ?? {};
await this._appModel.api.updateDoc(this._docId, {
options: {
...currentOptions,
tutorial: {
lastSlideIndex: this._currentSlideIndex.get(),
}
}
});
}
private async _changeSlide(slideIndex: number) {
this._currentSlideIndex.set(slideIndex);
await this._saveCurrentSlidePositionDebounced();
}
private async _previousSlide() {
await this._changeSlide(this._currentSlideIndex.get() - 1);
}
private async _nextSlide() {
await this._changeSlide(this._currentSlideIndex.get() + 1);
}
private async _finishTutorial() {
this._saveCurrentSlidePositionDebounced.cancel();
await this._saveCurrentSlidePosition();
await urlState().pushUrl({});
}
private async _restartTutorial() {
const doRestart = async () => {
const urlId = this._currentDoc!.id;
const {trunkId} = parseUrlId(urlId);
const docApi = this._appModel.api.getDocAPI(urlId);
await docApi.replace({sourceDocId: trunkId, resetTutorialMetadata: true});
};
confirmModal(
'Do you want to restart the tutorial? All progress will be lost.',
'Restart',
doRestart
); );
} }
private _restartGIFs() {
return (element: HTMLElement) => {
setTimeout(() => {
const imgs = element.querySelectorAll('img');
for (const img of imgs) {
// Re-assigning src to itself is a neat way to restart a GIF.
// eslint-disable-next-line no-self-assign
img.src = img.src;
}
}, 0);
};
}
private _openLightbox(src: string) { private _openLightbox(src: string) {
modal((ctl) => { modal((ctl) => {
this.onDispose(ctl.close); 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', ` const cssPopupFooter = styled('div', `
display: flex; display: flex;
@ -526,20 +303,7 @@ const cssTryItOutBox = styled('div', `
background-color: ${theme.tutorialsPopupBoxBg}; 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', ` const cssPopupFooterButton = styled('div', `
--icon-color: ${theme.controlSecondaryFg}; --icon-color: ${theme.controlSecondaryFg};

View File

@ -2,26 +2,26 @@
* This module export a component for editing some document settings consisting of the timezone, * This module export a component for editing some document settings consisting of the timezone,
* (new settings to be added here ...). * (new settings to be added here ...).
*/ */
import {makeT} from 'app/client/lib/localization'; import {GristDoc} from 'app/client/components/GristDoc';
import {Computed, Disposable, dom, fromKo, IDisposableOwner, styled} from 'grainjs'; import {ACIndexImpl} from 'app/client/lib/ACIndex';
import {ACSelectItem, buildACSelect} from "app/client/lib/ACSelect"; import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {ACIndexImpl} from "app/client/lib/ACIndex"; import {makeT} from 'app/client/lib/localization';
import {docListHeader} from "app/client/ui/DocMenuCss"; 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 {showTransientTooltip} from 'app/client/ui/tooltips';
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {select} from 'app/client/ui2018/menus'; import {select} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker'; import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete'; import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete';
import {EngineCode} from 'app/common/DocumentSettings'; import {EngineCode} from 'app/common/DocumentSettings';
import {GristLoadConfig} from 'app/common/gristUrls'; import {GristLoadConfig} from 'app/common/gristUrls';
import {propertyCompare} from "app/common/gutil"; import {propertyCompare} from 'app/common/gutil';
import {getCurrency, locales} from "app/common/Locales"; import {getCurrency, locales} from 'app/common/Locales';
import {GristDoc} from 'app/client/components/GristDoc'; import {Computed, Disposable, dom, fromKo, IDisposableOwner, styled} from 'grainjs';
import * as moment from "moment-timezone"; 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';
const t = makeT('DocumentSettings'); const t = makeT('DocumentSettings');

View File

@ -3,6 +3,7 @@ import {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec'; import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight'; import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
import {buildAiButton} from 'app/client/ui/FormulaAssistance';
import {GristTooltips} from 'app/client/ui/GristTooltips'; import {GristTooltips} from 'app/client/ui/GristTooltips';
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {withInfoTooltip} from 'app/client/ui/tooltips'; 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 {textInput} from 'app/client/ui2018/editableLabel';
import {cssIconButton, icon} from 'app/client/ui2018/icons'; import {cssIconButton, icon} from 'app/client/ui2018/icons';
import {IconName} from 'app/client/ui2018/IconList'; 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 {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor'; import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
import {sanitizeIdent} from 'app/common/gutil'; import {sanitizeIdent} from 'app/common/gutil';
@ -356,6 +358,7 @@ export function buildFormulaConfig(
]), ]),
formulaBuilder(onSaveConvertToFormula), formulaBuilder(onSaveConvertToFormula),
cssEmptySeparator(), cssEmptySeparator(),
dom.maybe(GRIST_FORMULA_ASSISTANT(), () => cssRow(buildAiButton(gristDoc, origColumn))),
cssRow(textButton( cssRow(textButton(
t("Convert to trigger formula"), t("Convert to trigger formula"),
dom.on("click", convertFormulaToTrigger), dom.on("click", convertFormulaToTrigger),

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import {ActionGroup} from 'app/common/ActionGroup'; import {ActionGroup} from 'app/common/ActionGroup';
import {Prompt, Suggestion} from 'app/common/AssistancePrompts';
import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions'; import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
import {FormulaProperties} from 'app/common/GranularAccessClause'; import {FormulaProperties} from 'app/common/GranularAccessClause';
import {UIRowId} from 'app/common/UIRowId'; 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. * 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. * Fetch content at a url.

View File

@ -583,6 +583,9 @@ export interface GristLoadConfig {
// TODO: remove when comments will be released. // TODO: remove when comments will be released.
featureComments?: boolean; featureComments?: boolean;
// TODO: remove once released.
featureFormulaAssistant?: boolean;
// Email address of the support user. // Email address of the support user.
supportEmail?: string; supportEmail?: string;

View File

@ -11,10 +11,13 @@ export const DEPS = { fetch };
export async function sendForCompletion(prompt: string): Promise<string> { export async function sendForCompletion(prompt: string): Promise<string> {
let completion: string|null = null; let completion: string|null = null;
let retries: number = 0; let retries: number = 0;
const openApiKey = process.env.OPENAI_API_KEY;
const model = process.env.COMPLETION_MODEL || "text-davinci-002";
while(retries++ < 3) { while(retries++ < 3) {
try { try {
if (process.env.OPENAI_API_KEY) { if (openApiKey) {
completion = await sendForCompletionOpenAI(prompt); completion = await sendForCompletionOpenAI(prompt, openApiKey, model);
} }
if (process.env.HUGGINGFACE_API_KEY) { if (process.env.HUGGINGFACE_API_KEY) {
completion = await sendForCompletionHuggingFace(prompt); completion = await sendForCompletionHuggingFace(prompt);
@ -33,8 +36,7 @@ export async function sendForCompletion(prompt: string): Promise<string> {
} }
async function sendForCompletionOpenAI(prompt: string) { async function sendForCompletionOpenAI(prompt: string, apiKey: string, model = "text-davinci-002") {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) { if (!apiKey) {
throw new Error("OPENAI_API_KEY not set"); throw new Error("OPENAI_API_KEY not set");
} }
@ -51,7 +53,7 @@ async function sendForCompletionOpenAI(prompt: string) {
max_tokens: 150, max_tokens: 150,
temperature: 0, temperature: 0,
// COMPLETION_MODEL of `code-davinci-002` may be better if you have access to it. // 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"], stop: ["\n\n"],
}), }),
}, },

View File

@ -1,4 +1,5 @@
import {getPageTitleSuffix, GristLoadConfig, HideableUiElements, IHideableUiElement} from 'app/common/gristUrls'; import {getPageTitleSuffix, GristLoadConfig, HideableUiElements, IHideableUiElement} from 'app/common/gristUrls';
import {isAffirmative} from 'app/common/gutil';
import {getTagManagerSnippet} from 'app/common/tagManager'; import {getTagManagerSnippet} from 'app/common/tagManager';
import {Document} from 'app/common/UserAPI'; import {Document} from 'app/common/UserAPI';
import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager'; 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), survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID, tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
activation: getActivation(req as RequestWithLogin | undefined), 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), supportedLngs: readLoadedLngs(req?.i18n),
namespaces: readLoadedNamespaces(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, supportEmail: SUPPORT_EMAIL,
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale, userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
...extra, ...extra,

View File

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