mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
da323fb741
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
1102 lines
32 KiB
TypeScript
1102 lines
32 KiB
TypeScript
import * as commands from 'app/client/components/commands';
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
|
import {makeT} from 'app/client/lib/localization';
|
|
import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
|
import {ChatMessage} from 'app/client/models/entities/ColumnRec';
|
|
import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features';
|
|
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
|
|
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
|
import {createUserImage} from 'app/client/ui/UserImage';
|
|
import {loadingDots} from 'app/client/ui2018/loaders';
|
|
import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
|
|
import {AssistanceResponse, AssistanceState} from 'app/common/AssistancePrompts';
|
|
import {commonUrls} from 'app/common/gristUrls';
|
|
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
|
import {theme} from 'app/client/ui2018/cssVars';
|
|
import {autoGrow} from 'app/client/ui/forms';
|
|
import {IconName} from 'app/client/ui2018/IconList';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {cssLink} from 'app/client/ui2018/links';
|
|
import {DocAction} from 'app/common/DocActions';
|
|
import {movable} from 'app/client/lib/popupUtils';
|
|
|
|
import debounce from 'lodash/debounce';
|
|
import {Computed, Disposable, dom, DomContents, DomElementArg, keyframes,
|
|
makeTestId,
|
|
MutableObsArray, obsArray, Observable, styled} from 'grainjs';
|
|
import noop from 'lodash/noop';
|
|
import {marked} from 'marked';
|
|
|
|
const t = makeT('FormulaEditor');
|
|
const testId = makeTestId('test-formula-editor-');
|
|
|
|
/**
|
|
* An extension or the FormulaEditor that provides assistance for writing formulas.
|
|
* It renders itself in the detached FormulaEditor and adds some extra UI elements.
|
|
* - Save button: a subscription for the Enter key that saves the formula and closes the assistant.
|
|
* - Preview button: a new functionality that allows to preview the formula in a temporary column.
|
|
* - Two info cards: that describes what this is and how to use it.
|
|
* - A chat component: that allows to communicate with the assistant.
|
|
*/
|
|
export class FormulaAssistant extends Disposable {
|
|
/** Chat component */
|
|
private _chat: ChatHistory;
|
|
/** State of the user input */
|
|
private _userInput = Observable.create(this, '');
|
|
/** Is formula description card dismissed */
|
|
private _isFormulaInfoClosed: Observable<boolean>;
|
|
/** Is Ai card dismissed */
|
|
private _isAssistantInfoClosed: Observable<boolean>;
|
|
/** Are any cards dismissed */
|
|
private _cardsVisible: Observable<boolean>;
|
|
/** Dom element that holds the user input */
|
|
// TODO: move it to a separate component
|
|
private _input: HTMLTextAreaElement;
|
|
/** Do we need to show an intro, we show it when history is empty */
|
|
private _introVisible: Observable<boolean>;
|
|
/** Do we need to show a robot icon, we show it when history is empty and assistant is disabled */
|
|
private _robotIconVisible: Observable<boolean>;
|
|
/** Is chat active, we show it when history is not empty */
|
|
private _chatActive = Observable.create(this, false);
|
|
/** Is the request pending */
|
|
private _waiting = Observable.create(this, false);
|
|
/** Is this feature enabled at all */
|
|
private _assistantEnabled = GRIST_FORMULA_ASSISTANT();
|
|
/** Preview column id */
|
|
private _transformColId: string;
|
|
/** Method to invoke when we are closed, it saves or reverts */
|
|
private _triggerFinalize: (() => void) = noop;
|
|
/** What action button was clicked, by default close without saving */
|
|
private _action: 'save' | 'cancel' | 'close' = 'close';
|
|
// Our dom element (used for resizing).
|
|
private _domElement: HTMLElement;
|
|
// Input wrapper element (used for resizing).
|
|
private _inputWrapper: HTMLElement;
|
|
/**
|
|
* Debounced version of the method that will force parent editor to resize, we call it often
|
|
* as we have an ability to resize the chat window.
|
|
*/
|
|
private _resizeEditor = debounce(() => {
|
|
if (!this.isDisposed()) {
|
|
this._options.editor.resize();
|
|
}
|
|
}, 10);
|
|
|
|
constructor(private _options: {
|
|
column: ColumnRec,
|
|
field?: ViewFieldRec,
|
|
gristDoc: GristDoc,
|
|
editor: FormulaEditor
|
|
}) {
|
|
super();
|
|
|
|
if (!this._options.field) {
|
|
// TODO: field is not passed only for rules (as there is no preview there available to the user yet)
|
|
// this should be implemented but it requires creating a helper column to helper column and we don't
|
|
// have infrastructure for that yet.
|
|
throw new Error('Formula assistant requires a field to be passed.');
|
|
}
|
|
|
|
this._chat = ChatHistory.create(this, {
|
|
...this._options,
|
|
copyClicked: this._copyClicked.bind(this),
|
|
});
|
|
|
|
const hasHistory = Computed.create(this, use => use(this._chat.length) > 0);
|
|
if (hasHistory.get()) {
|
|
this._chatActive.set(true);
|
|
}
|
|
|
|
this.autoDispose(commands.createGroup({
|
|
activateAssistant: () => {
|
|
this._robotIconClicked();
|
|
setTimeout(() => {
|
|
this._input.focus();
|
|
}, 0);
|
|
}
|
|
}, this, true));
|
|
|
|
// Calculate some flags what to show when.
|
|
this._isFormulaInfoClosed = this.autoDispose(_options.gristDoc.appModel.dismissedPopup('formulaHelpInfo'));
|
|
this._isAssistantInfoClosed = this.autoDispose(_options.gristDoc.appModel.dismissedPopup('formulaAssistantInfo'));
|
|
this._cardsVisible = Computed.create(this, use => {
|
|
const seenInfo = use(this._isFormulaInfoClosed);
|
|
const seenAi = use(this._isAssistantInfoClosed);
|
|
const aiEnable = use(this._assistantEnabled);
|
|
const nothingToShow = seenInfo && (seenAi || !aiEnable);
|
|
if (nothingToShow) {
|
|
return false;
|
|
}
|
|
if (use(hasHistory)) {
|
|
return false;
|
|
}
|
|
if (use(this._chatActive)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
this._introVisible = Computed.create(this, use => {
|
|
if (use(hasHistory)) {
|
|
return false;
|
|
}
|
|
if (use(this._chatActive)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
this._robotIconVisible = Computed.create(this, use => {
|
|
if (!use(this._assistantEnabled)) { return false; }
|
|
if (use(hasHistory)) {
|
|
return false;
|
|
}
|
|
if (use(this._introVisible)) {
|
|
return false;
|
|
}
|
|
if (use(this._chatActive)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Unfortunately we need to observe the size of the formula editor dom and resize it accordingly.
|
|
const observer = new ResizeObserver(this._resizeEditor);
|
|
observer.observe(this._options.editor.getDom());
|
|
this.onDispose(() => observer.disconnect());
|
|
|
|
// Start bundling all actions from this moment on and close the editor as soon,
|
|
// as user tries to do something different.
|
|
const bundleInfo = this._options.gristDoc.docData.startBundlingActions({
|
|
description: 'Formula Editor',
|
|
prepare: () => this._preparePreview(),
|
|
finalize: () => this._cleanupPreview(),
|
|
shouldIncludeInBundle: (a) => {
|
|
const tableId = this._options.column.table.peek().tableId.peek();
|
|
const allowed = a.length === 1
|
|
&& a[0][0] === 'ModifyColumn'
|
|
&& a[0][1] === tableId
|
|
&& typeof a[0][2] === 'string'
|
|
&& [this._transformColId, this._options.column.id.peek()].includes(a[0][2]);
|
|
return allowed;
|
|
}
|
|
});
|
|
|
|
this._triggerFinalize = bundleInfo.triggerFinalize;
|
|
this.onDispose(() => {
|
|
// This will be noop if already called.
|
|
this._triggerFinalize();
|
|
});
|
|
}
|
|
|
|
// The main dom added to the editor and the bottom (3 buttons and chat window).
|
|
public buildDom() {
|
|
// When the tools are resized, resize the editor.
|
|
const observer = new ResizeObserver(this._resizeEditor);
|
|
this._domElement = cssTools(
|
|
(el) => observer.observe(el),
|
|
dom.onDispose(() => observer.disconnect()),
|
|
cssButtons(
|
|
primaryButton(t('Save'), dom.on('click', () => {
|
|
this.saveOrClose();
|
|
}), testId('save-button')),
|
|
basicButton(t('Preview'), dom.on('click', async () => {
|
|
await this.preview();
|
|
}), testId('preview-button')),
|
|
this.buildInlineRobotButton(),
|
|
),
|
|
this.buildInfoCards(),
|
|
this.buildChat(),
|
|
);
|
|
return this._domElement;
|
|
}
|
|
|
|
public buildInfoCards() {
|
|
return cssCardList(
|
|
dom.show(this._cardsVisible),
|
|
dom.maybe(use => use(this._assistantEnabled) && !use(this._isAssistantInfoClosed), () =>
|
|
buildCard({
|
|
close: () => this._isAssistantInfoClosed.set(true),
|
|
icon: "Robot",
|
|
title: t("Grist's AI Formula Assistance. "),
|
|
content: dom('span',
|
|
t('Need help? Our AI assistant can help.'), ' ',
|
|
textButton(t('Ask the bot.'), dom.on('click', this._robotIconClicked.bind(this)),),
|
|
),
|
|
args: [testId('ai-well')]
|
|
}),
|
|
),
|
|
dom.maybe(use => !use(this._isFormulaInfoClosed), () =>
|
|
buildCard({
|
|
close: () => this._isFormulaInfoClosed.set(true),
|
|
icon: 'Help',
|
|
title: t("Formula Help. "),
|
|
content: dom('span',
|
|
t('See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.', {
|
|
helpFunction: cssLink(t('Function List'), {href: commonUrls.functions, target: '_blank'}),
|
|
formulaCheat: cssLink(t('Formula Cheat Sheet'), {href: commonUrls.formulaSheet, target: '_blank'}),
|
|
community: cssLink(t('Community'), {href: commonUrls.community, target: '_blank'}),
|
|
})),
|
|
args: [testId('formula-well')]
|
|
})
|
|
),
|
|
);
|
|
}
|
|
|
|
public buildChat() {
|
|
return dom.maybe(this._assistantEnabled, () => {
|
|
setTimeout(() => {
|
|
if (!this.isDisposed()) {
|
|
// Scroll to the bottom of the chat right after it is rendered without the animation.
|
|
this._chat.scrollDown(false);
|
|
}
|
|
this._options.editor.resize();
|
|
}, 0);
|
|
return cssChat(
|
|
testId('chat'),
|
|
dom.maybe(this._chatActive, () => [
|
|
cssTopGreenBorder(
|
|
movable({
|
|
onStart: this._onResizeStart.bind(this),
|
|
onMove: this._onResizeMove.bind(this),
|
|
})
|
|
),
|
|
]),
|
|
this._buildIntro(),
|
|
this._chat.buildDom(),
|
|
this._buildChatInput(),
|
|
);
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Save button handler. We just store the action and wait for the bundler to finalize.
|
|
*/
|
|
public saveOrClose() {
|
|
this._action = 'save';
|
|
this._triggerFinalize();
|
|
}
|
|
|
|
/**
|
|
* Cancel button handler.
|
|
*/
|
|
public async cancel() {
|
|
this._action = 'cancel';
|
|
this._triggerFinalize();
|
|
}
|
|
|
|
/**
|
|
* Preview button handler.
|
|
*/
|
|
public async preview() {
|
|
const tableId = this._options.column.table.peek().tableId.peek();
|
|
// const colId = this._options.column.colId.peek();
|
|
const formula = this._options.editor.getCellValue();
|
|
const isFormula = true;
|
|
await this._options.gristDoc.docData.sendAction(
|
|
['ModifyColumn', tableId, this._transformColId, {formula, isFormula}
|
|
]);
|
|
if (!this.isDisposed()) {
|
|
this._options.editor.focus();
|
|
}
|
|
}
|
|
|
|
public buildInlineRobotButton() {
|
|
return cssRobotButton(
|
|
icon('Robot'),
|
|
dom.show(this._robotIconVisible),
|
|
dom.on('click', this._robotIconClicked.bind(this)),
|
|
testId('robot-button'),
|
|
);
|
|
}
|
|
|
|
private async _preparePreview() {
|
|
const docData = this._options.gristDoc.docData;
|
|
const tableId = this._options.column.table.peek().tableId.peek();
|
|
|
|
// Add a new column to the table, and set it as the transform column.
|
|
const colInfo = await docData.sendAction(['AddColumn', tableId, 'gristHelper_Transform', {
|
|
type: this._options.column.type.peek(),
|
|
label: this._options.column.colId.peek(),
|
|
isFormula: true,
|
|
formula: this._options.column.formula.peek(),
|
|
}]);
|
|
this._options.field?.colRef(colInfo.colRef); // Don't save, it is only in browser.
|
|
this._transformColId = colInfo.colId;
|
|
|
|
// Update the transform column so that it points to the original column.
|
|
const transformColumn = this._options.field?.column.peek();
|
|
if (transformColumn) {
|
|
transformColumn.isTransforming(true);
|
|
this._options.column.isTransforming(true);
|
|
transformColumn.origColRef(this._options.column.getRowId()); // Don't save
|
|
}
|
|
}
|
|
|
|
private async _cleanupPreview() {
|
|
// Mark that we did finalize already.
|
|
this._triggerFinalize = noop;
|
|
const docData = this._options.gristDoc.docData;
|
|
const tableId = this._options.column.table.peek().tableId.peek();
|
|
const column = this._options.column;
|
|
try {
|
|
if (this._action === 'save') {
|
|
const formula = this._options.editor.getCellValue();
|
|
// Modify column right away, so that it looks smoother on the ui, when we
|
|
// switch the column for the field.
|
|
await docData.sendActions([
|
|
['ModifyColumn', tableId, column.colId.peek(), { formula, isFormula: true}],
|
|
]);
|
|
}
|
|
// Switch the column for the field, this isn't sending any actions, we are just restoring it to what it is
|
|
// in database. But now the column has already correct data as it was already calculated.
|
|
this._options.field?.colRef(column.getRowId());
|
|
|
|
// Now trigger the action in our owner that should dispose us. The save
|
|
// method will be no op if we saved anything.
|
|
if (this._action === 'save') {
|
|
commands.allCommands.fieldEditSaveHere.run();
|
|
} else if (this._action === 'cancel') {
|
|
commands.allCommands.fieldEditCancel.run();
|
|
} else {
|
|
if (this._action !== 'close') {
|
|
throw new Error('Unexpected value for _action');
|
|
}
|
|
if (!this.isDisposed()) {
|
|
commands.allCommands.fieldEditCancel.run();
|
|
}
|
|
}
|
|
await docData.sendActions([
|
|
['RemoveColumn', tableId, this._transformColId]
|
|
]);
|
|
} finally {
|
|
// Repeat the change, in case of an error.
|
|
this._options.field?.colRef(column.getRowId());
|
|
column.isTransforming(false);
|
|
}
|
|
}
|
|
|
|
private _onResizeStart() {
|
|
const start = this._domElement?.clientHeight;
|
|
const total = this._options.editor.getDom().clientHeight;
|
|
return {
|
|
start, total
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resize handler for the chat window.
|
|
*/
|
|
private _onResizeMove(x: number, y: number, {start, total}: {start: number, total: number}) {
|
|
// We want to keep the formula well at least 100px tall.
|
|
const minFormulaHeight = 100;
|
|
// The total height of the tools, input and resize line.
|
|
const toolsHeight = 43 + this._inputWrapper.clientHeight + 7;
|
|
const desiredHeight = start - y;
|
|
// Calculate the correct height in the allowed range.
|
|
const calculatedHeight = Math.max(toolsHeight + 10, Math.min(total - minFormulaHeight, desiredHeight));
|
|
this._domElement.style.height = `${calculatedHeight}px`;
|
|
}
|
|
|
|
/**
|
|
* Builds the chat input at the bottom of the chat.
|
|
*/
|
|
private _buildChatInput() {
|
|
// Make sure we dispose the previous input.
|
|
if (this._input) {
|
|
dom.domDispose(this._input);
|
|
}
|
|
const ask = () => this._ask();
|
|
// Input is created by hand, as we need a finer control of the user input than what is available
|
|
// in generic textInput control.
|
|
this._input = cssInput(
|
|
dom.on('input', (ev: Event) => {
|
|
this._userInput.set((ev.target as HTMLInputElement).value);
|
|
}),
|
|
autoGrow(this._userInput),
|
|
dom.onKeyDown({
|
|
Enter$: (ev) => {
|
|
// If shift is pressed, we want to insert a new line.
|
|
if (!ev.shiftKey) {
|
|
ev.preventDefault();
|
|
ask().catch(reportError);
|
|
}
|
|
},
|
|
Escape: this.cancel.bind(this),
|
|
}),
|
|
dom.autoDispose(this._userInput.addListener(value => this._input.value = value)),
|
|
dom.prop('disabled', this._waiting),
|
|
dom.autoDispose(this._waiting.addListener(value => {
|
|
if (!value) {
|
|
setTimeout(() => this._input.focus(), 0);
|
|
}
|
|
})),
|
|
);
|
|
return this._inputWrapper = cssHContainer(
|
|
testId('chat-input'),
|
|
dom.style('margin-top', 'auto'),
|
|
dom.cls(cssTopBorder.className),
|
|
dom.cls(cssVSpace.className),
|
|
dom.on('click', () => this._input.focus()),
|
|
dom.show(this._chatActive),
|
|
cssInputWrapper(
|
|
dom.cls(cssTypography.className),
|
|
this._input,
|
|
dom.domComputed(this._waiting, (waiting) => {
|
|
if (!waiting) { return cssClickableIcon('FieldAny', dom.on('click', ask)); }
|
|
else { return cssLoadingDots(); }
|
|
})
|
|
),
|
|
cssVContainer(
|
|
cssHBox(
|
|
cssPlainButton(
|
|
icon('Script'),
|
|
t('New Chat'),
|
|
dom.on('click', this._clear.bind(this)),
|
|
testId('chat-new')
|
|
),
|
|
cssPlainButton(icon('Revert'), t('Regenerate'),
|
|
dom.on('click', this._regenerate.bind(this)), dom.style('margin-left', '8px'),
|
|
testId('chat-regenerate')
|
|
),
|
|
),
|
|
dom.style('padding-bottom', '0'),
|
|
dom.style('padding-top', '12px'),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Builds the intro section of the chat panel. TODO the copy.
|
|
*/
|
|
private _buildIntro() {
|
|
return dom.maybe(this._introVisible, () => cssInfo(
|
|
testId('chat-intro'),
|
|
cssTopHeader(t("Grist's AI Assistance")),
|
|
cssHeader(t('Tips')),
|
|
cssCardList(
|
|
buildCard({
|
|
title: 'Example prompt: ',
|
|
content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ',
|
|
}),
|
|
buildCard({
|
|
title: 'Example Values: ',
|
|
content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ',
|
|
}),
|
|
),
|
|
cssHeader(t('Capabilities')),
|
|
cssCardList(
|
|
buildCard({
|
|
title: 'Example prompt: ',
|
|
content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ',
|
|
}),
|
|
buildCard({
|
|
title: 'Example Values: ',
|
|
content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ',
|
|
}),
|
|
),
|
|
cssHeader(t('Data')),
|
|
cssCardList(
|
|
buildCard({
|
|
title: 'Data usage. ',
|
|
content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ',
|
|
}),
|
|
buildCard({
|
|
title: 'Data sharing. ',
|
|
content: 'Some instructions for how to draft a prompt. A link to even more examples in support. ',
|
|
}),
|
|
)
|
|
));
|
|
}
|
|
|
|
private _robotIconClicked() {
|
|
this._chatActive.set(true);
|
|
}
|
|
|
|
private _copyClicked(entry: ChatMessage) {
|
|
this._options.editor.setFormula(entry.formula!);
|
|
}
|
|
|
|
private async _sendMessage(description: string, regenerate = false) {
|
|
// Destruct options.
|
|
const {column, gristDoc} = this._options;
|
|
// Get the state of the chat from the column.
|
|
const prevState = column.chatHistory.peek().get().state;
|
|
// Send the message back to the AI with previous state and a mark that we want to regenerate.
|
|
// We can't modify the state here as we treat it as a black box, so we only removed last message
|
|
// from ai from the chat, we grabbed last question and we are sending it back to the AI with a
|
|
// flag that it should clear last response and regenerate it.
|
|
const {reply, suggestedActions, state} = await askAI(gristDoc, {
|
|
column, description, state: prevState,
|
|
regenerate,
|
|
});
|
|
console.debug('suggestedActions', {suggestedActions, reply, state});
|
|
// If back-end is capable of conversation, keep its state.
|
|
const chatHistoryNew = column.chatHistory.peek();
|
|
const value = chatHistoryNew.get();
|
|
value.state = state;
|
|
const formula = (suggestedActions[0]?.[3] as any)?.formula as string;
|
|
// If model has a conversational skills (and maintains a history), we might get actually
|
|
// some markdown text back, so we need to parse it.
|
|
const prettyMessage = state ? (reply || formula || '') : (formula || reply || '');
|
|
// Add it to the chat.
|
|
this._chat.addResponse(prettyMessage, formula, suggestedActions[0]);
|
|
}
|
|
|
|
private _clear() {
|
|
this._chat.clear();
|
|
this._userInput.set('');
|
|
}
|
|
|
|
private async _regenerate() {
|
|
if (this._waiting.get()) {
|
|
return;
|
|
}
|
|
this._chat.removeLastResponse();
|
|
const last = this._chat.lastQuestion();
|
|
if (!last) {
|
|
return;
|
|
}
|
|
this._chat.thinking();
|
|
this._waiting.set(true);
|
|
await this._sendMessage(last, true).finally(() => this._waiting.set(false));
|
|
}
|
|
|
|
private async _ask() {
|
|
if (this._waiting.get()) {
|
|
return;
|
|
}
|
|
const message= this._userInput.get();
|
|
if (!message) { return; }
|
|
this._chat.addQuestion(message);
|
|
this._chat.thinking();
|
|
this._userInput.set('');
|
|
this._waiting.set(true);
|
|
await this._sendMessage(message, false).finally(() => this._waiting.set(false));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A model for the chat panel. It is responsible for keeping the history of the chat and
|
|
* sending messages to the AI.
|
|
*/
|
|
class ChatHistory extends Disposable {
|
|
public history: MutableObsArray<ChatMessage>;
|
|
public length: Computed<number>;
|
|
|
|
private _element: HTMLElement;
|
|
|
|
constructor(private _options: {
|
|
column: ColumnRec,
|
|
gristDoc: GristDoc,
|
|
copyClicked: (entry: ChatMessage) => void,
|
|
}) {
|
|
super();
|
|
const column = this._options.column;
|
|
// Create observable array of messages that is connected to the column's chatHistory.
|
|
this.history = this.autoDispose(obsArray(column.chatHistory.peek().get().messages));
|
|
this.autoDispose(this.history.addListener((cur) => {
|
|
const chatHistory = column.chatHistory.peek();
|
|
chatHistory.set({...chatHistory.get(), messages: [...cur]});
|
|
}));
|
|
this.length = Computed.create(this, use => use(this.history).length); // ??
|
|
}
|
|
|
|
public thinking() {
|
|
this.history.push({
|
|
message: '...',
|
|
sender: 'ai',
|
|
});
|
|
this.scrollDown();
|
|
}
|
|
|
|
public supportsMarkdown() {
|
|
return this._options.column.chatHistory.peek().get().state !== undefined;
|
|
}
|
|
|
|
public addResponse(message: string, formula: string|null, action?: DocAction) {
|
|
// Clear any thinking from messages.
|
|
this.history.set(this.history.get().filter(x => x.message !== '...'));
|
|
this.history.push({
|
|
message,
|
|
sender: 'ai',
|
|
formula,
|
|
action
|
|
});
|
|
this.scrollDown();
|
|
}
|
|
|
|
public addQuestion(message: string) {
|
|
this.history.set(this.history.get().filter(x => x.message !== '...'));
|
|
this.history.push({
|
|
message,
|
|
sender: 'user',
|
|
});
|
|
}
|
|
|
|
public lastQuestion() {
|
|
const list = this.history.get();
|
|
if (list.length === 0) {
|
|
return null;
|
|
}
|
|
const lastMessage = list[list.length - 1];
|
|
if (lastMessage?.sender === 'user') {
|
|
return lastMessage.message;
|
|
}
|
|
throw new Error('No last question found');
|
|
}
|
|
|
|
public removeLastResponse() {
|
|
const lastMessage = this.history.get()[this.history.get().length - 1];
|
|
if (lastMessage?.sender === 'ai') {
|
|
this.history.pop();
|
|
}
|
|
}
|
|
|
|
public clear() {
|
|
this.history.set([]);
|
|
const {column} = this._options;
|
|
// Get the state of the chat from the column.
|
|
const prevState = column.chatHistory.peek().get();
|
|
prevState.state = undefined;
|
|
}
|
|
|
|
public scrollDown(smooth = true) {
|
|
this._element.scroll({
|
|
top: 99999,
|
|
behavior: smooth ? 'smooth' : 'auto'
|
|
});
|
|
}
|
|
|
|
public buildDom() {
|
|
return this._element = cssHistory(
|
|
dom.forEach(this.history, entry => {
|
|
if (entry.sender === 'user') {
|
|
return cssMessage(
|
|
cssAvatar(buildAvatar(this._options.gristDoc)),
|
|
dom('span',
|
|
dom.text(entry.message),
|
|
testId('user-message'),
|
|
testId('chat-message'),
|
|
)
|
|
);
|
|
} else {
|
|
return cssAiMessage(
|
|
cssAvatar(cssAiImage()),
|
|
entry.message === '...' ? cssCursor() :
|
|
this._render(entry.message,
|
|
testId('assistant-message'),
|
|
testId('chat-message'),
|
|
),
|
|
cssCopyIconWrapper(
|
|
dom.show(Boolean(entry.formula)),
|
|
icon('Copy', dom.on('click', () => this._options.copyClicked(entry))),
|
|
)
|
|
);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders the message as markdown if possible, otherwise as a code block.
|
|
*/
|
|
private _render(message: string, ...args: DomElementArg[]) {
|
|
const doc = this._options.gristDoc;
|
|
if (this.supportsMarkdown()) {
|
|
return dom('div',
|
|
(el) => {
|
|
const content = sanitizeHTML(marked(message, {
|
|
highlight: (code) => {
|
|
const codeBlock = buildHighlightedCode(code, {
|
|
gristTheme: doc.currentTheme,
|
|
maxLines: 60,
|
|
}, cssCodeStyles.cls(''));
|
|
return codeBlock.innerHTML;
|
|
},
|
|
}));
|
|
el.innerHTML = content;
|
|
},
|
|
...args
|
|
);
|
|
|
|
} else {
|
|
return buildHighlightedCode(message, {
|
|
gristTheme: doc.currentTheme,
|
|
maxLines: 100,
|
|
}, cssCodeStyles.cls(''));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends the message to the backend and returns the response.
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds a card with the given title and content.
|
|
*/
|
|
function buildCard(options: {
|
|
icon?: IconName,
|
|
title: string,
|
|
content: DomContents,
|
|
close?: () => void,
|
|
args?: DomElementArg[]
|
|
}) {
|
|
return cssCard(
|
|
options.icon && dom('div', cssCard.cls(`-icon`), icon(options.icon)),
|
|
dom('div', cssCard.cls('-body'), dom('span',
|
|
dom('span', cssCard.cls('-title'), options.title),
|
|
dom('span', cssCard.cls('-content'), options.content),
|
|
)),
|
|
options.icon && dom('div',
|
|
dom.on('click', options.close ?? noop),
|
|
cssCard.cls('-close'),
|
|
icon('CrossSmall'),
|
|
testId('well-close'),
|
|
),
|
|
...(options.args ?? [])
|
|
);
|
|
}
|
|
|
|
/** Builds avatar image for user or assistant. */
|
|
function buildAvatar(grist: GristDoc) {
|
|
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', ''));
|
|
}
|
|
}
|
|
|
|
// TODO: for now this icon is hidden as more design is needed. It overlaps various elements.
|
|
const detachRobotVisible = false;
|
|
export function buildRobotIcon() {
|
|
if (!detachRobotVisible) { return null; }
|
|
return dom.maybe(GRIST_FORMULA_ASSISTANT(), () =>
|
|
cssDetachedRobotIcon(
|
|
'Robot',
|
|
dom.on('click', () => {
|
|
commands.allCommands.detachEditor.run();
|
|
commands.allCommands.activateAssistant.run();
|
|
}),
|
|
testId('detached-robot-icon'),
|
|
)
|
|
);
|
|
}
|
|
|
|
const cssDetachedRobotIcon = styled(icon, `
|
|
left: -25px;
|
|
--icon-color: ${theme.iconButtonPrimaryBg};
|
|
position: absolute;
|
|
cursor: pointer;
|
|
&:hover {
|
|
--icon-color: ${theme.iconButtonPrimaryHoverBg};
|
|
}
|
|
`);
|
|
|
|
const cssInfo = styled('div', `
|
|
overflow: auto;
|
|
height: 100%;
|
|
`);
|
|
|
|
const cssTopHeader = styled('div', `
|
|
font-size: 20px;
|
|
padding-left: 16px;
|
|
padding-right: 16px;
|
|
margin-top: 20px;
|
|
color: ${theme.inputFg};
|
|
`);
|
|
|
|
const cssHeader = styled('div', `
|
|
font-size: 16px;
|
|
padding-left: 16px;
|
|
padding-right: 16px;
|
|
margin: 10px 0px;
|
|
color: ${theme.inputFg};
|
|
`);
|
|
|
|
|
|
const cssTopGreenBorder = styled('div', `
|
|
background: ${theme.accentBorder};
|
|
height: 7px;
|
|
border-top: 3px solid ${theme.pageBg};
|
|
border-bottom: 3px solid ${theme.pageBg};
|
|
cursor: ns-resize;
|
|
flex: none;
|
|
`);
|
|
|
|
const cssChat = styled('div', `
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-grow: 1;
|
|
`);
|
|
|
|
|
|
const cssRobotButton = styled('div', `
|
|
padding-left: 9px;
|
|
padding-right: 9px;
|
|
padding-top: 4px;
|
|
padding-bottom: 6px;
|
|
margin-left: -8px;
|
|
--icon-color: ${theme.controlPrimaryBg};
|
|
cursor: pointer;
|
|
&:hover {
|
|
--icon-color: ${theme.controlPrimaryHoverBg};
|
|
}
|
|
`);
|
|
|
|
const cssCardList = styled('div', `
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
padding: 16px;
|
|
padding-top: 0px;
|
|
& a {
|
|
font-weight: bold;
|
|
}
|
|
`);
|
|
|
|
const cssCard = styled('div', `
|
|
position: relative;
|
|
display: flex;
|
|
column-gap: 8px;
|
|
padding: 8px;
|
|
padding-bottom: 12px;
|
|
color: ${theme.inputFg};
|
|
border-radius: 4px;
|
|
background-color: ${theme.cardCompactWidgetBg};
|
|
&-icon {
|
|
--icon-color: ${theme.accentText};
|
|
}
|
|
&-title {
|
|
font-weight: 600;
|
|
}
|
|
&-close {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 4px;
|
|
height: 20px;
|
|
width: 20px;
|
|
cursor: pointer;
|
|
}
|
|
&-close:hover {
|
|
background-color: ${theme.pageHoverBg};
|
|
--icon-color: ${theme.linkHover};
|
|
border-radius: 4px;
|
|
}
|
|
&-body {
|
|
padding-top: 2px;
|
|
padding-right: 12px;
|
|
line-height: 1.6em;
|
|
}
|
|
& button {
|
|
font-weight: 600;
|
|
}
|
|
`);
|
|
|
|
const cssTopBorder = styled('div', `
|
|
border-top: 1px solid ${theme.inputBorder};
|
|
`);
|
|
|
|
const cssVSpace = styled('div', `
|
|
padding-top: 18px;
|
|
padding-bottom: 18px;
|
|
`);
|
|
|
|
const cssHContainer = styled('div', `
|
|
padding-left: 18px;
|
|
padding-right: 18px;
|
|
display: flex;
|
|
flex-shrink: 0;
|
|
flex-direction: column;
|
|
`);
|
|
|
|
const cssTypography = styled('div', `
|
|
color: ${theme.inputFg};
|
|
`);
|
|
|
|
const cssHBox = styled('div', `
|
|
display: flex;
|
|
`);
|
|
|
|
const cssVContainer = styled('div', `
|
|
padding-top: 18px;
|
|
padding-bottom: 18px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
`);
|
|
|
|
|
|
const cssHistory = styled('div', `
|
|
overflow: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
color: ${theme.inputFg};
|
|
`);
|
|
|
|
|
|
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;
|
|
`);
|
|
|
|
const cssInputWrapper = styled('div', `
|
|
display: flex;
|
|
border: 1px solid ${theme.inputBorder};
|
|
border-radius: 3px;
|
|
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 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;
|
|
& pre {
|
|
background: ${theme.cellBg};
|
|
font-size: 10px;
|
|
}
|
|
`);
|
|
|
|
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 cssCopyIconWrapper = styled('div', `
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: none;
|
|
cursor: pointer;
|
|
.${cssAiMessage.className}:hover & {
|
|
display: flex;
|
|
}
|
|
`);
|
|
|
|
const blink = keyframes(`
|
|
0% { opacity: 1; }
|
|
50% { opacity: 0; }
|
|
100% { opacity: 1; }
|
|
`);
|
|
|
|
const cssCursor = styled('div', `
|
|
height: 1rem;
|
|
width: 3px;
|
|
background-color: ${theme.darkText};
|
|
animation: ${blink} 1s infinite;
|
|
`);
|
|
|
|
const cssLoadingDots = styled(loadingDots, `
|
|
--dot-size: 4px;
|
|
`);
|
|
|
|
|
|
const cssButtons = styled('div', `
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
padding: 8px;
|
|
`);
|
|
|
|
const cssTools = styled('div._tools_container', `
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
`);
|
|
|
|
const cssClickableIcon = styled(icon, `
|
|
cursor: pointer;
|
|
`);
|
|
|
|
|
|
const cssInput = styled('textarea', `
|
|
border: 0px;
|
|
flex-grow: 1;
|
|
outline: none;
|
|
padding: 4px 6px;
|
|
padding-top: 6px;
|
|
resize: none;
|
|
min-height: 28px;
|
|
background: transparent;
|
|
}
|
|
`);
|