@nodetool-ai/runtime 0.8.3 → 0.8.4-a13.1
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/canvas-codec.d.ts +3 -0
- package/dist/canvas-codec.d.ts.map +1 -0
- package/dist/canvas-codec.js +12 -0
- package/dist/canvas-codec.js.map +1 -0
- package/dist/context-packer.d.ts +1 -1
- package/dist/context-packer.js +1 -1
- package/dist/context.d.ts +3 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +13 -3
- package/dist/context.js.map +1 -1
- package/dist/host-binaries.d.ts.map +1 -1
- package/dist/host-binaries.js +4 -3
- package/dist/host-binaries.js.map +1 -1
- package/dist/http-client-spans.d.ts +9 -0
- package/dist/http-client-spans.d.ts.map +1 -0
- package/dist/http-client-spans.js +88 -0
- package/dist/http-client-spans.js.map +1 -0
- package/dist/image-codec.d.ts +6 -3
- package/dist/image-codec.d.ts.map +1 -1
- package/dist/image-codec.js +17 -17
- package/dist/image-codec.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/media-ref-bytes.d.ts.map +1 -1
- package/dist/media-ref-bytes.js +31 -6
- package/dist/media-ref-bytes.js.map +1 -1
- package/dist/otlp-json-trace-exporter.d.ts +89 -0
- package/dist/otlp-json-trace-exporter.d.ts.map +1 -0
- package/dist/otlp-json-trace-exporter.js +155 -0
- package/dist/otlp-json-trace-exporter.js.map +1 -0
- package/dist/providers/antigravity-provider.d.ts +113 -0
- package/dist/providers/antigravity-provider.d.ts.map +1 -0
- package/dist/providers/antigravity-provider.js +527 -0
- package/dist/providers/antigravity-provider.js.map +1 -0
- package/dist/providers/azure-openai-provider.d.ts +69 -0
- package/dist/providers/azure-openai-provider.d.ts.map +1 -0
- package/dist/providers/azure-openai-provider.js +233 -0
- package/dist/providers/azure-openai-provider.js.map +1 -0
- package/dist/providers/base-provider.d.ts.map +1 -1
- package/dist/providers/base-provider.js +19 -1
- package/dist/providers/base-provider.js.map +1 -1
- package/dist/providers/bedrock-provider.d.ts +52 -0
- package/dist/providers/bedrock-provider.d.ts.map +1 -0
- package/dist/providers/bedrock-provider.js +648 -0
- package/dist/providers/bedrock-provider.js.map +1 -0
- package/dist/providers/bedrock-transport.d.ts +37 -0
- package/dist/providers/bedrock-transport.d.ts.map +1 -0
- package/dist/providers/bedrock-transport.js +186 -0
- package/dist/providers/bedrock-transport.js.map +1 -0
- package/dist/providers/chatgpt-provider.d.ts.map +1 -1
- package/dist/providers/chatgpt-provider.js +77 -32
- package/dist/providers/chatgpt-provider.js.map +1 -1
- package/dist/providers/claude-agent-provider.d.ts +29 -51
- package/dist/providers/claude-agent-provider.d.ts.map +1 -1
- package/dist/providers/claude-agent-provider.js +144 -202
- package/dist/providers/claude-agent-provider.js.map +1 -1
- package/dist/providers/claude-cli.d.ts +265 -0
- package/dist/providers/claude-cli.d.ts.map +1 -0
- package/dist/providers/claude-cli.js +488 -0
- package/dist/providers/claude-cli.js.map +1 -0
- package/dist/providers/codex-provider.d.ts +9 -2
- package/dist/providers/codex-provider.d.ts.map +1 -1
- package/dist/providers/codex-provider.js +25 -11
- package/dist/providers/codex-provider.js.map +1 -1
- package/dist/providers/core-tools.d.ts +4 -1
- package/dist/providers/core-tools.d.ts.map +1 -1
- package/dist/providers/core-tools.js +5 -3
- package/dist/providers/core-tools.js.map +1 -1
- package/dist/providers/dreamina-pending.d.ts +22 -0
- package/dist/providers/dreamina-pending.d.ts.map +1 -0
- package/dist/providers/dreamina-pending.js +86 -0
- package/dist/providers/dreamina-pending.js.map +1 -0
- package/dist/providers/dreamina-provider.d.ts +5 -0
- package/dist/providers/dreamina-provider.d.ts.map +1 -1
- package/dist/providers/dreamina-provider.js +88 -19
- package/dist/providers/dreamina-provider.js.map +1 -1
- package/dist/providers/extension-tab.d.ts +18 -5
- package/dist/providers/extension-tab.d.ts.map +1 -1
- package/dist/providers/extension-tab.js +71 -15
- package/dist/providers/extension-tab.js.map +1 -1
- package/dist/providers/gemini-web-provider.d.ts +14 -14
- package/dist/providers/gemini-web-provider.d.ts.map +1 -1
- package/dist/providers/gemini-web-provider.js +264 -189
- package/dist/providers/gemini-web-provider.js.map +1 -1
- package/dist/providers/http-transport.d.ts.map +1 -1
- package/dist/providers/http-transport.js +2 -0
- package/dist/providers/http-transport.js.map +1 -1
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +30 -7
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/node-llama-cpp-provider.d.ts.map +1 -1
- package/dist/providers/node-llama-cpp-provider.js +2 -4
- package/dist/providers/node-llama-cpp-provider.js.map +1 -1
- package/dist/providers/openai-provider.d.ts +2 -0
- package/dist/providers/openai-provider.d.ts.map +1 -1
- package/dist/providers/openai-provider.js +9 -5
- package/dist/providers/openai-provider.js.map +1 -1
- package/dist/providers/safe-url.d.ts.map +1 -1
- package/dist/providers/safe-url.js +8 -0
- package/dist/providers/safe-url.js.map +1 -1
- package/dist/providers/vgenv-provider.d.ts +33 -0
- package/dist/providers/vgenv-provider.d.ts.map +1 -0
- package/dist/providers/vgenv-provider.js +261 -0
- package/dist/providers/vgenv-provider.js.map +1 -0
- package/dist/python-graph-resolver.d.ts.map +1 -1
- package/dist/python-graph-resolver.js +12 -2
- package/dist/python-graph-resolver.js.map +1 -1
- package/dist/python-stdio-bridge.d.ts.map +1 -1
- package/dist/python-stdio-bridge.js +33 -10
- package/dist/python-stdio-bridge.js.map +1 -1
- package/dist/run-trace-processor.d.ts +3 -0
- package/dist/run-trace-processor.d.ts.map +1 -1
- package/dist/run-trace-processor.js +56 -40
- package/dist/run-trace-processor.js.map +1 -1
- package/dist/telemetry.d.ts +2 -2
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +33 -34
- package/dist/telemetry.js.map +1 -1
- package/dist/token-counter.d.ts +4 -13
- package/dist/token-counter.d.ts.map +1 -1
- package/dist/token-counter.js +68 -18
- package/dist/token-counter.js.map +1 -1
- package/dist/trace-context-manager.d.ts +12 -0
- package/dist/trace-context-manager.d.ts.map +1 -0
- package/dist/trace-context-manager.js +30 -0
- package/dist/trace-context-manager.js.map +1 -0
- package/dist/trace-exporters.d.ts +1 -1
- package/dist/trace-exporters.d.ts.map +1 -1
- package/dist/trace-exporters.js +4 -5
- package/dist/trace-exporters.js.map +1 -1
- package/dist/trace-semantic-conventions.d.ts +2 -0
- package/dist/trace-semantic-conventions.d.ts.map +1 -0
- package/dist/trace-semantic-conventions.js +2 -0
- package/dist/trace-semantic-conventions.js.map +1 -0
- package/dist/tracing-module.d.ts +3 -0
- package/dist/tracing-module.d.ts.map +1 -0
- package/dist/tracing-module.js +12 -0
- package/dist/tracing-module.js.map +1 -0
- package/package.json +35 -18
|
@@ -4,19 +4,23 @@
|
|
|
4
4
|
* No API key: requests run on the user's Google account and its Gemini plan.
|
|
5
5
|
*
|
|
6
6
|
* A turn is submitted through the page's own composer, so the site computes
|
|
7
|
-
* its anti-abuse tokens itself. The page
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* its anti-abuse tokens itself. The page streams the turn over a WebChannel
|
|
8
|
+
* of base64 protobuf frames, which this module does not parse. The composer's
|
|
9
|
+
* stop button shows while the turn runs. When it goes away, the provider reads
|
|
10
|
+
* the stored turn with the read call the page itself uses (`hNvQHb`). The tab
|
|
11
|
+
* renders no reply text while it is hidden, so the read is the only reliable
|
|
12
|
+
* place to get the result.
|
|
11
13
|
*
|
|
12
14
|
* The tab stays in the background. Chrome can freeze a background tab, and
|
|
13
15
|
* a frozen Gemini runs a send click only when the tab is shown, which starts
|
|
14
16
|
* a second turn later. The extension tab unfreezes the page before each
|
|
15
17
|
* script. A click that still starts no request is dropped with a reload, and
|
|
16
|
-
* the turn is retried once.
|
|
18
|
+
* the turn is retried once. Chrome also throttles the timers of a page hidden
|
|
19
|
+
* for a while to one wake-up a minute, so no page script waits on a timer: each
|
|
20
|
+
* one does a single step, and every wait polls from here ({@link pollTab}).
|
|
17
21
|
*
|
|
18
22
|
* A video finishes minutes after its turn ends. The provider polls the chat
|
|
19
|
-
* with the read
|
|
23
|
+
* with the same read until the video appears.
|
|
20
24
|
*
|
|
21
25
|
* Generated media need the user's Google cookies and send no CORS headers for
|
|
22
26
|
* the page, so the browser itself downloads them ({@link ExtensionTab.download}).
|
|
@@ -33,19 +37,21 @@ import { PROVIDER_IDS } from "@nodetool-ai/protocol";
|
|
|
33
37
|
import { fetchExternalMedia } from "../external-media-fetch.js";
|
|
34
38
|
import { BaseProvider } from "./base-provider.js";
|
|
35
39
|
import { composeDelta, composePrompt } from "./chatgpt-provider.js";
|
|
36
|
-
import { createExtensionTab, stageBytesInTab } from "./extension-tab.js";
|
|
40
|
+
import { createExtensionTab, pollTab, stageBytesInTab } from "./extension-tab.js";
|
|
37
41
|
import { detectImageMime } from "./image-mime.js";
|
|
38
42
|
const log = createLogger("nodetool.runtime.providers.gemini-web");
|
|
39
43
|
const HOST = "gemini.google.com";
|
|
40
44
|
const BASE_URL = `https://${HOST}`;
|
|
41
45
|
/** The model id that keeps the mode the user picked last in Gemini. */
|
|
42
46
|
export const GEMINI_WEB_AUTO_MODEL = "auto";
|
|
43
|
-
/** The image model: Gemini's own image tool, run by the current mode. */
|
|
47
|
+
/** The image model: Gemini's own image tool, run by the current mode. `gemini-web-image:<mode id>` picks a mode. */
|
|
44
48
|
export const GEMINI_WEB_IMAGE_MODEL = "gemini-web-image";
|
|
45
49
|
/** The video model: Gemini's video tool (Veo). */
|
|
46
50
|
export const GEMINI_WEB_VIDEO_MODEL = "gemini-web-video";
|
|
47
51
|
const COMPOSER = "rich-textarea .ql-editor";
|
|
48
52
|
const SUBMIT = "gem-icon-button.send-button button";
|
|
53
|
+
/** The send button turns into a stop button while a turn runs. */
|
|
54
|
+
const STOP = "gem-icon-button.send-button.stop";
|
|
49
55
|
const ATTACHMENT = "uploader-file-preview";
|
|
50
56
|
const INPUT_AREA = "input-area-v2";
|
|
51
57
|
const TOOLS_BUTTON = "gem-icon-button.menu-button button";
|
|
@@ -60,7 +66,21 @@ const VIDEO_POLL_INTERVAL_MS = 5_000;
|
|
|
60
66
|
const READY_TIMEOUT_MS = 20_000;
|
|
61
67
|
const APP_SETTLE_TIMEOUT_MS = 10_000;
|
|
62
68
|
/** The send script waits in the page for uploads to finish before it clicks. */
|
|
63
|
-
|
|
69
|
+
/** How long the send button may stay disabled while attachments upload. */
|
|
70
|
+
const SEND_READY_TIMEOUT_MS = 75_000;
|
|
71
|
+
/** How long after the send click the page has to start its request. */
|
|
72
|
+
const SEND_START_TIMEOUT_MS = 10_000;
|
|
73
|
+
/** How long the chat read may lag behind the end of a turn. */
|
|
74
|
+
const TURN_STORED_TIMEOUT_MS = 30_000;
|
|
75
|
+
/** How long a background tab may take to render a composer menu button. */
|
|
76
|
+
const MENU_TIMEOUT_MS = 10_000;
|
|
77
|
+
/** How long an opened menu may take to show its items. */
|
|
78
|
+
const MENU_ITEM_TIMEOUT_MS = 3_000;
|
|
79
|
+
/** The first open of the mode picker after a page load can take a few seconds. */
|
|
80
|
+
const MODE_LIST_TIMEOUT_MS = 6_000;
|
|
81
|
+
/** A paste before the uploader is ready shows no preview. Paste again after this wait. */
|
|
82
|
+
const PASTE_TIMEOUT_MS = 3_000;
|
|
83
|
+
const PASTE_ATTEMPTS = 5;
|
|
64
84
|
const CHAT_TIMEOUT_MS = 10 * 60_000;
|
|
65
85
|
const IMAGE_TIMEOUT_MS = 10 * 60_000;
|
|
66
86
|
const VIDEO_TIMEOUT_MS = 15 * 60_000;
|
|
@@ -71,20 +91,45 @@ let tab = null;
|
|
|
71
91
|
/** Replace the tab. Tests use this to avoid a real browser. */
|
|
72
92
|
export function setGeminiWebTab(next) {
|
|
73
93
|
tab = next;
|
|
74
|
-
|
|
94
|
+
liveModes = null;
|
|
75
95
|
}
|
|
76
96
|
function currentTab() {
|
|
77
97
|
return (tab ??= createExtensionTab(HOST));
|
|
78
98
|
}
|
|
79
99
|
/** Calls share one tab, so each waits for the previous one. */
|
|
100
|
+
let activeSignal;
|
|
80
101
|
let queue = Promise.resolve();
|
|
81
102
|
function serialized(run) {
|
|
82
|
-
const
|
|
103
|
+
const owned = async () => {
|
|
104
|
+
try {
|
|
105
|
+
return await run();
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
activeSignal = undefined;
|
|
109
|
+
await tab?.release?.();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const next = queue.then(owned, owned);
|
|
83
113
|
queue = next.catch(() => undefined);
|
|
84
114
|
return next;
|
|
85
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* The modes of the Gemini mode picker, listed before any call has read the
|
|
118
|
+
* live picker. An account can offer more, or fewer. Picking a mode the
|
|
119
|
+
* account lacks fails with the list of the modes it has.
|
|
120
|
+
*/
|
|
121
|
+
const KNOWN_MODES = [
|
|
122
|
+
{ id: "8c46e95b1a07cecc", name: "3.5 Flash-Lite" },
|
|
123
|
+
{ id: "56fdd199312815e2", name: "3.8 Flash" },
|
|
124
|
+
{ id: "e6fa609c3fa255c0", name: "3.1 Pro" },
|
|
125
|
+
{ id: "95a4c0abcba24364", name: "4 Argon" }
|
|
126
|
+
];
|
|
86
127
|
/** Modes read from the page during an earlier call, so listing never attaches. */
|
|
87
|
-
let
|
|
128
|
+
let liveModes = null;
|
|
129
|
+
/** The live modes when a call has read them, else {@link KNOWN_MODES}. */
|
|
130
|
+
function listedModes() {
|
|
131
|
+
return liveModes?.modes ?? KNOWN_MODES;
|
|
132
|
+
}
|
|
88
133
|
function sleep(ms, signal) {
|
|
89
134
|
return new Promise((resolve, reject) => {
|
|
90
135
|
if (signal?.aborted)
|
|
@@ -95,7 +140,12 @@ function sleep(ms, signal) {
|
|
|
95
140
|
});
|
|
96
141
|
}
|
|
97
142
|
async function evaluate(expression, timeoutMs) {
|
|
98
|
-
|
|
143
|
+
activeSignal?.throwIfAborted();
|
|
144
|
+
return (await currentTab().evaluate(expression, timeoutMs, activeSignal));
|
|
145
|
+
}
|
|
146
|
+
/** {@link pollTab} on the Gemini tab. */
|
|
147
|
+
function poll(expression, timeoutMs) {
|
|
148
|
+
return pollTab(currentTab(), expression, timeoutMs, activeSignal);
|
|
99
149
|
}
|
|
100
150
|
/** The page path of a chat drops the `c_` prefix of its id. */
|
|
101
151
|
function chatPath(conversationId) {
|
|
@@ -112,7 +162,7 @@ async function waitForComposer() {
|
|
|
112
162
|
while (!ready && Date.now() < deadline) {
|
|
113
163
|
ready = await evaluate(`/*nt:ready*/ !!document.querySelector(${JSON.stringify(COMPOSER)})`);
|
|
114
164
|
if (!ready)
|
|
115
|
-
await sleep(250);
|
|
165
|
+
await sleep(250, activeSignal);
|
|
116
166
|
}
|
|
117
167
|
// `oPEP7c` holds the signed-in account. A signed-out page has a composer but no account.
|
|
118
168
|
const signedIn = await evaluate(`/*nt:session*/ !!(window.WIZ_global_data && WIZ_global_data.oPEP7c)`);
|
|
@@ -124,7 +174,7 @@ async function waitForComposer() {
|
|
|
124
174
|
// click before that starts no request. The mode picker is the last part.
|
|
125
175
|
const settled = Date.now() + APP_SETTLE_TIMEOUT_MS;
|
|
126
176
|
while (Date.now() < settled && !(await evaluate(`/*nt:settled*/ !!document.querySelector(${JSON.stringify(MODE_BUTTON)})`))) {
|
|
127
|
-
await sleep(250);
|
|
177
|
+
await sleep(250, activeSignal);
|
|
128
178
|
}
|
|
129
179
|
}
|
|
130
180
|
/** Wait until the open chat shows its earlier turns. */
|
|
@@ -133,13 +183,13 @@ async function waitForHistory() {
|
|
|
133
183
|
while (Date.now() < deadline) {
|
|
134
184
|
if (await evaluate(`/*nt:history*/ document.querySelectorAll("user-query").length > 0`))
|
|
135
185
|
return;
|
|
136
|
-
await sleep(250);
|
|
186
|
+
await sleep(250, activeSignal);
|
|
137
187
|
}
|
|
138
188
|
throw new ChatUnavailableError("The Gemini chat did not load its earlier turns");
|
|
139
189
|
}
|
|
140
190
|
/** Open a new chat with no tool selected. */
|
|
141
191
|
async function openNewChat() {
|
|
142
|
-
await currentTab().navigate(`${BASE_URL}/app
|
|
192
|
+
await currentTab().navigate(`${BASE_URL}/app`, activeSignal);
|
|
143
193
|
await waitForComposer();
|
|
144
194
|
// A selected tool shows as a chip with a close icon.
|
|
145
195
|
await evaluate(`/*nt:clear-tools*/ (async () => {
|
|
@@ -151,207 +201,217 @@ async function openNewChat() {
|
|
|
151
201
|
}
|
|
152
202
|
/** Turn on the tool whose menu item shows `icon`. */
|
|
153
203
|
async function enableTool(icon) {
|
|
154
|
-
await
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (!
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (chip) return true;
|
|
175
|
-
}
|
|
176
|
-
throw new Error("The Gemini " + icon + " tool did not turn on");
|
|
177
|
-
})()`);
|
|
204
|
+
const tools = await poll(`/*nt:tool-menu*/ !!document.querySelector(${JSON.stringify(TOOLS_BUTTON)})`, MENU_TIMEOUT_MS);
|
|
205
|
+
if (!tools)
|
|
206
|
+
throw new Error("The Gemini composer has no tools menu");
|
|
207
|
+
await evaluate(`/*nt:tool-open*/ (() => { document.querySelector(${JSON.stringify(TOOLS_BUTTON)}).click(); return true; })()`);
|
|
208
|
+
const item = await poll(`/*nt:tool-item*/ (() => {
|
|
209
|
+
const item = [...document.querySelectorAll(${JSON.stringify(`${OVERLAY} [role=menuitemcheckbox]`)})].find((b) => b.querySelector(${JSON.stringify(`mat-icon[fonticon="${icon}"]`)}));
|
|
210
|
+
return item ? (item.getAttribute("aria-checked") === "true" ? "on" : "off") : null;
|
|
211
|
+
})()`, MENU_ITEM_TIMEOUT_MS);
|
|
212
|
+
if (!item)
|
|
213
|
+
throw new Error(`The Gemini tools menu has no ${icon} tool. The account may not offer it`);
|
|
214
|
+
if (item === "off") {
|
|
215
|
+
await evaluate(`/*nt:tool-pick*/ (() => {
|
|
216
|
+
const item = [...document.querySelectorAll(${JSON.stringify(`${OVERLAY} [role=menuitemcheckbox]`)})].find((b) => b.querySelector(${JSON.stringify(`mat-icon[fonticon="${icon}"]`)}));
|
|
217
|
+
if (item) item.click();
|
|
218
|
+
return true;
|
|
219
|
+
})()`);
|
|
220
|
+
}
|
|
221
|
+
const chip = await poll(`/*nt:tool-chip*/ [...document.querySelectorAll(${JSON.stringify(`${INPUT_AREA} button`)})].some((b) => b.querySelector(${JSON.stringify(`mat-icon[fonticon="${icon}"]`)}))`, MENU_ITEM_TIMEOUT_MS);
|
|
222
|
+
if (!chip)
|
|
223
|
+
throw new Error(`The Gemini ${icon} tool did not turn on`);
|
|
178
224
|
}
|
|
179
225
|
/** Pick a video aspect ratio in the composer's aspect menu. */
|
|
180
226
|
async function selectVideoAspect(aspectRatio) {
|
|
181
227
|
const icon = aspectRatio === "9:16" ? "crop_9_16" : "crop_16_9";
|
|
182
|
-
await evaluate(`/*nt:aspect*/ (
|
|
183
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
228
|
+
const state = await evaluate(`/*nt:aspect*/ (() => {
|
|
184
229
|
const button = [...document.querySelectorAll(${JSON.stringify(`${INPUT_AREA} button`)})].find((b) => b.querySelector('mat-icon[fonticon^="crop_"]'));
|
|
185
230
|
if (!button) throw new Error("The Gemini video composer has no aspect ratio menu");
|
|
186
|
-
if (button.querySelector(
|
|
231
|
+
if (button.querySelector(${JSON.stringify(`mat-icon[fonticon="${icon}"]`)})) return "selected";
|
|
187
232
|
button.click();
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
233
|
+
return "opened";
|
|
234
|
+
})()`);
|
|
235
|
+
if (state === "selected")
|
|
236
|
+
return;
|
|
237
|
+
const picked = await poll(`/*nt:aspect-pick*/ (() => {
|
|
238
|
+
const option = [...document.querySelectorAll(${JSON.stringify(`${OVERLAY} [role=menuitemradio]`)})].find((b) => b.querySelector(${JSON.stringify(`mat-icon[fonticon="${icon}"]`)}));
|
|
239
|
+
if (!option) return false;
|
|
194
240
|
option.click();
|
|
195
241
|
return true;
|
|
196
|
-
})()
|
|
242
|
+
})()`, MENU_ITEM_TIMEOUT_MS);
|
|
243
|
+
if (!picked)
|
|
244
|
+
throw new Error(`The Gemini aspect ratio menu has no ${aspectRatio} option`);
|
|
197
245
|
}
|
|
198
246
|
/**
|
|
199
247
|
* Open the mode picker, record its modes, and pick `modeId` unless it is
|
|
200
248
|
* {@link GEMINI_WEB_AUTO_MODEL}. Modes carry a stable `data-mode-id`.
|
|
201
249
|
*/
|
|
202
250
|
async function selectMode(modeId) {
|
|
203
|
-
const modes = await evaluate(`/*nt:modes*/ (async () => {
|
|
204
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
205
251
|
// A background tab renders the mode picker seconds after the composer.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
items
|
|
252
|
+
const toggle = await poll(`/*nt:mode-menu*/ !!document.querySelector(${JSON.stringify(MODE_BUTTON)})`, MENU_TIMEOUT_MS);
|
|
253
|
+
let modes = [];
|
|
254
|
+
if (toggle) {
|
|
255
|
+
await evaluate(`/*nt:mode-open*/ (() => {
|
|
256
|
+
const toggle = document.querySelector(${JSON.stringify(MODE_BUTTON)});
|
|
257
|
+
if (toggle.getAttribute("aria-expanded") !== "true") toggle.click();
|
|
258
|
+
return true;
|
|
259
|
+
})()`);
|
|
260
|
+
// The first open after a page load can take a few seconds.
|
|
261
|
+
modes = (await poll(`/*nt:modes*/ (() => {
|
|
262
|
+
const items = [...document.querySelectorAll("[data-mode-id]")];
|
|
263
|
+
if (items.length === 0) return null;
|
|
264
|
+
const modes = items.map((item) => ({ id: item.getAttribute("data-mode-id"), name: item.innerText.split("\\n")[0].trim() }));
|
|
265
|
+
const wanted = ${JSON.stringify(modeId)};
|
|
266
|
+
const pick = items.find((item) => item.getAttribute("data-mode-id") === wanted);
|
|
267
|
+
const toggle = document.querySelector(${JSON.stringify(MODE_BUTTON)});
|
|
268
|
+
if (pick) pick.click();
|
|
269
|
+
else if (toggle && toggle.getAttribute("aria-expanded") === "true") toggle.click();
|
|
270
|
+
return modes;
|
|
271
|
+
})()`, MODE_LIST_TIMEOUT_MS)) ?? [];
|
|
272
|
+
await sleep(300);
|
|
218
273
|
}
|
|
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
274
|
if (modes.length > 0) {
|
|
228
|
-
|
|
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
|
-
};
|
|
275
|
+
liveModes = { at: Date.now(), modes: modes.filter((m) => m.id).map((m) => ({ id: m.id, name: m.name || m.id })) };
|
|
232
276
|
}
|
|
233
277
|
if (modeId !== GEMINI_WEB_AUTO_MODEL && !modes.some((m) => m.id === modeId)) {
|
|
234
278
|
throw new Error(`Gemini has no mode ${modeId}. Its modes are: ${modes.map((m) => `${m.id} (${m.name})`).join(", ") || "none found"}`);
|
|
235
279
|
}
|
|
236
280
|
}
|
|
237
|
-
/**
|
|
281
|
+
/**
|
|
282
|
+
* Paste images into the composer, the way a clipboard paste would. The page
|
|
283
|
+
* drops a paste that arrives before its uploader is ready, and the mode
|
|
284
|
+
* button can appear before that. So paste again until the previews show.
|
|
285
|
+
*/
|
|
238
286
|
async function attachImages(images) {
|
|
239
287
|
if (images.length === 0)
|
|
240
288
|
return;
|
|
241
289
|
const files = [];
|
|
242
290
|
for (const [index, image] of images.entries()) {
|
|
243
291
|
const type = detectImageMime(image);
|
|
244
|
-
const id = await stageBytesInTab(currentTab(), image);
|
|
292
|
+
const id = await stageBytesInTab(currentTab(), image, activeSignal);
|
|
245
293
|
files.push({ id, name: `image-${index + 1}.${type.split("/")[1] ?? "png"}`, type });
|
|
246
294
|
}
|
|
247
|
-
|
|
295
|
+
const paste = `/*nt:attach*/ (() => {
|
|
248
296
|
const files = ${JSON.stringify(files)};
|
|
249
297
|
const composer = document.querySelector(${JSON.stringify(COMPOSER)});
|
|
250
298
|
const transfer = new DataTransfer();
|
|
251
299
|
for (const f of files) {
|
|
252
300
|
const binary = atob(window.__ntStage[f.id]);
|
|
253
|
-
delete window.__ntStage[f.id];
|
|
254
301
|
transfer.items.add(new File([Uint8Array.from(binary, (c) => c.charCodeAt(0))], f.name, { type: f.type }));
|
|
255
302
|
}
|
|
256
303
|
composer.focus();
|
|
257
304
|
composer.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
|
258
305
|
return true;
|
|
259
|
-
})()
|
|
306
|
+
})()`;
|
|
307
|
+
let attached = false;
|
|
308
|
+
for (let attempt = 1; !attached && attempt <= PASTE_ATTEMPTS; attempt++) {
|
|
309
|
+
await evaluate(paste);
|
|
310
|
+
attached = !!(await poll(`/*nt:attached*/ document.querySelectorAll(${JSON.stringify(ATTACHMENT)}).length >= ${files.length}`, PASTE_TIMEOUT_MS));
|
|
311
|
+
}
|
|
312
|
+
await evaluate(`/*nt:unstage*/ (() => { for (const id of ${JSON.stringify(files.map((f) => f.id))}) delete window.__ntStage[id]; return true; })()`);
|
|
313
|
+
if (!attached)
|
|
314
|
+
throw new Error("Gemini did not take the pasted images: no attachment preview appeared");
|
|
315
|
+
}
|
|
316
|
+
/** The send click started no request. The caller reloads the page and sends again. */
|
|
317
|
+
class NotSentError extends Error {
|
|
260
318
|
}
|
|
261
319
|
/**
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
320
|
+
* Type the text and click send once. The click waits for the attachment
|
|
321
|
+
* previews and an enabled button. Then the page must show the new turn: a
|
|
322
|
+
* new user query or the stop button.
|
|
265
323
|
*/
|
|
266
324
|
async function submit(text, attachments) {
|
|
267
|
-
await evaluate(`/*nt:send*/ (
|
|
268
|
-
|
|
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 };
|
|
325
|
+
await evaluate(`/*nt:send*/ (() => {
|
|
326
|
+
window.__ntGemini = { queries: document.querySelectorAll("user-query").length };
|
|
289
327
|
const composer = document.querySelector(${JSON.stringify(COMPOSER)});
|
|
290
328
|
composer.focus();
|
|
291
329
|
document.execCommand("insertText", false, ${JSON.stringify(text)});
|
|
330
|
+
return true;
|
|
331
|
+
})()`);
|
|
292
332
|
// The send button slides in after the first character. A click during
|
|
293
333
|
// that animation starts no request.
|
|
294
|
-
await sleep(500);
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
button
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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);
|
|
334
|
+
await sleep(500, activeSignal);
|
|
335
|
+
const ready = await poll(`/*nt:send-ready*/ (() => {
|
|
336
|
+
const button = document.querySelector(${JSON.stringify(SUBMIT)});
|
|
337
|
+
const previews = document.querySelectorAll(${JSON.stringify(ATTACHMENT)});
|
|
338
|
+
const uploading = [...previews].some((p) => p.querySelector("mat-progress-spinner, [role=progressbar]"));
|
|
339
|
+
return !!button && !button.disabled && button.getAttribute("aria-disabled") !== "true" && previews.length >= ${attachments} && !uploading;
|
|
340
|
+
})()`, SEND_READY_TIMEOUT_MS);
|
|
341
|
+
if (!ready)
|
|
342
|
+
throw new Error("Gemini did not accept the message: the send button stayed disabled or the upload did not finish");
|
|
343
|
+
await evaluate(`/*nt:click*/ (() => { document.querySelector(${JSON.stringify(SUBMIT)}).click(); return true; })()`);
|
|
344
|
+
const started = await poll(`/*nt:sent*/ !!window.__ntGemini && (document.querySelectorAll("user-query").length > window.__ntGemini.queries || !!document.querySelector(${JSON.stringify(STOP)}))`, SEND_START_TIMEOUT_MS);
|
|
345
|
+
if (!started)
|
|
346
|
+
throw new NotSentError("Gemini did not start a request after the send click");
|
|
314
347
|
}
|
|
315
|
-
/**
|
|
316
|
-
async function
|
|
348
|
+
/** The chat id in the page path, or null on a new chat that has none yet. */
|
|
349
|
+
async function currentChatId() {
|
|
350
|
+
const pathname = await evaluate(`/*nt:location*/ location.pathname`);
|
|
351
|
+
const id = /^\/app\/([0-9a-f]+)$/.exec(pathname)?.[1];
|
|
352
|
+
return id ? `c_${id}` : null;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Wait until the turn after `priorTurnId` ends, then read it from the chat.
|
|
356
|
+
* The stop button shows while the turn runs. The read finds the turn only
|
|
357
|
+
* once Gemini has stored it, a moment after the button goes away.
|
|
358
|
+
*/
|
|
359
|
+
async function waitForReply(priorTurnId, timeoutMs, signal) {
|
|
317
360
|
const started = Date.now();
|
|
361
|
+
let endedAt = null;
|
|
318
362
|
let failures = 0;
|
|
319
363
|
for (;;) {
|
|
320
364
|
await sleep(POLL_INTERVAL_MS, signal);
|
|
321
365
|
try {
|
|
322
|
-
const
|
|
366
|
+
const running = await evaluate(`/*nt:running*/ !!document.querySelector(${JSON.stringify(STOP)})`);
|
|
367
|
+
const conversationId = running ? null : await currentChatId();
|
|
368
|
+
if (conversationId) {
|
|
369
|
+
const reply = parseChatTurn(await readChat(conversationId));
|
|
370
|
+
if (reply.turnId && reply.turnId !== priorTurnId)
|
|
371
|
+
return reply;
|
|
372
|
+
}
|
|
323
373
|
failures = 0;
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
374
|
+
if (running)
|
|
375
|
+
endedAt = null;
|
|
376
|
+
else
|
|
377
|
+
endedAt ??= Date.now();
|
|
328
378
|
}
|
|
329
379
|
catch (error) {
|
|
330
|
-
if (error instanceof Error && error.message.startsWith("The Gemini tab navigated"))
|
|
331
|
-
throw error;
|
|
332
380
|
log.warn("Gemini poll failed", { failures: failures + 1, error: error instanceof Error ? error.message : String(error) });
|
|
333
381
|
if (++failures > MAX_POLL_FAILURES)
|
|
334
382
|
throw error;
|
|
335
383
|
}
|
|
384
|
+
if (endedAt !== null && Date.now() - endedAt > TURN_STORED_TIMEOUT_MS) {
|
|
385
|
+
const conversationId = await currentChatId().catch(() => null);
|
|
386
|
+
throw new Error(`Gemini ended the turn, but its chat shows no reply.${chatLink(conversationId)}`);
|
|
387
|
+
}
|
|
336
388
|
if (Date.now() - started > timeoutMs)
|
|
337
389
|
throw new Error(`Gemini did not finish within ${timeoutMs / 1000}s`);
|
|
338
390
|
}
|
|
339
391
|
}
|
|
340
392
|
/**
|
|
341
|
-
* Prepare the page with `setup`, paste the images, send the text, and
|
|
393
|
+
* Prepare the page with `setup`, paste the images, send the text, and read
|
|
342
394
|
* the reply. When the click starts no request, `setup` runs again, which
|
|
343
395
|
* reloads the page and drops the pending click, and the send is retried once.
|
|
344
396
|
*/
|
|
345
397
|
async function sendAndRead(setup, text, images, timeoutMs, signal) {
|
|
398
|
+
activeSignal = signal;
|
|
399
|
+
let priorTurnId;
|
|
346
400
|
for (let attempt = 1;; attempt++) {
|
|
401
|
+
activeSignal?.throwIfAborted();
|
|
347
402
|
await setup();
|
|
403
|
+
activeSignal?.throwIfAborted();
|
|
404
|
+
// A continued chat already has turns. The reply is the next one.
|
|
405
|
+
const conversationId = await currentChatId();
|
|
406
|
+
priorTurnId = conversationId ? parseChatTurn(await readChat(conversationId)).turnId : null;
|
|
348
407
|
await attachImages(images);
|
|
349
408
|
try {
|
|
409
|
+
activeSignal?.throwIfAborted();
|
|
350
410
|
await submit(text, images.length);
|
|
351
411
|
break;
|
|
352
412
|
}
|
|
353
413
|
catch (error) {
|
|
354
|
-
if (!(error instanceof
|
|
414
|
+
if (!(error instanceof NotSentError))
|
|
355
415
|
throw error;
|
|
356
416
|
if (attempt > 1)
|
|
357
417
|
throw new Error("Gemini did not send the message from the background tab. Show the Gemini tab once and try again");
|
|
@@ -359,10 +419,7 @@ async function sendAndRead(setup, text, images, timeoutMs, signal) {
|
|
|
359
419
|
}
|
|
360
420
|
}
|
|
361
421
|
log.info("Gemini turn submitted", { images: images.length });
|
|
362
|
-
|
|
363
|
-
if (response.status !== 200)
|
|
364
|
-
throw new Error(`Gemini request failed: HTTP ${response.status}`);
|
|
365
|
-
return parseStreamGenerate(response.body);
|
|
422
|
+
return waitForReply(priorTurnId, timeoutMs, signal);
|
|
366
423
|
}
|
|
367
424
|
/** Read a chat the way the page does, with the `hNvQHb` call. Returns the raw response body. */
|
|
368
425
|
async function readChat(conversationId) {
|
|
@@ -388,7 +445,7 @@ async function readChat(conversationId) {
|
|
|
388
445
|
return await response.text();
|
|
389
446
|
})()`);
|
|
390
447
|
}
|
|
391
|
-
/** Each `wrb.fr` entry's payload in a batchexecute
|
|
448
|
+
/** Each `wrb.fr` entry's payload in a batchexecute body, one JSON frame per line. */
|
|
392
449
|
function payloads(body) {
|
|
393
450
|
const found = [];
|
|
394
451
|
for (const line of body.split("\n")) {
|
|
@@ -423,6 +480,7 @@ function contains(value, match) {
|
|
|
423
480
|
return Object.values(value).some((item) => contains(item, match));
|
|
424
481
|
return false;
|
|
425
482
|
}
|
|
483
|
+
const VIDEO_PLACEHOLDER = /https?:\/\/googleusercontent\.com\/generated_video_content\//;
|
|
426
484
|
/** A candidate's reply text, without the placeholders Gemini puts where media go. */
|
|
427
485
|
function candidateText(candidate) {
|
|
428
486
|
const parts = Array.isArray(candidate[1]) ? candidate[1] : [];
|
|
@@ -433,32 +491,41 @@ function candidateText(candidate) {
|
|
|
433
491
|
.trim();
|
|
434
492
|
}
|
|
435
493
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* `video_generation` entry in a status payload.
|
|
494
|
+
* The newest turn in an `hNvQHb` chat read and its first candidate. The
|
|
495
|
+
* payload lists the turns at `[0]`, newest first. A turn holds its ids at
|
|
496
|
+
* `[0]` and its candidates at `[3][0]`. A read of a chat that Gemini has not
|
|
497
|
+
* stored yet carries an error entry and no payload.
|
|
441
498
|
*/
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
499
|
+
function newestTurn(body) {
|
|
500
|
+
const payload = payloads(body)[0];
|
|
501
|
+
const turn = Array.isArray(payload?.[0]) ? payload[0][0] : null;
|
|
502
|
+
if (!Array.isArray(turn))
|
|
503
|
+
return null;
|
|
504
|
+
const candidates = Array.isArray(turn[3]) ? turn[3][0] : null;
|
|
505
|
+
const candidate = Array.isArray(candidates) && Array.isArray(candidates[0]) ? candidates[0] : null;
|
|
506
|
+
return { turn, candidate };
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Parse the newest turn of an `hNvQHb` chat read. Generated images sit under
|
|
510
|
+
* the candidate's `[12]` as `lh3.googleusercontent.com/gg/` URLs. A started
|
|
511
|
+
* video shows as a video placeholder in the text or a `video_generation` entry.
|
|
512
|
+
*/
|
|
513
|
+
export function parseChatTurn(body) {
|
|
514
|
+
const found = newestTurn(body);
|
|
515
|
+
if (!found)
|
|
516
|
+
return { conversationId: null, turnId: null, text: "", images: [], videoPending: false };
|
|
517
|
+
const ids = Array.isArray(found.turn[0]) ? found.turn[0] : [];
|
|
518
|
+
const conversationId = typeof ids[0] === "string" ? ids[0] : null;
|
|
519
|
+
const turnId = typeof ids[1] === "string" ? ids[1] : null;
|
|
520
|
+
const candidate = found.candidate;
|
|
521
|
+
const videoPending = contains(found.turn, (text) => text === "video_generation") ||
|
|
522
|
+
(candidate !== null && contains(candidate[1], (text) => VIDEO_PLACEHOLDER.test(text)));
|
|
456
523
|
if (!candidate)
|
|
457
|
-
return { conversationId, text: "", images: [], videoPending };
|
|
524
|
+
return { conversationId, turnId, text: "", images: [], videoPending };
|
|
458
525
|
const images = new Set();
|
|
459
526
|
const walk = (value) => {
|
|
460
527
|
if (typeof value === "string") {
|
|
461
|
-
if (/^https:\/\/lh3\.googleusercontent\.com\/gg-dl
|
|
528
|
+
if (/^https:\/\/lh3\.googleusercontent\.com\/gg(-dl)?\//.test(value))
|
|
462
529
|
images.add(value);
|
|
463
530
|
}
|
|
464
531
|
else if (Array.isArray(value)) {
|
|
@@ -466,23 +533,19 @@ export function parseStreamGenerate(body) {
|
|
|
466
533
|
}
|
|
467
534
|
};
|
|
468
535
|
walk(candidate[12]);
|
|
469
|
-
return { conversationId, text: candidateText(candidate), images: [...images], videoPending };
|
|
536
|
+
return { conversationId, turnId, text: candidateText(candidate), images: [...images], videoPending };
|
|
470
537
|
}
|
|
471
538
|
/**
|
|
472
|
-
* Find the generated video in an `hNvQHb` chat read.
|
|
473
|
-
*
|
|
474
|
-
* A finished video is an array that holds `"video/mp4"` and, at `[7]`, its
|
|
539
|
+
* Find the generated video in the newest turn of an `hNvQHb` chat read. A
|
|
540
|
+
* finished video is an array that holds `"video/mp4"` and, at `[7]`, its
|
|
475
541
|
* URLs: thumbnails on lh3 and the file itself on
|
|
476
542
|
* `contribution.usercontent.google.com`.
|
|
477
543
|
*/
|
|
478
544
|
export function findGeneratedVideo(body) {
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
if (!Array.isArray(turn))
|
|
545
|
+
const found = newestTurn(body);
|
|
546
|
+
if (!found)
|
|
482
547
|
return { url: null, text: "" };
|
|
483
|
-
const
|
|
484
|
-
const candidate = Array.isArray(candidates) && Array.isArray(candidates[0]) ? candidates[0] : null;
|
|
485
|
-
const text = candidate ? candidateText(candidate) : "";
|
|
548
|
+
const text = found.candidate ? candidateText(found.candidate) : "";
|
|
486
549
|
let entry = null;
|
|
487
550
|
const walk = (value) => {
|
|
488
551
|
if (entry || !Array.isArray(value))
|
|
@@ -492,7 +555,7 @@ export function findGeneratedVideo(body) {
|
|
|
492
555
|
else
|
|
493
556
|
value.forEach(walk);
|
|
494
557
|
};
|
|
495
|
-
walk(turn);
|
|
558
|
+
walk(found.turn);
|
|
496
559
|
const urls = entry && Array.isArray(entry[7]) ? entry[7].filter((u) => typeof u === "string") : [];
|
|
497
560
|
const url = urls.find((u) => u.startsWith("https://contribution.usercontent.google.com/download")) ?? null;
|
|
498
561
|
return { url, text };
|
|
@@ -522,7 +585,7 @@ async function runChatTurnHoldingTab(turn) {
|
|
|
522
585
|
const setup = async () => {
|
|
523
586
|
if (turn.conversationId) {
|
|
524
587
|
const path = chatPath(turn.conversationId);
|
|
525
|
-
await currentTab().navigate(`${BASE_URL}${path}
|
|
588
|
+
await currentTab().navigate(`${BASE_URL}${path}`, activeSignal);
|
|
526
589
|
await waitForComposer();
|
|
527
590
|
// A deleted or foreign chat redirects to a new one.
|
|
528
591
|
const pathname = await evaluate(`/*nt:location*/ location.pathname`);
|
|
@@ -534,7 +597,7 @@ async function runChatTurnHoldingTab(turn) {
|
|
|
534
597
|
else {
|
|
535
598
|
await openNewChat();
|
|
536
599
|
}
|
|
537
|
-
if (turn.model !== GEMINI_WEB_AUTO_MODEL || !
|
|
600
|
+
if (turn.model !== GEMINI_WEB_AUTO_MODEL || !liveModes || Date.now() - liveModes.at > MODELS_TTL_MS) {
|
|
538
601
|
await selectMode(turn.model);
|
|
539
602
|
}
|
|
540
603
|
};
|
|
@@ -556,21 +619,26 @@ export class GeminiWebProvider extends BaseProvider {
|
|
|
556
619
|
}
|
|
557
620
|
/**
|
|
558
621
|
* Listing never attaches to the tab: it returns `auto` plus the modes read
|
|
559
|
-
* during an earlier call
|
|
560
|
-
* as a model id.
|
|
622
|
+
* during an earlier call, or the known public modes before any call. A mode
|
|
623
|
+
* id from the Gemini mode picker also works as a model id.
|
|
561
624
|
*/
|
|
562
625
|
async getAvailableLanguageModels() {
|
|
563
626
|
const auto = { id: GEMINI_WEB_AUTO_MODEL, name: "Gemini (current mode)", provider: PROVIDER_IDS.GEMINI_WEB };
|
|
564
|
-
return [auto, ...(
|
|
627
|
+
return [auto, ...listedModes().map((m) => ({ id: m.id, name: `Gemini ${m.name}`, provider: PROVIDER_IDS.GEMINI_WEB }))];
|
|
565
628
|
}
|
|
629
|
+
/** One image model for the current mode, and one for each listed mode. */
|
|
566
630
|
async getAvailableImageModels() {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
631
|
+
const image = (id, name) => ({
|
|
632
|
+
id,
|
|
633
|
+
name,
|
|
634
|
+
provider: PROVIDER_IDS.GEMINI_WEB,
|
|
635
|
+
supportedTasks: ["text_to_image", "image_to_image"],
|
|
636
|
+
aspectRatios: ["1:1", "16:9", "9:16", "4:3", "3:4"]
|
|
637
|
+
});
|
|
638
|
+
return [
|
|
639
|
+
image(GEMINI_WEB_IMAGE_MODEL, "Gemini Images (web)"),
|
|
640
|
+
...listedModes().map((m) => image(`${GEMINI_WEB_IMAGE_MODEL}:${m.id}`, `Gemini Images (${m.name})`))
|
|
641
|
+
];
|
|
574
642
|
}
|
|
575
643
|
async getAvailableVideoModels() {
|
|
576
644
|
return [{
|
|
@@ -626,7 +694,7 @@ export class GeminiWebProvider extends BaseProvider {
|
|
|
626
694
|
}
|
|
627
695
|
if (!result) {
|
|
628
696
|
// The caller may have sent only the delta; a new chat needs the whole conversation.
|
|
629
|
-
const messages =
|
|
697
|
+
const messages = args.loadFullHistory ? await args.loadFullHistory() : args.messages;
|
|
630
698
|
const images = await this.lastUserImages(messages, args.signal);
|
|
631
699
|
result = await serialized(() => runChatTurnHoldingTab({ model: args.model, text: composePrompt(messages), images, signal: args.signal }));
|
|
632
700
|
}
|
|
@@ -665,6 +733,13 @@ export class GeminiWebProvider extends BaseProvider {
|
|
|
665
733
|
return this.generateVideo(params.prompt ?? "", image, params);
|
|
666
734
|
}
|
|
667
735
|
async generateImage(prompt, images, params) {
|
|
736
|
+
// `gemini-web-image:<mode id>` runs the image tool in that mode.
|
|
737
|
+
const modeId = params.model?.id.startsWith(`${GEMINI_WEB_IMAGE_MODEL}:`) ? params.model.id.slice(GEMINI_WEB_IMAGE_MODEL.length + 1) : null;
|
|
738
|
+
const setup = async () => {
|
|
739
|
+
await openNewChat();
|
|
740
|
+
if (modeId)
|
|
741
|
+
await selectMode(modeId);
|
|
742
|
+
};
|
|
668
743
|
const text = [
|
|
669
744
|
images.length > 0
|
|
670
745
|
? "Edit the attached image(s) as described and reply with one image. Do not ask questions."
|
|
@@ -676,7 +751,7 @@ export class GeminiWebProvider extends BaseProvider {
|
|
|
676
751
|
// The download runs in the same turn on the tab, so no other call can
|
|
677
752
|
// navigate the tab away in between.
|
|
678
753
|
return serialized(async () => {
|
|
679
|
-
const reply = await sendAndRead(
|
|
754
|
+
const reply = await sendAndRead(setup, text, images, IMAGE_TIMEOUT_MS, params.signal);
|
|
680
755
|
const url = reply.images.at(-1);
|
|
681
756
|
if (!url) {
|
|
682
757
|
throw new Error(`Gemini finished without an image${reply.text ? `: ${reply.text.slice(0, 300)}` : ""}.${chatLink(reply.conversationId)}`);
|