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