diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 060e5026f97..979d3dbaa57 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -27,4 +27,5 @@ export * from "./throttler.js"; export * from "./timestamp.js"; export * from "./fetch-with-creds.js"; export * from "./iterator-from-stream.js"; -export * from "./data.js"; \ No newline at end of file +export * from "./data.js"; +export * from "./veo.js"; \ No newline at end of file diff --git a/packages/utils/src/veo.ts b/packages/utils/src/veo.ts new file mode 100644 index 00000000000..baf20da8490 --- /dev/null +++ b/packages/utils/src/veo.ts @@ -0,0 +1,31 @@ +export const veoDailyLimitStorageKey = "veoDailyLimitReached"; + +export const isVeoDailyLimitReached = () => { + const itemStr = localStorage.getItem(veoDailyLimitStorageKey); + if (!itemStr) { + return false; + } + const item = JSON.parse(itemStr); + const now = new Date(); + if (now.getTime() > item.expiresAt) { + // Item has expired, remove from storage + localStorage.removeItem(veoDailyLimitStorageKey); + return false; + } + + return true; +}; + +export const setVeoDailyLimitExpirationKey = () => { + localStorage.setItem( + veoDailyLimitStorageKey, + JSON.stringify({ + value: "yes", + expiresAt: new Date().getTime() + 24 * 60 * 60 * 1000, + }) + ); +}; + +export const removeVeoDailyLimitExpirationKey = () => { + localStorage.removeItem(veoDailyLimitStorageKey); +}; \ No newline at end of file diff --git a/packages/visual-editor/src/a2/a2/utils.ts b/packages/visual-editor/src/a2/a2/utils.ts index 57ee87c122c..25194c08a12 100644 --- a/packages/visual-editor/src/a2/a2/utils.ts +++ b/packages/visual-editor/src/a2/a2/utils.ts @@ -113,6 +113,14 @@ export type ErrorWithMetadata = { $error: string; metadata?: ErrorMetadata }; export type NonPromise = T extends Promise ? never : T; +export type SnackbarAction = { + title: string; + action: string; + value?: string; + callback?: () => Promise | void; + cssClass?: string; +}; + function ok(o: Outcome>): o is NonPromise { return !(o && typeof o === "object" && "$error" in o); } @@ -472,6 +480,29 @@ function tr(strings: TemplateStringsArray, ...values: unknown[]): string { .trim(); } +export function dispatchShowCustomSnackbarEvent( + message: string, + actions: SnackbarAction[] = [], + snackType: string = "info" +) { + if (typeof window !== "undefined") { + const snackbarEvent = new CustomEvent("showCustomSnackbarEvent", { + bubbles: true, + cancelable: true, + composed: true, + detail: { + snackbarId: globalThis.crypto.randomUUID(), + message, + snackType, + actions, + persistent: true, + replaceAll: false, + }, + }); + window.dispatchEvent(snackbarEvent); + } +} + const DOC_MIME_TYPE = "application/vnd.google-apps.document"; const SHEETS_MIME_TYPE = "application/vnd.google-apps.spreadsheet"; const SLIDES_MIME_TYPE = "application/vnd.google-apps.presentation"; diff --git a/packages/visual-editor/src/a2/video-generator/main.ts b/packages/visual-editor/src/a2/video-generator/main.ts index 2fce10cde77..d23b82774e2 100644 --- a/packages/visual-editor/src/a2/video-generator/main.ts +++ b/packages/visual-editor/src/a2/video-generator/main.ts @@ -9,6 +9,7 @@ import { Template } from "../a2/template.js"; import { ToolManager } from "../a2/tool-manager.js"; import { defaultLLMContent, + dispatchShowCustomSnackbarEvent, encodeBase64, err, ErrorReason, @@ -18,6 +19,7 @@ import { isStoredData, joinContent, ok, + SnackbarAction, toLLMContent, toText, toTextConcat, @@ -35,6 +37,10 @@ import { Outcome, Schema, } from "@breadboard-ai/types"; +import { + removeVeoDailyLimitExpirationKey, + setVeoDailyLimitExpirationKey, +} from "@breadboard-ai/utils"; import { A2ModuleArgs } from "../runnable-module-factory.js"; import { driveFileToBlob, toGcsAwareChunk } from "../a2/data-transforms.js"; @@ -249,6 +255,8 @@ async function invoke( }); } + initAiCreditsLimitation(); + console.log(`PROMPT(${modelName}): ${combinedInstruction}`); // 2) Call backend to generate video. @@ -412,3 +420,59 @@ async function describe( }, }; } + +const showSnackWithActionButton = (message: string, action: SnackbarAction) => { + const showCustomSnackbarEventTimeout = setTimeout(() => { + clearTimeout(showCustomSnackbarEventTimeout); + dispatchShowCustomSnackbarEvent("", [action], "error"); + }); + + throw new Error(message); +}; + +const initAiCreditsLimitation = () => { + // @TODO integrate the backend once it's ready for google user detection and if limit is reached + const limitReached = false; + const isGoogleUser = true; + + if (!limitReached) { + removeVeoDailyLimitExpirationKey(); + return; + } + + if (isGoogleUser) { + // @TODO integrate the backend once it's ready for out of credits detection + const outOfCredits = false; + if (outOfCredits) { + showSnackWithActionButton( + "You need more AI credits to create more videos. Each video you generate uses 20 AI credits from your Google AI plan.", + { + title: "Get more AI credits", + action: "getMoreAiCredits", + callback: () => { + // window.open("url goes here"); + }, + cssClass: "long-button", + } + ); + } else { + // Set expiration time of the localstorage key + setVeoDailyLimitExpirationKey(); + dispatchShowCustomSnackbarEvent( + "You’ve reached the daily limit for creating videos. Each video you generate after that will use 20 AI credits from your Google AI plan." + ); + } + } else { + showSnackWithActionButton( + "You’ve reached the daily limit for creating videos. If you want to create more videos, come back tomorrow, or upgrade to a Google AI plan", + { + title: "See Google AI plans", + action: "seeGoogleAiPlans", + cssClass: "long-button", + callback: () => { + // window.open("url goes here"); + }, + } + ); + } +}; \ No newline at end of file diff --git a/packages/visual-editor/src/main-base.ts b/packages/visual-editor/src/main-base.ts index 335c6bea917..609ec98c8c1 100644 --- a/packages/visual-editor/src/main-base.ts +++ b/packages/visual-editor/src/main-base.ts @@ -291,6 +291,7 @@ abstract class MainBase extends SignalWatcher(LitElement) { this.embedHandler = args.embedHandler; this.#addRuntimeEventHandlers(); + this.#addCustomEventHandlers(); this.boardServer = this.runtime.googleDriveBoardServer; @@ -443,6 +444,21 @@ abstract class MainBase extends SignalWatcher(LitElement) { } } + #addCustomEventHandlers() { + window.addEventListener("showCustomSnackbarEvent", ((evt: CustomEvent) => { + if (evt.detail) { + this.snackbar( + evt.detail.message, + evt.detail.snackType, + evt.detail.actions || [], + evt.detail.persistent || false, + evt.detail.snackbarId, + evt.detail.replaceAll || false + ); + } + }) as EventListener); + } + #addRuntimeEventHandlers() { if (!this.runtime) { console.error("No runtime found"); diff --git a/packages/visual-editor/src/ui/elements/toast/snackbar.ts b/packages/visual-editor/src/ui/elements/toast/snackbar.ts index 4d747844666..f1092c0d346 100644 --- a/packages/visual-editor/src/ui/elements/toast/snackbar.ts +++ b/packages/visual-editor/src/ui/elements/toast/snackbar.ts @@ -80,7 +80,7 @@ export class Snackbar extends LitElement { #messages { color: var(--text-color); flex: 1 1 auto; - margin-right: var(--bb-grid-size-11); + margin-right: var(--bb-grid-size-3); a, a:visited { color: var(--light-dark-p-40); @@ -108,6 +108,11 @@ export class Snackbar extends LitElement { opacity: 0.7; transition: opacity 0.2s cubic-bezier(0, 0, 0.3, 1); + &.long-button { + min-width: 160px; + margin: 0; + } + &:not([disabled]) { cursor: pointer; @@ -257,6 +262,7 @@ export class Snackbar extends LitElement { (action) => action.value, (action) => { return html`