mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
'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().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', '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) => {
|
||||
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;
|
||||
`);
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user