@nodetool-ai/runtime 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.nodetool-build-stamp +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +3 -2
- package/dist/context.js.map +1 -1
- package/dist/image-model-tasks.d.ts +20 -0
- package/dist/image-model-tasks.d.ts.map +1 -1
- package/dist/image-model-tasks.js +44 -1
- package/dist/image-model-tasks.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/prompt-asset-refs.d.ts +12 -1
- package/dist/prompt-asset-refs.d.ts.map +1 -1
- package/dist/prompt-asset-refs.js +9 -4
- package/dist/prompt-asset-refs.js.map +1 -1
- package/dist/providers/api-route-provider.d.ts +22 -0
- package/dist/providers/api-route-provider.d.ts.map +1 -0
- package/dist/providers/api-route-provider.js +56 -0
- package/dist/providers/api-route-provider.js.map +1 -0
- package/dist/providers/base-provider.d.ts.map +1 -1
- package/dist/providers/base-provider.js +22 -3
- package/dist/providers/base-provider.js.map +1 -1
- package/dist/providers/chatgpt-provider.d.ts +48 -0
- package/dist/providers/chatgpt-provider.d.ts.map +1 -0
- package/dist/providers/chatgpt-provider.js +506 -0
- package/dist/providers/chatgpt-provider.js.map +1 -0
- package/dist/providers/context-window.d.ts.map +1 -1
- package/dist/providers/context-window.js +1 -0
- package/dist/providers/context-window.js.map +1 -1
- package/dist/providers/cost-calculator.d.ts.map +1 -1
- package/dist/providers/cost-calculator.js +9 -0
- package/dist/providers/cost-calculator.js.map +1 -1
- package/dist/providers/credential-check.d.ts +8 -0
- package/dist/providers/credential-check.d.ts.map +1 -1
- package/dist/providers/credential-check.js +22 -0
- package/dist/providers/credential-check.js.map +1 -1
- package/dist/providers/custom-openai-provider.d.ts.map +1 -1
- package/dist/providers/custom-openai-provider.js +19 -1
- package/dist/providers/custom-openai-provider.js.map +1 -1
- package/dist/providers/dreamina-provider.d.ts +5 -3
- package/dist/providers/dreamina-provider.d.ts.map +1 -1
- package/dist/providers/dreamina-provider.js +245 -34
- package/dist/providers/dreamina-provider.js.map +1 -1
- package/dist/providers/extension-tab.d.ts +29 -0
- package/dist/providers/extension-tab.d.ts.map +1 -0
- package/dist/providers/extension-tab.js +144 -0
- package/dist/providers/extension-tab.js.map +1 -0
- package/dist/providers/fake-provider.d.ts +3 -1
- package/dist/providers/fake-provider.d.ts.map +1 -1
- package/dist/providers/fake-provider.js +63 -9
- package/dist/providers/fake-provider.js.map +1 -1
- package/dist/providers/fake-schema-answer.d.ts +10 -0
- package/dist/providers/fake-schema-answer.d.ts.map +1 -0
- package/dist/providers/fake-schema-answer.js +64 -0
- package/dist/providers/fake-schema-answer.js.map +1 -0
- package/dist/providers/fal-provider.d.ts.map +1 -1
- package/dist/providers/fal-provider.js +5 -2
- package/dist/providers/fal-provider.js.map +1 -1
- package/dist/providers/gemini-web-provider.d.ts +76 -0
- package/dist/providers/gemini-web-provider.d.ts.map +1 -0
- package/dist/providers/gemini-web-provider.js +752 -0
- package/dist/providers/gemini-web-provider.js.map +1 -0
- package/dist/providers/huggingface-provider.d.ts.map +1 -1
- package/dist/providers/huggingface-provider.js +17 -1
- package/dist/providers/huggingface-provider.js.map +1 -1
- package/dist/providers/index.d.ts +10 -2
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +36 -8
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/llama-provider.d.ts +8 -0
- package/dist/providers/llama-provider.d.ts.map +1 -1
- package/dist/providers/llama-provider.js +23 -5
- package/dist/providers/llama-provider.js.map +1 -1
- package/dist/providers/lmstudio-provider.js +2 -2
- package/dist/providers/lmstudio-provider.js.map +1 -1
- package/dist/providers/manifest-models.d.ts +2 -12
- package/dist/providers/manifest-models.d.ts.map +1 -1
- package/dist/providers/manifest-models.js +45 -3
- package/dist/providers/manifest-models.js.map +1 -1
- package/dist/providers/meta-provider.d.ts +53 -6
- package/dist/providers/meta-provider.d.ts.map +1 -1
- package/dist/providers/meta-provider.js +289 -6
- package/dist/providers/meta-provider.js.map +1 -1
- package/dist/providers/meta-segmentation.d.ts +27 -0
- package/dist/providers/meta-segmentation.d.ts.map +1 -0
- package/dist/providers/meta-segmentation.js +141 -0
- package/dist/providers/meta-segmentation.js.map +1 -0
- package/dist/providers/meta-transcription.d.ts +31 -0
- package/dist/providers/meta-transcription.d.ts.map +1 -0
- package/dist/providers/meta-transcription.js +187 -0
- package/dist/providers/meta-transcription.js.map +1 -0
- package/dist/providers/node-llama-cpp-provider.d.ts +6 -3
- package/dist/providers/node-llama-cpp-provider.d.ts.map +1 -1
- package/dist/providers/node-llama-cpp-provider.js +75 -58
- package/dist/providers/node-llama-cpp-provider.js.map +1 -1
- package/dist/providers/ollama-provider.d.ts +23 -0
- package/dist/providers/ollama-provider.d.ts.map +1 -1
- package/dist/providers/ollama-provider.js +140 -27
- package/dist/providers/ollama-provider.js.map +1 -1
- package/dist/providers/openai-compat/client.d.ts +10 -1
- package/dist/providers/openai-compat/client.d.ts.map +1 -1
- package/dist/providers/openai-compat/client.js +21 -3
- package/dist/providers/openai-compat/client.js.map +1 -1
- package/dist/providers/openai-compat/index.d.ts +1 -1
- package/dist/providers/openai-compat/index.d.ts.map +1 -1
- package/dist/providers/openai-compat/index.js +1 -1
- package/dist/providers/openai-compat/index.js.map +1 -1
- package/dist/providers/openai-compat/types.d.ts +4 -0
- package/dist/providers/openai-compat/types.d.ts.map +1 -1
- package/dist/providers/openai-compat-provider.d.ts +11 -0
- package/dist/providers/openai-compat-provider.d.ts.map +1 -1
- package/dist/providers/openai-compat-provider.js +47 -2
- package/dist/providers/openai-compat-provider.js.map +1 -1
- package/dist/providers/openai-provider.d.ts +13 -0
- package/dist/providers/openai-provider.d.ts.map +1 -1
- package/dist/providers/openai-provider.js +41 -7
- package/dist/providers/openai-provider.js.map +1 -1
- package/dist/providers/opper-provider.d.ts +31 -0
- package/dist/providers/opper-provider.d.ts.map +1 -0
- package/dist/providers/opper-provider.js +91 -0
- package/dist/providers/opper-provider.js.map +1 -0
- package/dist/providers/provider-request-log.d.ts.map +1 -1
- package/dist/providers/provider-request-log.js +3 -2
- package/dist/providers/provider-request-log.js.map +1 -1
- package/dist/providers/python-provider.d.ts.map +1 -1
- package/dist/providers/python-provider.js +5 -3
- package/dist/providers/python-provider.js.map +1 -1
- package/dist/providers/responses-api.d.ts +12 -2
- package/dist/providers/responses-api.d.ts.map +1 -1
- package/dist/providers/responses-api.js +54 -3
- package/dist/providers/responses-api.js.map +1 -1
- package/dist/providers/scripted-provider.d.ts.map +1 -1
- package/dist/providers/scripted-provider.js +6 -1
- package/dist/providers/scripted-provider.js.map +1 -1
- package/dist/providers/types.d.ts +16 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/providers/vllm-provider.d.ts +7 -0
- package/dist/providers/vllm-provider.d.ts.map +1 -1
- package/dist/providers/vllm-provider.js +15 -3
- package/dist/providers/vllm-provider.js.map +1 -1
- package/dist/python-bridge-base.d.ts +16 -6
- package/dist/python-bridge-base.d.ts.map +1 -1
- package/dist/python-bridge-base.js +117 -40
- package/dist/python-bridge-base.js.map +1 -1
- package/dist/python-bridge-codec.d.ts +16 -1
- package/dist/python-bridge-codec.d.ts.map +1 -1
- package/dist/python-bridge-codec.js +60 -2
- package/dist/python-bridge-codec.js.map +1 -1
- package/dist/python-bridge-framing.d.ts +10 -1
- package/dist/python-bridge-framing.d.ts.map +1 -1
- package/dist/python-bridge-framing.js +33 -8
- package/dist/python-bridge-framing.js.map +1 -1
- package/dist/python-bridge-types.d.ts +26 -5
- package/dist/python-bridge-types.d.ts.map +1 -1
- package/dist/python-bridge-types.js.map +1 -1
- package/dist/python-node-executor.d.ts +14 -3
- package/dist/python-node-executor.d.ts.map +1 -1
- package/dist/python-node-executor.js +185 -3
- package/dist/python-node-executor.js.map +1 -1
- package/dist/python-stdio-bridge.d.ts +6 -0
- package/dist/python-stdio-bridge.d.ts.map +1 -1
- package/dist/python-stdio-bridge.js +25 -2
- package/dist/python-stdio-bridge.js.map +1 -1
- package/dist/run-trace-context.d.ts +2 -27
- package/dist/run-trace-context.d.ts.map +1 -1
- package/dist/run-trace-context.js +4 -51
- package/dist/run-trace-context.js.map +1 -1
- package/dist/run-trace-processor.d.ts.map +1 -1
- package/dist/run-trace-processor.js +7 -6
- package/dist/run-trace-processor.js.map +1 -1
- package/dist/run-trace-scope.d.ts +28 -0
- package/dist/run-trace-scope.d.ts.map +1 -0
- package/dist/run-trace-scope.js +51 -0
- package/dist/run-trace-scope.js.map +1 -0
- package/dist/run-trace-serialization.d.ts.map +1 -1
- package/dist/run-trace-serialization.js +4 -3
- package/dist/run-trace-serialization.js.map +1 -1
- package/dist/span-record.d.ts +4 -0
- package/dist/span-record.d.ts.map +1 -0
- package/dist/span-record.js +48 -0
- package/dist/span-record.js.map +1 -0
- package/dist/swappable-python-bridge.d.ts +6 -6
- package/dist/swappable-python-bridge.d.ts.map +1 -1
- package/dist/swappable-python-bridge.js +10 -10
- package/dist/swappable-python-bridge.js.map +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +55 -3
- package/dist/testing.js.map +1 -1
- package/dist/trace-exporters.d.ts +1 -2
- package/dist/trace-exporters.d.ts.map +1 -1
- package/dist/trace-exporters.js +1 -47
- package/dist/trace-exporters.js.map +1 -1
- package/dist/traced-storage.d.ts +17 -0
- package/dist/traced-storage.d.ts.map +1 -0
- package/dist/traced-storage.js +41 -0
- package/dist/traced-storage.js.map +1 -0
- package/package.json +7 -5
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini through the user's own logged-in gemini.google.com tab, driven by
|
|
3
|
+
* the NodeTool Chrome extension: chat, image generation and video generation.
|
|
4
|
+
* No API key: requests run on the user's Google account and its Gemini plan.
|
|
5
|
+
*
|
|
6
|
+
* A turn is submitted through the page's own composer, so the site computes
|
|
7
|
+
* its anti-abuse tokens itself. The page script records the response of the
|
|
8
|
+
* page's own `StreamGenerate` request, and this module parses it. The tab
|
|
9
|
+
* renders nothing while it is hidden, so the response is the only reliable
|
|
10
|
+
* place to read the result.
|
|
11
|
+
*
|
|
12
|
+
* The tab stays in the background. Chrome can freeze a background tab, and
|
|
13
|
+
* a frozen Gemini runs a send click only when the tab is shown, which starts
|
|
14
|
+
* a second turn later. The extension tab unfreezes the page before each
|
|
15
|
+
* script. A click that still starts no request is dropped with a reload, and
|
|
16
|
+
* the turn is retried once.
|
|
17
|
+
*
|
|
18
|
+
* A video finishes minutes after its turn ends. The provider polls the chat
|
|
19
|
+
* with the read call the page itself uses (`hNvQHb`) until the video appears.
|
|
20
|
+
*
|
|
21
|
+
* Generated media need the user's Google cookies and send no CORS headers for
|
|
22
|
+
* the page, so the browser itself downloads them ({@link ExtensionTab.download}).
|
|
23
|
+
*
|
|
24
|
+
* Image and video turns start a new Gemini chat. A chat records its Gemini
|
|
25
|
+
* chat as the provider session and the next turn continues it, sending only
|
|
26
|
+
* the new messages. Every turn shows in the user's history. No selector
|
|
27
|
+
* depends on interface text, so a localized Gemini works. One tab serves
|
|
28
|
+
* every call, so calls run one at a time.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
import { createLogger } from "@nodetool-ai/config";
|
|
32
|
+
import { PROVIDER_IDS } from "@nodetool-ai/protocol";
|
|
33
|
+
import { fetchExternalMedia } from "../external-media-fetch.js";
|
|
34
|
+
import { BaseProvider } from "./base-provider.js";
|
|
35
|
+
import { composeDelta, composePrompt } from "./chatgpt-provider.js";
|
|
36
|
+
import { createExtensionTab, stageBytesInTab } from "./extension-tab.js";
|
|
37
|
+
import { detectImageMime } from "./image-mime.js";
|
|
38
|
+
const log = createLogger("nodetool.runtime.providers.gemini-web");
|
|
39
|
+
const HOST = "gemini.google.com";
|
|
40
|
+
const BASE_URL = `https://${HOST}`;
|
|
41
|
+
/** The model id that keeps the mode the user picked last in Gemini. */
|
|
42
|
+
export const GEMINI_WEB_AUTO_MODEL = "auto";
|
|
43
|
+
/** The image model: Gemini's own image tool, run by the current mode. */
|
|
44
|
+
export const GEMINI_WEB_IMAGE_MODEL = "gemini-web-image";
|
|
45
|
+
/** The video model: Gemini's video tool (Veo). */
|
|
46
|
+
export const GEMINI_WEB_VIDEO_MODEL = "gemini-web-video";
|
|
47
|
+
const COMPOSER = "rich-textarea .ql-editor";
|
|
48
|
+
const SUBMIT = "gem-icon-button.send-button button";
|
|
49
|
+
const ATTACHMENT = "uploader-file-preview";
|
|
50
|
+
const INPUT_AREA = "input-area-v2";
|
|
51
|
+
const TOOLS_BUTTON = "gem-icon-button.menu-button button";
|
|
52
|
+
const MODE_BUTTON = "[data-test-id=bard-mode-menu-button]";
|
|
53
|
+
const OVERLAY = ".cdk-overlay-container";
|
|
54
|
+
/** Tools and aspect options are found by their icon, which no locale changes. */
|
|
55
|
+
const VIDEO_TOOL_ICON = "movie";
|
|
56
|
+
/** The full-size original. Without a size suffix the URL serves a 512 px preview. */
|
|
57
|
+
const FULL_SIZE_SUFFIX = "=s0-d-I";
|
|
58
|
+
const POLL_INTERVAL_MS = 1_500;
|
|
59
|
+
const VIDEO_POLL_INTERVAL_MS = 5_000;
|
|
60
|
+
const READY_TIMEOUT_MS = 20_000;
|
|
61
|
+
const APP_SETTLE_TIMEOUT_MS = 10_000;
|
|
62
|
+
/** The send script waits in the page for uploads to finish before it clicks. */
|
|
63
|
+
const SEND_TIMEOUT_MS = 90_000;
|
|
64
|
+
const CHAT_TIMEOUT_MS = 10 * 60_000;
|
|
65
|
+
const IMAGE_TIMEOUT_MS = 10 * 60_000;
|
|
66
|
+
const VIDEO_TIMEOUT_MS = 15 * 60_000;
|
|
67
|
+
const DOWNLOAD_TIMEOUT_MS = 180_000;
|
|
68
|
+
const MAX_POLL_FAILURES = 5;
|
|
69
|
+
const MODELS_TTL_MS = 10 * 60_000;
|
|
70
|
+
let tab = null;
|
|
71
|
+
/** Replace the tab. Tests use this to avoid a real browser. */
|
|
72
|
+
export function setGeminiWebTab(next) {
|
|
73
|
+
tab = next;
|
|
74
|
+
liveModels = null;
|
|
75
|
+
}
|
|
76
|
+
function currentTab() {
|
|
77
|
+
return (tab ??= createExtensionTab(HOST));
|
|
78
|
+
}
|
|
79
|
+
/** Calls share one tab, so each waits for the previous one. */
|
|
80
|
+
let queue = Promise.resolve();
|
|
81
|
+
function serialized(run) {
|
|
82
|
+
const next = queue.then(run, run);
|
|
83
|
+
queue = next.catch(() => undefined);
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
86
|
+
/** Modes read from the page during an earlier call, so listing never attaches. */
|
|
87
|
+
let liveModels = null;
|
|
88
|
+
function sleep(ms, signal) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
if (signal?.aborted)
|
|
91
|
+
return reject(signal.reason ?? new Error("Aborted"));
|
|
92
|
+
const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms);
|
|
93
|
+
const onAbort = () => { clearTimeout(timer); reject(signal?.reason ?? new Error("Aborted")); };
|
|
94
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async function evaluate(expression, timeoutMs) {
|
|
98
|
+
return (await currentTab().evaluate(expression, timeoutMs));
|
|
99
|
+
}
|
|
100
|
+
/** The page path of a chat drops the `c_` prefix of its id. */
|
|
101
|
+
function chatPath(conversationId) {
|
|
102
|
+
return `/app/${conversationId.replace(/^c_/, "")}`;
|
|
103
|
+
}
|
|
104
|
+
/** A link to the chat for error messages. */
|
|
105
|
+
function chatLink(conversationId) {
|
|
106
|
+
return conversationId ? ` The chat is ${BASE_URL}${chatPath(conversationId)}` : "";
|
|
107
|
+
}
|
|
108
|
+
/** Wait for the composer, then check that a Google account is signed in. */
|
|
109
|
+
async function waitForComposer() {
|
|
110
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
111
|
+
let ready = false;
|
|
112
|
+
while (!ready && Date.now() < deadline) {
|
|
113
|
+
ready = await evaluate(`/*nt:ready*/ !!document.querySelector(${JSON.stringify(COMPOSER)})`);
|
|
114
|
+
if (!ready)
|
|
115
|
+
await sleep(250);
|
|
116
|
+
}
|
|
117
|
+
// `oPEP7c` holds the signed-in account. A signed-out page has a composer but no account.
|
|
118
|
+
const signedIn = await evaluate(`/*nt:session*/ !!(window.WIZ_global_data && WIZ_global_data.oPEP7c)`);
|
|
119
|
+
if (!signedIn)
|
|
120
|
+
throw new Error("Gemini is not signed in. Open https://gemini.google.com in Chrome, sign in, and try again");
|
|
121
|
+
if (!ready)
|
|
122
|
+
throw new Error("The Gemini composer did not appear. The gemini.google.com page layout may have changed");
|
|
123
|
+
// A background tab renders the rest of the composer seconds later, and a
|
|
124
|
+
// click before that starts no request. The mode picker is the last part.
|
|
125
|
+
const settled = Date.now() + APP_SETTLE_TIMEOUT_MS;
|
|
126
|
+
while (Date.now() < settled && !(await evaluate(`/*nt:settled*/ !!document.querySelector(${JSON.stringify(MODE_BUTTON)})`))) {
|
|
127
|
+
await sleep(250);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Wait until the open chat shows its earlier turns. */
|
|
131
|
+
async function waitForHistory() {
|
|
132
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
133
|
+
while (Date.now() < deadline) {
|
|
134
|
+
if (await evaluate(`/*nt:history*/ document.querySelectorAll("user-query").length > 0`))
|
|
135
|
+
return;
|
|
136
|
+
await sleep(250);
|
|
137
|
+
}
|
|
138
|
+
throw new ChatUnavailableError("The Gemini chat did not load its earlier turns");
|
|
139
|
+
}
|
|
140
|
+
/** Open a new chat with no tool selected. */
|
|
141
|
+
async function openNewChat() {
|
|
142
|
+
await currentTab().navigate(`${BASE_URL}/app`);
|
|
143
|
+
await waitForComposer();
|
|
144
|
+
// A selected tool shows as a chip with a close icon.
|
|
145
|
+
await evaluate(`/*nt:clear-tools*/ (async () => {
|
|
146
|
+
const chips = [...document.querySelectorAll(${JSON.stringify(`${INPUT_AREA} button`)})]
|
|
147
|
+
.filter((b) => b.querySelector('mat-icon[fonticon="close"]') && !b.closest(${JSON.stringify(ATTACHMENT)}));
|
|
148
|
+
for (const chip of chips) chip.click();
|
|
149
|
+
return chips.length;
|
|
150
|
+
})()`);
|
|
151
|
+
}
|
|
152
|
+
/** Turn on the tool whose menu item shows `icon`. */
|
|
153
|
+
async function enableTool(icon) {
|
|
154
|
+
await evaluate(`/*nt:tool*/ (async () => {
|
|
155
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
156
|
+
const icon = ${JSON.stringify(icon)};
|
|
157
|
+
let tools = null;
|
|
158
|
+
for (let i = 0; i < 40 && !tools; i++) {
|
|
159
|
+
tools = document.querySelector(${JSON.stringify(TOOLS_BUTTON)});
|
|
160
|
+
if (!tools) await sleep(250);
|
|
161
|
+
}
|
|
162
|
+
if (!tools) throw new Error("The Gemini composer has no tools menu");
|
|
163
|
+
tools.click();
|
|
164
|
+
let item = null;
|
|
165
|
+
for (let i = 0; i < 20 && !item; i++) {
|
|
166
|
+
await sleep(150);
|
|
167
|
+
item = [...document.querySelectorAll(${JSON.stringify(`${OVERLAY} [role=menuitemcheckbox]`)})].find((b) => b.querySelector('mat-icon[fonticon="' + icon + '"]'));
|
|
168
|
+
}
|
|
169
|
+
if (!item) throw new Error("The Gemini tools menu has no " + icon + " tool. The account may not offer it");
|
|
170
|
+
if (item.getAttribute("aria-checked") !== "true") item.click();
|
|
171
|
+
for (let i = 0; i < 20; i++) {
|
|
172
|
+
await sleep(150);
|
|
173
|
+
const chip = [...document.querySelectorAll(${JSON.stringify(`${INPUT_AREA} button`)})].find((b) => b.querySelector('mat-icon[fonticon="' + icon + '"]'));
|
|
174
|
+
if (chip) return true;
|
|
175
|
+
}
|
|
176
|
+
throw new Error("The Gemini " + icon + " tool did not turn on");
|
|
177
|
+
})()`);
|
|
178
|
+
}
|
|
179
|
+
/** Pick a video aspect ratio in the composer's aspect menu. */
|
|
180
|
+
async function selectVideoAspect(aspectRatio) {
|
|
181
|
+
const icon = aspectRatio === "9:16" ? "crop_9_16" : "crop_16_9";
|
|
182
|
+
await evaluate(`/*nt:aspect*/ (async () => {
|
|
183
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
184
|
+
const button = [...document.querySelectorAll(${JSON.stringify(`${INPUT_AREA} button`)})].find((b) => b.querySelector('mat-icon[fonticon^="crop_"]'));
|
|
185
|
+
if (!button) throw new Error("The Gemini video composer has no aspect ratio menu");
|
|
186
|
+
if (button.querySelector('mat-icon[fonticon=${JSON.stringify(icon)}]')) return true;
|
|
187
|
+
button.click();
|
|
188
|
+
let option = null;
|
|
189
|
+
for (let i = 0; i < 20 && !option; i++) {
|
|
190
|
+
await sleep(150);
|
|
191
|
+
option = [...document.querySelectorAll(${JSON.stringify(`${OVERLAY} [role=menuitemradio]`)})].find((b) => b.querySelector('mat-icon[fonticon=${JSON.stringify(icon)}]'));
|
|
192
|
+
}
|
|
193
|
+
if (!option) throw new Error("The Gemini aspect ratio menu has no ${aspectRatio} option");
|
|
194
|
+
option.click();
|
|
195
|
+
return true;
|
|
196
|
+
})()`);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Open the mode picker, record its modes, and pick `modeId` unless it is
|
|
200
|
+
* {@link GEMINI_WEB_AUTO_MODEL}. Modes carry a stable `data-mode-id`.
|
|
201
|
+
*/
|
|
202
|
+
async function selectMode(modeId) {
|
|
203
|
+
const modes = await evaluate(`/*nt:modes*/ (async () => {
|
|
204
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
205
|
+
// A background tab renders the mode picker seconds after the composer.
|
|
206
|
+
let toggle = null;
|
|
207
|
+
for (let i = 0; i < 40 && !toggle; i++) {
|
|
208
|
+
toggle = document.querySelector(${JSON.stringify(MODE_BUTTON)});
|
|
209
|
+
if (!toggle) await sleep(250);
|
|
210
|
+
}
|
|
211
|
+
if (!toggle) return [];
|
|
212
|
+
// The first open after a page load can take a few seconds.
|
|
213
|
+
if (toggle.getAttribute("aria-expanded") !== "true") toggle.click();
|
|
214
|
+
let items = [];
|
|
215
|
+
for (let i = 0; i < 40 && items.length === 0; i++) {
|
|
216
|
+
await sleep(150);
|
|
217
|
+
items = [...document.querySelectorAll("[data-mode-id]")];
|
|
218
|
+
}
|
|
219
|
+
const modes = items.map((item) => ({ id: item.getAttribute("data-mode-id"), name: item.innerText.split("\\n")[0].trim() }));
|
|
220
|
+
const wanted = ${JSON.stringify(modeId)};
|
|
221
|
+
const pick = items.find((item) => item.getAttribute("data-mode-id") === wanted);
|
|
222
|
+
if (pick) pick.click();
|
|
223
|
+
else if (toggle.getAttribute("aria-expanded") === "true") toggle.click();
|
|
224
|
+
await sleep(300);
|
|
225
|
+
return modes;
|
|
226
|
+
})()`);
|
|
227
|
+
if (modes.length > 0) {
|
|
228
|
+
liveModels = {
|
|
229
|
+
at: Date.now(),
|
|
230
|
+
models: modes.filter((m) => m.id).map((m) => ({ id: m.id, name: `Gemini ${m.name || m.id}`, provider: PROVIDER_IDS.GEMINI_WEB }))
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (modeId !== GEMINI_WEB_AUTO_MODEL && !modes.some((m) => m.id === modeId)) {
|
|
234
|
+
throw new Error(`Gemini has no mode ${modeId}. Its modes are: ${modes.map((m) => `${m.id} (${m.name})`).join(", ") || "none found"}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Paste images into the composer, the way a clipboard paste would. */
|
|
238
|
+
async function attachImages(images) {
|
|
239
|
+
if (images.length === 0)
|
|
240
|
+
return;
|
|
241
|
+
const files = [];
|
|
242
|
+
for (const [index, image] of images.entries()) {
|
|
243
|
+
const type = detectImageMime(image);
|
|
244
|
+
const id = await stageBytesInTab(currentTab(), image);
|
|
245
|
+
files.push({ id, name: `image-${index + 1}.${type.split("/")[1] ?? "png"}`, type });
|
|
246
|
+
}
|
|
247
|
+
await evaluate(`/*nt:attach*/ (() => {
|
|
248
|
+
const files = ${JSON.stringify(files)};
|
|
249
|
+
const composer = document.querySelector(${JSON.stringify(COMPOSER)});
|
|
250
|
+
const transfer = new DataTransfer();
|
|
251
|
+
for (const f of files) {
|
|
252
|
+
const binary = atob(window.__ntStage[f.id]);
|
|
253
|
+
delete window.__ntStage[f.id];
|
|
254
|
+
transfer.items.add(new File([Uint8Array.from(binary, (c) => c.charCodeAt(0))], f.name, { type: f.type }));
|
|
255
|
+
}
|
|
256
|
+
composer.focus();
|
|
257
|
+
composer.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
|
258
|
+
return true;
|
|
259
|
+
})()`);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Record the page's `StreamGenerate` responses in `window.__ntGemini`, type
|
|
263
|
+
* the text and click send once. The script waits for the attachment previews
|
|
264
|
+
* and an enabled button first, then checks that the page started a request.
|
|
265
|
+
*/
|
|
266
|
+
async function submit(text, attachments) {
|
|
267
|
+
await evaluate(`/*nt:send*/ (async () => {
|
|
268
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
269
|
+
if (!window.__ntGeminiHooked) {
|
|
270
|
+
window.__ntGeminiHooked = true;
|
|
271
|
+
const open = XMLHttpRequest.prototype.open;
|
|
272
|
+
const send = XMLHttpRequest.prototype.send;
|
|
273
|
+
XMLHttpRequest.prototype.open = function (method, url) {
|
|
274
|
+
this.__ntUrl = String(url);
|
|
275
|
+
return open.apply(this, arguments);
|
|
276
|
+
};
|
|
277
|
+
XMLHttpRequest.prototype.send = function () {
|
|
278
|
+
const state = window.__ntGemini;
|
|
279
|
+
if (state && /StreamGenerate/.test(this.__ntUrl || "")) {
|
|
280
|
+
state.started = true;
|
|
281
|
+
this.addEventListener("loadend", () => {
|
|
282
|
+
state.response = { status: this.status, body: String(this.responseText || "") };
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return send.apply(this, arguments);
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
window.__ntGemini = { started: false, response: null };
|
|
289
|
+
const composer = document.querySelector(${JSON.stringify(COMPOSER)});
|
|
290
|
+
composer.focus();
|
|
291
|
+
document.execCommand("insertText", false, ${JSON.stringify(text)});
|
|
292
|
+
// The send button slides in after the first character. A click during
|
|
293
|
+
// that animation starts no request.
|
|
294
|
+
await sleep(500);
|
|
295
|
+
const deadline = Date.now() + ${SEND_TIMEOUT_MS - 15_000};
|
|
296
|
+
for (;;) {
|
|
297
|
+
if (Date.now() > deadline) throw new Error("Gemini did not accept the message: the send button stayed disabled or the upload did not finish");
|
|
298
|
+
const button = document.querySelector(${JSON.stringify(SUBMIT)});
|
|
299
|
+
const previews = document.querySelectorAll(${JSON.stringify(ATTACHMENT)});
|
|
300
|
+
const uploading = [...previews].some((p) => p.querySelector("mat-progress-spinner, [role=progressbar]"));
|
|
301
|
+
if (button && !button.disabled && button.getAttribute("aria-disabled") !== "true" && previews.length >= ${attachments} && !uploading) {
|
|
302
|
+
button.click();
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
await sleep(250);
|
|
306
|
+
}
|
|
307
|
+
const sendDeadline = Date.now() + 10000;
|
|
308
|
+
while (Date.now() < sendDeadline) {
|
|
309
|
+
if (window.__ntGemini.started) return true;
|
|
310
|
+
await sleep(250);
|
|
311
|
+
}
|
|
312
|
+
throw new Error("NT_NOT_SENT");
|
|
313
|
+
})()`, SEND_TIMEOUT_MS);
|
|
314
|
+
}
|
|
315
|
+
/** Poll until the page's `StreamGenerate` request ends and return its response. */
|
|
316
|
+
async function waitForResponse(timeoutMs, signal) {
|
|
317
|
+
const started = Date.now();
|
|
318
|
+
let failures = 0;
|
|
319
|
+
for (;;) {
|
|
320
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
321
|
+
try {
|
|
322
|
+
const state = await evaluate(`/*nt:result*/ (window.__ntGemini ?? null)`);
|
|
323
|
+
failures = 0;
|
|
324
|
+
if (!state)
|
|
325
|
+
throw new Error("The Gemini tab navigated away during the turn");
|
|
326
|
+
if (state.response)
|
|
327
|
+
return state.response;
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (error instanceof Error && error.message.startsWith("The Gemini tab navigated"))
|
|
331
|
+
throw error;
|
|
332
|
+
log.warn("Gemini poll failed", { failures: failures + 1, error: error instanceof Error ? error.message : String(error) });
|
|
333
|
+
if (++failures > MAX_POLL_FAILURES)
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
if (Date.now() - started > timeoutMs)
|
|
337
|
+
throw new Error(`Gemini did not finish within ${timeoutMs / 1000}s`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Prepare the page with `setup`, paste the images, send the text, and parse
|
|
342
|
+
* the reply. When the click starts no request, `setup` runs again, which
|
|
343
|
+
* reloads the page and drops the pending click, and the send is retried once.
|
|
344
|
+
*/
|
|
345
|
+
async function sendAndRead(setup, text, images, timeoutMs, signal) {
|
|
346
|
+
for (let attempt = 1;; attempt++) {
|
|
347
|
+
await setup();
|
|
348
|
+
await attachImages(images);
|
|
349
|
+
try {
|
|
350
|
+
await submit(text, images.length);
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
if (!(error instanceof Error && error.message.includes("NT_NOT_SENT")))
|
|
355
|
+
throw error;
|
|
356
|
+
if (attempt > 1)
|
|
357
|
+
throw new Error("Gemini did not send the message from the background tab. Show the Gemini tab once and try again");
|
|
358
|
+
log.warn("Gemini did not send the message; reloading the page and retrying once");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
log.info("Gemini turn submitted", { images: images.length });
|
|
362
|
+
const response = await waitForResponse(timeoutMs, signal);
|
|
363
|
+
if (response.status !== 200)
|
|
364
|
+
throw new Error(`Gemini request failed: HTTP ${response.status}`);
|
|
365
|
+
return parseStreamGenerate(response.body);
|
|
366
|
+
}
|
|
367
|
+
/** Read a chat the way the page does, with the `hNvQHb` call. Returns the raw response body. */
|
|
368
|
+
async function readChat(conversationId) {
|
|
369
|
+
return evaluate(`/*nt:read*/ (async () => {
|
|
370
|
+
const w = window.WIZ_global_data;
|
|
371
|
+
const params = new URLSearchParams({
|
|
372
|
+
rpcids: "hNvQHb",
|
|
373
|
+
"source-path": location.pathname,
|
|
374
|
+
bl: w.cfb2h,
|
|
375
|
+
"f.sid": w.FdrFJe,
|
|
376
|
+
hl: document.documentElement.lang || "en",
|
|
377
|
+
_reqid: String(100000 + Math.floor(Math.random() * 900000)),
|
|
378
|
+
rt: "c"
|
|
379
|
+
});
|
|
380
|
+
const request = JSON.stringify([[["hNvQHb", JSON.stringify([${JSON.stringify(conversationId)}, 10, null, 1, [1], [4], null, 1]), null, "generic"]]]);
|
|
381
|
+
const response = await fetch("/_/BardChatUi/data/batchexecute?" + params, {
|
|
382
|
+
method: "POST",
|
|
383
|
+
credentials: "include",
|
|
384
|
+
headers: { "content-type": "application/x-www-form-urlencoded;charset=UTF-8", "x-same-domain": "1" },
|
|
385
|
+
body: new URLSearchParams({ "f.req": request, at: w.SNlM0e }).toString()
|
|
386
|
+
});
|
|
387
|
+
if (!response.ok) throw new Error("Gemini chat read failed: HTTP " + response.status);
|
|
388
|
+
return await response.text();
|
|
389
|
+
})()`);
|
|
390
|
+
}
|
|
391
|
+
/** Each `wrb.fr` entry's payload in a batchexecute-style body, one JSON frame per line. */
|
|
392
|
+
function payloads(body) {
|
|
393
|
+
const found = [];
|
|
394
|
+
for (const line of body.split("\n")) {
|
|
395
|
+
if (!line.startsWith("["))
|
|
396
|
+
continue;
|
|
397
|
+
let frame;
|
|
398
|
+
try {
|
|
399
|
+
frame = JSON.parse(line);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// A partial or non-JSON line carries nothing to read.
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (!Array.isArray(frame))
|
|
406
|
+
continue;
|
|
407
|
+
for (const entry of frame) {
|
|
408
|
+
if (!Array.isArray(entry) || entry[0] !== "wrb.fr" || typeof entry[2] !== "string")
|
|
409
|
+
continue;
|
|
410
|
+
const payload = JSON.parse(entry[2]);
|
|
411
|
+
if (Array.isArray(payload))
|
|
412
|
+
found.push(payload);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return found;
|
|
416
|
+
}
|
|
417
|
+
function contains(value, match) {
|
|
418
|
+
if (typeof value === "string")
|
|
419
|
+
return match(value);
|
|
420
|
+
if (Array.isArray(value))
|
|
421
|
+
return value.some((item) => contains(item, match));
|
|
422
|
+
if (value && typeof value === "object")
|
|
423
|
+
return Object.values(value).some((item) => contains(item, match));
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
/** A candidate's reply text, without the placeholders Gemini puts where media go. */
|
|
427
|
+
function candidateText(candidate) {
|
|
428
|
+
const parts = Array.isArray(candidate[1]) ? candidate[1] : [];
|
|
429
|
+
return parts
|
|
430
|
+
.filter((part) => typeof part === "string")
|
|
431
|
+
.join("")
|
|
432
|
+
.replace(/https?:\/\/googleusercontent\.com\/(image_generation_content|generated_video_content)\/\S+/g, "")
|
|
433
|
+
.trim();
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Parse a `StreamGenerate` response body. A payload holds the ids at `[1]`
|
|
437
|
+
* and the reply candidates at `[4]`. Later frames repeat the candidate with
|
|
438
|
+
* more content, so the last one wins. Generated images sit under the
|
|
439
|
+
* candidate's `[12]` as `gg-dl` URLs. A started video tool shows as a
|
|
440
|
+
* `video_generation` entry in a status payload.
|
|
441
|
+
*/
|
|
442
|
+
export function parseStreamGenerate(body) {
|
|
443
|
+
let conversationId = null;
|
|
444
|
+
let candidate = null;
|
|
445
|
+
let videoPending = false;
|
|
446
|
+
for (const payload of payloads(body)) {
|
|
447
|
+
const ids = payload[1];
|
|
448
|
+
if (Array.isArray(ids) && typeof ids[0] === "string")
|
|
449
|
+
conversationId = ids[0];
|
|
450
|
+
const candidates = payload[4];
|
|
451
|
+
if (Array.isArray(candidates) && Array.isArray(candidates[0]))
|
|
452
|
+
candidate = candidates[0];
|
|
453
|
+
if (contains(payload, (text) => text === "video_generation"))
|
|
454
|
+
videoPending = true;
|
|
455
|
+
}
|
|
456
|
+
if (!candidate)
|
|
457
|
+
return { conversationId, text: "", images: [], videoPending };
|
|
458
|
+
const images = new Set();
|
|
459
|
+
const walk = (value) => {
|
|
460
|
+
if (typeof value === "string") {
|
|
461
|
+
if (/^https:\/\/lh3\.googleusercontent\.com\/gg-dl\//.test(value))
|
|
462
|
+
images.add(value);
|
|
463
|
+
}
|
|
464
|
+
else if (Array.isArray(value)) {
|
|
465
|
+
value.forEach(walk);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
walk(candidate[12]);
|
|
469
|
+
return { conversationId, text: candidateText(candidate), images: [...images], videoPending };
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Find the generated video in an `hNvQHb` chat read. The payload lists the
|
|
473
|
+
* chat's turns at `[0]`, newest first, and a turn's candidates at `[3][0]`.
|
|
474
|
+
* A finished video is an array that holds `"video/mp4"` and, at `[7]`, its
|
|
475
|
+
* URLs: thumbnails on lh3 and the file itself on
|
|
476
|
+
* `contribution.usercontent.google.com`.
|
|
477
|
+
*/
|
|
478
|
+
export function findGeneratedVideo(body) {
|
|
479
|
+
const payload = payloads(body)[0];
|
|
480
|
+
const turn = Array.isArray(payload?.[0]) ? payload[0][0] : null;
|
|
481
|
+
if (!Array.isArray(turn))
|
|
482
|
+
return { url: null, text: "" };
|
|
483
|
+
const candidates = Array.isArray(turn[3]) ? turn[3][0] : null;
|
|
484
|
+
const candidate = Array.isArray(candidates) && Array.isArray(candidates[0]) ? candidates[0] : null;
|
|
485
|
+
const text = candidate ? candidateText(candidate) : "";
|
|
486
|
+
let entry = null;
|
|
487
|
+
const walk = (value) => {
|
|
488
|
+
if (entry || !Array.isArray(value))
|
|
489
|
+
return;
|
|
490
|
+
if (value.includes("video/mp4"))
|
|
491
|
+
entry = value;
|
|
492
|
+
else
|
|
493
|
+
value.forEach(walk);
|
|
494
|
+
};
|
|
495
|
+
walk(turn);
|
|
496
|
+
const urls = entry && Array.isArray(entry[7]) ? entry[7].filter((u) => typeof u === "string") : [];
|
|
497
|
+
const url = urls.find((u) => u.startsWith("https://contribution.usercontent.google.com/download")) ?? null;
|
|
498
|
+
return { url, text };
|
|
499
|
+
}
|
|
500
|
+
function aspectHint(aspectRatio, width, height) {
|
|
501
|
+
if (aspectRatio)
|
|
502
|
+
return `Aspect ratio: ${aspectRatio}.`;
|
|
503
|
+
if (width && height)
|
|
504
|
+
return `Size: about ${width}x${height} pixels.`;
|
|
505
|
+
return "";
|
|
506
|
+
}
|
|
507
|
+
function imagesOf(content) {
|
|
508
|
+
return Array.isArray(content) ? content.filter((part) => part.type === "image_url") : [];
|
|
509
|
+
}
|
|
510
|
+
function hashSystemPrompt(messages) {
|
|
511
|
+
const system = messages
|
|
512
|
+
.filter((m) => m.role === "system")
|
|
513
|
+
.map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)))
|
|
514
|
+
.join("\n\n");
|
|
515
|
+
return createHash("sha256").update(system).digest("hex").slice(0, 16);
|
|
516
|
+
}
|
|
517
|
+
/** The chat to continue is gone, so the caller starts a new one. Thrown before anything is sent. */
|
|
518
|
+
class ChatUnavailableError extends Error {
|
|
519
|
+
}
|
|
520
|
+
/** Run one chat turn, in a new chat or a continued one. The caller holds the tab. */
|
|
521
|
+
async function runChatTurnHoldingTab(turn) {
|
|
522
|
+
const setup = async () => {
|
|
523
|
+
if (turn.conversationId) {
|
|
524
|
+
const path = chatPath(turn.conversationId);
|
|
525
|
+
await currentTab().navigate(`${BASE_URL}${path}`);
|
|
526
|
+
await waitForComposer();
|
|
527
|
+
// A deleted or foreign chat redirects to a new one.
|
|
528
|
+
const pathname = await evaluate(`/*nt:location*/ location.pathname`);
|
|
529
|
+
if (pathname !== path)
|
|
530
|
+
throw new ChatUnavailableError(`Gemini chat ${turn.conversationId} is no longer available`);
|
|
531
|
+
// The history loads after the composer. A send before it starts a new chat.
|
|
532
|
+
await waitForHistory();
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
await openNewChat();
|
|
536
|
+
}
|
|
537
|
+
if (turn.model !== GEMINI_WEB_AUTO_MODEL || !liveModels || Date.now() - liveModels.at > MODELS_TTL_MS) {
|
|
538
|
+
await selectMode(turn.model);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
const reply = await sendAndRead(setup, turn.text, turn.images, CHAT_TIMEOUT_MS, turn.signal);
|
|
542
|
+
const conversationId = reply.conversationId ?? turn.conversationId;
|
|
543
|
+
if (!conversationId)
|
|
544
|
+
throw new Error("Gemini replied without a chat id");
|
|
545
|
+
return { conversationId, text: reply.text };
|
|
546
|
+
}
|
|
547
|
+
export class GeminiWebProvider extends BaseProvider {
|
|
548
|
+
static requiredSecrets() {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
constructor(_secrets = {}) {
|
|
552
|
+
super(PROVIDER_IDS.GEMINI_WEB);
|
|
553
|
+
}
|
|
554
|
+
declaredCapabilities() {
|
|
555
|
+
return ["text_to_image", "image_to_image", "text_to_video", "image_to_video"];
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Listing never attaches to the tab: it returns `auto` plus the modes read
|
|
559
|
+
* during an earlier call. A mode id from the Gemini mode picker also works
|
|
560
|
+
* as a model id.
|
|
561
|
+
*/
|
|
562
|
+
async getAvailableLanguageModels() {
|
|
563
|
+
const auto = { id: GEMINI_WEB_AUTO_MODEL, name: "Gemini (current mode)", provider: PROVIDER_IDS.GEMINI_WEB };
|
|
564
|
+
return [auto, ...(liveModels?.models ?? [])];
|
|
565
|
+
}
|
|
566
|
+
async getAvailableImageModels() {
|
|
567
|
+
return [{
|
|
568
|
+
id: GEMINI_WEB_IMAGE_MODEL,
|
|
569
|
+
name: "Gemini Images (web)",
|
|
570
|
+
provider: PROVIDER_IDS.GEMINI_WEB,
|
|
571
|
+
supportedTasks: ["text_to_image", "image_to_image"],
|
|
572
|
+
aspectRatios: ["1:1", "16:9", "9:16", "4:3", "3:4"]
|
|
573
|
+
}];
|
|
574
|
+
}
|
|
575
|
+
async getAvailableVideoModels() {
|
|
576
|
+
return [{
|
|
577
|
+
id: GEMINI_WEB_VIDEO_MODEL,
|
|
578
|
+
name: "Gemini Video (web)",
|
|
579
|
+
provider: PROVIDER_IDS.GEMINI_WEB,
|
|
580
|
+
supportedTasks: ["text_to_video", "image_to_video"],
|
|
581
|
+
aspectRatios: ["16:9", "9:16"]
|
|
582
|
+
}];
|
|
583
|
+
}
|
|
584
|
+
/** Chats by conversation thread id, for callers that do not persist the session themselves. */
|
|
585
|
+
sessions = new Map();
|
|
586
|
+
async generateMessage(args) {
|
|
587
|
+
const { text } = await this.chatTurn(args);
|
|
588
|
+
return { role: "assistant", content: text };
|
|
589
|
+
}
|
|
590
|
+
async *generateMessages(args) {
|
|
591
|
+
const { text, session } = await this.chatTurn(args);
|
|
592
|
+
if (args.threadId)
|
|
593
|
+
yield { type: "session", session };
|
|
594
|
+
yield { type: "chunk", content: text, content_type: "text", done: true };
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Continue the chat the prior session names, or start a new one. A chat
|
|
598
|
+
* continues when the model and system prompt still match and there are new
|
|
599
|
+
* messages. Then only those are sent. A chat that is gone falls back to a
|
|
600
|
+
* new one with the whole conversation.
|
|
601
|
+
*/
|
|
602
|
+
async chatTurn(args) {
|
|
603
|
+
if (args.tools && args.tools.length > 0) {
|
|
604
|
+
log.warn("Gemini in the browser cannot call tools; sending the conversation without them", { tools: args.tools.length });
|
|
605
|
+
}
|
|
606
|
+
const systemHash = hashSystemPrompt(args.messages);
|
|
607
|
+
const threadId = args.threadId ?? null;
|
|
608
|
+
const prior = args.providerSession ?? (threadId ? this.sessions.get(threadId) ?? null : null);
|
|
609
|
+
const canResume = prior != null &&
|
|
610
|
+
prior.providerId === this.provider &&
|
|
611
|
+
prior.model === args.model &&
|
|
612
|
+
(prior.systemHash == null || prior.systemHash === systemHash) &&
|
|
613
|
+
args.messages.length > prior.checkpoint;
|
|
614
|
+
let result = null;
|
|
615
|
+
if (canResume && prior) {
|
|
616
|
+
const delta = args.messages.slice(prior.checkpoint);
|
|
617
|
+
const images = await this.lastUserImages(delta, args.signal);
|
|
618
|
+
try {
|
|
619
|
+
result = await serialized(() => runChatTurnHoldingTab({ model: args.model, text: composeDelta(delta), images, conversationId: prior.token, signal: args.signal }));
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
if (!(error instanceof ChatUnavailableError))
|
|
623
|
+
throw error;
|
|
624
|
+
log.warn("Gemini chat cannot continue; starting a new one", { conversationId: prior.token, error: error.message });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (!result) {
|
|
628
|
+
// The caller may have sent only the delta; a new chat needs the whole conversation.
|
|
629
|
+
const messages = canResume && args.loadFullHistory ? await args.loadFullHistory() : args.messages;
|
|
630
|
+
const images = await this.lastUserImages(messages, args.signal);
|
|
631
|
+
result = await serialized(() => runChatTurnHoldingTab({ model: args.model, text: composePrompt(messages), images, signal: args.signal }));
|
|
632
|
+
}
|
|
633
|
+
const session = {
|
|
634
|
+
providerId: this.provider,
|
|
635
|
+
model: args.model,
|
|
636
|
+
token: result.conversationId,
|
|
637
|
+
checkpoint: args.messages.length,
|
|
638
|
+
systemHash
|
|
639
|
+
};
|
|
640
|
+
// Cache only when the caller does not thread the session itself; its copy is authoritative.
|
|
641
|
+
if (threadId && args.providerSession == null)
|
|
642
|
+
this.sessions.set(threadId, session);
|
|
643
|
+
return { text: result.text, session };
|
|
644
|
+
}
|
|
645
|
+
/** The images of the last user message among `messages`. Gemini gets no earlier ones. */
|
|
646
|
+
async lastUserImages(messages, signal) {
|
|
647
|
+
const last = [...messages].reverse().find((m) => m.role === "user");
|
|
648
|
+
const dropped = messages.filter((m) => m !== last).reduce((n, m) => n + imagesOf(m.content).length, 0);
|
|
649
|
+
if (dropped > 0)
|
|
650
|
+
log.warn("Gemini gets images from the last user message only; dropping earlier ones", { dropped });
|
|
651
|
+
return last ? Promise.all(imagesOf(last.content).map((part) => this.imageBytes(part, signal))) : [];
|
|
652
|
+
}
|
|
653
|
+
async textToImage(params) {
|
|
654
|
+
return this.generateImage(params.prompt, [], params);
|
|
655
|
+
}
|
|
656
|
+
async imageToImage(images, params) {
|
|
657
|
+
if (images.length === 0)
|
|
658
|
+
throw new Error("imageToImage needs at least one source image");
|
|
659
|
+
return this.generateImage(params.prompt, images, { ...params, width: params.targetWidth, height: params.targetHeight });
|
|
660
|
+
}
|
|
661
|
+
async textToVideo(params) {
|
|
662
|
+
return this.generateVideo(params.prompt, null, params);
|
|
663
|
+
}
|
|
664
|
+
async imageToVideo(image, params) {
|
|
665
|
+
return this.generateVideo(params.prompt ?? "", image, params);
|
|
666
|
+
}
|
|
667
|
+
async generateImage(prompt, images, params) {
|
|
668
|
+
const text = [
|
|
669
|
+
images.length > 0
|
|
670
|
+
? "Edit the attached image(s) as described and reply with one image. Do not ask questions."
|
|
671
|
+
: "Generate one image. Do not ask questions.",
|
|
672
|
+
prompt,
|
|
673
|
+
aspectHint(params.aspectRatio, params.width, params.height),
|
|
674
|
+
params.negativePrompt ? `Avoid: ${params.negativePrompt}.` : ""
|
|
675
|
+
].filter(Boolean).join("\n\n");
|
|
676
|
+
// The download runs in the same turn on the tab, so no other call can
|
|
677
|
+
// navigate the tab away in between.
|
|
678
|
+
return serialized(async () => {
|
|
679
|
+
const reply = await sendAndRead(openNewChat, text, images, IMAGE_TIMEOUT_MS, params.signal);
|
|
680
|
+
const url = reply.images.at(-1);
|
|
681
|
+
if (!url) {
|
|
682
|
+
throw new Error(`Gemini finished without an image${reply.text ? `: ${reply.text.slice(0, 300)}` : ""}.${chatLink(reply.conversationId)}`);
|
|
683
|
+
}
|
|
684
|
+
return currentTab().download(`${url}${FULL_SIZE_SUFFIX}`, DOWNLOAD_TIMEOUT_MS);
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
async generateVideo(prompt, image, params) {
|
|
688
|
+
const text = [
|
|
689
|
+
image ? "Animate the attached image into a video." : "",
|
|
690
|
+
prompt,
|
|
691
|
+
params.negativePrompt ? `Avoid: ${params.negativePrompt}.` : ""
|
|
692
|
+
].filter(Boolean).join("\n\n");
|
|
693
|
+
if (!text)
|
|
694
|
+
throw new Error("A Gemini video needs a prompt or a start image");
|
|
695
|
+
const timeoutMs = params.timeoutSeconds ? params.timeoutSeconds * 1000 : VIDEO_TIMEOUT_MS;
|
|
696
|
+
return serialized(async () => {
|
|
697
|
+
const setup = async () => {
|
|
698
|
+
await openNewChat();
|
|
699
|
+
await enableTool(VIDEO_TOOL_ICON);
|
|
700
|
+
await selectVideoAspect(params.aspectRatio === "9:16" ? "9:16" : "16:9");
|
|
701
|
+
};
|
|
702
|
+
const reply = await sendAndRead(setup, text, image ? [image] : [], CHAT_TIMEOUT_MS, params.signal);
|
|
703
|
+
if (!reply.conversationId)
|
|
704
|
+
throw new Error("Gemini replied without a chat id");
|
|
705
|
+
if (!reply.videoPending) {
|
|
706
|
+
throw new Error(`Gemini did not start a video${reply.text ? `: ${reply.text.slice(0, 300)}` : ""}.${chatLink(reply.conversationId)}`);
|
|
707
|
+
}
|
|
708
|
+
const started = Date.now();
|
|
709
|
+
let failures = 0;
|
|
710
|
+
let lastText = reply.text;
|
|
711
|
+
for (;;) {
|
|
712
|
+
await sleep(VIDEO_POLL_INTERVAL_MS, params.signal);
|
|
713
|
+
try {
|
|
714
|
+
const found = findGeneratedVideo(await readChat(reply.conversationId));
|
|
715
|
+
failures = 0;
|
|
716
|
+
lastText = found.text || lastText;
|
|
717
|
+
if (found.url) {
|
|
718
|
+
log.info("Gemini video ready", { conversationId: reply.conversationId, seconds: Math.round((Date.now() - started) / 1000) });
|
|
719
|
+
return currentTab().download(found.url, DOWNLOAD_TIMEOUT_MS);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
log.warn("Gemini video poll failed", { failures: failures + 1, error: error instanceof Error ? error.message : String(error) });
|
|
724
|
+
if (++failures > MAX_POLL_FAILURES)
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
if (Date.now() - started > timeoutMs) {
|
|
728
|
+
throw new Error(`Gemini did not finish the video within ${timeoutMs / 1000}s${lastText ? ` (last reply: ${lastText.slice(0, 200)})` : ""}.${chatLink(reply.conversationId)}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
/** Bytes of one image part: inline data, a data URI, an asset URI or a URL. */
|
|
734
|
+
async imageBytes(part, signal) {
|
|
735
|
+
const { data, uri } = part.image;
|
|
736
|
+
if (data instanceof Uint8Array && data.length > 0)
|
|
737
|
+
return data;
|
|
738
|
+
const fromDataUri = (value) => new Uint8Array(Buffer.from(value.slice(value.indexOf(",") + 1), "base64"));
|
|
739
|
+
if (typeof data === "string" && data.length > 0)
|
|
740
|
+
return data.startsWith("data:") ? fromDataUri(data) : new Uint8Array(Buffer.from(data, "base64"));
|
|
741
|
+
if (!uri)
|
|
742
|
+
throw new Error("Image part has neither data nor a URI");
|
|
743
|
+
const resolved = uri.startsWith("data:") ? uri : await this.resolveUri(uri);
|
|
744
|
+
if (resolved.startsWith("data:"))
|
|
745
|
+
return fromDataUri(resolved);
|
|
746
|
+
const response = await fetchExternalMedia(resolved, signal ? { signal } : undefined);
|
|
747
|
+
if (!response.ok)
|
|
748
|
+
throw new Error(`Image download failed: HTTP ${response.status}`);
|
|
749
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
//# sourceMappingURL=gemini-web-provider.js.map
|