(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:
Jarosław Sadziński
2023-07-05 17:36:45 +02:00
parent 75d979abdb
commit d13b9b9019
26 changed files with 501 additions and 106 deletions

View File

@@ -32,7 +32,6 @@ export class DocComm extends Disposable implements ActiveDocAPI {
public addAttachments = this._wrapMethod("addAttachments");
public findColFromValues = this._wrapMethod("findColFromValues");
public getFormulaError = this._wrapMethod("getFormulaError");
public getAssistance = this._wrapMethod("getAssistance");
public fetchURL = this._wrapMethod("fetchURL");
public autocomplete = this._wrapMethod("autocomplete");
public removeInstanceFromDoc = this._wrapMethod("removeInstanceFromDoc");

View File

@@ -185,6 +185,10 @@ export class GristDoc extends DisposableWithEvents {
public readonly currentTheme = this.docPageModel.appModel.currentTheme;
public get docApi() {
return this.docPageModel.appModel.api.getDocAPI(this.docPageModel.currentDocId.get()!);
}
private _actionLog: ActionLog;
private _undoStack: UndoStack;
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;

View File

@@ -43,8 +43,8 @@ export interface CustomAction { label: string, action: () => void }
*/
export type MessageType = string | (() => DomElementArg);
// Identifies supported actions. These are implemented in NotifyUI.
export type NotifyAction = 'upgrade' | 'renew' | 'personal' | 'report-problem' | 'ask-for-help' | CustomAction;
export type NotifyAction = 'upgrade' | 'renew' | 'personal' | 'report-problem'
| 'ask-for-help' | 'manage' | CustomAction;
export interface INotifyOptions {
message: MessageType; // A string, or a function that builds dom.
timestamp?: number;

View File

@@ -121,7 +121,7 @@ export function reportError(err: Error|string, ev?: ErrorEvent): void {
const options: Partial<INotifyOptions> = {
title: "Reached plan limit",
key: `limit:${details.limit.quantity || message}`,
actions: ['upgrade'],
actions: details.tips?.some(t => t.action === 'manage') ? ['manage'] : ['upgrade'],
};
if (details.tips && details.tips.some(tip => tip.action === 'add-members')) {
// When adding members would fix a problem, give more specific advice.

View File

@@ -30,6 +30,10 @@ function buildAction(action: NotifyAction, item: Notification, options: IBeaconO
return dom('a', cssToastAction.cls(''), t("Upgrade Plan"), {target: '_blank'},
{href: commonUrls.plans});
}
case 'manage':
if (urlState().state.get().billing === 'billing') { return null; }
return dom('a', cssToastAction.cls(''), t("Manage billing"), {target: '_blank'},
{href: urlState().makeUrl({billing: 'billing'})});
case 'renew':
// If already on the billing page, nothing to return.
if (urlState().state.get().billing === 'billing') { return null; }

View File

@@ -142,6 +142,7 @@ export const vars = {
onboardingPopupZIndex: new CustomProp('onboarding-popup-z-index', '1000'),
floatingPopupZIndex: new CustomProp('floating-popup-z-index', '1002'),
tutorialModalZIndex: new CustomProp('tutorial-modal-z-index', '1003'),
pricingModalZIndex: new CustomProp('pricing-modal-z-index', '1004'),
notificationZIndex: new CustomProp('notification-z-index', '1100'),
browserCheckZIndex: new CustomProp('browser-check-z-index', '5000'),
tooltipZIndex: new CustomProp('tooltip-z-index', '5000'),

View File

@@ -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;
}
/**

View File

@@ -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') ||