mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Billing for formula assistant
Summary: Adding limits for AI calls and connecting those limits with a Stripe Account. - New table in homedb called `limits` - All calls to the AI are not routed through DocApi and measured. - All products now contain a special key `assistantLimit`, with a default value 0 - Limit is reset every time the subscription has changed its period - The billing page is updated with two new options that describe the AI plan - There is a new popup that allows the user to upgrade to a higher plan - Tiers are read directly from the Stripe product with a volume pricing model Test Plan: Updated and added Reviewers: georgegevoian, paulfitz Reviewed By: georgegevoian Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3907
This commit is contained in:
@@ -17,7 +17,6 @@ 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';
|
||||
@@ -61,7 +60,7 @@ export class FormulaAssistant extends Disposable {
|
||||
/** Is the request pending */
|
||||
private _waiting = Observable.create(this, false);
|
||||
/** Is this feature enabled at all */
|
||||
private _assistantEnabled = GRIST_FORMULA_ASSISTANT();
|
||||
private _assistantEnabled: Computed<boolean>;
|
||||
/** Preview column id */
|
||||
private _transformColId: string;
|
||||
/** Method to invoke when we are closed, it saves or reverts */
|
||||
@@ -90,6 +89,12 @@ export class FormulaAssistant extends Disposable {
|
||||
}) {
|
||||
super();
|
||||
|
||||
this._assistantEnabled = Computed.create(this, use => {
|
||||
const enabledByFlag = use(GRIST_FORMULA_ASSISTANT());
|
||||
const notAnonymous = Boolean(this._options.gristDoc.appModel.currentValidUser);
|
||||
return enabledByFlag && notAnonymous;
|
||||
});
|
||||
|
||||
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
|
||||
@@ -263,6 +268,8 @@ export class FormulaAssistant extends Disposable {
|
||||
this._buildIntro(),
|
||||
this._chat.buildDom(),
|
||||
this._buildChatInput(),
|
||||
// Stop propagation of mousedown events, as the formula editor will still focus.
|
||||
dom.on('mousedown', (ev) => ev.stopPropagation()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -516,7 +523,7 @@ export class FormulaAssistant extends Disposable {
|
||||
this._options.editor.setFormula(entry.formula!);
|
||||
}
|
||||
|
||||
private async _sendMessage(description: string, regenerate = false) {
|
||||
private async _sendMessage(description: string, regenerate = false): Promise<ChatMessage> {
|
||||
// Destruct options.
|
||||
const {column, gristDoc} = this._options;
|
||||
// Get the state of the chat from the column.
|
||||
@@ -539,7 +546,12 @@ export class FormulaAssistant extends Disposable {
|
||||
// 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]);
|
||||
return {
|
||||
message: prettyMessage,
|
||||
formula,
|
||||
action: suggestedActions[0],
|
||||
sender: 'ai',
|
||||
};
|
||||
}
|
||||
|
||||
private _clear() {
|
||||
@@ -556,9 +568,7 @@ export class FormulaAssistant extends Disposable {
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
this._chat.thinking();
|
||||
this._waiting.set(true);
|
||||
await this._sendMessage(last, true).finally(() => this._waiting.set(false));
|
||||
await this._doAsk(last);
|
||||
}
|
||||
|
||||
private async _ask() {
|
||||
@@ -568,10 +578,22 @@ export class FormulaAssistant extends Disposable {
|
||||
const message= this._userInput.get();
|
||||
if (!message) { return; }
|
||||
this._chat.addQuestion(message);
|
||||
this._chat.thinking();
|
||||
this._userInput.set('');
|
||||
await this._doAsk(message);
|
||||
}
|
||||
|
||||
private async _doAsk(message: string) {
|
||||
this._chat.thinking();
|
||||
this._waiting.set(true);
|
||||
await this._sendMessage(message, false).finally(() => this._waiting.set(false));
|
||||
try {
|
||||
const response = await this._sendMessage(message, false);
|
||||
this._chat.addResponse(response);
|
||||
} catch(err) {
|
||||
this._chat.thinking(false);
|
||||
throw err;
|
||||
} finally {
|
||||
this._waiting.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,32 +623,36 @@ class ChatHistory extends Disposable {
|
||||
this.length = Computed.create(this, use => use(this.history).length); // ??
|
||||
}
|
||||
|
||||
public thinking() {
|
||||
this.history.push({
|
||||
message: '...',
|
||||
sender: 'ai',
|
||||
});
|
||||
this.scrollDown();
|
||||
public thinking(on = true) {
|
||||
if (!on) {
|
||||
// Find all index of all thinking messages.
|
||||
const messages = [...this.history.get()].filter(m => m.message === '...');
|
||||
// Remove all thinking messages.
|
||||
for (const message of messages) {
|
||||
this.history.splice(this.history.get().indexOf(message), 1);
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
public addResponse(message: ChatMessage) {
|
||||
// Clear any thinking from messages.
|
||||
this.history.set(this.history.get().filter(x => x.message !== '...'));
|
||||
this.history.push({
|
||||
message,
|
||||
sender: 'ai',
|
||||
formula,
|
||||
action
|
||||
});
|
||||
this.thinking(false);
|
||||
this.history.push({...message, sender: 'ai'});
|
||||
this.scrollDown();
|
||||
}
|
||||
|
||||
public addQuestion(message: string) {
|
||||
this.history.set(this.history.get().filter(x => x.message !== '...'));
|
||||
this.thinking(false);
|
||||
this.history.push({
|
||||
message,
|
||||
sender: 'user',
|
||||
@@ -740,18 +766,13 @@ async function askAI(grist: GristDoc, options: {
|
||||
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 result = await grist.docApi.getAssistance({
|
||||
context: {type: 'formula', tableId, colId},
|
||||
text: description,
|
||||
state,
|
||||
regenerate,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -154,13 +154,14 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
dom.on('mousedown', (ev) => {
|
||||
// If we are detached, allow user to click and select error text.
|
||||
if (this.isDetached.get()) {
|
||||
// If the focus is already in this editor, don't steal it. This is needed for detached editor with
|
||||
// some input elements (mainly the AI assistant).
|
||||
const inInput = document.activeElement instanceof HTMLInputElement
|
||||
|| document.activeElement instanceof HTMLTextAreaElement;
|
||||
if (inInput && this._dom.contains(document.activeElement)) {
|
||||
// If we clicked on input element in our dom, don't do anything. We probably clicked on chat input, in AI
|
||||
// tools box.
|
||||
const clickedOnInput = ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement;
|
||||
if (clickedOnInput && this._dom.contains(ev.target)) {
|
||||
// By not doing anything special here we assume that the input element will take the focus.
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow clicking the error message.
|
||||
if (ev.target instanceof HTMLElement && (
|
||||
ev.target.classList.contains('error_msg') ||
|
||||
|
||||
Reference in New Issue
Block a user