@nodetool-ai/runtime 0.8.3 → 0.8.4-a24.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.
Files changed (147) hide show
  1. package/dist/.nodetool-build-stamp +1 -1
  2. package/dist/canvas-codec.d.ts +3 -0
  3. package/dist/canvas-codec.d.ts.map +1 -0
  4. package/dist/canvas-codec.js +12 -0
  5. package/dist/canvas-codec.js.map +1 -0
  6. package/dist/context-packer.d.ts +1 -1
  7. package/dist/context-packer.js +1 -1
  8. package/dist/context.d.ts +3 -0
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +13 -3
  11. package/dist/context.js.map +1 -1
  12. package/dist/host-binaries.d.ts.map +1 -1
  13. package/dist/host-binaries.js +4 -3
  14. package/dist/host-binaries.js.map +1 -1
  15. package/dist/http-client-spans.d.ts +9 -0
  16. package/dist/http-client-spans.d.ts.map +1 -0
  17. package/dist/http-client-spans.js +88 -0
  18. package/dist/http-client-spans.js.map +1 -0
  19. package/dist/image-codec.d.ts +6 -3
  20. package/dist/image-codec.d.ts.map +1 -1
  21. package/dist/image-codec.js +17 -17
  22. package/dist/image-codec.js.map +1 -1
  23. package/dist/index.d.ts +3 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/media-ref-bytes.d.ts.map +1 -1
  28. package/dist/media-ref-bytes.js +31 -6
  29. package/dist/media-ref-bytes.js.map +1 -1
  30. package/dist/otlp-json-trace-exporter.d.ts +89 -0
  31. package/dist/otlp-json-trace-exporter.d.ts.map +1 -0
  32. package/dist/otlp-json-trace-exporter.js +155 -0
  33. package/dist/otlp-json-trace-exporter.js.map +1 -0
  34. package/dist/providers/antigravity-provider.d.ts +113 -0
  35. package/dist/providers/antigravity-provider.d.ts.map +1 -0
  36. package/dist/providers/antigravity-provider.js +527 -0
  37. package/dist/providers/antigravity-provider.js.map +1 -0
  38. package/dist/providers/azure-openai-provider.d.ts +69 -0
  39. package/dist/providers/azure-openai-provider.d.ts.map +1 -0
  40. package/dist/providers/azure-openai-provider.js +233 -0
  41. package/dist/providers/azure-openai-provider.js.map +1 -0
  42. package/dist/providers/base-provider.d.ts.map +1 -1
  43. package/dist/providers/base-provider.js +19 -1
  44. package/dist/providers/base-provider.js.map +1 -1
  45. package/dist/providers/bedrock-provider.d.ts +52 -0
  46. package/dist/providers/bedrock-provider.d.ts.map +1 -0
  47. package/dist/providers/bedrock-provider.js +648 -0
  48. package/dist/providers/bedrock-provider.js.map +1 -0
  49. package/dist/providers/bedrock-transport.d.ts +37 -0
  50. package/dist/providers/bedrock-transport.d.ts.map +1 -0
  51. package/dist/providers/bedrock-transport.js +186 -0
  52. package/dist/providers/bedrock-transport.js.map +1 -0
  53. package/dist/providers/chatgpt-provider.d.ts.map +1 -1
  54. package/dist/providers/chatgpt-provider.js +77 -32
  55. package/dist/providers/chatgpt-provider.js.map +1 -1
  56. package/dist/providers/claude-agent-provider.d.ts +29 -51
  57. package/dist/providers/claude-agent-provider.d.ts.map +1 -1
  58. package/dist/providers/claude-agent-provider.js +144 -202
  59. package/dist/providers/claude-agent-provider.js.map +1 -1
  60. package/dist/providers/claude-cli.d.ts +265 -0
  61. package/dist/providers/claude-cli.d.ts.map +1 -0
  62. package/dist/providers/claude-cli.js +488 -0
  63. package/dist/providers/claude-cli.js.map +1 -0
  64. package/dist/providers/codex-provider.d.ts +9 -2
  65. package/dist/providers/codex-provider.d.ts.map +1 -1
  66. package/dist/providers/codex-provider.js +25 -11
  67. package/dist/providers/codex-provider.js.map +1 -1
  68. package/dist/providers/core-tools.d.ts +4 -1
  69. package/dist/providers/core-tools.d.ts.map +1 -1
  70. package/dist/providers/core-tools.js +5 -3
  71. package/dist/providers/core-tools.js.map +1 -1
  72. package/dist/providers/dreamina-pending.d.ts +22 -0
  73. package/dist/providers/dreamina-pending.d.ts.map +1 -0
  74. package/dist/providers/dreamina-pending.js +86 -0
  75. package/dist/providers/dreamina-pending.js.map +1 -0
  76. package/dist/providers/dreamina-provider.d.ts +5 -0
  77. package/dist/providers/dreamina-provider.d.ts.map +1 -1
  78. package/dist/providers/dreamina-provider.js +88 -19
  79. package/dist/providers/dreamina-provider.js.map +1 -1
  80. package/dist/providers/extension-tab.d.ts +18 -5
  81. package/dist/providers/extension-tab.d.ts.map +1 -1
  82. package/dist/providers/extension-tab.js +71 -15
  83. package/dist/providers/extension-tab.js.map +1 -1
  84. package/dist/providers/gemini-provider.d.ts +3 -1
  85. package/dist/providers/gemini-provider.d.ts.map +1 -1
  86. package/dist/providers/gemini-provider.js +32 -1
  87. package/dist/providers/gemini-provider.js.map +1 -1
  88. package/dist/providers/gemini-web-provider.d.ts +14 -14
  89. package/dist/providers/gemini-web-provider.d.ts.map +1 -1
  90. package/dist/providers/gemini-web-provider.js +264 -189
  91. package/dist/providers/gemini-web-provider.js.map +1 -1
  92. package/dist/providers/http-transport.d.ts.map +1 -1
  93. package/dist/providers/http-transport.js +2 -0
  94. package/dist/providers/http-transport.js.map +1 -1
  95. package/dist/providers/index.d.ts +8 -0
  96. package/dist/providers/index.d.ts.map +1 -1
  97. package/dist/providers/index.js +30 -7
  98. package/dist/providers/index.js.map +1 -1
  99. package/dist/providers/node-llama-cpp-provider.d.ts.map +1 -1
  100. package/dist/providers/node-llama-cpp-provider.js +2 -4
  101. package/dist/providers/node-llama-cpp-provider.js.map +1 -1
  102. package/dist/providers/openai-provider.d.ts +2 -0
  103. package/dist/providers/openai-provider.d.ts.map +1 -1
  104. package/dist/providers/openai-provider.js +19 -6
  105. package/dist/providers/openai-provider.js.map +1 -1
  106. package/dist/providers/safe-url.d.ts.map +1 -1
  107. package/dist/providers/safe-url.js +8 -0
  108. package/dist/providers/safe-url.js.map +1 -1
  109. package/dist/providers/vgenv-provider.d.ts +33 -0
  110. package/dist/providers/vgenv-provider.d.ts.map +1 -0
  111. package/dist/providers/vgenv-provider.js +261 -0
  112. package/dist/providers/vgenv-provider.js.map +1 -0
  113. package/dist/python-graph-resolver.d.ts.map +1 -1
  114. package/dist/python-graph-resolver.js +12 -2
  115. package/dist/python-graph-resolver.js.map +1 -1
  116. package/dist/python-stdio-bridge.d.ts.map +1 -1
  117. package/dist/python-stdio-bridge.js +33 -10
  118. package/dist/python-stdio-bridge.js.map +1 -1
  119. package/dist/run-trace-processor.d.ts +3 -0
  120. package/dist/run-trace-processor.d.ts.map +1 -1
  121. package/dist/run-trace-processor.js +56 -40
  122. package/dist/run-trace-processor.js.map +1 -1
  123. package/dist/telemetry.d.ts +2 -2
  124. package/dist/telemetry.d.ts.map +1 -1
  125. package/dist/telemetry.js +33 -34
  126. package/dist/telemetry.js.map +1 -1
  127. package/dist/token-counter.d.ts +4 -13
  128. package/dist/token-counter.d.ts.map +1 -1
  129. package/dist/token-counter.js +68 -18
  130. package/dist/token-counter.js.map +1 -1
  131. package/dist/trace-context-manager.d.ts +12 -0
  132. package/dist/trace-context-manager.d.ts.map +1 -0
  133. package/dist/trace-context-manager.js +30 -0
  134. package/dist/trace-context-manager.js.map +1 -0
  135. package/dist/trace-exporters.d.ts +1 -1
  136. package/dist/trace-exporters.d.ts.map +1 -1
  137. package/dist/trace-exporters.js +4 -5
  138. package/dist/trace-exporters.js.map +1 -1
  139. package/dist/trace-semantic-conventions.d.ts +2 -0
  140. package/dist/trace-semantic-conventions.d.ts.map +1 -0
  141. package/dist/trace-semantic-conventions.js +2 -0
  142. package/dist/trace-semantic-conventions.js.map +1 -0
  143. package/dist/tracing-module.d.ts +3 -0
  144. package/dist/tracing-module.d.ts.map +1 -0
  145. package/dist/tracing-module.js +12 -0
  146. package/dist/tracing-module.js.map +1 -0
  147. 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 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.
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 call the page itself uses (`hNvQHb`) until the video appears.
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
- const SEND_TIMEOUT_MS = 90_000;
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
- liveModels = null;
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 next = queue.then(run, run);
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 liveModels = null;
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
- return (await currentTab().evaluate(expression, timeoutMs));
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 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
- })()`);
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*/ (async () => {
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('mat-icon[fonticon=${JSON.stringify(icon)}]')) return true;
231
+ if (button.querySelector(${JSON.stringify(`mat-icon[fonticon="${icon}"]`)})) return "selected";
187
232
  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");
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
- 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]")];
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
- 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
- };
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
- /** Paste images into the composer, the way a clipboard paste would. */
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
- await evaluate(`/*nt:attach*/ (() => {
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
- * 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.
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*/ (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 };
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 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);
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
- /** Poll until the page's `StreamGenerate` request ends and return its response. */
316
- async function waitForResponse(timeoutMs, signal) {
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 state = await evaluate(`/*nt:result*/ (window.__ntGemini ?? null)`);
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 (!state)
325
- throw new Error("The Gemini tab navigated away during the turn");
326
- if (state.response)
327
- return state.response;
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 parse
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 Error && error.message.includes("NT_NOT_SENT")))
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
- 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);
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-style body, one JSON frame per line. */
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
- * 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.
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
- 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
- }
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\//.test(value))
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. 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
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 payload = payloads(body)[0];
480
- const turn = Array.isArray(payload?.[0]) ? payload[0][0] : null;
481
- if (!Array.isArray(turn))
545
+ const found = newestTurn(body);
546
+ if (!found)
482
547
  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) : "";
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 || !liveModels || Date.now() - liveModels.at > MODELS_TTL_MS) {
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. A mode id from the Gemini mode picker also works
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, ...(liveModels?.models ?? [])];
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
- 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
- }];
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 = canResume && args.loadFullHistory ? await args.loadFullHistory() : args.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(openNewChat, text, images, IMAGE_TIMEOUT_MS, params.signal);
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)}`);