(core) Floating formula editor

Summary:
Adding a way to detach an editor. Initially only implemented for the formula editor, includes redesign for the AI part.
- Initially, the detached editor is tight with the formula assistant and both are behind GRIST_FORMULA_ASSISTANT flag, but this can be relaxed
later on, as the detached editor can be used on its own.

- Detached editor is only supported in regular fields and on the creator panel. It is not supported yet for conditional styles, due to preview limitations.
- Old code for the assistant was removed completely, as it was only a temporary solution, but the AI conversation part was copied to the new one.
- Prompting was not modified in this diff, it will be included in the follow-up with more test cases.

Test Plan: Added only new tests; existing tests should pass.

Reviewers: JakubSerafin

Reviewed By: JakubSerafin

Differential Revision: https://phab.getgrist.com/D3863
This commit is contained in:
Jarosław Sadziński
2023-06-02 13:25:14 +02:00
parent e10067ff78
commit da323fb741
36 changed files with 2022 additions and 823 deletions

View File

@@ -3,7 +3,6 @@ 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';
@@ -13,7 +12,6 @@ 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';
@@ -134,6 +132,8 @@ export function buildFormulaConfig(
// Helper function to clear temporary state (will be called when column changes or formula editor closes)
const clearState = () => bundleChanges(() => {
// For a detached editor, we may have already been disposed when user switched page.
if (owner.isDisposed()) { return; }
maybeFormula.set(false);
maybeTrigger.set(false);
formulaField = null;
@@ -277,6 +277,8 @@ export function buildFormulaConfig(
// Converts column to formula column.
const onSaveConvertToFormula = async (column: ColumnRec, formula: string) => {
// For a detached editor, we may have already been disposed when user switched page.
if (owner.isDisposed()) { return; }
// For non formula column, we will not convert it to formula column when expression is empty,
// as it means we were trying to convert data column to formula column, but changed our mind.
const notBlank = Boolean(formula);
@@ -362,7 +364,6 @@ 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),

View File

@@ -1,7 +1,9 @@
import {documentCursor} from 'app/client/lib/popupUtils';
import {hoverTooltip} from 'app/client/ui/tooltips';
import {isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Disposable, dom, DomArg, DomContents, IDisposable, makeTestId, Observable, styled} from 'grainjs';
import {Disposable, dom, DomContents, DomElementArg,
IDisposable, makeTestId, Observable, styled} from 'grainjs';
const POPUP_INITIAL_PADDING_PX = 16;
const POPUP_MIN_HEIGHT = 300;
@@ -15,9 +17,11 @@ export interface PopupOptions {
content?: () => DomContents;
onClose?: () => void;
closeButton?: boolean;
closeButtonHover?: () => DomContents;
autoHeight?: boolean;
/** Defaults to false. */
stopClickPropagationOnMove?: boolean;
args?: DomElementArg[];
}
export class FloatingPopup extends Disposable {
@@ -34,7 +38,7 @@ export class FloatingPopup extends Disposable {
private _resize = false;
private _cursorGrab: IDisposable|null = null;
constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) {
constructor(protected _options: PopupOptions = {}) {
super();
if (_options.stopClickPropagationOnMove){
@@ -98,7 +102,7 @@ export class FloatingPopup extends Disposable {
}
protected _buildArgs(): any {
return this._args;
return this._options.args ?? [];
}
private _rememberPosition() {
@@ -272,12 +276,12 @@ export class FloatingPopup extends Disposable {
// 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')
),
!this._options.closeButton ? null : cssPopupHeaderButton(
icon('CrossBig'),
),
dom.style('visibility', 'hidden'),
),
cssPopupTitle(
@@ -285,19 +289,20 @@ export class FloatingPopup extends Disposable {
testId('title'),
),
cssPopupButtons(
!this._options.closeButton ? null : cssPopupHeaderButton(
icon('CrossSmall'),
dom.on('click', () => {
this._options.onClose?.() ?? this._closePopup();
}),
testId('close'),
),
this._popupMinimizeButtonElement = cssPopupHeaderButton(
isMinimized ? icon('Maximize'): icon('Minimize'),
hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}),
dom.on('click', () => this._minimizeOrMaximize()),
testId('minimize-maximize'),
),
!this._options.closeButton ? null : cssPopupHeaderButton(
icon('CrossBig'),
dom.on('click', () => {
this._options.onClose?.() ?? this._closePopup();
}),
testId('close'),
this._options.closeButtonHover && hoverTooltip(this._options.closeButtonHover())
),
// Disable dragging when a button in the header is clicked.
dom.on('mousedown', ev => ev.stopPropagation()),
dom.on('touchstart', ev => ev.stopPropagation()),
@@ -345,20 +350,7 @@ export class FloatingPopup extends Disposable {
private _forceCursor() {
this._cursorGrab?.dispose();
const type = this._resize ? 'ns-resize' : 'grabbing';
const cursorStyle: HTMLStyleElement = document.createElement('style');
cursorStyle.innerHTML = `*{cursor: ${type}!important;}`;
cursorStyle.id = 'cursor-style';
document.head.appendChild(cursorStyle);
const cursorOwner = {
dispose() {
if (this.isDisposed()) { return; }
document.head.removeChild(cursorStyle);
},
isDisposed() {
return !cursorStyle.isConnected;
}
};
this._cursorGrab = cursorOwner;
this._cursorGrab = documentCursor(type);
}
}
@@ -372,7 +364,7 @@ const POPUP_HEIGHT = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDIN
const POPUP_HEIGHT_MOBILE = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px) - (2 * 50px)))`;
const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`;
const cssPopup = styled('div', `
const cssPopup = styled('div.floating-popup', `
position: fixed;
display: flex;
flex-direction: column;

View File

@@ -1,605 +0,0 @@
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 {AssistanceResponse, AssistanceState} 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().messages.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().messages));
const hasHistory = history.get().length > 0;
const enabled = Observable.create(owner, hasHistory);
const introVisible = Observable.create(owner, !hasHistory);
owner.autoDispose(history.addListener((cur) => {
const chatHistory = column.chatHistory.peek();
chatHistory.set({...chatHistory.get(), messages: [...cur]});
}));
const submit = async (regenerate: boolean = false) => {
// Send most recent question, and send back any conversation
// state we have been asked to track.
const chatHistory = column.chatHistory.peek().get();
const messages = chatHistory.messages.filter(msg => msg.sender === 'user');
const description = messages[messages.length - 1]?.message || '';
console.debug('description', {description});
const {reply, suggestedActions, state} = await askAI(grist, {
column, description, state: chatHistory.state,
regenerate,
});
console.debug('suggestedActions', {suggestedActions, reply});
const firstAction = suggestedActions[0] as any;
// Add the formula to the history.
const formula = firstAction ? firstAction[3].formula as string : undefined;
// Add to history
history.push({
message: formula || reply || '(no reply)',
sender: 'ai',
formula
});
// If back-end is capable of conversation, keep its state.
if (state) {
const chatHistoryNew = column.chatHistory.peek();
const value = chatHistoryNew.get();
value.state = state;
chatHistoryNew.set(value);
}
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(true));
};
const newChat = () => {
// Clear the history.
history.set([]);
column.chatHistory.peek().set({messages: []});
// 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, {
gristTheme: grist.currentTheme,
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) => {
if (formula) {
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, options: {
column: ColumnRec,
description: string,
regenerate?: boolean,
state?: AssistanceState
}): Promise<AssistanceResponse> {
const {column, description, state, regenerate} = options;
const tableId = column.table.peek().tableId.peek();
const colId = column.colId.peek();
try {
const result = await grist.docComm.getAssistance({
context: {type: 'formula', tableId, colId},
text: description,
state,
regenerate,
});
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

@@ -86,6 +86,7 @@ function resize(el: HTMLTextAreaElement) {
export function autoGrow(text: Observable<string>) {
return (el: HTMLTextAreaElement) => {
el.addEventListener('input', () => resize(el));
dom.autoDisposeElem(el, text.addListener(() => resize(el)));
setTimeout(() => resize(el), 10);
dom.autoDisposeElem(el, text.addListener(val => {
// Changes to the text are not reflected by the input event (witch is used by the autoGrow)

View File

@@ -266,7 +266,11 @@ export function setHoverTooltip(
*/
export function tooltipCloseButton(ctl: ITooltipControl): HTMLElement {
return cssTooltipCloseButton(icon('CrossSmall'),
dom.on('click', () => ctl.close()),
dom.on('mousedown', (ev) =>{
ev.stopPropagation();
ev.preventDefault();
ctl.close();
}),
testId('tooltip-close'),
);
}