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.
- package/README.md +50 -36
- package/package.json +2 -2
- package/src/browser/base-web-adapter.js +1178 -0
- package/src/browser/cdp-browser.js +62 -0
- package/src/browser/cdp-state.js +52 -4
- package/src/browser/chatgpt-web-adapter.js +301 -992
- package/src/browser/claude-web-adapter.js +205 -0
- package/src/browser/deepseek-web-adapter.js +337 -0
- package/src/browser/gemini-web-adapter.js +465 -0
- package/src/browser/glm-web-adapter.js +246 -0
- package/src/browser/kimi-web-adapter.js +285 -0
- package/src/browser/provider-registry.js +199 -0
- package/src/cli/main.js +276 -59
- package/src/cli/notice-store.js +125 -0
- package/src/cli/prompt-input.js +504 -170
- package/src/cli/render-events.js +14 -8
- package/src/cli/self-update.js +145 -0
- package/src/cli/startup-notices.js +161 -0
- package/src/platform/paths.js +7 -0
- package/src/protocol/markers.js +3 -2
- package/src/protocol/prompt-builder.js +9 -5
- package/src/protocol/xml-protocol.js +193 -11
- package/src/runtime/agent-runtime.js +131 -18
- package/src/session/agent-session.js +4 -1
- package/src/session/canonical-transcript.js +2 -2
- package/src/session/session-export.js +2 -2
- package/src/shared/fetch-json.js +24 -0
- package/src/shared/limits.js +11 -1
- package/src/shared/package-info.js +23 -0
- package/src/shared/usage-limit.js +2 -2
|
@@ -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
|
+
// (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
|
+
}
|