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:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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