mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
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,27 +45,107 @@ 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);
|
||||
|
||||
this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup()));
|
||||
|
||||
this.onDispose(() => {
|
||||
this._closePopup();
|
||||
});
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this._showPopup();
|
||||
this.showPopup();
|
||||
await this._loadSlides();
|
||||
}
|
||||
|
||||
protected _buildTitle() {
|
||||
return dom('span', dom.text(this._gristDoc.docPageModel.currentDocTitle), testId('popup-header'));
|
||||
}
|
||||
|
||||
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'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
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() {
|
||||
const tableId = 'GristDocTutorial';
|
||||
if (!this._docData.getTable(tableId)) {
|
||||
@ -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),
|
||||
|
330
app/client/ui/FloatingPopup.ts
Normal file
330
app/client/ui/FloatingPopup.ts
Normal 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};
|
||||
}
|
||||
`);
|
578
app/client/ui/FormulaAssistance.ts
Normal file
578
app/client/ui/FormulaAssistance.ts
Normal 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(
|
||||
'Grist’s AI Formula Assistance. Need help? Our AI assistant can help. ',
|
||||
textButton('Ask the bot.', dom.on('click', robotClicked)),
|
||||
dom.show(robotWellVisible)
|
||||
),
|
||||
cssWell(
|
||||
'Formula Help. See our Function List and Formula Cheat Sheet, or visit our Community for more help.',
|
||||
dom.show(helpWellVisible)
|
||||
),
|
||||
dom.show(use => use(robotWellVisible) || use(helpWellVisible))
|
||||
);
|
||||
};
|
||||
const buildDom = () => {
|
||||
return [
|
||||
cssButtonsWrapper(
|
||||
cssButtons(
|
||||
primaryButton('Save', dom.show(saveButtonVisible), dom.on('click', saveClicked)),
|
||||
basicButton('Preview', dom.show(previewButtonVisible), dom.on('click', previewClicked)),
|
||||
textButton('🤖', dom.show(robotButtonVisible), dom.on('click', robotClicked)),
|
||||
dom.show(
|
||||
use => use(previewButtonVisible) || use(saveButtonVisible) || use(robotButtonVisible)
|
||||
)
|
||||
)
|
||||
),
|
||||
buildWells(),
|
||||
];
|
||||
};
|
||||
return {
|
||||
buildDom,
|
||||
preview,
|
||||
hideHelp() {
|
||||
robotWellVisible.set(false);
|
||||
helpWellVisible.set(false);
|
||||
},
|
||||
hideRobot() {
|
||||
robotButtonVisible.set(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildChat(owner: Disposable, context: Context & { formulaClicked: (formula: string) => void }) {
|
||||
const { grist, column } = context;
|
||||
|
||||
const history = owner.autoDispose(obsArray(column.chatHistory.peek().get()));
|
||||
const hasHistory = history.get().length > 0;
|
||||
const enabled = Observable.create(owner, hasHistory);
|
||||
const introVisible = Observable.create(owner, !hasHistory);
|
||||
owner.autoDispose(history.addListener((cur) => {
|
||||
column.chatHistory.peek().set([...cur]);
|
||||
}));
|
||||
|
||||
const submit = async () => {
|
||||
// Ask about suggestion, and send the whole history. Currently the chat is implemented by just sending
|
||||
// all previous user prompts back to the AI. This is subject to change (and probably should be done in the backend).
|
||||
const prompt = history.get().filter(x => x.sender === 'user')
|
||||
.map(entry => entry.message)
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
console.debug('prompt', prompt);
|
||||
const { suggestedActions } = await askAI(grist, column, prompt);
|
||||
console.debug('suggestedActions', suggestedActions);
|
||||
const firstAction = suggestedActions[0] as any;
|
||||
// Add the formula to the history.
|
||||
const formula = firstAction[3].formula as string;
|
||||
// Add to history
|
||||
history.push({
|
||||
message: formula,
|
||||
sender: 'ai',
|
||||
formula
|
||||
});
|
||||
return formula;
|
||||
};
|
||||
|
||||
const chatEnterClicked = async (val: string) => {
|
||||
if (!val) { return; }
|
||||
// Hide intro.
|
||||
introVisible.set(false);
|
||||
// Add question to the history.
|
||||
history.push({
|
||||
message: val,
|
||||
sender: 'user',
|
||||
});
|
||||
// Submit all questions to the AI.
|
||||
context.formulaClicked(await submit());
|
||||
};
|
||||
|
||||
const regenerateClick = async () => {
|
||||
// Remove the last AI response from the history.
|
||||
history.pop();
|
||||
// And submit again.
|
||||
context.formulaClicked(await submit());
|
||||
};
|
||||
|
||||
const newChat = () => {
|
||||
// Clear the history.
|
||||
history.set([]);
|
||||
// Show intro.
|
||||
introVisible.set(true);
|
||||
};
|
||||
|
||||
const userPrompt = Observable.create(owner, '');
|
||||
|
||||
const userImage = () => {
|
||||
const user = grist.app.topAppModel.appObs.get()?.currentUser || null;
|
||||
if (user) {
|
||||
return (createUserImage(user, 'medium'));
|
||||
} else {
|
||||
// TODO: this will not happen, as this should be only for logged in users.
|
||||
return (dom('div', ''));
|
||||
}
|
||||
};
|
||||
|
||||
const buildHistory = () => {
|
||||
return cssVBox(
|
||||
dom.forEach(history, entry => {
|
||||
if (entry.sender === 'user') {
|
||||
return cssMessage(
|
||||
cssAvatar(userImage()),
|
||||
dom.text(entry.message),
|
||||
);
|
||||
} else {
|
||||
return cssAiMessage(
|
||||
cssAvatar(cssAiImage()),
|
||||
buildHighlightedCode(entry.message, { maxLines: 10 }, cssCodeStyles.cls('')),
|
||||
cssCopyIconWrapper(
|
||||
icon('Copy', dom.on('click', () => context.formulaClicked(entry.message))),
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const buildIntro = () => {
|
||||
return cssVBox(
|
||||
dom.cls(cssTopGreenBorder.className),
|
||||
dom.cls(cssTypography.className),
|
||||
dom.style('flex-grow', '1'),
|
||||
dom.style('min-height', '0'),
|
||||
dom.style('overflow-y', 'auto'),
|
||||
dom.maybe(introVisible, () =>
|
||||
cssHContainer(
|
||||
dom.style('margin-bottom', '10px'),
|
||||
cssVBox(
|
||||
dom('h4', 'Grist’s AI Assistance'),
|
||||
dom('h5', 'Tips'),
|
||||
cssWell(
|
||||
'“Example prompt” Some instructions for how to draft a prompt. A link to even more examples in support.'
|
||||
),
|
||||
cssWell(
|
||||
'Example Values. Instruction about entering example values in the column, maybe with an image?'
|
||||
),
|
||||
dom('h5', 'Capabilities'),
|
||||
cssWell(
|
||||
'Formula Assistance Only. Python code. Spreadsheet functions? May sometimes get it wrong. '
|
||||
),
|
||||
cssWell('Conversational. Remembers what was said and allows follow-up corrections.'),
|
||||
dom('h5', 'Data'),
|
||||
cssWell(
|
||||
'Data Usage. Something about how we can see prompts to improve the feature and product, but cannot see doc.'
|
||||
),
|
||||
cssWell(
|
||||
'Data Sharing. Something about OpenAI, what’s being transmitted. How does it expose doc data?'
|
||||
),
|
||||
textButton('Learn more', dom.style('align-self', 'flex-start'))
|
||||
)
|
||||
)
|
||||
),
|
||||
dom.maybe(
|
||||
use => !use(introVisible),
|
||||
() => buildHistory()
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const buildButtons = () => {
|
||||
// We will show buttons only if we have a history.
|
||||
return dom.maybe(use => use(history).length > 0, () => cssVContainer(
|
||||
cssHBox(
|
||||
cssPlainButton(icon('Script'), 'New Chat', dom.on('click', newChat)),
|
||||
cssPlainButton(icon('Revert'), 'Regenerate', dom.on('click', regenerateClick), dom.style('margin-left', '8px')),
|
||||
),
|
||||
dom.style('padding-bottom', '0'),
|
||||
dom.style('padding-top', '12px'),
|
||||
));
|
||||
};
|
||||
|
||||
const buildInput = () => {
|
||||
return cssHContainer(
|
||||
dom.cls(cssTopBorder.className),
|
||||
dom.cls(cssVSpace.className),
|
||||
cssInputWrapper(
|
||||
dom.cls(cssTextInput.className),
|
||||
dom.cls(cssTypography.className),
|
||||
rawTextInput(userPrompt, chatEnterClicked, noop),
|
||||
icon('FieldAny')
|
||||
),
|
||||
buildButtons()
|
||||
);
|
||||
};
|
||||
|
||||
const buildDom = () => {
|
||||
return dom.maybe(enabled, () => cssVFullBox(
|
||||
buildIntro(),
|
||||
cssSpacer(),
|
||||
buildInput(),
|
||||
dom.style('overflow', 'hidden'),
|
||||
dom.style('flex-grow', '1')
|
||||
));
|
||||
};
|
||||
|
||||
return {
|
||||
buildDom,
|
||||
show() {
|
||||
enabled.set(true);
|
||||
introVisible.set(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and opens up a Formula Popup with an AI assistant.
|
||||
*/
|
||||
function openAIAssistant(grist: GristDoc, column: ColumnRec) {
|
||||
const owner = MultiHolder.create(null);
|
||||
const props: Context = { grist, column };
|
||||
|
||||
// Build up all components, and wire up them to each other.
|
||||
|
||||
// First is the formula editor displayed in the upper part of the popup.
|
||||
const formulaEditor = buildFormula(owner, props);
|
||||
|
||||
// Next are the buttons in the middle. It has a Save, Preview, and Robot button, and probably some wells
|
||||
// with tips or other buttons.
|
||||
const controls = buildControls(owner, {
|
||||
...props,
|
||||
// Pass a formula accessor, it is used to get the current formula and apply or preview it.
|
||||
currentFormula: () => formulaEditor.get(),
|
||||
// Event or saving, we listen to it to close the popup.
|
||||
savedClicked() {
|
||||
grist.formulaPopup.clear();
|
||||
},
|
||||
// Handler for robot icon click. We hide the robot icon and the help, and show the chat area.
|
||||
robotClicked() {
|
||||
chat.show();
|
||||
controls.hideHelp();
|
||||
controls.hideRobot();
|
||||
}
|
||||
});
|
||||
|
||||
// Now, the chat area. It has a history of previous questions, and a prompt for the user to ask a new
|
||||
// question.
|
||||
const chat = buildChat(owner, {...props,
|
||||
// When a formula is clicked (or just was returned from the AI), we set it in the formula editor and hit
|
||||
// the preview button.
|
||||
formulaClicked: (formula: string) => {
|
||||
formulaEditor.set(formula);
|
||||
controls.preview().catch(reportError);
|
||||
},
|
||||
});
|
||||
|
||||
const header = `${column.table.peek().tableNameDef.peek()}.${column.label.peek()}`;
|
||||
const popup = FloatingPopup.create(null, {
|
||||
title: () => header,
|
||||
content: () => [
|
||||
formulaEditor.buildDom(),
|
||||
controls.buildDom(),
|
||||
chat.buildDom(),
|
||||
],
|
||||
onClose: () => grist.formulaPopup.clear(),
|
||||
closeButton: true,
|
||||
autoHeight: true,
|
||||
});
|
||||
|
||||
popup.autoDispose(owner);
|
||||
popup.showPopup();
|
||||
|
||||
// Add this popup to the main holder (and dispose the previous one).
|
||||
grist.formulaPopup.autoDispose(popup);
|
||||
}
|
||||
|
||||
async function askAI(grist: GristDoc, column: ColumnRec, description: string): Promise<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…
Reference in New Issue
Block a user