wtagent 0.1.0 → 0.2.0

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.
@@ -0,0 +1,465 @@
1
+ import path from "node:path";
2
+ import { BaseWebAdapter, firstVisible } from "./base-web-adapter.js";
3
+ import { BrowserAdapterError } from "../shared/errors.js";
4
+ import { isUsageLimitNotice } from "../shared/usage-limit.js";
5
+
6
+ export { isConnectionLostError } from "./base-web-adapter.js";
7
+
8
+ const GEMINI_URL = "https://gemini.google.com/app";
9
+
10
+ // Gemini Web adapter. The live application exposes stable custom elements for
11
+ // user and model turns. Each model response also owns a response-specific
12
+ // message-content id and structural response actions that appear only after the
13
+ // turn is complete, so the shared runtime does not depend on translated prose.
14
+ export class GeminiWebAdapter extends BaseWebAdapter {
15
+ constructor(options = {}) {
16
+ super({
17
+ ...options,
18
+ baseUrl: options.baseUrl ?? GEMINI_URL,
19
+ providerName: "Gemini",
20
+ });
21
+ }
22
+
23
+ conversationUrlPattern() {
24
+ // /app is a blank chat; a sent/saved conversation adds one id segment.
25
+ return /^\/app\/[^/]+/;
26
+ }
27
+
28
+ composerLocators() {
29
+ return [
30
+ this.page.locator(
31
+ '[data-test-id="textarea-wrapper"] .ql-editor[contenteditable="true"][role="textbox"]',
32
+ ),
33
+ this.page.locator('div.ql-editor[contenteditable="true"][role="textbox"]'),
34
+ this.page.getByRole("textbox", { name: /Gemini|prompt|提示/i }),
35
+ ];
36
+ }
37
+
38
+ sendButtonLocators() {
39
+ return [
40
+ this.page.locator(
41
+ 'button[aria-label="发送"], button[aria-label="Send"], '
42
+ + 'button[aria-label="Send message"]',
43
+ ),
44
+ this.page.getByRole("button", { name: /^(发送|Send(?: message)?)$/i }),
45
+ ];
46
+ }
47
+
48
+ stopButtonLocators() {
49
+ return [
50
+ this.page.locator(
51
+ 'button[aria-label="停止回答"], button[aria-label="Stop response"]',
52
+ ),
53
+ this.page.getByRole("button", { name: /^(停止回答|Stop response)$/i }),
54
+ ];
55
+ }
56
+
57
+ newConversationControls() {
58
+ return [
59
+ this.page.locator('a[href="/app"]'),
60
+ this.page.getByRole("button", { name: /^(发起新对话|新对话|New chat)$/i }),
61
+ this.page.getByRole("link", { name: /^(发起新对话|新对话|New chat)$/i }),
62
+ ];
63
+ }
64
+
65
+ authUrlPattern() {
66
+ return /^\/(?:v\d+\/)?signin(?:\/|$)|^\/ServiceLogin(?:\/|$)|^\/o\/oauth2\//;
67
+ }
68
+
69
+ loginControlLocators() {
70
+ return [
71
+ this.page.locator("#identifierId"),
72
+ this.page.locator('input[type="email"][autocomplete="username"]'),
73
+ this.page.getByRole("button", { name: /^(登录|Sign in)$/i }),
74
+ this.page.getByRole("link", { name: /^(登录|Sign in)$/i }),
75
+ ];
76
+ }
77
+
78
+ assistantMessages() {
79
+ return this.page.locator("model-response");
80
+ }
81
+
82
+ userMessages() {
83
+ return this.page.locator("user-query");
84
+ }
85
+
86
+ conversationMessages() {
87
+ return this.page.locator("user-query, model-response");
88
+ }
89
+
90
+ async messageIdentity(message) {
91
+ const modelContent = message.locator(
92
+ 'message-content[id^="message-content-id-"]',
93
+ ).first();
94
+ // `getAttribute()` waits for the locator by default. User rows never have
95
+ // message-content, so probing it directly added one full 15-second timeout
96
+ // for every user message captured before and after a send.
97
+ if (await modelContent.count().catch(() => 0) > 0) {
98
+ const modelId = await modelContent.getAttribute("id").catch(() => null);
99
+ if (modelId) {
100
+ return { id: modelId, turn: null };
101
+ }
102
+ }
103
+
104
+ const userContent = message.locator(
105
+ '.query-content[id^="user-query-content-"]',
106
+ ).first();
107
+ const userId = await userContent.count().catch(() => 0) > 0
108
+ ? await userContent.getAttribute("id").catch(() => null)
109
+ : null;
110
+ return { id: userId || null, turn: null };
111
+ }
112
+
113
+ async assistantText(message) {
114
+ // Read Gemini's rendered answer body before falling back to the enclosing
115
+ // message-content. Attachment/source chips (for example a trailing "TXT")
116
+ // live beside `.markdown` and are UI chrome, not model output.
117
+ const markdown = message.locator("message-content .markdown").last();
118
+ if (await markdown.count().catch(() => 0) > 0) {
119
+ const cleaned = await markdown.evaluate((element) => {
120
+ const clone = element.cloneNode(true);
121
+ clone.querySelectorAll(
122
+ "sources-carousel-inline, source-inline-chip, source-footnote, "
123
+ + ".hide-from-message-actions",
124
+ ).forEach((item) => item.remove());
125
+ return clone.innerText;
126
+ }).catch(() => null);
127
+ if (cleaned != null) {
128
+ return cleaned;
129
+ }
130
+ return await markdown.innerText().catch(() => "");
131
+ }
132
+ const content = message.locator("message-content").last();
133
+ if (await content.count().catch(() => 0) > 0) {
134
+ return await content.innerText().catch(() => "");
135
+ }
136
+ return await message.innerText().catch(() => "");
137
+ }
138
+
139
+ hasReliableCompletionSignal() {
140
+ return true;
141
+ }
142
+
143
+ async isAssistantGenerating(message) {
144
+ const busy = message.locator('[aria-busy="true"]').first();
145
+ if (await busy.isVisible().catch(() => false)) {
146
+ return true;
147
+ }
148
+
149
+ // Gemini mounts the response action icons only after the stream is
150
+ // finished. Test the icon itself: the enclosing `.response-footer.complete`
151
+ // currently has rendered dimensions but Playwright still reports it as
152
+ // hidden, while the Regenerate/Redo control is reliably visible.
153
+ const completed = await firstVisible([
154
+ message.locator('[data-test-id="regenerate-button"]'),
155
+ message.getByRole("button", { name: /^(Redo|Regenerate|重做)$/i }),
156
+ message.getByRole("button", { name: /^(Copy|复制)$/i }),
157
+ ]);
158
+ return !completed;
159
+ }
160
+
161
+ async attachFiles(files) {
162
+ this.requirePage();
163
+ const paths = (files ?? [])
164
+ .map((file) => (typeof file === "string" ? file : file?.path))
165
+ .filter(Boolean);
166
+ if (paths.length === 0) {
167
+ return { attached: [], failed: [] };
168
+ }
169
+
170
+ try {
171
+ const input = this.page.locator('input[type="file"]').first();
172
+ if (await input.count().catch(() => 0) > 0) {
173
+ await input.setInputFiles(paths, { timeout: 15_000 });
174
+ } else {
175
+ // Follow Gemini's visible two-step path: open Upload & tools, then click
176
+ // the Upload files menu item. The latter emits a real filechooser event.
177
+ const uploadMenus = [
178
+ this.page.locator(
179
+ 'button[aria-label="Upload & tools"], '
180
+ + 'button[aria-label="上传和工具"]',
181
+ ),
182
+ this.page.getByRole("button", {
183
+ name: /^(Upload & tools|上传和工具|上传.*工具)$/i,
184
+ }),
185
+ ];
186
+ let uploadMenu = null;
187
+ const menuDeadline = Date.now() + 10_000;
188
+ while (!uploadMenu && Date.now() < menuDeadline) {
189
+ uploadMenu = await firstVisible(uploadMenus);
190
+ if (!uploadMenu) {
191
+ await this.page.waitForTimeout(250);
192
+ }
193
+ }
194
+ if (!uploadMenu) {
195
+ throw new Error("Gemini Upload & tools control was not found");
196
+ }
197
+ if (!await this.#clickWithNativePointer(uploadMenu)) {
198
+ await uploadMenu.click();
199
+ }
200
+
201
+ const uploadFiles = this.page.locator(
202
+ '[data-test-id="local-images-files-uploader-button"]',
203
+ ).first();
204
+ await uploadFiles.waitFor({ state: "visible", timeout: 10_000 });
205
+ const chooserPromise = this.page.waitForEvent("filechooser", {
206
+ timeout: 15_000,
207
+ });
208
+ if (!await this.#clickWithNativePointer(uploadFiles)) {
209
+ await uploadFiles.click();
210
+ }
211
+ const chooser = await chooserPromise;
212
+ await chooser.setFiles(paths);
213
+ }
214
+ } catch (error) {
215
+ await this.writeDiagnostics("gemini-attach-files-failed");
216
+ return {
217
+ attached: [],
218
+ failed: paths.map((filePath) => ({ path: filePath, message: error.message })),
219
+ };
220
+ }
221
+
222
+ // Wait for the final filename to appear in the composer before sending.
223
+ const finalName = path.basename(paths.at(-1));
224
+ await this.page.locator('[data-test-id="textarea-wrapper"]')
225
+ .filter({ hasText: finalName })
226
+ .first()
227
+ .waitFor({ state: "visible", timeout: 30_000 })
228
+ .catch(() => null);
229
+ return { attached: [...paths], failed: [] };
230
+ }
231
+
232
+ async fillComposer(composer, text) {
233
+ this.requirePage();
234
+ const expected = String(text ?? "");
235
+ let clipboardUsed = false;
236
+
237
+ try {
238
+ const activated = await this.#clickWithNativePointer(composer);
239
+ if (!activated) {
240
+ await super.fillComposer(composer, expected);
241
+ } else {
242
+ const active = await composer.evaluate(
243
+ (element) => document.activeElement === element,
244
+ ).catch(() => false);
245
+ if (!active) {
246
+ await composer.focus().catch(() => null);
247
+ }
248
+
249
+ const currentValue = await this.#readComposerText(composer);
250
+ if (currentValue) {
251
+ await this.page.keyboard.press(
252
+ process.platform === "darwin" ? "Meta+A" : "Control+A",
253
+ );
254
+ await this.page.keyboard.press("Backspace");
255
+ }
256
+
257
+ // Preserve the requested human-like order: click, a short pause, paste,
258
+ // then another short pause before the pointer clicks Send.
259
+ await this.page.waitForTimeout(Math.floor(Math.random() * 1_001));
260
+ clipboardUsed = await this.#pasteWithClipboard(expected);
261
+ if (!clipboardUsed) {
262
+ await this.page.keyboard.insertText(expected);
263
+ }
264
+ }
265
+
266
+ let actual = await this.#readComposerText(composer, expected);
267
+ if (actual !== expected) {
268
+ // Quill's contenteditable `fill()` can append when its internal
269
+ // selection is stale. Clear through the focused keyboard path first so
270
+ // a recovery attempt can never duplicate the full protocol prompt.
271
+ await composer.focus().catch(() => null);
272
+ await this.page.keyboard.press(
273
+ process.platform === "darwin" ? "Meta+A" : "Control+A",
274
+ ).catch(() => null);
275
+ await this.page.keyboard.press("Backspace").catch(() => null);
276
+ await this.page.waitForTimeout(50);
277
+ await composer.fill(expected).catch(() => null);
278
+ actual = await this.#readComposerText(composer, expected);
279
+ }
280
+ if (actual !== expected) {
281
+ throw new BrowserAdapterError(
282
+ `${this.providerName} did not commit the complete prompt text.`,
283
+ {
284
+ code: "COMPOSER_FILL_FAILED",
285
+ details: {
286
+ expectedLength: expected.length,
287
+ actualLength: actual?.length ?? null,
288
+ },
289
+ },
290
+ );
291
+ }
292
+
293
+ await this.page.waitForTimeout(Math.floor(Math.random() * 2_001));
294
+ } finally {
295
+ if (clipboardUsed) {
296
+ await this.#restoreClipboard();
297
+ }
298
+ }
299
+ }
300
+
301
+ async submitComposer(composer) {
302
+ const sendButton = await firstVisible(this.sendButtonLocators());
303
+ if (
304
+ sendButton
305
+ && await sendButton.isEnabled().catch(() => false)
306
+ && await this.#clickWithNativePointer(sendButton)
307
+ ) {
308
+ return;
309
+ }
310
+ await super.submitComposer(composer);
311
+ }
312
+
313
+ async dismissTransientOverlays() {
314
+ const consent = await firstVisible([
315
+ this.page.getByRole("button", { name: /^(Accept all|全部接受|同意)$/i }),
316
+ ]);
317
+ await consent?.click().catch(() => null);
318
+ }
319
+
320
+ async scrollConversationToBottom() {
321
+ const last = this.conversationMessages().last();
322
+ await last.scrollIntoViewIfNeeded().catch(() => null);
323
+ }
324
+
325
+ async findUsageLimitMarker(message) {
326
+ const rowText = await message.innerText().catch(() => "");
327
+ const alert = this.page.locator('[role="alert"], [data-test-id*="toast"]')
328
+ .last();
329
+ // An absent alert is the normal case. Calling innerText() directly on that
330
+ // empty locator consumes the page's full 15-second default timeout after
331
+ // every otherwise-complete Gemini response.
332
+ const alertText = await alert.count().catch(() => 0) > 0
333
+ && await alert.isVisible().catch(() => false)
334
+ ? await alert.innerText().catch(() => "")
335
+ : "";
336
+ const text = [alertText, rowText].filter(Boolean).join(" ");
337
+ return isUsageLimitNotice(text) ? text.trim().slice(0, 160) : null;
338
+ }
339
+
340
+ async findGenerationErrorMarker(message) {
341
+ const error = message.locator(
342
+ '[role="alert"], [data-test-id*="error"], .error-message, .response-error',
343
+ ).first();
344
+ if (!await error.isVisible().catch(() => false)) {
345
+ return null;
346
+ }
347
+ const text = await error.innerText().catch(() => "");
348
+ return text.trim().slice(0, 160) || "model error";
349
+ }
350
+
351
+ async #readComposerText(composer, expected = null) {
352
+ const raw = await composer.evaluate((element) => {
353
+ if (typeof element.value === "string") {
354
+ return element.value;
355
+ }
356
+ // Gemini uses Quill: every pasted plain-text line becomes a direct <p>,
357
+ // and an empty line becomes <p><br></p>. `innerText` inserts an extra
358
+ // layout newline between those blocks, while `textContent` removes all
359
+ // line breaks. Reconstructing the direct blocks recovers the exact text
360
+ // that was committed, including long multi-line protocol prompts.
361
+ if (element.isContentEditable) {
362
+ return [...element.childNodes].map((node) => {
363
+ const value = node.nodeType === Node.TEXT_NODE
364
+ ? node.textContent
365
+ : (node.innerText ?? node.textContent ?? "");
366
+ return value === "\n" ? "" : value;
367
+ }).join("\n");
368
+ }
369
+ return element.innerText ?? element.textContent ?? "";
370
+ }).catch(() => null);
371
+ if (raw == null) {
372
+ return null;
373
+ }
374
+ let normalized = String(raw).replaceAll("\r\n", "\n");
375
+ if (expected != null) {
376
+ const normalizedExpected = String(expected).replaceAll("\r\n", "\n");
377
+ // Quill serializes the first leading ASCII space of an indented line as
378
+ // &nbsp; (U+00A0). It is the same committed prompt text, so canonicalize
379
+ // only when the expected character at that exact position is a space.
380
+ normalized = normalized.replace(/\u00a0/g, (character, index) => (
381
+ normalizedExpected[index] === " " ? " " : character
382
+ ));
383
+ }
384
+ // Quill may expose one layout newline after the last paragraph even when
385
+ // the committed text itself has no trailing newline.
386
+ if (expected != null && !expected.endsWith("\n") && normalized.endsWith("\n")) {
387
+ normalized = normalized.slice(0, -1);
388
+ }
389
+ return normalized;
390
+ }
391
+
392
+ async #clickWithNativePointer(locator) {
393
+ await locator.scrollIntoViewIfNeeded().catch(() => null);
394
+ const box = await locator.boundingBox().catch(() => null);
395
+ if (!box || !this.page.mouse) {
396
+ return false;
397
+ }
398
+ const x = box.x + box.width / 2;
399
+ const y = box.y + box.height / 2;
400
+ await this.page.mouse.move(x, y, { steps: 8 });
401
+ await this.page.waitForTimeout(75);
402
+ await this.page.mouse.down();
403
+ await this.page.waitForTimeout(80);
404
+ await this.page.mouse.up();
405
+ await this.page.waitForTimeout(200);
406
+ return true;
407
+ }
408
+
409
+ async #pasteWithClipboard(text) {
410
+ if (!this.context) {
411
+ return false;
412
+ }
413
+ const origin = new URL(this.page.url()).origin;
414
+ await this.context.grantPermissions(
415
+ ["clipboard-read", "clipboard-write"],
416
+ { origin },
417
+ ).catch(() => null);
418
+
419
+ const copied = await this.page.evaluate(async (value) => {
420
+ if (!navigator.clipboard?.read || !navigator.clipboard?.writeText) {
421
+ return false;
422
+ }
423
+ try {
424
+ window.__wtagentPreviousClipboard = await navigator.clipboard.read();
425
+ await navigator.clipboard.writeText(value);
426
+ return true;
427
+ } catch {
428
+ delete window.__wtagentPreviousClipboard;
429
+ return false;
430
+ }
431
+ }, text).catch(() => false);
432
+ if (!copied) {
433
+ return false;
434
+ }
435
+
436
+ try {
437
+ await this.page.keyboard.press(
438
+ process.platform === "darwin" ? "Meta+V" : "Control+V",
439
+ );
440
+ return true;
441
+ } catch {
442
+ await this.#restoreClipboard();
443
+ return false;
444
+ }
445
+ }
446
+
447
+ async #restoreClipboard() {
448
+ if (typeof this.page.evaluate !== "function") {
449
+ return;
450
+ }
451
+ await this.page.evaluate(async () => {
452
+ const previous = window.__wtagentPreviousClipboard;
453
+ delete window.__wtagentPreviousClipboard;
454
+ if (!previous || !navigator.clipboard?.write) {
455
+ return;
456
+ }
457
+ try {
458
+ await navigator.clipboard.write(previous);
459
+ } catch {
460
+ // Best effort only: do not fail a correctly committed prompt if the OS
461
+ // revokes clipboard permission after the paste.
462
+ }
463
+ }).catch(() => null);
464
+ }
465
+ }